Przejdź do treści
Praktyczne poradniki dla programistów
PORADNIK • PROGRAMISTA JAVA

GraalVM Native Image w Spring Boot 3: kiedy kompilacja AOT ma sens, a kiedy szkodzi?

Rate this post

Zespół dostaje alert z platformy: nowa instancja serwisu Spring Boot wstaje zbyt długo, autoskaler dokłada pody, a limit pamięci na węźle jest już napięty. Ktoś proponuje GraalVM Native Image jako szybki przełącznik w pipeline. Zanim pojawi się osobny profil builda, potrzebna jest decyzja: czy kompilacja AOT usuwa realny koszt operacyjny, czy tylko przenosi go na czas budowania, testów i diagnostyki.

GraalVM Native Image w Spring Boot 3 — decyzja architektoniczna, nie modny przełącznik

Native Image nie jest kolejnym flagą optymalizacji w application.yml. To inny model dostarczania tej samej aplikacji: zamiast pakietu uruchamianego na JVM powstaje binarny plik wykonywalny, w którym duża część pracy Springa odbywa się wcześniej. W Spring Boot 3 ten model jest wspierany oficjalnie, ale nadal wymaga świadomego doboru usług, a nie masowej migracji całego monolitu. [3]

Co zmienia AOT względem klasycznego uruchamiania JVM

Klasyczna aplikacja Java startuje na maszynie wirtualnej. Kod bajtowy jest ładowany, a kompilator JIT optymalizuje gorące ścieżki dopiero w trakcie działania. Dlatego pierwsze sekundy — czasem minuty przy ciężkim kontekście Springa — są drogie, a rozgrzana JVM potrafi później być bardzo szybka.

GraalVM Native Image działa w modelu ahead-of-time. Kompilator analizuje osiągalny kod, zamyka zbiór klas i produkuje natywny plik wykonywalny. Spring AOT wchodzi wcześniej: na etapie builda przetwarza kontekst aplikacji, generuje kod inicjalizujący beany i ogranicza skanowanie, które normalnie dzieje się przy każdym starcie. Efekt uboczny jest konkretny — szybszy cold start i zwykle niższy narzut pamięci na początku życia procesu. [4]

Kompromis jest równie konkretny. Build trwa dłużej, obraz bywa większy, a część dynamiki Javy przestaje być darmowa. Refleksja, dynamiczne proxy, JNI i ładowanie zasobów muszą być znane w czasie kompilacji albo opisane hintami. Diagnostyka też się zmienia: brak pełnego JIT oznacza inny profil CPU pod długim obciążeniem i mniej oczywiste ślady, gdy brakuje metadanych. [5]

Sens praktyczny jest wąski i mierzalny. Native Image ma dopasować model uruchomieniowy do obciążenia, w którym start, restart albo gęstość procesów naprawdę kosztuje. Nie zastępuje każdej usługi, która „po prostu działa na Javie”.

Co sprawdzić: czy problemem jest cold start, limit RAM albo koszt skalowania do zera — a nie ogólne „chcemy nowocześniejszy build”. Jeśli usługa stoi tygodniami i SLA nie zależy od czasu inicjalizacji, AOT rzadko jest pierwszym ruchem.

Krok 1: wybierz Native Image, gdy czas startu ma wymierną wartość

Czas startu ma wartość tylko wtedy, gdy ktoś za niego płaci: użytkownik czeka, kolejka rośnie, a platforma dolicza puste sekundy przy każdym skalowaniu. Bez tej zależności sama liczba „start w 80 ms zamiast 8 s” jest ciekawostką z benchmarku.

Scenariusze, w których AOT zwykle ma sens

Najczytelniejszy przypadek to serverless i zadania na żądanie. Funkcja, krótki job albo konsument zdarzeń, który wstaje, przetwarza paczkę i gaśnie, płaci za inicjalizację przy niemal każdym wywołaniu. Tu Spring Boot native skraca okno, w którym request wisi na cold starcie, a platforma trzyma zasób bez wykonywania logiki biznesowej.

Drugi przypadek to mikrousługi Java w Kubernetesie z agresywnym autoskalowaniem. Rolling update, częste restarty po błędach konfiguracji i HPA, które dokłada repliki przy skoku ruchu, mnożą koszt startu. Jeśli health check czeka na pełny kontekst Springa, a probe zabija pod zanim aplikacja zdąży obsłużyć ruch, szybszy start zmienia stabilność wdrożenia, nie tylko wykres.

Programista w słuchawkach koduje przy dwóch monitorach
Źródło: Pexels | Autor: hitesh choudhary

Trzeci przypadek to ciasny budżet pamięci. Niższy narzut procesu pozwala zmieścić więcej replik na węźle albo zejść z klasy maszyny. To ma sens przy wielu lekkich usługach, nie przy jednej aplikacji, która i tak trzyma duży cache albo pulę połączeń większą niż oszczędność z Native Image.

Lekki serwis webhooków, budzony tylko przy skokach ruchu z systemu płatności, skorzysta zwykle bardziej niż stale obciążony backend zamówień. Ten drugi po rozgrzaniu żyje z optymalizacji JIT i rzadko płaci za start.

  • Policz uruchomienia na dobę: deploye, restarty, skalowanie, crony.
  • Zmierz medianę oraz wysokie percentyle czasu do gotowości, nie sam czas procesu JVM.
  • Sprawdź, czy start wchodzi do SLA — opóźnienie pierwszego requestu, czas joba, okno niedostępności przy rollout.

Co sprawdzić: jeśli liczba startów jest niska, a percentyl startu nie dotyka użytkownika ani kosztu platformy, krok 1 kończy się odmową. Nie buduj native „na zapas”.

Krok 2: nie zakładaj, że niższe zużycie RAM oznacza automatycznie niższy koszt

Mniejsze RSS przy starcie dobrze wygląda w porównaniu kontenerów. Rachunek platformy składa się jednak z CPU pod obciążeniem, czasu CI, rozmiaru obrazu, cache warstw i czasu, który zespół spędza na błędach wychodzących dopiero w binarce.

Jak uczciwie porównać aplikację JVM i obraz natywny

Porównanie ma sens tylko przy identycznym limicie kontenera, tej samej bazie, tym samym profilu ruchu i tej samej konfiguracji pul. Osobno zapisuj: czas startu, pamięć w spoczynku, CPU przy stałym RPS, przepustowość, opóźnienia, rozmiar obrazu i czas pipeline.

Native Image często obniża pamięć na starcie, bo nie niesie pełnego runtime JVM w tej samej formie. Pod długotrwałym obciążeniem JIT potrafi dogonić albo przegonić binarkę na gorących ścieżkach — szczególnie tam, gdzie kod jest polimorficzny, a profil ruchu stabilny przez wiele godzin. Usługa działająca tygodniami prawie nie korzysta z przewagi cold startu.

Do kosztu dolicz operacje. Kompilacja GraalVM w CI bywa liczona w minutach, nie w sekundach. Bez cache warstw i bez współdzielonego toolchainu każdy branch płaci ten czas ponownie. Diagnostyka produkcyjna wymaga ludzi, którzy czytają logi braku metadanych, a nie tylko heap JVM.

WymiarKlasyczna JVMSpring Boot native
StartWolniejszy, zależny od kontekstuZwykle wyraźnie krótszy
Pamięć na starcieWyższy narzutCzęsto niższy
Długie obciążenieJIT poprawia gorące ścieżkiProfil stabilny, czasem słabszy peak
Build CIKrótkiDługi, wrażliwy na cache
Dynamika koduRefleksja działa w runtimeWymaga znanych hintów

Typowy błąd tego kroku: wybranie wariantu z niższym RAM bez testu przepustowości. Kontener mieści się w limicie, ale p95 latency rośnie i autoskaler i tak dokłada repliki — oszczędność pamięci znika w CPU.

Co sprawdzić: oba artefakty na tym samym ruchu, z tym samym limitem i tą samą bazą. Decyzja bez kolumny „CPU pod obciążeniem” i „czas CI” jest niepełna.

Krok 3: prześwietl zależności pod kątem refleksji, proxy i dynamicznego ładowania klas

Kompilacja, która przechodzi, nie oznacza, że każda ścieżka biznesowa przejdzie. Native Image zamyka świat w buildzie. To, czego nie zobaczył — albo czego nie dostał w konfiguracji — potrafi wybuchnąć przy rzadkim endpoincie.

Najczęstsze źródła problemów kompatybilności

Kompilator musi znać zachowania odkrywane normalnie w runtime. Reflection, serializacja, dynamiczne proxy, JNI, odczyt zasobów z classpath i generowanie klas są na liście ryzyk. Frameworki skanujące classpath „w locie” też przestają być przezroczyste.

Spring Boot 3 i Spring AOT pokrywają typowe elementy samego Springa: wiele beanów, warunków i konfiguracji dostaje wygenerowany kod oraz hinty. To nie obejmuje automatycznie każdej biblioteki zewnętrznej, własnego integratora XML/JSON ani starszego modułu, który ładuje klasy po nazwie z properties.

Krytyczne miejsca w praktyce to: własne ObjectMapper z modułami rejestrowanymi warunkowo, klienci HTTP z dynamicznymi proxy, sterowniki używające JNI, deserializacja komunikatów z kolejki oraz endpointy administracyjne wołane raz na deploy. Build zielony, a wariant błędu z nietypowym payloadem kończy się brakiem klasy albo metody dopiero na produkcji.

Nie dokładaj hintów „na ślepo” do całego pakietu. Szerokie otwarcie refleksji powiększa binarkę i osłabia analizę osiągalności. Lepsza ścieżka: raport kompatybilności starterów, lista bibliotek z refleksją, testy ścieżek rzadkich, potem wąskie runtime hints tylko tam, gdzie test native pada.

  1. Wypisz startery i biblioteki spoza ekosystemu Springa.
  2. Oznacz te, które używają refleksji, proxy albo JNI.
  3. Uruchom testy native na ścieżkach rzadkich, nie tylko na happy path.
  4. Dopisz hint dopiero po konkretnym błędzie, z komentarzem, który endpoint go wymaga.

Co sprawdzić: czy testy obejmują deserializację, bezpieczeństwo, profile i endpointy używane rzadko, ale krytycznie. Zielona kompilacja bez tych testów nie jest sygnałem gotowości.

Krok 4: zacznij od małej usługi i zbuduj powtarzalny proces AOT

Pierwszy native build na największym systemie w repozytorium uczy głównie cierpliwości. Pilot na małej usłudze uczy procesu: cache, profile Maven albo Gradle, testy binarki i rollback do JVM.

Minimalna ścieżka wdrożeniowa w Spring Boot 3

Krok 1. Wybierz serwis o małej liczbie integracji, stabilnych kontraktach i jasnym profilu startu. Dobry kandydat: webhook, lekki BFF, konsument jednej kolejki. Zły kandydat na start: monolit z dziesiątkami warunkowych modułów i ręcznym skanowaniem pakietów.

Zbliżenie kolorowego kodu programistycznego na ekranie komputera
Źródło: Pexels | Autor: Godfrey Atima

Krok 2. Rozdziel build JVM i build native. W Maven albo Gradle zostaw zwykły pakiet oraz profil albo task korzystający z narzędzi Spring Boot i GraalVM. Dzięki temu porównanie z kroku 2 nie wymaga przebudowy historii gita, a rollback nie czeka na nowy pipeline. [1]

Krok 3. Testy integracyjne odpalaj także przeciw binarce, nie tylko przeciw aplikacji na JVM. Ten sam zestaw danych, te same profile, ten sam sekret konfiguracji. Różnica w zachowaniu AOT wychodzi właśnie tutaj, nie w teście jednostkowym mockującym kontekst.

Krok 4. Opublikuj obraz kontenerowy i sprawdź go w docelowej orkiestracji: limity, proby, zmienne środowiskowe, czas do Ready. Lokalny sukces binarki na laptopie nie zastępuje węzła z innym CPU i innymi limitami cgroup.

Przez etap pilotażowy zostaw oba artefakty. JVM jest punktem odniesienia dla metryk i awaryjną ścieżką, gdy native wyłoży integrację, której testy nie pokryły. Usunięcie wariantu JVM po pierwszym zielonym buildzie zamyka rollback w momencie, gdy jest najbardziej potrzebny.

Typowy błąd: jeden wspólny pipeline, w którym native jest obowiązkowy na każdym commicie, zanim cache i czas kompilacji są opanowane. Kolejka CI rośnie, zespół omija testy, a jakość hintów spada.

Co sprawdzić: powtarzalność builda na CI, czas kompilacji z cache i bez niego, health checki, logowanie, konfiguracja środowiskowa oraz migracje bazy wykonywane przy starcie. Jeśli migracja zakłada długi start JVM, probe trzeba ustawić od nowa, nie kopiować ze starego Deploymentu.

Krok 5: zaplanuj testy, obserwowalność i debugowanie zanim binarka trafi na produkcję

Udany build native jest warunkiem koniecznym, nie dowodem gotowości. Błędy metadanych są tańsze przy kompilacji i w teście integracyjnym niż w pierwszym ruchu produkcyjnym po rollout.

Inny model diagnozowania problemów niż w klasycznej JVM

Zestaw testów native powinien przejść pełny przepływ: konfiguracja, bezpieczeństwo, połączenie z bazą, kolejka, serializacja komunikatów, integracje HTTP. Sam kontekst Springa bez I/O nie wykryje braku sterownika, złego hintu dla DTO ani problemu z zaufanym magazynem certyfikatów.

Na JVM stack trace, heap dump i debugger przyłączony do procesu są domyślną ścieżką. W binarce GraalVM część tych narzędzi albo nie istnieje, albo wymaga świadomej decyzji już przy kompilacji. Ślady bywają krótsze, nazwy klas po reachability analysis nie zawsze odpowiadają temu, co widać w IDE, a brak metadanych kończy się wyjątkiem w stylu „class not found” albo „no default constructor” dopiero na konkretnym payloadzie. Zespół, który pierwszy raz widzi taki log na produkcji, traci czas na szukanie biblioteki, której w obrazie po prostu nie ma — bo kompilator uznał ją za nieosiągalną.

Obserwowalność też nie przenosi się sama. Spring Boot Actuator, metryki Micrometer i tracing OpenTelemetry działają w native, o ile startery są na liście zależności i konfiguracja nie polega na skanowaniu, którego AOT nie wykonał. Agent Javy doczepiany w runtime — klasyczny profiler, niektóre agenty APM — w binarce nie zadziała. Zamiast niego potrzebny jest build z wbudowanym eksporterem albo osobny sidecar. Logi muszą wychodzić na stdout w formacie, który zbiera platforma; plik wewnątrz kontenera efemerycznego znika razem z podem.

Co wdrożyć, zanim native zobaczy ruch

Testy na binarce, nie obok niej. Ten sam zestaw, który przechodzi na JVM, odpal na obrazie native w CI: bezpieczeństwo (wygasły token, zła rola), deserializacja nietypowego JSON-a, timeout do bazy, wiadomość z kolejki o nieznanym typie, profil prod bez lokalnych domyślnych haseł. Happy path nie łapie braku hintu na klasie błędu.

Przykład. Endpoint zwraca 200 na poprawnym zamówieniu, a wariant z polem polimorficznym type=REFUND rzuca wyjątek brakującej klasy dopiero w binarce. Test JVM tego nie pokaże, bo Jackson dociągnie podtyp refleksją. Jeden przypadek w teście native jest tańszy niż rollback po pierwszym zwrocie płatności.

Sygnały startu i zdrowia. Czas do Ready, liczba nieudanych prob, wersja binarki w logu startu i metryka pierwszego requestu. Jeśli probe startup jest skopiowany z JVM i czeka kilkadziesiąt sekund „na wszelki wypadek”, zespół nie zobaczy regresji startu. Ustaw próg blisko zmierzonego czasu native i alarm, gdy wzrośnie, nie gdy przekroczy starą, luźną wartość.

Ścieżka debugowania z góry. Ustal, co wolno włączyć w buildzie produkcyjnym: czy stack trace ma numery linii (kosztuje rozmiar i czas kompilacji), gdzie lądują logi braku metadanych, kto ma prawo odpalić wariant JVM tego samego tagu. Rollback do JVM z kroku 4 jest bezużyteczny, jeśli nikt nie wie, którym obrazem go wykonać i jak porównać trace ID między oboma artefaktami.

  • Build native w CI na gałęzi release, z tym samym JDK GraalVM co lokalnie.
  • Testy integracyjne przeciw binarce: auth, serializacja, baza, kolejka, HTTP na zewnątrz.
  • Metryki i logi bez agenta JVM; wersja i commit w każdym logu startu.
  • Probe i limity ustawione od zmierzonego startu native, nie skopiowane z Deploymentu JVM.
  • Udokumentowany rollback: który tag JVM, jakie różnice w zachowaniu, kto decyduje.

Co sprawdzić: czy po celowym wyłączeniu hintu dla jednego DTO test native pada w CI, a nie dopiero na produkcji. Jeśli zespół nie umie tego błędu odtworzyć i nie ma obrazu JVM o tym samym commicie, binarka nie jest gotowa — niezależnie od zielonego builda.

GraalVM Native Image w Spring Boot 3 — decyzja architektoniczna, nie modny przełącznik

Native Image w Spring Boot 3 to zamknięcie świata aplikacji w momencie builda. Spring AOT generuje kod inicjalizacji, a GraalVM składa binarkę, która nie dociąga klas „w locie”. To nie jest flaga -Dspring.aot.enabled do odhaczenia na koniec sprintu. To wybór modelu uruchamiania: szybszy start i zwykle mniejszy zestaw klas w zamian za dłuższy build, sztywniejsze zależności i inną ścieżkę, gdy coś nie wejdzie do analizy osiągalności. [2]

Programista pracujący nad kodem przy dwóch monitorach w nowoczesnym biurze
Źródło: Pexels | Autor: Zayed Hossain

Kompilacja AOT ma sens tam, gdzie koszt startu JVM jest widoczny w produkcie albo w rachunku za infrastrukturę. Szkodzi tam, gdzie proces żyje godzinami, ruch jest stały, a zespół płaci za kompilację i hinty bez zysku, który da się pokazać na liczbie. Modny przełącznik wygląda tak: jeden zielony build native, komunikat „jesteśmy cloud native” i brak odpowiedzi, który wskaźnik miał się poprawić.

Zanim padnie „włączamy native wszędzie”, ustal trzy rzeczy na piśmie. Po pierwsze, który sygnał ma się zmienić: czas do pierwszego poprawnego requestu, czas scale-out, koszt krótkich jobów, nie „RAM wygląda niżej na wykresie idle”. Po drugie, które usługi są w ogóle kandydatami — mało integracji, stabilny kontrakt, rzadka refleksja spoza Springa. Po trzecie, czego nie ruszacie w tej iteracji: monolitu z warunkowym skanowaniem pakietów, bibliotek ładujących klasy po nazwie z properties, ścieżek, których nikt nie umie opisać testem.

JVM zostaje narzędziem, nie wstydliwym fallbackiem. Długie procesy, peak throughput i biblioteki oparte na dynamicznym ładowaniu klas często wychodzą lepiej na klasycznym obrazie. Native wygrywa, gdy instancja często powstaje i szybko znika. Jeśli oba artefakty nie mają właściciela metryki, decyzja jest estetyczna.

Co sprawdzić: czy w ticketcie jest nazwany zysk (start, skala, job), kandydat usługi i jawna lista „nie teraz”. Brak tej trójki oznacza, że AOT jest ozdobą pipeline’u.

Krok 1: wybierz Native Image, gdy czas startu ma wymierną wartość

Czas startu uzasadnia native tylko wtedy, gdy ktoś za niego płaci: użytkownik czekający na zimną instancję, autoskaler trzymający nadmiar replik „na wszelki wypadek” albo job, którego właściwa praca jest krótsza niż rozgrzewanie JVM. Sam fakt, że binarka wstaje szybciej na laptopie, nie jest biznesowym argumentem.

Gdzie sekundy startu wchodzą do rachunku

Skala od zera i częsty scale-out. Funkcja albo kontener, który wstaje na żądanie, mnoży start przez liczbę wywołań. Jeśli JVM potrzebuje kilku–kilkunastu sekund do pierwszego sensownego response, a ruch jest szarpany, native bywa różnicą między „użytkownik czeka” a „użytkownik nie zauważa”. Przy jednej replice, która żyje tydzień, ta sama różnica ginie w szumie.

Krótkie joby i zadania wsadowe. Migracja nocna trwająca 40 minut nie zyska na ścięciu startu z 12 s do 200 ms. Job co minutę, który robi jedno wywołanie HTTP i kończy pracę w pół sekundy, oddaje większość slotu na rozruch JVM. Tu AOT skraca jednostkę pracy, nie tylko wykres startu.

Wiele krótko żyjących środowisk. Preview na pull request, test kontraktowy stawiający świeży kontener, rollout, w którym dziesiątki podów wstaje naraz. Zysk jest operacyjny: kolejka środowisk się nie zapycha, a okno, w którym stary i nowy Deployment nachodzą na siebie, jest krótsze. To wciąż trzeba zmierzyć na docelowym limicie CPU, nie na maszynie dewelopera.

Liczy się czas do pierwszej poprawnej odpowiedzi, nie PID w logu. Proces może wystartować w 80 ms i kolejne 2 s spędzić na puli połączeń, migracji albo pierwszym żądaniu, które dogrzewa cache. Metryka „process started” bez requestu biznesowego zawyża zysk native.

Przykład. Lekki BFF wołany przy skoku ruchu: JVM do pierwszego 200 potrzebuje 9 s, native około 0,3 s, autoskaler dokłada replikę przy każdym piku. Przy dwudziestu takich startach dziennie zespół albo trzyma zapas replik, albo akceptuje dziewięć sekund degradacji. Ten sam BFF na stałych dwóch replikach, bez scale-to-zero, oddaje te 8,7 s raz na deploy. Drugi przypadek nie uzasadnia osobnego profilu AOT.

Odrzuć native na starcie, gdy: proces jest zawsze włączony i rzadko się restartuje; praca po starcie trwa wielokrotnie dłużej niż różnica startu; pierwsze żądanie i tak czeka na zewnętrzny system wolniejszy niż JVM; zespół nie ma liczby „przed”, z którą porówna binarkę. Szybszy start, którego nikt nie wstawi do SLO ani do kosztu nadmiarowych replik, jest ciekawostką z README.

  1. Zmierz czas do pierwszego poprawnego requestu na JVM, na limicie CPU z produkcji, nie na idle laptopa.
  2. Policz, ile razy dziennie instancja realnie wstaje: deploy, skala, cron, preview.
  3. Porównaj ten iloczyn z kosztem dłuższego CI i utrzymania drugiego artefaktu.
  4. Zostaw native tylko tam, gdzie iloczyn jest widoczny w SLO albo w liczbie trzymanych replik.

Co sprawdzić: jedna tabela na kandydata — start JVM, start native, liczba startów na dobę, czy ruch jest przerywany. Puste pole „liczba startów” oznacza, że Krok 1 nie został podjęty, tylko założony.

Pytania od czytelników

Pytanie czytelnika

Czy Native Image przyspieszy każdą aplikację Spring Boot?

Odpowiedź redakcji: Nie. Największe korzyści dotyczą usług często uruchamianych, skalowanych do zera lub ograniczonych pamięcią. Przy długotrwałym obciążeniu przewaga może być niewielka.
Pytanie czytelnika

Jak sprawdzić, czy biblioteka jest gotowa do działania w Native Image?

Odpowiedź redakcji: Sprawdź jej kompatybilność, sposób użycia refleksji, proxy i JNI, a następnie uruchom testy native obejmujące także rzadkie ścieżki i nietypowe dane.
Pytanie czytelnika

Czy można zostawić równolegle wariant JVM i native?

Odpowiedź redakcji: Tak. Rozdzielenie buildów pozwala porównywać wydajność, zachować prosty rollback i nie uzależniać całego procesu wdrożeniowego od jednego artefaktu.
Pytanie czytelnika

Jakie metryki porównać przed decyzją o migracji?

Odpowiedź redakcji: Porównaj czas startu i gotowości, pamięć, CPU przy stałym ruchu, przepustowość, opóźnienia, rozmiar obrazu oraz czas i koszt pipeline.

Źródła

Tematy:Trendy w programowaniu