Spring native im produktiven Einsatz
Dank der Java GraalVM in Kombination mit dem Werkzeug native-image lassen sich Java Anwendungen in nativ kompilierte Binaries überführen, die sogar statisch gelinkt sein können.
Damit entfällt die Initialisierung der HotSpot VM sowie das Laden und Initialisiern von Klassen.
Eine derartig gebaute Java Anwendung lässt sich in sehr kurzer Zeit starten.
Selbst mit umfangreichen Abhängigkeiten wie Thymeleaf und Spring Security kann man hier im Bereich von einer Sekunde den vollständigen Anwendungsstart erwarten.
Dazu kommt ein gut vorhersagbarer Speicherverbrauch zur Laufzeit, der zudem auch geringer ausfällt, als bei der sehr dynamischen HotSpot VM. Der Tradeoff von GraalVM-Native-Anwendungen ist, dass die Peakperformance im Vergleich zu HotSpot geringer ausfällt und Oracle zudem bestimmte Performanceoptimierungen nur in einer kommerziellen Variante anbietet.
Wie verhält sich Spring Boot mit Spring native - derzeit in Version 0.11.1 als beta - in Bezug auf den produktiven Einsatz?
Worauf gilt es zu achten und was funktioniert nicht, wie man es erwartet?
Diese Aspekte werden im folgenden betrachtet.
Typische Geschäftsanwendung mit Spring Boot native
Viele Demos von GraalVM native-image mit Spring Boot verwenden ein überschaubares Feature-Set oder sind sogar nur auf der Ebene einer Hello-World Anwendung.
In Zusammenarbeit mit einem Kunden konnten wir ein Projekt mit Spring Boot und Spring Native für den produktiven Einsatz entwickeln.
Da die Vorteile von Spring Native nicht zwingend gebraucht wurden, gab es stets die Option, klassisches Spring Boot als Fallback zu nutzen, sollten die fachlichen Anforderungen an technische Grenzen von Spring Native stoßen.
Das war zum Glück nicht der Fall.
Die Anwendung befindet sich inzwischen im produktiven Einsatz und es konnten auch im längeren Betrieb mit wechselnder Last keine Probleme festgestellt werden, die durch Spring Native bzw. Substrate VM zur Laufzeit zurückzuführen wären.
Folgende Technologien wurden in der Anwendung verwendet:
-
Java 17 (aktuelles LTS, Entwicklung mit Java 15 und Java 16)
-
Maven basierter Build
-
Spring Security (Spring Boot Starter Security)
-
Asynchrone Operationen (
@Async
) -
Java Mail (Spring Boot Starter Mail)
-
Tomcat als Servlet Container (Spring Boot Starter Web)
-
Zugriff auf externe HTTP Dienste (
RestTemplate
) -
Thymeleaf (Spring Boot Starter Thymeleaf)
-
WebJARs für clientseitige Abhängigkeiten (jQuery, Bootstrap, Typeahead)
-
Persistenz mit JDBC und JPA (Spring Boot Starter Data JPA, JDBC, JPA native Queries)
-
Datenbanktreiber (MariaDB für Produktion und H2 in Entwicklung)
-
Flyway zur Schemaverwaltung (in Entwicklung, inkompatibel mit Spring Native, s.u.)
-
OpenAPI basierte Dokumentation zusammen mit Spring RestDocs
-
Spring Boot Devtools (in Entwicklung)
-
Spring Boot Actuator (Spring Boot Starter Actuator)
-
Micrometer mit Prometheus (micrometer-registry-prometheus)
-
Logging mit slf4j
Damit ergibt sich schon ein recht umfangreicher und repräsentativer Stack.
Die Auslieferung und der spätere Betrieb erfolgt als Docker Image, die finale Größe ist mit komprimierter Anwendung ca. 74 MB, die Startzeit ist relativ langsam mit ca. 1 Sekunde inkl. Tomcat.
Der dominierende Anteil scheint Spring Security zu sein, dies wurde jedoch nicht eingehend untersucht.
Für das Projektsetup wurde https://start.spring.io/ verwendet, hier wurde "Spring native [experimental]" als Abhängigkeit gewählt.
Dadurch wurden die wichtigsten Einstellungen in der pom.xml
bereits vorgenommen, um ein GraalVM native-image Artefakt zu erzeugen.
Während der Entwicklung wurden Spring Native 0.10 und 0.11 veröffentlicht. Die Anpassungen an die neueren Versionen waren relativ schnell erledigt, jedoch musste nach den z.T. neu auftretenden Fehlern erst rechechiert werden. Für ein Projekt im Beta-Stadium ist das jedoch akzeptabel.
Ahead of Time (AoT) Compilation
Bei Verwendung des GraalVM native-image wird das sogenannte closed-world Prinzip angewendet.
Damit ist gemeint, dass nicht zur Laufzeit neue Klassen hinzukommen dürfen, die dann dynamisch geladen werden.
Reflection ist ebenfalls ein nicht mehr vollumfänglich zur Verfügung stehender Aspekt, denn dazu wird normalerweise die Dynamik der JVM genutzt.
Als Alternative kann das zwar durch die Substrate VM emuliert werden, doch die dazu nötigen Metadaten machen den Build langsamer und vor allem die Anwendung auch größer.
Daher sollten Anwendungen und Libraries, die für das GraalVM native-image optimiert werden, auf Reflection soweit wie möglich verzichten.
Spring AoT
Spring bringt seit Spring Native 0.11 ebenfalls einen AoT Modus mit.
In einem zusätzlichen Build-Schritt sollen so z.B. Spring Beans frühzeitig analysiert, initialisiert und miteinander verbunden werden.
Damit wird die Startupzeit zum einen verbessert, zum anderen wird weniger Reflection Code benötigt.
Weiterhin werden Konfigurationen zur Steuerung des GraalVM native-image Compilers erzeugt.
So können zum Beispiel JDK-Proxies erzeugt werden, die unter anderem für Spring AoP benötigt werden.
Das ganze kommt jedoch mit einer für Spring Entwickler ggf. sehr überraschenden Eigenschaft:
Da bereits zur Buildzeit die Anwendung initialisiert wird, wird die Konfiguration deutlich statischer.
Ist man es sonst gewohnt, z.B. durch Properties oder Profile zwischen verschiedenen Konfigurationen der Anwendung zu wechseln, wird dies nun zur Buildzeit fixiert.
Evtl. soll sich dies noch ändern, aktuell entscheidet sich jedoch zur Buildzeit, welche Beans registriert werden, und wie diese initialisiert werden.
Eine dynamische Steuerung von Loglevel und auch die Belegung von ConfigurationProperty
Klassen ist jedoch möglich.
Spring Native Hints
Spring native bringt die Möglichkeit mit, über Java-Annotationen die für die Steuerung des GraalVM native-image Schritts genutzten Konfigurationsdatein zu erzeugen. Das ist typsicherer und mit Code-Completion einfacher, als in JSON-Dateien zu arbeiten.
Die Hints können an beliebigen Klassen, die als @Configuration
markiert sind, angegeben werden.
Typische Verwendung finden die Hints in diesen Fällen:
-
Es sollen neben Class-Dateien andere Ressourcen zur Laufzeit verfügbar gemacht werden
-
Bereitstellung von Reflection-Informationen, die durch die Anwendung oder Libraries benötigt werden
-
Erzeugung von JDK Proxies und Spring AoP Proxies
Als Entwickler vergisst man oft, an wie vielen Stellen Reflection zum Einsatz kommt.
Schon bei einfachen Dingen, wie Spring WebMVC Model-Objekten, die mit der Thymeleaf Template-Engine verwendet werden, führten zu Programmfehlern.
Abhilfe schaffen hier entsprechend Hints: @TypeHint(types = LineQuery.class)
sorgt für die Berücksichtigung durch GraalVM _ native-image_.
Auch @ConfigurationProperty
Ziele müssen mitunter entsprechend annotiert sein.
Zum Teil erkennt das Spring AoT Maven Plugin die Klassen bereits und erzeugt die Hints selbstständig.
Auch für die Serialisierung von/zu JSON mit Jackson müssen die Klassen entsprechend markiert werden.
Überraschungen kann man auch an anderer Stelle erleben:
Selbst die Konfiguration der JPA Naming-Strategie benötigt einen entsprechenden @TypeHint(typeNames = "org.hibernate.boot.model.naming.PhysicalNamingStrategyStandardImpl")
.
Verwendet man @EnableJpaAuditing
müssen die zu verwendenden Listener und Annotationen aufgeführt werden.
Hier bleibt zu hoffen, dass Spring AoT dies zukünftig selbstständig erkennen kann, und auf
@TypeHint(types = AuditingEntityListener.class)
sowie @TypeHint(types = CreatedDate.class)
verzichtet werden kann.
Verwendet man das RestTemplate
mit der JDK HTTP-Implementierung ist auch dazu ein Hint erforderlich @TypeHint(types = org.springframework.http.client.SimpleClientHttpRequestFactory.class)
.
Bei der Verwendung von Native-Queries im Spring Data JPA Umfeld wird ein Proxy-Hint benötigt: @JdkProxyHint(types = {NativeQueryImplementor.class, QueryImplementor.class})
Für die mit @EnableAsync
aktivierte @Async
-Annotation wird Spring AoP mit entsprechenden Proxies verwendet.
Leider war auch dazu ein Hint erforderlich: @AotProxyHint(targetClass=MailNotifier.class, proxyFeatures = ProxyBits.IS_STATIC)
, auch hier bleibt zu hoffen, dass eine automatische Generierung möglich ist.
Eine Alternative dazu, dass der Entwickler entsprechende Angaben machen muss, stellt der Tracing Agent dar. Ist er aktiviert, zeichnet er auf, wie die Anwendung sich verhält und generiert daraus die entsprechende Konfiguration. Hier zahlt sich aus, wenn es eine Testautomatisierung gibt, die die Anwendung einmal durch alle wichtigen Zustände führt.
Spring Boot Profiles und Autoconfigurations
Wie bereits erwähnt ist aktuell eine große Einschränkung, dass die Anwendung nicht flexibel für unterschiedliche Ausprägungen konfiguriert werden kann.
Das gilt für Autoconfigurations gleichermaßen, wie für Java Config in der programmatisch Beans in Abhängigkeit von bestimmten Umgebungszuständen registriert werden oder @Profile
Beans.
Mehr Informationen dazu liefert die Dokumentation: https://docs.spring.io/spring-native/docs/current/reference/htmlsingle/#support-spring-boot
Testing
Es ist wichtig zu verstehen, dass eine Anwendung als GraalVM native-image sich anders verhält, als die selbe Codebasis in HotSpot. Es ist daher unerläßlich entsprechende Tests auch im native Modus auszuführen. Mit Spring Native 0.11 ist dafür Unterstützung für Junit 5 bereitgestellt worden. Mockito wird allerdings derzeit nicht unterstützt.
In der Praxis erscheinen auch vor allem Integrationstests relevant, die das Zusammenspiel der verschiedenen Komponenten und möglicherweise sogar Umsysteme testen, als Komponententests.
Flyway
Flyway ist neben Liquibase ein häufig verwendetes Werkzeug zur Verwaltung von Datenbankmigrationen.
Ist die Abhängigkeit aktiviert, so crasht aktuell eine Spring Native Anwendung beim Start mit einer Nullpointer Exception.
Abhilfe lässt sich - wieder einmal - durch einen Hint schaffen: @ResourceHint(patterns = "org/flywaydb/core/internal/version.txt")
.
Allerdings verwendet Flyway, um nach den zu verwendenden Migrationen zu suchen, ein Verfahren, dass nicht mit der Substrate VM-Laufzeit kompatibel ist. Somit stürzt die Anwendung nicht direkt ab, jedoch werden die Datenbankmigrationen nicht gefunden und damit auch nicht ausgeführt.
Hier bleibt aktuell lediglich die separate Ausführung der Migrationen - z.B. durch das Flyway Maven Plugin.
Um sicher zu gehen, dass die Datenstrukturen zu den JPA Entities passen, kann durch spring.jpa.hibernate.ddl-auto=validate
eine Validierung durch Hibernate aktiviert werden.
Auch wenn dies keine komplette Sicherheit liefert, hat sich dies bereits bewährt, um schnelles Feedback zu erhalten, ob ggf. Migrationen fehlen.
Das zugehörige Ticket für Flyway mit Spring native ist https://github.com/flyway/flyway/issues/2927
WebJARs
WebJARs stellen Artefakte für den Browser zur Verwendung mit Maven oder Gradle zur Verfügung.
Das sind in der Regel CSS und JavaScript Ressourcen, die durch den Browser nachgeladen werden.
Spring Boot unterstützt die Auflösung der angefragten Ressourcen auf JAR Archive, im Kontext von native-image funktioniert das jedoch nicht.
Das Problem ist vermutlich vergleichbar mit dem bei Flyway.
Abhilfe schafft dabei ein eigener AbstractResourceResolver
, der in Spring WebMVC konfiguriert wird.
@Bean
public WebMvcConfigurer configurer()
{
return new WebMvcConfigurer()
{
@Override
public void addResourceHandlers(ResourceHandlerRegistry registry)
{
registry
.addResourceHandler("/webjars/**")
.addResourceLocations("classpath:META-INF/resources/webjars/")
.resourceChain(true)
.addResolver(new WebJarsVersionResourceResolver());
}
};
}
Devtools
Gerade zur Entwicklung serverseitiger Anwendungen mit Thymeleaf ist der live-reload durch die Spring Boot Devtools extrem praktisch.
Um die Abhängigkeit lediglich zur Entwicklungszeit zu aktivieren, wurde mit einem separaten Maven-Profil local
gearbeitet, dass die Abhängigkeit enthält.
<profiles>
<profile>
<id>local</id>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-devtools</artifactId>
<scope>runtime</scope>
<optional>true</optional>
</dependency>
</dependencies>
</profile>
...
Build und Kompression
Für den Build mit Maven kann sehr gut ein Build-Container mit Docker eingesetzt werden.
Auf diese Weise kann ein Setup von GraalVM und dem native-image Tool und sogar einer JVM auf dem Buildsystem vermieden werden.
Spring bietet dazu eine Integration mit den Paketo Builder-Images an.
Eine entsprechende Konfiguration kann im Abschnitt zum spring-boot-maven-plugin
vorgenommen werden.
Der Ressourcenbedarf im Build ist nicht unerheblich: Für die betrachtete Anwendung werden auf einem Notebook alle Kerne gut ausgelastet, gut 12 GB RAM belegt und der Build dauert inkl. Erzeugung von Dokumentation und Docker Image ca. 9 Minuten.
Durch Tomcat und den relativ umfangreichen Einsatz von weiteren Technologien wird das finale Docker-Image mit 212 MB relativ groß. Die gemessene Startzeit auf dem selben Notebook beträgt ca. 1.5 Sekunden.
Die Imagegröße läßt sich noch weiter optimieren: Das nativ übersetzte Programm kann gepackt werden. Dafür gibt es verschiedene Ansätze, der UPX Packer wurde speziell für Programme entwickelt, die sich weiterhin normal starten lassen.
upx
Kompression im Buildpack<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
<configuration>
<classifier>${repackage.classifier}</classifier>
<image>
<builder>paketobuildpacks/builder:tiny</builder>
<env>
<BP_NATIVE_IMAGE>true</BP_NATIVE_IMAGE>
<BP_JVM_VERSION>17.*</BP_JVM_VERSION>
<BP_BINARY_COMPRESSION_METHOD>upx</BP_BINARY_COMPRESSION_METHOD>
</env>
</image>
</configuration>
</plugin>
Wird die Kompression mit UPX aktiviert, erhöht sich die Buildzeit auf rund 11 Minuten.
Leider ist der UPX Packer nicht in der Lage, mehrere CPU Kerne zu nutzen.
Das Image ist im Ergebnis mit ca. 71 MB deutlich kleiner.
Die Startzeit bleibt im selben Bereich, wie die der ungepackten Anwendung oder wird sogar leicht besser.
Fazit
Spring Native ist noch als Beta-Version kommuniziert.
Anwendungen können damit jedoch bereits heute für den produktiven Einsatz entwickelt werden.
Der Fokus wird dabei jedoch sinnvollerweise auf Anwendungen liegen, die von den geänderten Eigenschaften, wie sehr kurzer Startzeit und dem geringen und besser planbaren Ressourcenbedarf zur Laufzeit auch echten Nutzen beziehen.
Das könnten beispielsweise sehr dynamisch skalierende Function-as-a-Service Anwendungen sein.
Java ist damit realistisch in Bereichen einsetzbar, die bisher speziellen Ökosystemen, wie dem ebenfalls statisch gelinkten Go vorbehalten waren.
Auch wenn man auf einige vielleicht liebgewonnene Eigenschaften, wie die dynamische Konfiguration der Anwendung oder bestmögliche Performance verzichten muss, gibt es dafür einiges im Gegenzug.
Aus Entwicklersicht bleiben jedoch noch einige Wünsche offen: Manuell diverse Hints zu konfigurieren führt zu viel try-and-error und Rechercheaufwand. Durch die extrem langsamen Builds ist die Turnaroundzeit auch unerträglich hoch. Ein inkrementeller Build für Test und Entwicklung wäre hier mehr als wünschenswert.
Ebenfalls sollte dabei nicht vergessen werden, dass noch nicht alle Libraries mit dem GraalVM native-image einwandfrei funktionieren. Einen eingehenden Blick ist das Thema aber auf jeden Fall wert, zumal der Einstieg mit Spring Boot sehr leicht fällt.
Zu den Themen Kubernetes, Docker und Cloudarchitektur bieten wir sowohl Beratung, Entwicklungsunterstützung als auch passende Schulungen an:
Auch für Ihren individuellen Bedarf können wir Workshops und Schulungen anbieten. Sprechen Sie uns gerne an.