Micronaut oder Spring Boot für GraalVM Native Images
Für ein internes Werkzeug mit REST-API sollte die Auslieferung als kleines, schnell startendes natives Binary erfolgen. Als Framework standen Spring Boot und Micronaut zur Auswahl, im Team gab es vor allem Spring-Erfahrung. Statt nach Bauchgefühl zu entscheiden, haben wir beide Kandidaten mit identischen Prototypen als GraalVM Native Image gebaut und vermessen. Die Messwerte und die Erkenntnisse aus dem Vergleich stellen wir in diesem Beitrag vor.
Ausgangslage
Die Anforderungen an den Technologie-Stack waren typisch: Das Werkzeug soll als einzelnes statisches Binary auf Linux laufen, als Container ohne JVM ausgeliefert werden und in kurzer Zeit starten. Der native Build läuft bei jedem Merge in der CI, damit Fehler im nativen Artefakt früh auffallen. Buildzeit und Zuverlässigkeit des Native-Image-Builds sind damit wichtiger Teil des täglichen Entwicklungszyklus.
Damit die Entscheidung nachvollziehbar bleibt, haben wir vorab ein Umkehrkriterium festgelegt: Liegt Spring Boot bei Imagegröße und Speicherverbrauch innerhalb von 20 Prozent von Micronaut und kommt vor allem ohne manuelle Reachability-Metadaten aus, gewinnt die vorhandene Spring-Erfahrung im Team. Andernfalls fällt die Wahl auf Micronaut.
Vorgehen
Verglichen wurde anhand von zwei identischen Prototypen. Beide implementieren dieselbe Minifunktionalität: Einen HTTP GET-Endpunkt, der Daten aus einer JSON-Datei liest, ein Bearer-Auth-Filter, das Fehlerformat der API als JSON-Envelope und ein DTO, das deserialisiert und wieder serialisiert wird. Die Antworten beider Prototypen sind byte-identisch, auf der JVM wie im nativen Binary.
Gebaut wurde in beiden Fällen mit denselben Native-Image-Optionen und demselben Build-Container, ohne lokale GraalVM-Installation.
<buildArgs>
<buildArg>--no-fallback</buildArg>
<buildArg>-Os</buildArg>
<buildArg>--gc=serial</buildArg>
<buildArg>-march=compatibility</buildArg>
</buildArgs>
Als Versionen kamen Micronaut 4.10.17 mit Netty und Micronaut Serde sowie Spring Boot 4.1.0 mit WebMVC, Tomcat und Jackson zum Einsatz.
Der Build lief im Container ghcr.io/graalvm/native-image-community:25 mit Maven 3.9.11.
Beide Builds nutzen das GraalVM Reachability-Metadata-Repository, um manuelle und potentiell fehlerananfällige Tätigkeiten zu minimieren.
Gemessen wurden fünf Werte:
-
Größe des nativen Image
-
Buildzeit bei warmem Dependency-Cache
-
Startzeit bis zur ersten HTTP-200-Antwort
-
Speicherverbrauch (VmRSS) nach 1000 Requests
-
Anzahl der Reachability-Metadaten, die von Hand ergänzt werden mussten
Dies sollte als evidenzbasierte Basis der Entscheidung dienen.
Messwerte
| Metrik | Micronaut 4.10.17 | Spring Boot 4.1.0 | Spring zu Micronaut |
|---|---|---|---|
Größe des nativen Image |
54,5 MB |
70,4 MB |
+29 % |
Buildzeit nativ (clean) |
112 s |
137 s |
+22 % |
Start bis zur ersten HTTP-200-Antwort |
51 ms |
82 ms |
+61 % |
VmRSS nach 1000 Requests |
98,1 MB |
109,2 MB |
+11 % |
Manuelle Reachability-Metadaten |
0 |
2 |
Beide Frameworks liefern lauffähige native Binaries. Die Abstände bei den jeweiligen Metriken sind prozentual deutlich erkennbar und favorisieren durchgehend eine Variante.
Wichtigste Erkenntnis: Metadaten
Aufschlussreicher als die Größen- und Zeitwerte war der Umgang mit Reflection.
Der Micronaut-Prototyp lief ohne Änderungen nativ, weil Dependency Injection und Serialisierung zur Compile-Zeit generiert werden.
Der Spring-Prototyp benötigte zwei manuelle Eingriffe:
einen Resource-Hint für die JSON-Datei und eine Reflection-Registrierung für das Fehler-DTO.
@RegisterReflectionForBinding(ApiError.class)
Der zweite Eintrag war nur durch Verwendung des nativen Binaries im Test zu finden. Das Fehler-DTO wird im Auth-Filter serialisiert und taucht in keiner Controller-Signatur auf, die AOT-Analyse konnte es daher nicht erkennen. Ohne die Registrierung lieferte der 401-Pfad im nativen Binary einen HTTP 500, während sämtliche Tests auf der JVM grün blieben. Genau diese Fehlerklasse ist das Risiko bei nativ ausgelieferten Anwendungen: Lücken zeigen sich potentiell erst im nativen Artefakt, nicht in der JVM-Testsuite.
Lessons learned
Aus dem Vergleich haben wir einige übertragbare Erkenntnisse mitgenommen.
Kriterien vor der Messung festlegen.
Ein vorab dokumentiertes Umkehrkriterium verhindert, dass die Auswertung nachträglich zur bereits gefühlten Entscheidung passend interpretiert wird.
Identische Prototypen statt Feature-Vergleich.
Erst byte-identische Antworten machen die Messwerte vergleichbar, sonst vergleicht man Konfigurationsunterschiede statt Frameworks.
Tests müssen unbedingt auch gegen das native Artefakt laufen.
Eine grüne JVM-Testsuite sagt nichts über Reflection-Lücken im Binary aus.
In unserem Aufbau bleibt die Verwendung der JVM für einen schnelle Entwicklungszyklus.
Der native Build mit Tests gegen das Binary ist ein separates Qualitygate vor Merge und Release.
Der Build gehört in einen Container.
Mit ghcr.io/graalvm/native-image-community als Build-Umgebung ist der native Build auf jedem Entwicklerrechner und in der CI identisch reproduzierbar.
Toolchain-Versionen pinnen.
Im Vergleich fiel etwa das native-maven-plugin 1.1.6 mit einer Classload Exception unter Maven 3.9.11 aus, die Version 1.1.4 funktionierte zuverlässig.
Entscheidungen dokumentieren.
Messwerte, Kriterium und Ergebnis stehen bei uns in einem Architecture Decision Record, damit die Entscheidung später überprüfbar und revidierbar bleibt.
Fazit
Für dieses Anforderungsprofil fiel die Wahl auf Micronaut.
Das Umkehrkriterium wurde in zwei Punkten verfehlt:
Bei der Imagegröße mit 29 Prozent Abstand und vor allem zwei erforderlichen manuell zu tätigenden Metadaten-Einträgen, von denen einer nur durch Testen des nativen Binaries auffiel.
Spring Boot als Native Image ist inzwischen gut nutzbar und für Teams mit Spring-Fokus und überwiegendem JVM-Betrieb eine absolut valide Option.
Zu Micronaut, Spring Boot und Java bieten wir Beratung, Entwicklungsunterstützung und Schulungen an.
Auch für Ihren individuellen Bedarf können wir Workshops und Schulungen anbieten. Sprechen Sie uns gerne an.