Navigation
FIDA-Blog
Wissen - Success Stories - Whitepaper
newspaper Übersicht chevron_right Software-Entwicklung chevron_right Blog chevron_right Branchenübergreifend
SerPhoto
Blog

Schnell, schlank, cloudfähig: Java Native Build mit Quarkus

Quarkus zählt inzwischen zu den beliebtesten Java-Frameworks für moderne Cloud- und Container-Umgebungen. Es überzeugt durch eine schlanke API, starke Erweiterbarkeit und hervorragende Dokumentation. Besonders spannend ist das Native Build-Feature, mit dem sich Anwendungen in performante, kompakte Binaries übersetzen lassen – ideal für den Einsatz in der Cloud. In diesem Artikel geben wir einen kompakten Überblick: Wir erklären die zugrunde liegenden Technologien, zeigen, wie der Native Build in eine bestehende Quarkus-Anwendung integriert wird, worauf man achten sollte – und liefern abschließend Messwerte, die das Potenzial dieses Features belegen.

Was sind Native Build, und was versprechen wir uns daraus?

Native Build bezeichnet ein Kompilierprofil, an dessen Ende eine Native Executable steht, die ohne die Java Virtual Machine (JVM) ausführbar ist und somit ohne eine separate JDK-Installation auskommt. Dazu wird der bereits vorliegende JVM-Bytecode mittels einer speziellen Anwendung namens Mandrel direkt in Maschinen-Code übersetzt.

Die Idee, Code direkt vom Betriebssystem ausführen zu lassen, statt ihn jedes Mal von der JVM übersetzen zu lassen, ist nicht ganz neu, und wird bereits von der JVM selbst mittels der Just-In-Time-Kompilierung (JIT) genutzt. Eine Beschleunigung der einzelnen Routinen ist eher nicht zu erwarten. Dennoch erhoffen wir uns Zugewinne insbesondere beim Anwendungsstart und beim Speicherverbrauch, zwei Themen, die besonders im Cloud-Bereich eine Rolle spielen. Natürlich ließe sich dies ebenso mittels C++ oder moderner Low-Level-Sprachen wie Rust oder Go erreichen, aber zum einen sind die Frameworks und Bibliotheken im Java-Umfeld sehr viel ausgereifter, zum anderen können wir damit auf den großen Pool an Java-Entwicklern zurückgreifen und müssen weniger Leute anlernen.

Mandrel oder GraalVM?

Im Rahmen der Native Builds fällt meist der Name "GraalVM", und wird häufig austauschbar mit "Mandrel" verwendet. Tatsächlich handelt es sich dabei um zwei Distributionen der selben Software: GraalVM benutzt die Oracle JDK als Basis, während Mandrel auf der OpenJDK basiert. Wir werden im folgenden immer von Mandrel reden, da diese Variante u.a. mit dem Container-Images von Quarkus mitgeliefert wird. Generell sind beide Pakete aber im Nutzen austauschbar, genau wie es die JDK-Versionen sind.

Wie erstelle man einen Native Build?

Native Executables sind in der Regel Linux-Executables, und da Cross-Kompilierung nicht vorgesehen ist, haben Linux-Nutzer einen Heimvorteil. Aber auch Windows ist dank des Windows Subsystem for Linux (WSL) gut versorgt. Wirklich sinnvoll ist das Kompilieren auf dem Hostsystem jedoch nicht, da werden jede Menge zusätzliche Pakete benötigt werden, welche das System verümpeln, sofern sie sich überhaupt installieren lassen. Gerade bestimmte WSL-Setups, z.B. wenn sie von Rancher oder Docker Desktop kontrolliert werden, sind auf minimale Funktion ausgelegt und für diesen Prozess ungeeignet. Es ist folglich besser, den Container-Modus zu verwenden. Dabei ist aber Vorsicht geboten, da der notwendige Flag-Name davon abhängt, ob der Docker-Daemon lokal oder remote angesprochen wird. Letzteres ist insbesondere bei Windows-Hosts der Fall, da Docker dort entweder in WSL oder einer Virtuellen Maschine läuft und damit als remote angesehen wird. Bei unserer Maven-Version (3.9.6) hat sich zusätzlich das Problem ergeben, dass der in der Quarkus-Dokumentation benutzte Flag-Parameter -D nicht mit dem Flag-Namen klar kam, weshalb wir empfehlen, stattdessen den die lange Version --define zu benutzen:

java code

Wo liegt eigentlich das Problem?

Man möchte meinen, dass es keinen Unterschied geben sollte, da Container den kompletten Kompiliervorgang wegkapseln sollten. Aber irgendwie muss unser Quellcode (bzw. die im Zwischenschritt gebaute JAR-Datei) den Compiler erreichen, und genau hier liegt die Crux: lokale Container können diesen einfach über ein Volume einbinden, während remote Container die Daten mittels docker cp erhalten. Letzteres erzeugt aber zusätzliche Versionierungslayer, was für lokale Container vermieden werden soll.

Docker Images

Native Build ist sogar in der Lage, neben der reinen Executable zusätzlich Docker-Images zu produzieren. Dazu muss lediglich zusätzlich das Flag quarkus.container-image.build auf true gesetzt werden. Wurde das Projekt mit dem Quarkus-CLI-Tool erstellt, erhält man zusätzlich vorgefertigte Dockerfiles. Diese kann man direkt zum Bauen benutzen, indem man docker build -f src/main/docker/Dockerfile.native ausführt, was einem eine bessere Kontrolle über den Image-Namen gibt.

Was ist eigentlich mit Windows?

Generell ist es möglich, native Windows-Executables, sogenannte PE-Dateien zu erstellen. Leider haben sich uns dabei diverse Hindernisse in den Weg gestellt. Zum ersten benötigt man Visual Studio Community 2022 mit der C++-Workload, da bei den einfachen Buildtools kein C++-Compiler dabei ist. Danach muss man den Buildprozess entweder in einer von VSC bereitgestellten Environment-Konsole ausführen, oder über einen Batch-Call in der Datei %MANDREL_HOME%/bin/native-image.cmd aktivieren:

call "C:\Program Files\Microsoft Visual Studio\2022\Community\VC\Auxiliary\Build\vcvars64.bat"

Zuletzt wird man in den meisten Fällen noch dem Antivirus gut zureden. Dem gefällt es nämlich nicht, das während des Builds Executables in den User-Temp kopiert und ausgeführt werden:

Aus unserer Sicht lohnt sich dieser Aufwand nicht, da Windows als Zielplatform keine Relevanz hat. Insbesondere im Cloudbereich wird primär Linux verwendet.

Was ist Code-technisch zu beachten?

Im Allgemeinen gilt: wenn es möglich ist, eine eigenständige JAR-Datei zu bauen, die auf einer jungfräulichen JDK-Installation läuft, kann man daraus eine Native Executable machen. Artekfakte, welche reinen JVM-Bytecode produzieren, werden bei Quarkus direkt in die finale JAR-Datei gepackt, und da diese für den Native Build verwendet wird, ist ergo bereits alles wichtige vorhanden. Ein paar kleine Fallstricke gibt es dennoch.

SSL

Verschlüsselung ist aus dem modernen Web nicht mehr wegzudenken. Keine Webseite kommt heute ohne SSL aus, von APIs ganz zu schweigen. Umso verdrieslicher ist es, dass SSL von Haus aus nicht aktiviert ist. Man muss erst folgende Zeile in der applications.properties eintragen, damit es funktioniert:

quarkus.ssl.native=true

Tatsächlich gibt es eine ganze Reihe Quarkus-Erweiterungen, welche dieses Flag setzen, verlassen sollte man sich darauf aber nicht.

Reflection

Im Gegensatz zur JVM ist beim Native Build eine generelle Reflection nicht mehr möglich, da Mandrel ungenutzte Code-Pfade entfernt. Leider wird Reflection großflächig eingesetzt, u.a. bei der Serialisierung und bei ORMs wie Hibernate. Zum Glück bietet Quarkus eine Möglichkeit, Klassen zur Reflection anzumelden, die passende benannte Annotation @RegisterForReflection . Diese kann entweder an den entsprechenden Klassen angewandt werden, oder mittels des Parameters targets, was besonders bei Klassen aus externen Artefakten hilfreich ist:

reflections

Dependency Injection

Die Dependency Injection bereitet keine Probleme, solange sie im Hauptcode erfolgt, aber wenn Services aus einem Artefakt geladen werden sollen, müssen diese in der applications.properties angemeldet werden:

quarkus.index-dependency.kafka-quickstart-models.group-id=de.fida

quarkus.index-dependency.kafka-quickstart-models.artifact-id=kafka-quickstart-models

Dieses Problem tritt ebenfalls bei JVM Builds auf und ist kein Native-spezifisches Problem, liefert aber in allen Fällen undurchsichtige Fehlermeldungen, welche fälschlicherweise auf den Native Build zurückgeführt werden könnten.

AWS Lambda

Für AWS Lambda muss ein zusätzliches Artekfakt in der pom.xml eingetragen werden:

Dieses Artekfakt bietet weitere Plugins, welche sich in den Build-Prozess einhaken und das Ergebnis für AWS Lambda gefälliger machen. So wird hier anstatt einer reinen Executable eine function.zip angelegt, welche direkt im Control Panel von AWS hochgeladen werden kann. Außerdem muss eine Handler-Klasse angelegt werden:

TestLambda.java

Zudem ist es notwendig, den Handler in der application.properties zu hinterlegen:

application.properties

quarkus.lambda.handler=test

Diese Anpassungen sind nicht spezifisch für Native Build und werden genauso für Lambdas auf JVM-Basis benötigt.

Was ist bei der Ausführung zu beachten?

Während die Executable auf jedem modernen Linux lauffähig ist, macht sich die Kapselung mittel Docker in den meisten Fällen besser. Durch das weiter oben gebaute Image ist das Deployment zudem ein Kinderspiel. Für AWS Lambda sind sind ein paar mehr Einstellungen nötig. Zum einem muss die Runtime auf "Amazon Linux 2023" umgestellt werden, da es sich um eine reguläre Linux-Executable handelt und keine JVM gebraucht wird. Zum anderen muss die Umgebungsvariable DISABLESIGNALHANDLERS auf true gestellt werden, um einige Inkompatibilitäten zwischen Quarkus und der AWS Lambda Custom Runtime-Umgebung zu beheben. Zum Deployment kann dann einfach die function.zip hochgeladen werden.

Wie sieht es mit den Vorteilen aus?

Als Testfall haben wir uns eine einfache Anwendung überlegt, welche n zufällige UUIDs erzeugt und diese sortiert, und über einen einfachen REST-Endpunkt bzw. eine Lambda angesteuert wird.

Es zeigt sich am Anfang die erste große Stärke der Native Builds: die verringerte Startzeit. Der Native Build der Lambda-Variante startet kosistent in einem Viertel der Zeit, welche der JVM-Build benötigt. Bei der REST-Variante ist die Divergenz sogar noch größer, bedingt durch die weniger optimierte JVM gegenüber AWS.

Startzeit

Rest

Startzeit

AWS Lambda

Init

Auch bei den Ausführungszeiten gibt es Vorteile, zumindest so lange die Anwendung noch "kalt" ist. Einmal warmgelaufen kann die JVM dank JIT-Compilierung wieder mit den Native Builds mithalten.

Ausführungszeiten

Rest

Erzeugung
Sortierung

AWS Lambda

Erzeugung
Sortierung

Kommen wir zu dem großen Nachteil der Native Builds: der Bauzeit. Aufgrund der Funktionsweise von Mandrel wird der Quellcode doppelt kompiliert: zuerst zu JVM-Bytecode, welcher dann im zweiten Schritt erst zu nativen Bytecode übersetzt wird. Dadurch dehnt sich die Bauzeit bereits für unsere einfachen Beispiele auf mehrere Minuten. Zum Glück muss dieser Schritt pro Deployment nur einmal erfolgen, was in den meisten Situationen verkraftbar ist.

Fehlen da nicht Messungen?

Selbst dem unaufmerksamsten Leser sollte aufgefallen sein, dass nicht alle Messungen für jeden Testfall durchgeführt wurden. Das ist einfach mit der großen Ähnlichkeit der Fälle zu erklären: die lokal getesten Services verhalten sich nach dem Start wie ein warmes Lambda, sowohl im Speicherverbrauch als auch bei der Ausführungszeit. Daran würde auch ein Deployment auf z.B. Kubernetes nichts ändern, da außerhalb des Cloud-Umfeldes einmal deployte Services nicht heruntergefahren werden. Das passiert bei kommerziellen Cloudanbietern, da sich hier viel mehr Nutzer die tatsächlich existierende Hardware teilen.

Andersherum unterscheiden sich die Lambda-Testfälle beim Bauen in ihrer Komplexität nicht von den lokalen Fällen, womit keine Überraschungen in der Bauzeit zu erwarten sind.

Fazit

Der Einsatz von Quarkus für Java Native Builds bietet erhebliche Vorteile, insbesondere in modernen Cloud- und Container-Umgebungen. Durch die kompakte und performante Ausführung von Anwendungen ohne JVM erhöht sich die Effizienz, besonders bei Startzeiten und Speicherverbrauch. Trotz der längeren Bauzeit und einiger technischer Herausforderungen überwiegen die Vorteile für viele Anwendungsfälle, insbesondere im Cloud-Bereich. Möchtest Du mehr über die Möglichkeiten und Vorteile des Java Native Builds mit Quarkus erfahren? Kontaktiere uns bei FIDA, um Deine Projekte effizienter und zukunftssicher zu gestalten.

Über den Autor

Benjamin Kleiner ist Backend-Entwickler bei der FIDA und sorgt dafür, dass im Hintergrund alles reibungslos funktioniert. Mit Expertise in Java, OpenAPI, SQL, Kafka und AWS Lambda schafft er das stabile Rückgrat moderner Webanwendungen. Seine Leidenschaft für Accessibility und gutes UX treibt ihn an, benutzerfreundliche und leistungsfähige Lösungen zu entwickeln.