Kennis Bespaar tot wel 70% op je kosten met GraalVM Native Images

Bespaar tot wel 70% op je kosten met GraalVM Native Images

In november 2022 is SpringBoot 3.0 uitgekomen, met onder andere de nieuwe feature 'Native Images'. Deze feature is inmiddels bekend bij veel developers, vanwege de verbeterde performance en resource management. Ook wij wilden deze feature uitproberen en daarom hebben we onderzoek gedaan naar het gebruik van SpringBoot Native Images. In deze blog delen wij graag onze bevindingen! 

 

Wat is een Native Image?

Voordat je aan de slag kunt gaan met Native Images, moet de applicatie eerst geüpdatet worden naar 2.7.x en daarna naar 3.x. Houd ook rekening met de rename van 'Javax' naar 'Jakarta'. Hierna ben je klaar om aan de slag te gaan met Native Images!

Sinds 2018 richt GraalVM zich op het verbeteren van Java-applicaties. Native Images maken hier onderdeel van en dit zijn binaries die in vergelijking met een JVM extreem snel opstarten dankzij Ahead of Time-compilaties. Bij het bouwen van deze Native Images, wordt er een reachability scan uitgevoerd. Zo wordt er bijgehouden welke code wel én niet wordt gebruikt. Uiteindelijk wordt alleen de gebruikte code in de Native Image opgenomen. Dit heeft als gevolg dat de grootte van een gecompileerde Native Image veel kleiner is dan die van een normale. 

Naast dat de grootte van een Native Image veel kleiner is, zorgt het ook voor een verbeterde beveiliging. Door minder code, zijn er immers minder exploitatiemogelijkheden. Daarbij wordt een Native Image voor een specifiek besturingssysteem en CPU-architectuur gebouwd, omdat de Java bytecode wordt gecompileerd naar machinecode. Dit maakt het uitvoeren van een Native Image executable enorm snel door Ahead of Time-compilaties. Er is dus geen JVM of JRE/JDK nodig voor het draaien van een executable, waardoor dit de opstarttijd aanzienlijk verkort. 

 

Hoe bouw je een Native Image in Spring Boot? 

Met de release van SpringBoot 3.0 heeft het Spring-team ervoor gezorgd dat reachability-metadata wordt gegenereerd voor beans die in de Spring-applicatie worden gemaakt. Normaal gesproken gebeurd dit via reflection tijdens runtime, maar bij een Native Image is dit niet mogelijk. Daarom moeten alle classes van tevoren bekend zijn voor GraalVM. Spring doet dit nu automatisch wanneer de org.graalvm.buildtools.native plugin is ingeschakeld in Gradle of Maven. Op basis van je applicatie, worden nieuwe classes automatisch gegenereert in de build directory

 

Screenshot 2023-03-09 at 11.21.47 (1)

Classes bevatten de volledige Spring-context in code uitgeschreven en GraalVM gebruikt deze classes weer in de reachability scan. Tijdens deze reachability scan doet Spring een best effort-analyse van applicaties en zal je sommige reflections missen die je wel nodig hebt. Hier moet je dan zelf hints voor toevoegen. Dit zijn manieren om de reachability scan te laten weten dat je een bepaalde class nodig hebt, en welke soort methodes. Nadat Spring deze analyses heeft uitgevoerd, en classes heeft gegenereerd, zijn er drie opties om applicaties te deployen in een container. Hieronder staan deze weergegeven, inclusief vervolgstappen om tot een deployable te komen. De stappen processAot en aotClasses zijn standaard opgenomen in taken, zoals assemble en bootJARZodra AOT gereed is, zijn AOT-gerelateerde bestanden gegenereerd. 

 

Native compile met een multi-stage Dockerfile

Als je een nativeCompile doet, krijg je een uitvoerbaar bestand dat je in een Docker-container kan zetten. Zorg ervoor dat de base image van deze container dezelfde architectuur heeft als de machine waarop je de Native Image bouwt. Een multi-stage Dockerfile met een GraalVM JDK, waarin de Native Image-functionaliteit is geïnstalleerd, kan hierbij helpen! GraalVM biedt hier ook zelf images voor aan. Deze vind je hier. Na het bouwen van een executable, kan je deze in een 'Run Image' kopiëren. Deze image mag een kleine distroless base image zijn, maar houd er rekening mee dat er een aantal libraries in moeten zitten, zoals GCLib.

Je kan een static Native Image bouwen waar alle benodigde libraries inzitten, maar dat is lastig. Een andere optie is dan een bijna compleet static Native Image, waarbij alleen een GCLib library vanuit de base image aanwezig is. Je kan dit doen met de optie: -H:+StaticExecutableWithDynamicLibC. Het voorbeeld hieronder toont een multi-stage Dockerfile, waarin de Native Image wordt gebouwd. Zowel de builder als de run image zijn multi-arch docker images. Dit betekent dat ze ook werken op ARM64-architecturen. 

 

Multi-stage dockerfile 

FROM ghcr.io/graalvm/native-image:ol8-java17-22.3.1 as builder
RUN microdnf install findutils -y
WORKDIR /workspace
COPY build/libs/backend-0.0.1-SNAPSHOT.JAR /workspace/app.JAR
RUN JAR -xf app.JAR
RUN native-image -H:+StaticExecutableWithDynamicLibC -H:Name=backend --enable-https --enable-http -cp .:BOOT-INF/classes:`find BOOT-INF/lib | tr '\n' ':'`
RUN mkdir /workspace/executable && mv backend /workspace/executable/backend
 
FROM gcr.io/distroless/base-debian11
 
HEALTHCHECK \
    CMD curl -o /dev/null --silent --write-out '%{http_code}\n' http://localhost:8081/actuator/health | grep 200
 
EXPOSE 8080
EXPOSE 8081
 
COPY --from=builder /workspace/executable/backend ./
ENTRYPOINT ["/backend"]

 

BootBuildImage

Sinds SpringBoot 3.0 heeft Spring een bootBuildImage-taak die ook werkt met Native Images en distroless run images. Hierbij worden Paketo Buildpacks gebruikt om een builder image te configureren en buildpacks in een bepaalde volgorde uit te voeren. De run image heeft als basis de volgende onderdelen: distroless-like bionic + glibc + openssl + CA certs. Deze base image is 17.5MB groot. Het voordeel hiervan is dat je zelf geen onderhoud hebt aan de dockerfile. Via normale Spring releases komen updates voor zowel de builder als run image binnen. Om images te bouwen voor ARM64 is een multi-arch builder, zoals dashaun/builder:tiny nodig. Een voorbeeld van de Gradle-configuratie is hieronder te zien.

BootBuildImage gradle configuratie tasks {         bootBuildImage {         builder.set("dashaun/builder:tiny")     } }   graalvmNative {     binaries {         named("main") {             javaLauncher.set(javaToolchains.launcherFor {                 languageVersion.set(JavaLanguageVersion.of(17))                 vendor.set(JvmVendorSpec.matching("GraalVM Community"))             })         }     } }


BootJAR

bootJAR is een taak die alles compileert in AOT, maar uiteindelijk komt er gewoon een JAR-bestand uit dat kan worden gestart. Om gebruik te maken van gegenereerde AOT-componenten, kun je de JAR opstarten met de volgende optie: Dspring.aot.enabled=true. Wees je ervan bewust dat je hierbij niet dezelfde opstartvoordelen hebt, als bij de andere optie. De bootJAR-taak kan handig zijn als je dit als aparte taak op GitLab wilt uitvoeren. Alle informatie die later nog nodig is om een Native Image te maken, zit in de JAR. 

Volgens
dit artikel is de AOT-gecompileerde JAR efficiënter dan een normale JAR. Hoewel we dit zelf niet hebben getest, lijkt het geen kwaad te kunnen om AOT-gecompileerde JAR's te gebruiken in plaats van normale JAR's. 

De eerste stap is om te kijken of je applicatie een AOT-gecompileerde JAR kan bouwen en of je deze kunt implementeren en uitvoeren. De optie voor AOT-enabled kan ook standaard worden ingeschakeld in de bootJAR-taak van Gradle of Maven.

Hoe werken compiler hints?

Spring genereert veel hints via de reachability metadata en de AOT classes, maar deze zijn niet altijd compleet. Om missende hints toe te voegen, zijn integratietesten belangrijk. Start de applicatie eerst als Native Image en voer vervolgens een integratietest uit om uitzonderingen, zoals classNotFound te bekijken. Bij TVIP ontbraken bijvoorbeeld hints op classes die werden teruggegeven door http-calls en moesten we hints toevoegen aan de methode waar de call werd gedaan, met behulp van @RegisterReflectionForBinding(RestOrganisation::class). We voegden handmatig custom (de)serializers toe aan de reflect-config.json file in de src/main/resources/META-INF/native-image-folder. Op deze manier wordt de IdentityldSerializer opgenomen in de JAR.

Reflect-config

{
  "name": "com.example.serializers.IdentityIdSerializer",
  "allPublicMethods": true,
  "allDeclaredFields": true,
  "allDeclaredMethods": true,
  "allDeclaredConstructors": true
},


Bij één van de endpoints in de applicatie werd XML geretourneerd. Dit zorgde voor problemen! We hebben toen gebruikgemaakt van een hints-agent die wordt aangeboden door GraalVM. Hiermee wordt tijdens het uitvoeren van de applicatie bijgehouden welke reflection nodig is. Vervolgens worden er 6 JSON-bestanden gegenereerd met alle hints die nodig zijn voor de calls die zijn gemaakt tijdens het uitvoeren van de applicatie met de agent. Het is dus belangrijk om goede integratietesten te hebben, zodat hints snel worden gegenereerd en er niets over het hoofd wordt gezien. Dit kan je doen met het volgende commando:


JAR met agent

java -Dspring.aot.enabled=true \
    -agentlib:native-image-agent=config-output-dir=/path/to/output/dir \
    -JAR backend-0.0.1-SNAPSHOT.JAR


In de output directory staan nu de JSON-files, deze moeten we nu verplaatsen naar de src/main/resources/META-INF/native-image-folder. Hierna kunnen we een nieuwe Native Image maken op één van bovenstaande drie manieren. Zodra je dit doet, werkt ook het endpoint dat XML retourneert. De Native Image is nu 25MB groter geworden door de extra classes en methodes die via hints worden toegevoegd. Als er hints ontbreken, dan zie je dit soort exceptions in de logging:

java.lang.NoSuchMethodError:
io.github.threetenjaxb.core.LocalDateXmlAdapter.<init>() 

Wat zijn de verschillen tussen normale JVM-image en Native Image?

Nu we een volledig werkende Native Image hebben, kunnen we een vergelijking maken van een normale JVM-image en de Native Image. In onderstaande tabel vind je de vergelijking tussen de normale docker image van TVIP en de Native Image.

 

Statistiek JVM Native Image  Verschil Verschil in percentage
Docker image groote 474 MB 145 MB -329 MB -69,4 %
Memory gebruik in idle 329 MB 81 MB -248 MB -75,3 %
Memory gebruik na 10 requests 423 MB 113 MB -310 MB -73,2 %
Startup-tijd 4200 ms 173 ms -4000 ms -95,8 %
CPU usage op XML endpoint met 10000 elementen

Zonder warmup:    0,141

Met warmup:    0,05

0,03

Zonder warmup:    -0,111

    Met warmup:    -0,02

Zonder warmup :      -79 %

Met warmup:      -40 %

 

Van GraalVM wisten we al dat het snel opstart. Dit is ideaal voor microservices die schalen op basis van gebruik! Zo kan bijvoorbeeld Kubernetes tijdens piekgebruik snel nieuwe services opstarten om requests te verwerken. Daarnaast kan GraalVM gebruikt worden in 'lambda functies', zoals AWS deze aanbiedt. Hierbij wordt per request een microservice opgestart om een kleine taak uit te voeren. Hoe sneller deze opstart, hoe sneller het request afgehandeld kan worden. 

Bij de meeste cloud-diensten betaal je voor de hoeveelheid resources die je gebruikt. Bijvoorbeeld per MB werkgeheugen of voor CPU time. Ook betaal je voor de hoeveelheid opslag die je nodig hebt. Door gebruik te maken van Native Images ga je er hard op vooruit. Zoals je in het voorbeeld ziet, kan je tot 70% besparen! Als je op een cloud-platform en per gebruikte resource betaalt, levert Native Images je dus direct geld op. 

 

Stappenplan om gebruik te maken van Native Images

Om gebruik te maken van Native Images kan je het volgende stappenplan volgen, en bij iedere stap de applicatie volledig testen:

  1. Upgrade applicatie naar Spring Boot 2.7.x
  1. Upgrade applicatie naar Spring Boot 3.x (let op javax → jakarta wijziging)
  2. Voeg plugin toe: org.graalvm.buildtools.native
  3. Bouw een JAR met AOT processed files erin (gradle bootJAR of mvn package)
  4. Start de JAR met -Dspring.aot.enabled=true, zodat de AOT processed fies gebruikt worden
  5. Kijk of alle testen nog werken door (Gradle-check of Maven-test)
    1. Hangt hier de processTestAOT taak? Dan moet je de lifecycle management van je extensions/initializers controleren. Als hier bijvoorbeeld een testcontainer niet gestopt wordt die in een initializer wordt gestart, dan blijft deze taak hangen.
  6. Voeg de agent toe aan het start-up commando van de JAR en genereer de reflection configuratie door integratie testen ertegenaan te draaien
  7. Voeg de reflection configuratie toe aan de JAR door ze in src/main/resources/META-INF/native-image te zetten
  8. Bouw een Native Image op één van eerdergenoemde manieren (multi-stage Dockerfile, Native Image of bootBuildImage)
  9. Start de Native Image als executable en als docker image en draai de integratietesten ertegenaan
  10. Controleer de memory usage na het uitvoeren van de integratietesten, zodat je de limiet van je container op productie kan verlagen

 

Aandachtspunten bij het gebruik van Native Images

Net als elke feature, brengen ook Native Images een paar nadelen met zich mee. Allereerst is de buildtijd langer. Ons relatief kleine applicatie duurde 5 tot 7 minuten totdat we vanaf scratch een Docker-image gebouwd hadden. Cross compilation is ook niet mogelijk. Dit betekent dat je geen arm64 images kan bouwen voor een Intel x86-machine. Wel kan je op een multi stage een Dockerfile maken waar de builder een arm64 image is. Je bent dan wel afhankelijk van emulatie. Echter, we hebben ervaren dat dit erg traag is. 

De reflection configuratie hebben we nu vooral in JSON gedaan en moet je handmatig aanvullen via eerdergenoemde emthoden. Dit betekent dat het erg foutgevoelig is; een kleine aanpassen aan je applicatie kan betekenen dat je tijdens de runtime classes mist. Zorg hier dus voor goede integratietesten die de Native Image testen. Volgens docs van Spring zijn er meerdere methoden, zoals unit testen die kijken of geteste classes in de hints voorkomen. De belofte van Spring is dat de reachability scan steeds beter gaat worden. Voor nu moet je dus goed opletten of de reflection-configuratie écht alles bevat wat nodig is om de applicatie te laten draaien!