RE:NODE

Spring Boot აპლიკაციის production-ზე გაშვება Java სერვერზე

ავტორი RE:NODE-ის გუნდი
21 წუთის საკითხავი


Spring Boot აპლიკაცია ერთი ფაილის სახით ინსტალირდება: ეს არის fat jar, რომელშიც ერთად დევს შენი კოდი, მისი დამოკიდებულებები და ჩაშენებული web სერვერი. სერვერს სჭირდება სწორი ვერსიის Java runtime, jar-ი, port, რომელზეც მოუსმენს, და heap, რომელიც ჯდება იმ მეხსიერების ლიმიტში, რასაც იხდი. სწორედ ბოლო პუნქტზე ფუჭდება თითქმის ყველა წარუმატებელი Spring Boot deploy, რადგან JVM-ს სიამოვნებით მოაქვს მეტი მეხსიერების მოთხოვნა, ვიდრე პატარა გეგმას აქვს, ხოლო კონტეინერი, რომელიც ლიმიტის გადაჭარბების გამო შეჩერდა, გარედან გამოიყურება როგორც ავარია ყოველგვარი ახსნის გარეშე.

ეს სტატია Spring Boot-ის მთელ გზას მოიცავს, იმ შენიშვნებით, სადაც Quarkus და Micronaut განსხვავდება: jar-ის აწყობა Maven-ით ან Gradle-ით, Java-ს ვერსიის არჩევა, სწორ port-ზე მიბმა, heap-ის ზომის შერჩევა გეგმის მიხედვით, კონფიგურაციის jar-ს გარეთ შენახვა, PostgreSQL ან MySQL ბაზის მიერთება HikariCP-ით, health endpoint-ის გამოჩენა, proxy-ს უკან მუშაობა ისე, რომ კლიენტის მისამართი არ დაიკარგოს, და შეცდომები, რომლებსაც შეხვდები, როცა რამე არასწორადაა.

რა სჭირდება სერვერს fat jar-ის გასაშვებად#

Fat jar (ასევე uber jar ან executable jar) ჩვეულებრივი jar-ია, რომლის შიგნითაც ჩალაგებულია დამოკიდებულებების jar-ები და launcher კლასი, რომელმაც იცის მათი წაკითხვა. მას Spring Boot-ის Maven და Gradle plugin-ები აგენერირებს, ხოლო java -jar app.jar მთელი გაშვების ბრძანებაა. არ არის საჭირო application server-ის დაყენება, WAR-ის Tomcat-ში ჩაგდება ან classpath-ის ხელით აწყობა.

ჰოსტინგისთვის ეს ასე ითარგმნება:

  • JRE, და არა JDK. კომპილაციას შენს მანქანაზე ან CI-ში აკეთებ. სერვერი მხოლოდ შედეგს უშვებს, ამიტომ runtime საკმარისია.
  • სწორი major ვერსია. Spring Boot 3-ს სჭირდება Java 17 ან უფრო ახალი. Spring Boot 2.7 და უფრო ძველი ვერსიები Java 8-დან 17-მდე მუშაობს, მაგრამ open-source მხარდაჭერა მათ აღარ აქვთ. RE:NODE-ის Java აპლიკაციების ხაზი ნებისმიერ jar-ს უშვებს Java 17-ზე, 21-ზე ან 25-ზე, ამიტომ Boot 3 აპლიკაცია სამივეზე ჯდება, ხოლო Boot 2.x აპლიკაციებს მხოლოდ 17-ზე აქვს აზრი.
  • ერთი მოსასმენი port. ერთი ჩაშენებული სერვერი, ერთი port. Actuator-ის გადატანა მეორე port-ზე შეიძლება, მაგრამ ერთი allocation-ის მქონე გეგმაზე მას მთავარ port-ზე დატოვებ და სხვანაირად დაიცავ.
  • მეხსიერება არა მარტო heap-ისთვის. JVM-ის ფაქტობრივი მოცულობა არის heap, metaspace, thread-ების stack-ები, code cache, direct buffer-ები და garbage collector-ის სტრუქტურები. 512 MB heap ხშირად 700-800 MB პროცესს ნიშნავს. ქვემოთ ზომის შერჩევის თავის დიდი ნაწილი ამ წინადადების გასაკონკრეტებლადაა.

Java-ს ვერსია აირჩიე იმის მიხედვით, რისთვისაც აკომპილირებ, და არა იმით, რა არის ყველაზე ახალი. აკომპილირე --release 17-ით ან შესაბამისი Maven და Gradle პარამეტრით, და 17-ისთვის აწყობილი jar უცვლელად იმუშავებს 21-ზე და 25-ზე. საპირისპირო მიმართულებით ის ვარდება UnsupportedClassVersionError-ით, რაც ამოცანების გადაჭრის თავშია განხილული. თუ შენი დამოკიდებულებები ახალია, Java 21 გონივრული ნაგულისხმევია: ხელმისაწვდომია virtual thread-ები, garbage collector-ები უკეთ არის მორგებული კონტეინერებზე და მხარდაჭერის ხანგრძლივი პერიოდი აქვს. Java 25 ახალი ხაზია და ღირს გასინჯვა, თუ უახლეს long-term release-ზე გინდა ყოფნა.

jar-ის აწყობა Maven-ით ან Gradle-ით#

ააწყე CI-ში ან შენს მანქანაზე, იქვე გაუშვი ტესტები და სერვერზე მხოლოდ artefact-ი გაგზავნე. build-ის გაკეთება პატარა production გეგმაზე არ ღირს, იმავე მიზეზით, რის გამოც Rust-ის build-ებიც არ კეთდება: Maven ან Gradle თბილი დამოკიდებულებების cache-ით კარგად მუშაობს, მაგრამ ცივი build 1 GB კონტეინერში ნელია და შეიძლება მეხსიერების ლიმიტს მიაღწიოს.

Maven-ში jar-ს გამშვებად ხდის Spring Boot parent ან spring-boot-maven-plugin:

bash
$ ./mvnw -B clean package -DskipTests=false$ ls target/*.jartarget/shop-api-1.4.0.jar  target/shop-api-1.4.0.jar.original

.original ფაილი არის თხელი jar-ი repackage-მდე. ატვირთე მეორე. Gradle-ისთვის:

bash
$ ./gradlew clean bootJar$ ls build/libs/shop-api-1.4.0.jar

bootJar არის task, რომელიც გამშვებ არქივს აგენერირებს; ჩვეულებრივი jar თხელს ქმნის, რომელიც არ ეშვება. org.springframework.boot plugin-ით აწყობილი Gradle პროექტები ასევე ქმნიან -plain.jar-ს, რომელიც იმავე მიზეზით არ გჭირდება.

სამი წვრილმანი, რომელიც მოგვიანებით deploy-ს გადაგარჩენს:

  1. jar-ს მუდმივი სახელი დაარქვი. Maven-ში <build>-ის შიგნით მიუთითე <finalName>app</finalName>; Gradle-ში bootJar { archiveFileName = 'app.jar' }. გაშვების ბრძანება, რომელიც app.jar-ზე მიუთითებს, ყოველ release-ზე რედაქტირებას არ საჭიროებს.
  2. Build info health endpoint-ისთვის. spring-boot-maven-plugin-ს აქვს build-info goal, Gradle plugin-ს კი springBoot { buildInfo() }. ორივე შემთხვევაში Actuator-ის /actuator/info აჩვენებს იმ ვერსიას, რომელიც ნამდვილად მუშაობს, და ეს ნებისმიერი ინციდენტის პირველ კითხვას პასუხობს.
  3. შეამოწმე manifest-ი. unzip -p app.jar META-INF/MANIFEST.MF-მა უნდა აჩვენოს Main-Class, რომელიც org.springframework.boot.loader...-ია, და Start-Class, რომელიც შენი აპლიკაციაა. თუ ასე არ არის, repackage-ის ნაბიჯი არ შესრულებულა.

Quarkus და Micronaut artefact-ით განსხვავდება. Quarkus-ის ნაგულისხმევი quarkus-app სტრუქტურა დირექტორიაა და არა ერთი ფაილი: quarkus-run.jar და მის გვერდით lib საქაღალდე. თუ ერთი ფაილი გინდა, დააყენე quarkus.package.jar.type=uber-jar, ააწყე ./mvnw package-ით და გაუშვი java -jar target/*-runner.jar. Micronaut-ის shadowJar Gradle task და მისი Maven Shade კონფიგურაცია ერთ გამშვებ jar-ს ქმნის. ყველაფერი, რაც ქვემოთ heap-ზე, port-ებსა და ბაზებზეა ნათქვამი, ორივეზე ვრცელდება, უბრალოდ property-ების სახელები განსხვავდება.

ატვირთვა და გაშვება#

გზა, რომლითაც jar-ი სერვერამდე აღწევს, განსაზღვრავს, დანარჩენი რამდენად შეგიძლია ავტომატიზირება. სამი გზა არსებობს: ხელით ატვირთვა file manager-ით ან SFTP-ით; repository-ის GitHub-დან გადმოწერა და იქვე აწყობა; ან CI-ში აწყობა და მხოლოდ artefact-ის ატვირთვა.

RE:NODE-ზე ყველა აპლიკაციის სერვერს აქვს file manager, რომელიც არქივებს ადგილზე ხსნის, და SFTP თითო სერვერის credentials-ით. ორივე jar-ისთვის კარგია. პანელი ასევე უჭერს მხარს deploy-ს GitHub-დან GitHub App-ის მეშვეობით, რაც კერძო repository-ებზეც მუშაობს, ორი გადამრთველით: pull ყოველ გაშვებაზე და deploy push-ზე, რომელიც მოქმედ სერვერს გადატვირთავს, გაჩერებულს კი გაუშვებს. ეს წყაროდან სერვერამდე გზაა, ამიტომ ის მხოლოდ მაშინ გამოგადგება, თუ თავად სერვერს build-ის უფლება აქვს; CI-ში აწყობილი jar-ისთვის artefact-ის ატვირთვა უფრო მარტივია და build-ს production-ის მეხსიერების ბიუჯეტიდან გაიტანს. თუ git გზას იყენებ, ჩაწერე აწყობილი jar-ი commit-ში ან გამოიყენე branch, რომელიც მხოლოდ release artefact-ებს შეიცავს, და ნუ ელი, რომ გეგმა Maven-ს გაუშვებს.

როცა Java სერვერი იქმნება, მის მისამართზე მცირე სამუშაო გვერდი ჩნდება, სანამ შენს კოდს არ ატვირთავ, და ეს გვერდი შენს ფაილებს არასოდეს გადააწერს. როგორც კი jar-ი ადგილზეა და გაშვების პარამეტრები მას უთითებს, გვერდი შენი აპლიკაციით იცვლება. Startup tab-ზე ისმება environment variable-ები, კონსოლი კი ცოცხალ გამოტანას აჩვენებს start, stop, restart და kill ღილაკებით.

ატვირთვაjava -jarJDBCHTTPSHTTPშენი buildmvn packageClientsapp.jarfat jarProxy სლოტიTLS, X-Forwarded-ForJVM-Xmx, MaxRAMPercentageბაზაHikariCP pool
build-იდან გაშვებულ Spring Boot აპამდე

Port-ები: server.port და SERVER_PORT#

მოსასმენი port პირველია, რაც ფუჭდება. Spring Boot-ის ნაგულისხმევია 8080. ჰოსტინგის გეგმაზე შენ გამოგიყოფენ port-ს და აპლიკაციამ სწორედ მასზე უნდა მოუსმინოს და არა იმაზე, რასაც ლოკალურად იყენებდი.

Spring Boot ერთსა და იმავე property-ს რამდენიმე ადგილიდან კითხულობს და რიგი მნიშვნელოვანია. ბრძანების ხაზის არგუმენტები OS-ის environment variable-ებზე მაღლა დგას, ისინი კი jar-ის შიგნით მყოფ application.properties-ზე. environment variable SERVER_PORT არის server.port-ის relaxed-binding ფორმა, ამიტომ გარემოში მისი დაყენება კოდს საერთოდ არ საჭიროებს:

environment
SERVER_PORT=25565SPRING_PROFILES_ACTIVE=prod

ზემოთ მოცემული რიცხვი placeholder-ია; გამოიყენე შენი სერვერისთვის ნაჩვენები port. Pterodactyl-ზე დაფუძნებულ პანელებში allocation ხშირად SERVER_PORT-ად ჩნდება, ამიტომ დაშვებამდე შეამოწმე Startup და Network tab-ები, რას გაძლევს შენი სერვერი. თუ ცვლადი უკვე არსებობს და allocation-ს შეიცავს, არაფერი გჭირდება დააყენო: Spring მას server.port-ად აიღებს გაშვებისთანავე.

თუ ფაილში შენახვა გირჩევნია, ნაგულისხმევიანი მითითება კარგი პატერნია, რადგან ლოკალურადაც მუშაობს და production-შიც:

application.properties
server.port=${SERVER_PORT:8080}

მისამართი მეორე ნახევარია. Spring-ის ჩაშენებული სერვერი ნაგულისხმევად ყველა interface-ზე უსმენს, ამიტომ server.address არ გჭირდება. production-ისთვის მას 127.0.0.1 არ დაუყენო - ამით აპლიკაცია მხოლოდ საკუთარი კონტეინერის შიგნიდან იქნება მისაწვდომი და proxy bad gateway-ს დააბრუნებს. Quarkus ზოგ რეჟიმში ნაგულისხმევად localhost-ზე უკავშირდება და სჭირდება quarkus.http.host=0.0.0.0 და quarkus.http.port=${SERVER_PORT:8080}. Micronaut კითხულობს micronaut.server.port-ს და პატივს სცემს environment variable MICRONAUT_SERVER_PORT-ს.

JVM-ის ზომა გეგმის მეხსიერების ლიმიტის მიხედვით#

ეს ყველაზე მნიშვნელოვანი თავია. კონტეინერს მეხსიერების მკაცრი ლიმიტი აქვს, JVM კი, თუ დაუშვებ, თავად გადაწყვეტს, რამდენად დიდი გახადოს heap-ი. თანამედროვე JVM-ები კონტეინერის ლიმიტს cgroups-ით კითხულობენ და მაქსიმალურ heap-ს მის მეოთხედად ნაგულისხმევად აყენებენ. ეს უსაფრთხო ნაგულისხმევია და ამავე დროს ფლანგავს: 2 GB გეგმაზე ის 512 MB heap-ს გაძლევს და 1,5 GB უსაქმოდ რჩება.

რა ხდება ლიმიტთან, პლატფორმაზეა დამოკიდებული. RE:NODE-ზე, როცა პროცესი მეხსიერების ლიმიტს აღწევს, kernel კონტეინერს აჩერებს და ის swap-ის ნაცვლად სუფთად გადაიტვირთება. ორიგინალი Pterodactyl პირიქით იქცევა და მანქანას swap-ს უშვებს. სუფთა გაჩერება production სერვისისთვის უკეთესია, რადგან swap-ზე მყოფი JVM წამობით გაგრძელებად garbage collection პაუზებს იწვევს და შემდეგ ნელა კვდება. ეს ასევე ნიშნავს, რომ თუ heap ძალიან დიდი დააყენე, სიმპტომი იქნება გადატვირთვა აპლიკაციის ლოგში არაფრის გარეშე: პროცესი გარედან მოკლეს და JVM-ს OutOfMemoryError-ის ჩაწერის დრო არ მისცემია.

ორი განსხვავებული წარუმატებლობა უნდა გაარჩიო:

  • `java.lang.OutOfMemoryError: Java heap space`. heap შენი მონაცემებისთვის ძალიან მცირეა. JVM ამას ლოგავს და heap უნდა გაიზარდოს, ან აპლიკაციას leak აქვს.
  • გადატვირთვა Java-ს შეცდომის გარეშე. მთელმა პროცესმა კონტეინერის ლიმიტი გადააჭარბა. heap გეგმისთვის ძალიან დიდია, ან non-heap მეხსიერება გაიზარდა. heap უნდა შემცირდეს.

heap-ის დაყენების სამი გზა, უპირატესობის მიხედვით:

bash
# 1. კონტეინერის ლიმიტის პროცენტი (რეკომენდებული)$ java -XX:MaxRAMPercentage=65.0 -XX:InitialRAMPercentage=40.0 -jar app.jar# 2. აბსოლუტური ზღვარი$ java -Xms256m -Xmx768m -jar app.jar# 3. მხოლოდ გარემო, ბრძანების შეცვლის გარეშეJAVA_TOOL_OPTIONS="-XX:MaxRAMPercentage=65.0 -XX:+ExitOnOutOfMemoryError"

MaxRAMPercentage გეგმის შეცვლისას თავისით მასშტაბირდება, და ეს მისი არჩევის მთავარი მიზეზია: 2 GB-დან 4 GB-ზე გადახვალ და heap მიყვება, რედაქტირების გარეშე. -Xmx ზუსტია და სწორი არჩევანია, როცა გაზომე და ფიქსირებული მნიშვნელობა გინდა. ორივე რომ არის, -Xmx იმარჯვებს. JAVA_TOOL_OPTIONS-ს კითხულობს ყოველი JVM, რომელიც ეშვება, და ეს გამოგადგება, როცა გაშვების ბრძანებას ვერ ცვლი; JVM კონსოლის თავში ბეჭდავს Picked up JAVA_TOOL_OPTIONS: ..., რათა დაადასტურო, რომ ამოქმედდა. გაითვალისწინე, რომ ის ამავე გარემოში გაშვებულ ნებისმიერ სხვა Java პროცესზეც ვრცელდება.

პროცენტი გეგმაზეა დამოკიდებული, რადგან ფიქსირებული overhead პატარა გეგმაზე უფრო დიდი წილია:

გეგმის მეხსიერებაHeap-ის პარამეტრიმიზეზი
1 GB-Xmx384m - -Xmx512m500 MB ან მეტი non-heap-ზე მიდის
2 GBMaxRAMPercentage=60დაახლოებით 1,2 GB heap, 800 MB დანარჩენისთვის
4 GBMaxRAMPercentage=65Non-heap overhead უფრო მცირე წილია
6 GB და მეტიMaxRAMPercentage=70დატოვე ადგილი direct buffer-ებისა და მოულოდნელი ზრდისთვის

ეს საწყისი წერტილებია და არა შენი აპლიკაციის გაზომვები. სერვისი, რომელსაც დიდი cache-ები აქვს ან ბევრ NIO-ს იყენებს, მეტ დაცულ სივრცეს მოითხოვს. პატარა API მცირე დამოკიდებულებების ხით ნაკლებზეც მუშაობს. დააკვირდი კონსოლის მეხსიერების გრაფიკს ლიმიტის წინააღმდეგ რეალური ტრაფიკის ერთი დღის განმავლობაში და შეასწორე.

კიდევ რამდენიმე flag, რომელიც ღირს ცოდნა:

Flagეფექტი
-XX:+ExitOnOutOfMemoryErrorheap OOM-ზე გასვლა, რათა პლატფორმამ სუფთად გადატვირთოს
-XX:MaxMetaspaceSize=256mკლასის metadata-ს შეზღუდვა, რომელიც redeploy-ებზე შეიძლება გაჟონოს
-Xss512kუფრო პატარა thread stack-ები, თუ ასობით thread გაქვს
-XX:+UseSerialGC1 CPU-ზე ან 1,5 GB-ზე ნაკლებზე, ყველაზე დაბალი overhead-ის collector

ExitOnOutOfMemoryError ის flag-ია, რომელსაც უმეტესობა გამოტოვებს და არ უნდა გამოტოვოს. JVM, რომელმაც ერთ thread-ში heap OOM ამოაგდო, ხშირად ცოცხალია, მაგრამ დაზიანებული: ზოგ მოთხოვნას ემსახურება, ზოგს კი ვერა. გასვლა საშუალებას აძლევს ზემოთ აღწერილ restart ქცევას, რომ სუფთა პროცესი მოგცეს. იცოდე, რომ restart-ებს აკვირდებიან: crash detection ყოველ ორ წუთში ამოწმებს, uptime ხომ არ დაბრუნდა უკან ან სერვერი ხომ არ გამოირთო, შენ მიერ მოთხოვნილი restart-ები არ ითვლება, საათში სამი restart გაფრთხილებას და ავტომატურ ticket-ს იწვევს, ექვსი კი შეჩერებას. სერვისი, რომელიც ციკლში იტვირთება იმიტომ, რომ heap ძალიან დიდია, ზუსტად ის არის, რასაც ეს მექანიზმი ეძებს, ამიტომ ზომა გაასწორე, სანამ გაშვებულს დატოვებ.

collector პატარა გეგმებზე მნიშვნელოვანია. Java G1-ს ირჩევს მანქანებზე ორი ან მეტი CPU-თი და მინიმუმ 1792 MB-ით, სხვა შემთხვევაში კი serial collector-ზე გადადის. G1 კარგი ნაგულისხმევია 2 GB-დან ზემოთ. არ მოარგო, სანამ გრაფიკი ამას არ გეტყვის. CPU vs RAM game server-ებისთვის ზოგად აზრს გამოთქვამს: სანამ მეორე რესურსს იყიდი, გაზომე, რომელია შემზღუდველი, და ეს JVM-ზეც უცვლელად მუშაობს.

CPU მკაცრად არის შეზღუდული შენ ნაყიდ წილზე. JVM საკუთარ thread pool-ებს იმ CPU რაოდენობით ზომავს, რომელსაც ხედავს; ის კონტეინერის quota-დან გამოითვლება და მაღლა მრგვალდება. ნახევარი ბირთვის წილზე მყოფ სერვისს შეიძლება ეჩვენებოდეს, რომ ერთი აქვს, და compiler thread-ები და GC thread-ები მოთხოვნის thread-ებს ეჯიბრებიან. თუ გაშვება ნელია ან latency ნაკრებებად იზრდება, -XX:ActiveProcessorCount=1 JVM-ის წარმოდგენას მანქანაზე ამაგრებს და ყველაზე პატარა tier-ებზე ღირს ცდა.

კონფიგურაცია: application.properties და environment variable-ები#

jar-ი ყველგან ერთიდაიგივეა. შენს ლეპტოპსა და production-ს შორის იცვლება კონფიგურაცია და მისგან არაფერი უნდა იყოს jar-ის შიგნით. Spring Boot-ის გარეგანი კონფიგურაცია ნათელ რიგს გაძლევს, დაბალი პრიორიტეტიდან მაღალისკენ:

  1. application.properties ან application.yml, jar-ში ჩაწყობილი.
  2. იგივე ფაილები jar-ის გვერდით, სამუშაო დირექტორიაში ან მის გვერდით config/ საქაღალდეში.
  3. პროფილის ფაილები, როგორიცაა application-prod.properties.
  4. OS-ის environment variable-ები.
  5. Java-ს system property-ები (-Dkey=value) და ბრძანების ხაზის არგუმენტები (--key=value).

შედეგი სასარგებლოა: ნაგულისხმევები jar-ში ჩააწყე და production-ში მხოლოდ ის გადაფარე გარემოთი, რაც განსხვავდება. environment variable-ების binding-ის წესი მექანიკურია - დიდი ასოები, წერტილები და ტირეები ქვედა ტირეებად იქცევა:

PropertyEnvironment variable
server.portSERVER_PORT
spring.datasource.urlSPRING_DATASOURCE_URL
spring.datasource.hikari.maximum-pool-sizeSPRING_DATASOURCE_HIKARI_MAXIMUMPOOLSIZE
management.endpoint.health.show-detailsMANAGEMENT_ENDPOINT_HEALTH_SHOWDETAILS

ყურადღება მიაქციე pool size-ის სტრიქონს: ტირე maximum-pool-size-ში ქვედა ტირედ არ გადადის, უბრალოდ ქრება და სახელი ერთიანდება. გასაღების სახელში ქვედა ტირეები environment variable-ის უშედეგობის ჩვეულებრივი მიზეზია. ეჭვის შემთხვევაში test გარემოში გახსენი /actuator/env ან ერთხელ დააყენე --debug და წაიკითხე კონსოლში condition report-ი.

საიდუმლოებები jar-ს და repository-ს გარეთ შეინახე. ბაზის პაროლები, API გასაღებები და ხელმოწერის საიდუმლოებები environment variable-ებში უნდა იყოს, რომლებიც RE:NODE-ზე Startup tab-ზე ისმება. Environment variable-ები და საიდუმლოებები განმარტავს, რომელი სახეობა სად ეკუთვნის და რა არ უნდა ჩაიწეროს ლოგში. jar-ის გვერდით მდებარე კონფიგურაციის ფაილი ალტერნატივაა იმ მნიშვნელობებისთვის, რომელთა რედაქტირებაც გინდა restart ციკლზე ფიქრის გარეშე; file manager-ის რედაქტორი syntax highlighting-ით ამას კომფორტულს ხდის. Spring მუშაობისას application.properties-ს თავიდან არ კითხულობს, ამიტომ ცვლილებას ყოველთვის restart სჭირდება.

პროფილის გადამრთველი ყველაფერს აკავშირებს. application.properties შეინახე საერთო ნაგულისხმევებისთვის, application-prod.properties კი იმისთვის, რასაც production ცვლის, და გააქტიურე SPRING_PROFILES_ACTIVE=prod-ით:

application-prod.properties
server.port=${SERVER_PORT:8080}server.forward-headers-strategy=nativeserver.shutdown=gracefulspring.lifecycle.timeout-per-shutdown-phase=20sspring.jpa.open-in-view=falsespring.jpa.hibernate.ddl-auto=validatespring.main.lazy-initialization=falselogging.level.root=INFO

spring.jpa.open-in-view=false აჩერებს Spring-ს ყოველი web მოთხოვნის მთელი ხანგრძლივობით ბაზის connection-ის დაკავებაში. ის ნაგულისხმევად ჩართულია და გაშვებისას გაფრთხილებას ლოგავს, რადგან pool-ის ამოწურვის გავრცელებული წყაროა. ddl-auto=validate აპლიკაციას სქემის შეცვლის ნაცვლად შემოწმებას აიძულებს; production სქემები migration ხელსაწყოს ეკუთვნის, როგორიცაა Flyway ან Liquibase, და არა Hibernate-ის ვარაუდებს.

PostgreSQL ან MySQL ბაზის მიერთება#

Spring Boot DataSource-ს ავტომატურად აკონფიგურირებს სამი property-დან და classpath-ზე არსებული driver-იდან. driver დამოკიდებულებაა: org.postgresql:postgresql PostgreSQL-ისთვის, com.mysql:mysql-connector-j MySQL-ისთვის. URL შეიცავს host-ს, port-სა და ბაზის სახელს:

environment
SPRING_DATASOURCE_URL=jdbc:postgresql://db-host:5432/shop?sslmode=preferSPRING_DATASOURCE_USERNAME=shop_appSPRING_DATASOURCE_PASSWORD=change-me

MySQL-ისთვის URL იწყება jdbc:mysql://host:3306/dbname-ით და driver კლასი URL-იდან ავტომატურად მოიძებნება. host, მომხმარებელი და პაროლი იქიდან მოდის, სადაც ბაზა ცხოვრობს. ყველა აპლიკაციის სერვერს აქვს ბაზის სლოტები, რომლებიც პანელში იქმნება გენერირებული host-ით, მომხმარებლითა და პაროლით. დამოუკიდებელი ბაზისთვის არსებობს ბაზის ჰოსტინგი PostgreSQL-ითა და MySQL 8.4 LTS-ით, რომელთა credentials თითო სერვერზე გენერირდება და გეგმის საკუთარ host-სა და port-ზე მიიღწევა. მნიშვნელობები ჩააკოპირე ზემოთ მოცემულ სამ ცვლადში და არა jar-ში. PostgreSQL-ის remote connection-ის გზამკვლევი და MySQL-ისა connection string-ებს უფრო დეტალურად განიხილავს.

HikariCP pool-ის ზომა

Spring Boot ნაგულისხმევად HikariCP-ს იყენებს. ის ღია connection-ების pool-ს ინახავს და მოთხოვნებს გადასცემს. ნაგულისხმევი maximum-pool-size არის 10, minimum-idle კი იგივეზე ისმება, ამიტომ Boot აპლიკაცია გაშვებისას ათ connection-ს ხსნის და ინახავს. პატარა ბაზისთვის ეს ბევრია.

application-prod.properties
spring.datasource.hikari.maximum-pool-size=6spring.datasource.hikari.minimum-idle=2spring.datasource.hikari.connection-timeout=10000spring.datasource.hikari.idle-timeout=300000spring.datasource.hikari.max-lifetime=1500000spring.datasource.hikari.keepalive-time=120000

გასააზრებელი მნიშვნელობები:

  • `maximum-pool-size` არის ამ პროცესიდან connection-ების მკაცრი ზედა ზღვარი. მეტი არ ნიშნავს უფრო სწრაფს. 6 connection-იანი pool ერთი-ორი ბირთვის სერვისზე ბევრად მეტ ტრაფიკს ართმევს თავს, ვიდრე უმეტესობა ელის, რადგან 5 ms-იანი query ერთ connection-ს წამში 200 მოთხოვნის მომსახურების საშუალებას აძლევს. თუ მოთხოვნები connection-ებზე რიგში დგანან, რიცხვის გაზრდამდე ნელი query-ები მოძებნე.
  • `connection-timeout` არის დრო მილისეკუნდებში, რამდენსაც მოთხოვნა თავისუფალ connection-ს ელოდება, სანამ ჩავარდება. ნაგულისხმევი 30 წამი web მოთხოვნისთვის ძალიან გრძელია; 10 წამი ან ნაკლები ხილულად და სწრაფად ვარდება.
  • `max-lifetime` უნდა იყოს ბაზაზე ან შენსა და მას შორის ქსელურ მოწყობილობაზე არსებული ნებისმიერი connection-ის დროის ლიმიტზე მოკლე. Hikari connection-ს გამოიყვანს, სანამ სერვერი მას შენ ქვეშ დახურავდეს. ნაგულისხმევი 30 წუთია, ხოლო რეალურ ლიმიტზე რამდენიმე წუთით დაბალი მნიშვნელობა სწორია.
  • `keepalive-time` უქმ connection-ებს ping-ს უგზავნის, რაც stateful firewall-ებს მათ ჩუმად დახურვაში უშლის ხელს.

დათვალე connection-ები ყველაფერზე, რაც უკავშირდება. თუ აპლიკაციის ორ instance-ს უშვებ, ან მის გვერდით migration job-ს, ჯამები ბაზის საკუთარ connection ლიმიტს ემატება. Connection pool-ები და ლიმიტები ამ არითმეტიკას შეიცავს, MySQL-ის connection ლიმიტების სტატია კი სერვერის მხარეს ფარავს.

დაამატე Flyway ან Liquibase და migration-ები ჩააწყე src/main/resources/db/migration-ში. მაშინ აპლიკაცია გაშვებისას, ტრაფიკის მომსახურებამდე, საკუთარ სქემას განაახლებს. ეს გაშვებას ოდნავ აგრძელებს და deploy-ებს განმეორებადს ხდის, და სწორედ ეს არის ddl-auto=validate-ის დაყენების მთავარი მიზეზი.

Health check-ები Actuator-ით#

დაამატე spring-boot-starter-actuator და გამოაჩინე მხოლოდ ის, რაც გჭირდება. ნაგულისხმევად HTTP-ზე მხოლოდ health ჩანს, და ეს სწორი ინსტინქტია შესანარჩუნებლად:

application-prod.properties
management.endpoints.web.exposure.include=health,infomanagement.endpoint.health.probes.enabled=truemanagement.endpoint.health.show-details=nevermanagement.health.db.enabled=true

probe-ების ჩართვისას Spring /actuator/health-ის გარდა ხსნის /actuator/health/liveness-სა და /actuator/health/readiness-ს. Liveness პასუხობს კითხვას "პროცესი ისეთ მდგომარეობაშია, რომ გაგრძელება შეუძლია" და არასოდეს უნდა იყოს ბაზაზე დამოკიდებული. Readiness პასუხობს "უნდა მიიღოს თუ არა ამ instance-მა ტრაფიკი", და გამორთული ბაზა სწორედ აქ ეკუთვნის. საერთო /actuator/health აგროვებს რეგისტრირებულ indicator-ებს, მათ შორის datasource-ს, დისკის სივრცეს და ყველაფერს, რასაც შენ დაამატებ, და აბრუნებს {"status":"UP"}-ს HTTP 200-ით, ან 503-ს, როცა რამე გამორთულია.

გამოსცადე კონსოლიდან ან შენი მანქანიდან:

bash
$ curl -s -o /dev/null -w "%{http_code}\n" https://api.example.com/actuator/health200

ორი გაფრთხილება. არასოდეს გამოაჩინო env, heapdump, beans ან mappings საჯარო მისამართზე: ისინი კონფიგურაციას ჟონავენ, heapdump კი მეხსიერების შიგთავსს, საიდუმლოებების ჩათვლით. თუ გჭირდება, გადაიტანე ისინი ცალკე management port-ზე management.server.port-ით და ნუ გადაამისამართებ, ან დაიცავი Spring Security-ით. და health პასუხი იაფი იყოს: indicator, რომელიც სამ მესამე მხარის სერვისს ეძახის, monitor-ის ყოველ ping-ს დატვირთვად აქცევს. Graceful shutdown და health check-ები განმარტავს, რას აკეთებს და რას არა კარგი check, ხოლო ზემოთ მოცემული server.shutdown=graceful იმავე ამბის გამორთვის ნახევარია: restart-ზე დაჭერისას მიმდინარე მოთხოვნებს დასრულების საშუალებას აძლევს.

Proxy-ს უკან: TLS და X-Forwarded-For#

TLS-ს აპლიკაციის შიგნით ნუ დაასრულებ. აპლიკაციის გეგმები შეიცავს reverse-proxy სლოტს: მიუთითე A record ნაჩვენებ მისამართზე და სერტიფიკატი ავტომატურად გაიცემა და განახლდება, 21-დღიანი ფანჯრის ფარგლებში. შენი აპლიკაცია ჩვეულებრივ HTTP-ს ემსახურება გამოყოფილ port-ზე, proxy კი ნამდვილ კლიენტის მისამართს X-Forwarded-For-ში გადასცემს. რას აკეთებს reverse proxy მთელ ჯაჭვს განმარტავს.

Spring Boot ამ header-ებს არ ენდობა, სანამ არ უთხრა. ამის გარეშე request.getRemoteAddr() proxy-ს აბრუნებს, ყოველი redirect http://-ით შენდება და request.isSecure() false-ია. გამოსწორება ერთი property-ა:

application-prod.properties
server.forward-headers-strategy=native

ორი გამოსადეგი მნიშვნელობაა native და framework. native ჩაშენებულ სერვერს (Tomcat, Jetty ან Undertow) აძლევს X-Forwarded-*-ის თვითონ დამუშავების საშუალებას, და ეს სწორი პასუხია ჩვეულებრივი servlet აპლიკაციისთვის. framework იყენებს Spring-ის საკუთარ ForwardedHeaderFilter-ს, რაც არჩევანია WebFlux-ისთვის ან როცა გინდა, რომ Spring-მა გააკეთოს ეს სამუშაო. ორივეს ნუ დააყენებ. ორიდან ნებისმიერით getRemoteAddr() კლიენტს აბრუნებს, redirect-ები და Location header-ები https-ს იყენებენ, ხოლო ბმულებსა და OAuth callback-ებში გენერირებული აბსოლუტური URL-ები სწორია.

Tomcat header-ს მხოლოდ სანდო proxy-დან იღებს. შესაბამისი property არის server.tomcat.remoteip.internal-proxies, რომლის ნაგულისხმევი regular expression კერძო და loopback დიაპაზონებს ფარავს. თუ proxy შენს კონტეინერს ამ დიაპაზონს გარეთ მყოფი მისამართიდან უკავშირდება, header იგნორირდება და ლოგში არაფერი გეუბნება რატომ. გამოსცადე მოთხოვნით და debug endpoint-იდან დაბეჭდე X-Forwarded-For და გადაწყვეტილი remote მისამართი.

Quarkus-ისთვის ექვივალენტია quarkus.http.proxy.proxy-address-forwarding=true, მიმდინარე ვერსიებზე quarkus.http.proxy.allow-x-forwarded=true-ით. Micronaut-ისთვის კლიენტი გადაწყვიტე HttpClientAddressResolver-ით, რომელიც X-Forwarded-For-ს თავად კითხულობს. WebSocket-ებსა და server-sent event-ებს სჭირდება upgrade-ის დამუშავება და უქმობის timeout, რომელიც შენს heartbeat-ზე გრძელია; Websocket-ები reverse proxy-ს უკან ამას ფარავს და ის Spring-ის WebSocket-სა და SseEmitter-ზე ცვლილების გარეშე მუშაობს.

გაშვების დრო და მისი შემოკლება#

Spring Boot აპლიკაცია Hibernate-ით, security-ითა და ათიოდე starter-ით ნაწილობრივ ბირთვზე ხშირად 8-დან 25 წამამდე გრძელდება, deploy-ის შემდეგ პირველ გაშვებაზე კი უფრო დიდხანს, რადგან JVM-მა ყოველი კლასი უნდა ჩატვირთოს და გადაამოწმოს. ეს ნორმალურია და ორი მიზეზით მნიშვნელოვანია: monitor-ები, რომლებიც ზედმეტად ადრე ამოწმებენ, ავარიას აფიქსირებენ, და restart-ები ნელია.

რა ცვლის რიცხვს რეალურად:

  • მეტი CPU წილი გაშვებისას. გაშვება CPU-ზეა დამოკიდებული და უმეტესად ერთნაკადიანია. ნახევარბირთვიანი გეგმა შესამჩნევად უფრო დიდხანს გრძელდება, ვიდრე სრული ბირთვი, ამიტომ ყველაზე იაფი აჩქარება უფრო დიდი tier-ია და არა flag.
  • ნაკლები bean და ნაკლები auto-configuration. წაშალე starter-ები, რომლებსაც არ იყენებ. --debug ბეჭდავს დამთხვეულ auto-configuration-ების სიას, და თითოეული დროს ხარჯავს.
  • `-XX:TieredStopAtLevel=1`. JIT-ს სწრაფ compiler-ზე აჩერებს. გაშვება უფრო სწრაფია, მდგრადი throughput უფრო დაბალი. პატარა შიდა ხელსაწყოსთვის კარგია, დატვირთული API-სთვის - არა.
  • `-Xshare:auto` AppCDS-ით. Class data sharing არქივები გაშვებას აჩქარებს. Spring Boot 3.3 და უფრო ახალი მხარს უჭერს მის აწყობას jar-იდან; მიმდინარე ნაბიჯები Spring Boot-ის დოკუმენტაციაშია.
  • Lazy initialisation. spring.main.lazy-initialization=true bean-ების შექმნას პირველ გამოყენებამდე აგვიანებს. გაშვება უფრო სწრაფია, მაგრამ პირველი მოთხოვნა ამის საფასურს იხდის და არასწორად მორგებულ bean-ებს მომხმარებლები პოულობენ და არა გაშვების მომენტი. production-ისთვის გამორთული დატოვე.
  • დააინდექსე და გაუშვი migration-ები გაშვებამდე. ნელი გაშვება ხშირად Flyway migration-ია ან repository scan-ი და არა JVM.

თუ ცივი გაშვება შენი პრობლემის ძირია, Quarkus და Micronaut ზუსტად ამისთვისაა შექმნილი: ორივე dependency injection-ის ანალიზს build-ის დროს აკეთებს და JVM-ზე ერთ-ორ წამში ეშვება. native image 100 ms-ზე ნაკლებამდე ამცირებს, საფასურად კი გრძელი build-ები, რომლებსაც რამდენიმე გიგაბაიტი მეხსიერება სჭირდება, ამიტომ ისინი CI-ში გააკეთე.

ამოცანების გადაჭრა#

`UnsupportedClassVersionError: ... has been compiled by a more recent version of the Java Runtime`. jar-ი უფრო ახალი Java-სთვისაა აკომპილირებული, ვიდრე სერვერზე მუშაობს. აკომპილირე --release 17-ით, ან სერვერისთვის აირჩიე Java 21 ან 25.

`no main manifest attribute, in app.jar`. ატვირთე თხელი jar-ი, ჩვეულებრივ Maven-ის .original ფაილი ან Gradle-ის -plain.jar. ატვირთე repackage-ირებული.

`Web server failed to start. Port 8080 was already in use`. აპლიკაცია ცდილობს port-ზე მიბმას, რომელიც შენი არ არის, ან ორი ასლი მუშაობს. დააყენე SERVER_PORT გამოყოფილ port-ზე და დარწმუნდი, რომ ძველი პროცესი გაჩერდა.

სერვერი ისევ და ისევ იტვირთება stack trace-ის გარეშე. პროცესმა კონტეინერის მეხსიერების ლიმიტი გადააჭარბა. შეამცირე heap, მაგალითად MaxRAMPercentage=55, და შეამოწმე მეხსიერების გრაფიკი ლიმიტის წინააღმდეგ. გახსოვდეს, რომ საათში სამი restart გაფრთხილებას იწვევს.

`java.lang.OutOfMemoryError: Java heap space`. heap ძალიან მცირეა, ან რაღაც გროვდება. გაზარდე heap, თუ გეგმას ადგილი აქვს, ან გადაიღე heap dump -XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=/home/container/dump.hprof-ით და გახსენი profiler-ში. dump-ები შემდეგ წაშალე; ისინი heap-ის ტოლია.

`HikariPool-1 - Connection is not available, request timed out after 30000ms`. pool ამოწურულია. maximum-pool-size-ის გაზრდამდე მოძებნე ნელი query-ები და open-in-view, რომელიც connection-ებს იკავებს.

`Connection refused` ან `UnknownHostException` ბაზისთვის. SPRING_DATASOURCE_URL-ში host ან port არასწორია. ჩააკოპირე ისინი ბაზის გვერდიდან და გაიხსენე, რომ MySQL-ისა და PostgreSQL-ის port-ები განსხვავდება.

`FATAL: password authentication failed`. credentials არასწორია ან paste-იდან ზედმეტი ჰარები აქვს. თავიდან აკრიფე ცვლადი.

Redirect-ები `http://`-ზე მიდის და session-ები არ რჩება. forwarded header-ები იგნორირდება. დააყენე server.forward-headers-strategy=native და თუ მაინც არ გამოდის, შეამოწმე server.tomcat.remoteip.internal-proxies.

Bad gateway proxy-დან, მაშინ როცა კონსოლი ამბობს, რომ აპლიკაცია მუშაობს. აპლიკაცია 127.0.0.1-ზეა მიბმული, არასწორ port-ზე უსმენს, ან ჯერ ეშვება. განსჯამდე დაელოდე ხაზს Started ... in N seconds.

Environment variable იგნორირებულია. შეამოწმე relaxed binding. SPRING_DATASOURCE_HIKARI_MAXIMUM_POOL_SIZE არ მუშაობს; SPRING_DATASOURCE_HIKARI_MAXIMUMPOOLSIZE მუშაობს.

FAQ#

რამდენი მეხსიერება სჭირდება Spring Boot აპს?

პატარა API JPA-თი და security-ით ჩვეულებრივ 256-დან 384 MB-მდე heap-ს და 600-800 MB კონტეინერს ითხოვს, ამიტომ 1 GB tier მარტივ სერვისს ეყოფა, 2 GB კი კომფორტულია. უფრო დიდი cache-ები, დიდი შედეგების სიმრავლე და ბევრი thread ამას ზრდის. გაზომე მეხსიერების გრაფიკით და ვარაუდს ნუ დაეყრდნობი.

-Xmx გამოვიყენო თუ MaxRAMPercentage?

გამოიყენე MaxRAMPercentage, როცა გეგმის შეცვლას ელი, რადგან ის ლიმიტს მიჰყვება. გამოიყენე -Xmx, როცა გაქვს გაზომილი რიცხვი, რომლის ფიქსირებულად დატოვებაც გინდა. თუ ორივეა დაყენებული, -Xmx პრიორიტეტულია.

Quarkus ან Micronaut-ის იმავე გზით გაშვება შემიძლია?

დიახ. ორივე ქმნის jar-ს, რომელიც java -jar-ით ეშვება, heap flag-ები იდენტურია, განსხვავება კი property-ების სახელებშია: quarkus.http.port და micronaut.server.port server.port-ის ნაცვლად. მათი უფრო სწრაფი გაშვება პატარა გეგმებზე უპირატესობაა.

რომელი Java ვერსია ავირჩიო?

ყველაზე უსაფრთხოა აკომპილირება დამოკიდებულებების მიერ მხარდაჭერილი ყველაზე დაბალი ვერსიისთვის, გასაშვებად კი Java 21 კარგი ნაგულისხმევია. Boot 3-ს სჭირდება მინიმუმ 17. 17-ისთვის აწყობილი jar მუშაობს 21-ზე და 25-ზე, მაგრამ არა პირიქით.

მჭირდება თუ არა reverse proxy მის წინ?

გჭირდება რაღაც, რაც TLS-ს დაასრულებს და სერტიფიკატებს განაახლებს, და proxy სლოტი სწორედ ამას აკეთებს. დააყენე server.forward-headers-strategy, რათა შენმა აპლიკაციამ ნამდვილი კლიენტის მისამართი და სწორი scheme დაინახოს.

რატომ არის ჩემი აპი ნელი პირველი წუთის განმავლობაში?

JVM bytecode-ს ჯერ interpreter-ში უშვებს და ცხელ გზებს კომპილაციას უკეთებს, როგორც სწავლობს, ამიტომ პირველი მოთხოვნები უფრო ნელია და warm-up-ის დროს CPU გამოყენება მაღალია. პატარა CPU წილი მას ახანგრძლივებს. თუ readiness check-ები ამ პერიოდში ვარდება, მიეცი მათ უფრო გრძელი საწყისი დაყოვნება.