Skip to main content
Dailycoding Notes

Kleine Docker Images mit Spring Boot

Title

In der heutigen technologischen Landschaft ist die Cloud ein unverzichtbarer Bestandteil vieler Entwicklungsprozesse. Dabei steht die Effizienz der Anwendung im Vordergrund, vorallem wenn es um die Verwendung von Containern und das Deployment in der Cloud geht.

Ich persönlich verwende für private Projekte gerne die Google Cloud und darin den Cloud Run Service. Dieser bietet eine einfache Möglichkeit fertige Docker Container hochzufahren und danach wieder auf Null zurück zu skalieren, sofern der Container gerade nicht gebraucht wird.

In vielen meiner Projekte ist es nicht notwendig, dass die Container 24/7 laufen und dauerhaft Resourcen belegen. Vorallem freut sich mein Bankkonto darüber. Oft ist es verschmerzbar, wenn der erste Request an die Anwendung mal ein paar Sekunden dauert. Danach ist der Container ja gestartet und nimmt die folgenden Anfragen flott entgegen.

Die initiale Bereitstellung des Containers besteht aus dem Download des Images und dem Starten des Containers. Beides kann je nach Größe des Images und der Startup Time der Anwendung einige Sekunden dauern. In der Cloud ist es vorallem wichtig, dass die Anwendung schnell startet, damit keine unnötigen Resourcen benutzt werden. Das ist der Grund warum ich mich in diesem Blogbeitrag mit der Größe der Images und der Startup Time der Anwendung beschäftige.

Sprachen wie Rust oder Go, beziehungsweise Runtimes wie nodeJS sind dafür bekannt sehr schnelle Startup Times zu haben. Ich bin noch kein Go oder Rust Experte, aber das liegt vielleicht an der konsequenten Ablehnung von OOP!? Als großer Fan von Java und Kotlin und möchte diese Sprache natürlich auch in der Cloud verwenden. Ich bin es gewohnt mit diesen Sprachen zu arbeiten, habe meine Entwicklungsumgebung entsprechend optimiert und habe über die Jahre Patterns verinnerlicht, die dazu beitragen, dass ich schnell ans Ziel komme. Ich möchte diese Vorteile nicht missen und deshalb habe ich mich auf die Suche nach Möglichkeiten gemacht, wie ich Kotlin und Spring Boot in der Cloud effizient einsetzen kann.

Zwei klare Zielsetzungen für die Cloud-Nutzung

In der Cloud spielen zwei wesentliche Faktoren eine entscheidende Rolle: Die Größe der Images und die Startup Time der Anwendung. Warum? Zum einen möchten wir die Downloadzeit minimieren, da die Images ständig auf neue Cluster verschoben werden müssen.

Cloud Run verwendet ungenutzte Ressourcen bestehender Kubernetes Cluster anderer Kunden. Daher muss mein eigenes Image immer mal wieder auf ein neues Cluster gezogen werden, wenn beispielsweise das Bisherige gelöscht wurde, die Resourcen vom Kunden selbst komplett benötigt werden oder aus anderen Gründen ein Wechsel des Cluster notwendig wird. Das Umschieben auf ein neues Cluster passiert im Hintergrund automatisch und wird von der Google Cloud Platform gemanaged. Ich als Nutzer habe wenig Einfluss darauf, wann und wo mein Image deployed wird.

Zum anderen ist es wichtig, den Speicherplatz und damit die Kosten in der Registry zu reduzieren. Um Cloud Run zu verwenden muss das Docker Image in einer Docker Registry, die von GCP verwaltet wird, abgelegt werden. Die Kosten für die Registry richten sich nach der Größe der Images. Je größer das Image, desto höher die Kosten. Daher sollte das Image möglichst klein gehalten werden. Die Kosten hierfür sind zwar nicht so hoch (ich bekomme 12 GB Speicher in der Atifact Registry für ca. 1 Euro im Monat), aber gerade für private Projekte gilt für mich: Jeder Euro zählt.

Für ein Rechenbeispiel kannst du den Google Cloud Calculator nutzen. Dort einfach in der Suche nach "Artifact Registry" suchen und die Kosten für die Registry berechnen.

Zudem sollte die Startup Time so kurz wie möglich sein. Aber warum? Die Cloud bietet die Möglichkeit, Anwendungen auf Null zu skalieren, wenn sie nicht benötigt werden. Das bedeutet, dass die Anwendung nicht dauerhaft läuft, sondern nur dann, wenn sie gebraucht wird. Das spart Ressourcen und damit Kosten. Wenn die Anwendung nun gestartet wird, sollte sie möglichst schnell bereit sein, um den eingehenden Request nicht unnötig lange warten zu lassen. Die Startup Time ist also ein wichtiger Faktor, um die Effizienz der Cloud optimal zu nutzen. In Cloud Run wird jeder Service, der konfigurierbar auf Null Container skaliert werden kann, nach circa 15 Minuten der Nichtnutzung komplett entfernt und wird erst beim nächsten Request wieder neu gestartet. Dann, wie oben schon beschrieben, möglichwerweise auf einem anderen Cluster.

Die Beispielanwendung

Als Beispiel wird eine einfache Spring Boot Anwendung kompiliert und in einen Container gepackt. Die Anwednung bietet einen Endpunkt mit einem einfachen Formular zur Eingabe des Namens. Nach dem Absenden des Formulars wird eine Begrüßung mit dem eingegebenen Namen zurückgegeben. Die Anwendung ist sehr einfach gehalten, da uns in erster Linie die Startup Time und die Gesamtgröße des Containers interessiert.

In einem größeren Projekt mit mehr Endpunkten und Beans, könnte die Startup Time etwas höher liegen und auch die Größe des Containers wird etwas höher sein, da mehr Klassen kompiliert und gepackt werden müssen. Die Sprache hat hierauf aber wenig Einfluss. Kotlin wird, genau wie Java, zu Java Bytecode kompilliert. Das Ergebnis ist also ähnlich.

Spring Boot ist nicht das aller schlankeste Framework, aber es ist quasi Standard und bietet viele Vorteile gegenüber einer eigenen Architektur. Mit der richtigen Auswahl an Startern und nur den nötigsten Modulen wird die Anwendung auch nicht unnötig schwer. Das Weglassen von Dependencies, die man nicht braucht oder irgendwann in Zukunft brauchen könnte, ist sowieso Best Practice, da sie gezielt Sicherheitslücken verhindert.

Screenshot der Beispielanwendung

Referenz: Spring Boot JAR

Um vergleichbare Zahlen zu erhalten, habe ich zuerst das normale ausfürbare JAR durch das mitgelieferte Gradle-Kommande ./gradlew bootJar erstellt. Das JAR hat eine Größe von 53,4 MB und eine Startup Time von 1,7 Sekunden.

Als nächstes habe ich das JAR in einen Container-Image gepackt. Dazu wollte ich das offizielle OpenJDK-Image verwenden. Das Image ist aber veraltet und wird nicht mehr weiterentwickelt. Alternativen dazu gibt es mittlerweile einige: Amazon Corretto, Liberica, Zulu und Eclipe Temurin. Daher habe ich mich für das Temurin-Image eclipse-temurin:17-jre-jammy entschieden. Temurin ist die Referenzimplementierung von OpenJDK und wird von der Eclipse Foundation verwaltet. Das Image ist mit 269 MB nicht gerade klein, aber schon kleiner als die vergleichbaren OpenJDK-Images. Zudem gibt es keine OpenJDK JRE Images für die Java Version 17. Das Image habe ich mit dem folgenden Dockerfile erstellt:

FROM eclipse-temurin:17.0.9_9-jre-jammy

COPY build/libs/*.jar app.jar

CMD ["java", "-jar", "./app.jar"]

Die Startup Time im Container betrug etwa 1,3 Sekunden, und die Image-Größe lag bei 322 MB – nicht ideal, habe ich aber in freier Wildbahn schon wesentlich schlechter gesehen.

Kompilieren als Native Image

Spring Boot bietet die Möglichkeit, die Anwendung als Native Image zu kompilieren. Dazu wird die Anwendung mit der JavaVM Implementierung GraalVM kompiliert. Die Vorgang dauert etwas länger als die Kompilierung zu einem JAR, aber die Startup Time sinkt enorm. Die Anwendung wird als ausführbare Binary übersetzt und benötigt dann keine JVM mehr. Die Anwendung wird also direkt auf dem Betriebssystem ausgeführt.

Beim Kompilieren wird die Anwendung durch einen Profiler analysiert und alle Beans, die zur Laufzeit zur Verfügung stehen müssen, bereits zur Compilezeit erstellt und im Speicher abgelegt werden. Das führt zu einem höheren RAM-Bedarf, aber zu einer erheblich schnelleren Startup Time. Auch ist die Größe des Binaries etwas größer da jetzt alle Funktionen der JVM mit in das statische Programm eingebaut werden müssen. Die Kompilierung mit ./gradlew nativeImage dauerte auf meinem aktuellen Notebook (i7-1165G7 @ 2.80GHz, 16 GB RAM) etwa 5 Minuten im Vergleich zu den 40 Sekunden für das JAR. Die Dateigröße des Native Images betrug 129 MB.

Da das Ergebnis ein ausführbares Binary ist, kann nun ein schlankeres Basis-Image verwendet werden. Alpine-Images sind von Haus aus sehr klein und kommen nur mit den nötigsten Abhängigkeiten. Ich habe das Binary aber im ersten Versuch in einem Alpine Container nicht zum laufen gebracht. Mehr dazu im nächsten Abschnitt.

Mit dem schlanken Ubuntu-Image ubuntu:jammy konnte die Anwendung aber gestartet werden. Das Image wurde mit dem folgenden Dockerfile erstellt:

FROM ubuntu:jammy

COPY build/native/nativeCompile/demo ./demo

CMD ["/demo"]

Die Image-Größe lag bei 230 MB – schon besser als das JAR, aber immer noch nicht optimal. Die Startup Time blieb natürlich bei 0,03 Sekunden. Das Binary hat sich ja nicht verändert.

Alpine Linux und das optimale Image

Das Problem mit Alpine hat mich aber lange nicht losgelassen. Irgendwann erinnerte ich mich daran, dass Alpine-Images mit musl anstelle von glibc ausgeliefert werden. musl ist eine alternative Implementierung der C Standard Bibliothek, ist aber nicht vollständig kompatibel zur weit verbreiteten glibc. Also habe ich dem Alpine-Image einen GNU C Library compatibility layer for musl spendiert. Das Resultat: Das Image war nun nur noch 139 MB groß, lediglich 10 MB mehr als das Native Image der Anwendung.

Hier noch das vollständige Dockerfile:

FROM alpine:3.18.5

RUN apk add gcompat

COPY build/native/nativeCompile/demo ./demo

CMD ["/demo"]

Die Startup Time blieb auch mit der alternativen C Library bei 0,03 Sekunden. Sehr gut!

Fazit

Versuch Image-Größe Startup-Time
JAR 322 MB 1.30 Sekunden
Native Image (Ubuntu) 203 MB 0.03 Sekunden
Native Image (Alpine) 139 MB 0.03 Sekunden

Gehen wir mal davon aus, dass innerhalb des GCP Netzwerkes für einfache Cloud Run Anwendungen der Image-Download über eine Leitung mit einer Kapazität von 1 Gbit/s läuft.

Daraus ergibt sich rechnerisch folgende Bereitstellungszeit.

Versuch Downloadzeit Bereitstellungszeit
JAR 2.6 Sekunden 3.9 Sekunden
Native Image (Ubuntu) 1.6 Sekunden 1.63 Sekunden
Native Image (Alpine) 1.1 Sekunden 1.13 Sekunden

Fairerweise muss noch die Bereitstellung des Docker-Containers auf einem Cluster eigerechnet werden. Diese Zeit dürfte aber bei allen Versuchen in etwa die selbe sein. Daher habe ich sie hier nicht mit einberechnet, zumal diese Zeit auch schwierig zu messen ist.

Diese Optimierungen sind ein erster Schritt, um Cloud-Deployments mit Java Containern effizienter zu gestalten. Natürlich sind diese Erkenntnisse in einem größeren Projekt mit mehr Endpunkten und Beans zu validieren. Aber die Ergebnisse zeigen auch, dass die Kombination aus Spring Boot, Native Image und Alpine Linux sich sehr gut für Cloud-Anwendungen eignet. Die beiden Zielsetzungen für die Cloud-Nutzung wurden in meinem Experiment erreicht. Die Images sind klein und die Startup Time ist sehr gering.

Ich habe in diesem Experiment bewusst auf Datenbank-Migrationen, umfangreiche Tests, Monitoring und Logging verzichtet. Es ging hier rein um eine Machbarkeitsstudie. Das Vorgehen muss sich in einem nächsten größeren Projekt erst noch beweisen. Gerade die Kompilezeit von über 5 Minuten ist natürlich für ein schnelle Deployment etwas hinderlich.

Happy Coding!