GraalVM & Native Images mit Spring Boot – Unsere Tipps und Tricks

von Thomas Engel und Jonas Holtkamp | 22. Juni 2022 | Allgemein, Deutsch, Software Engineering, Tools & Frameworks

Thomas Engel

Senior Developer

Jonas Holtkamp

Lead Developer

In Kürze

  • Insbesondere der Native Image Support von GraalVM ist noch in einer frühen Phase für Spring Boot
  • Nach dem initialen Setup unterscheidet sich der Entwicklungsprozess nicht grundlegend
  • Erhöhter Ressourcen- und Zeitverbrauch beim Kompilieren als Native Image
  • Fehler treten oftmals erst zur Laufzeit auf und erfordern manuelle Unterstützung bei der Konfiguration, deshalb viel und ausführlich testen
  • Die Versprechungen von GraalVM werden eingehalten, es kann sich lohnen!

Warum Spring Boot?

Es war im Herbst 2020, als der Spring Community mit den Releases des Spring Frameworks 5.3 und Spring Boot 2.4 der erste Support von GraalVM & Native Images vorgestellt wurde. Zwar im ersten Schritt nur experimentell, aber für uns Grund genug sich das Ganze etwas genauer anzuschauen. Die Versprechungen waren groß, denn neben den bekannten Vorteilen der sofortigen Startups, höherer Peak-Performance sowie einem allgemein geringeren Speicherverbrauch, sollte sich bei der Entwicklung mit GraalVM & Native Images nichts grundlegend ändern im Vergleich zu den bisherigen “normalen” Spring Boot-Anwendungen. Das wollten wir genau wissen und haben innerhalb unseres InnoLabs als Beispiel einen Spring Boot-Service zur Kundenverwaltung als Native Image entwickelt, um zu sehen ob die großen Versprechungen eingehalten werden. Wir stellen die einzelnen Schritte im Folgenden vor und zusätzlich könnt ihr sie auch in unserem Repository im Code nochmal im Detail nachvollziehen.

Vorbereitung

Zuerst einmal muss GraalVM installiert werden. Dies ist abhängig vom Betriebssystem, funktioniert aber mit der entsprechenden Anleitung in der offiziellen Dokumentation innerhalb von wenigen Minuten. Da der Support für Native Images standardmäßig nicht mit beinhaltet ist, muss dies im Anschluss separat gemacht werden, was über den GraalVM Updater aber ebenfalls sehr schnell erledigt ist. Auf dem Mac empfiehlt sich die Nutzung von SDKMAN!, um flexibel zwischen Java- und GraalVM-Versionen wechseln zu können.

Erstellen eines initialen Spring Boot-Services

Wenn GraalVM installiert ist, kann es mit der Entwicklung der Spring Boot-Anwendung losgehen. Dazu kann zum Beispiel über den Spring Initializr ein neues Projekt angelegt werden (Bild 1). Wir haben uns hier für ein Maven-Projekt und die Programmiersprache Java entschieden. Den Kotlin-Fans sei an dieser Stelle gesagt, dass wir dafür ebenfalls ein Beispiel angeschaut haben. Aber dazu später mehr.

Bild 1: Erstellen eines Spring Boot Services

Um aus der Spring Boot-Anwendung später ein Native Image bauen zu können, muss Spring Native als Abhängigkeit hinzugefügt werden (Bild 2). Spring Native bietet den Support um eine Spring-Anwendung unter Verwendung des GraalVM Native Images Compilers zu einem Native Image zu kompilieren. Damit das funktioniert, muss zusätzlich das Spring AOT (Ahead of Time Compilation) Maven Plugin hinzugefügt werden (Bild 3).

<dependencies>
  <!-- ... -->
  <dependency>
    <groupId>org.springframework.experimental</groupId>
    <artifactId>spring-native</artifactId>
    <version>0.9.1</version>
  </dependency>
</dependencies>

Bild 2: Hinzufügen von Spring Native in der pom.xml

<build>
  <plugins>
    <!-- ... -->
    <plugin>
      <groupId>org.springframework.experimental</groupId>
      <artifactId>spring-aot-maven-plugin</artifactId>
      <version>0.9.1</version>
      <executions>
        <execution>
          <id>test-generate</id>
          <goals>
            <goal>test-generate</goal>
          </goals>
        </execution>
        <execution>
          <id>generate</id>
          <goals>
            <goal>generate</goal>
          </goals>
        </execution>
      </executions>
    </plugin>
  </plugins>
</build>

Bild 3: Hinzufügen des AOT Plugins in der pom.xml

Anschließend wird ein neues Profil ergänzt (Bild 4), in dem die Konfiguration für die Erstellung des Native Images angepasst werden kann. Damit das Kompilieren eines einfachen Spring Boot-Services keine 30 Minuten oder noch länger dauert, um am Ende doch mit einem Out-of-Memory Fehler zu scheitern, sollte der verwendete Speicher ausreichend erhöht werden. Die Docker-Einstellungen dürfen dabei ebenfalls nicht vergessen werden! In unserem Fall waren dafür 10 GB ausreichend.

<profiles>
  <profile>
    <id>native</id>
    <build>
      <plugins>
        <plugin>
          <groupId>org.graalvm.nativeimage</groupId>
          <artifactId>native-image-maven-plugin</artifactId>
          <version>21.0.0</version>
          <configuration>
            <mainClass>de.senacor.graalvm.Application</mainClass>
            <buildArgs>-J-Xmx10G</buildArgs>
            <imageName>${project.artifactId}</imageName>
          </configuration>
          <executions>
            <execution>
              <goals>
                <goal>native-image</goal>
              </goals>
              <phase>package</phase>
            </execution>
          </executions>
        </plugin>
      </plugins>
    </build>
  </profile>
</profiles>

Bild 4: Hinzufügen von Spring Native in der pom.xml

Aber auch allgemein und mit genügend Speicher dauert das Kompilieren als Native Image deutlich länger im Vergleich zur Erstellung einer Jar-Datei. In unserem Beispiel dauerte es als Native Image bis zu 10 Minuten, während die Erstellung der Jar-Datei in knapp 30 Sekunden erfolgreich war.

Das sind aber auch schon alle notwendigen Anpassungen, um aus der Spring Boot-Anwendung unter Verwendung des erstellten Profils ein Native Image zu erzeugen. Gleichzeitig bietet sich aber weiterhin die Möglichkeit auch eine normale Jar-Datei zu erzeugen, wenn das erstellte Profil nicht angegeben wird (Bild 5.1 und Bild 5.2). Auf diese Weise ist man bei der Entwicklung nicht auf Native Images beschränkt.

mvn -Pnative package

Bild 5.1: Kompilieren als Native Image

mvn package

Bild 5.2: Kompilieren als Jar-Datei

Implementierung der Kundenverwaltung

Nachdem alle Vorbereitungen abgeschlossen sind und Spring Native integriert wurde, kann mit der Implementierung der Features losgelegt werden. Hier unterscheidet sich die Entwicklung erst einmal überhaupt nicht von der bereits bekannten Art und Weise Spring Boot- Anwendungen zu implementieren.

Wir haben uns für eine Kundenverwaltung und der Implementierung einer einfachen CRUD API entschieden, da das einen absoluten Standard Use Case darstellt. Kunden sollen angelegt, geändert und auch wieder gelöscht werden können – das alles über eine REST API und gespeichert in einer H2 Datenbank. Nachdem die Implementierung dieser Features abgeschlossen war, haben wir die Anwendung kompiliert, ein Native Image daraus erzeugt und gestartet.

Beim Startup war der Unterschied zu einer JVM-Anwendung direkt sehr deutlich erkennbar, da der Service innerhalb weniger Millisekunden hochgefahren war. Allerdings mussten wir schon beim ersten Request – dem Erstellen eines Kunden – feststellen, dass der Service noch nicht richtig funktioniert. Es gab eine Exception zur Laufzeit, weil das Data Transfer Object in der REST API nicht erstellt werden konnte (Bild 6).

com.fasterxml.jackson.databind.exc.InvalidDefinitionException:Cannot construct instance of
`de.senacor.innolab.graalvm.demo.customer.controller.openapiMock.model.CustomerDto`
(no Creators, like default constructor, exist): cannot deserialize from Object value
(no delegate- or property-based Creator)

Bild 6: Fehlermeldung in den Logs beim Erstellen eines Kunden

Der Grund dafür ist, dass diese Klasse beim Kompilieren nicht berücksichtigt wurde und deshalb zur Laufzeit nicht gefunden werden kann. Das ist auch das erste Problem, was bei Native Images deutlich wird. An einigen Stellen muss dem Compiler geholfen werden, welche Klassen in das erstellte Image gepackt werden müssen. Unter anderem daher fallen Fehler erst sehr spät zur Laufzeit auf.

Spring Native bietet genau für diesen Fall die Möglichkeit Native Hints zu konfigurieren. Hier können dem Compiler Klassen, Interfaces oder Resources mitgeteilt werden, die beim Kompilieren dem Native Image hinzugefügt werden sollen.

Bis wirklich alle Native Hints gefunden und konfiguriert sind, können in der Praxis durchaus mehrere Iterationen vergehen, weil zur Laufzeit immer nur der erste Fehler, die erste noch fehlende Klasse, angezeigt wird. Mit etwas Erfahrung lässt sich dieser Prozess optimieren, wodurch weniger Iterationen notwendig sind. Insbesondere bietet GraalVM einen Agenten mit dessen Hilfe vollständige Konfigurationsdateien erstellt werden können und der die Ermittlung der Native Hints einfacher macht. Nachdem alle Native Hints angegeben wurden (Bild 7), die Spring Boot-Anwendung erneut zu einem Native Image kompiliert und gestartet wurde, hat auch zur Laufzeit alles funktioniert.

@NativeHint(
  types = @TypeHint(types = {
    CustomerDto.class,
    CustomErrorResponse.class
  }),
  proxies = @ProxyHint(types = {
    PathVariable.class,
    SynthesizedAnnotation.class
  })
)
@Configuration(proxyBeanMethods = false)
public class NativeHintConfiguration {
}

Bild 7: Konfiguration der Native Hints

Erweiterung der Kundenverwaltung um REST-Aufruf

Neben den einfachen CRUD Operationen wollten wir wissen, wie es mit der Weiterentwicklung einer solchen Spring Boot-Anwendung ausschaut und haben dafür den Aufruf einer REST-Schnittstelle zur Validierung des Geburtsdatums hinzugefügt. Auch hier gab es erwartungsgemäß erst einmal keine Unterschiede in der normalen Entwicklung. Erst nachdem die Anwendung zu einem Native Image kompiliert und gestartet wurde, gab es erneut eine Exception zur Laufzeit. Dieses Mal musste der HTTP-Support in der Konfiguration zur Erstellung des Native Images hinzugefügt werden, da dieser nicht standardmäßig aktiviert ist. Nach einer weiteren Iteration und dem Hinzufügen eines noch fehlenden, neuen Native Hints hat aber auch zur Laufzeit wieder alles funktioniert.

Verwendung von Kotlin

Da sich Kotlin einer immer größer werdenden Beliebtheit erfreut, haben wir Spring Native natürlich auch damit ausprobiert. Dafür haben wir den eben beschriebenen Service zur Validierung des Geburtsdatums als Spring Boot Anwendung in Kotlin implementiert. Die einzelnen Schritte können auch hier in unserem Repository genauer nachvollzogen werden.

Im Grunde ändert sich nicht sehr viel im Vergleich zu einer Java-Anwendung. Nachdem ein neues Projekt mit der Programmiersprache Kotlin erstellt wurde, kann wie zuvor Spring Native hinzugefügt werden. Bei der Konfiguration der Start-Klasse gab es allerdings die ersten Probleme. Hier wurde die Klasse in der Kotlin-Datei nicht gefunden, weshalb wir sie daraufhin als Java-Klasse implementiert haben. Außerdem konnten die Native Hints für Kotlin Klassen nicht erstellt werden, weil an dieser Stelle ein Array erwartet, Kotlin.Array aber nicht akzeptiert wurde. Als Ersatz musste die Konfiguration deshalb über Konfigurationsdateien erfolgen, die im Resource Ordner abgelegt werden. Abgesehen von diesen beiden Aspekten gab es aber keine Unterschiede im Vergleich zur Implementierung mit Java.

Werden die GraalVM Versprechen eingehalten?

Die Versprechungen von GraalVM & Native Images sind groß und demnach auch unsere Erwartungen. Wir wollten kein wissenschaftliches Benchmarking daraus machen, sondern lediglich einen Eindruck davon bekommen, ob die Zahlen realistisch und nachvollziehbar sind, und so viel vorab, das ist uns gelungen.

Die Startup Zeiten waren bei beiden Services als Native Image um 95% geringer als bei der Jar-Datei. Auch der Speicherverbrauch war für den Service zur Kundenverwaltung um ca. 50%, beim Service zur Validierung sogar um ca. 75% geringer. Bei Requests haben wir eine Verringerung der Zeiten um etwas über 20% gesehen. Wir haben die Zahlen auf einer lokalen Maschine erhoben, nur kleine Microservices implementiert und den Speicherverbrauch lediglich im Idle Zustand nach dem Startup untersucht. Daher ist es unklar, inwiefern die Zahlen mit einem richtigen Produktions-Setup vergleichbar sind. Nichtsdestotrotz kann man sagen, dass die Versprechungen von GraalVM & Native Images im Großen und Ganzen eingehalten werden und insbesondere für Startup Zeiten und Speicherverbrauch eine enorme Verbesserung darstellen.

Fazit

Der Support von Native Images für Spring Boot befindet sich noch in einer recht frühen Phase. Der experimentelle Support ist zwar mittlerweile zu einem Beta-Support geworden, allerdings sind Breaking Changes nicht gänzlich ausgeschlossen und in der Vergangenheit auch während unseres InnoLabs vorgekommen. Spring Native bietet momentan neben den absolut notwendigen Features kaum weiteren Tool Support, wo andere Frameworks, wie beispielsweise Quarkus und Micronaut, bereits deutlich weiter sind. Das ist nicht zuletzt ein bereits integrierter Docker Build, so dass auf der Entwicklungsumgebung kein eigenes GraalVM installiert sein muss, oder im Falle von Quarkus die Möglichkeit Integrationstests gegen das erstellte Native Image direkt in den Buildprozess zu integrieren. Außerdem gibt es bei manchen Libraries, die noch nicht offiziell von Spring unterstützt werden, Probleme zur Laufzeit oder sie funktionieren nur mit einer manuellen Konfiguration. Diese manuelle Konfiguration ist aber meistens nicht einfach ersichtlich und erfordert Trial-and-Error oder die entsprechend notwendige Erfahrung.

Insgesamt ist Spring Boot aber auf einem sehr guten Weg, macht schnelle Fortschritte und kann die Versprechen für die von Spring unterstützten Libraries fast komplett halten, in dem sich die Entwicklung bei Native Images bis auf die Angabe der Native Hints nicht unterscheidet.

Andere Artikel in der Reihe