Container sind kein Allheilmittel und es gibt auch genügend Fälle, in denen der „klassische“ Betrieb einer Java-Anwendung ohne Container vollkommen ausreicht. In vielen meiner Projekte ist jedoch für das Deployment ein Container-Image gefordert, da die unterliegende Plattform, zum Beispiel Kubernetes oder AWS ECS, dies erzwingt.
Dadurch ergibt sich häufig die Frage, wie wir jetzt unsere Anwendung in ein Image verpackt bekommen. Dafür gibt es natürlich keine allgemeingültige Antwort. Es hängt, meiner Meinung nach, stark vom Wissen im Team und der Frage, wie viel Kontrolle ich über den gesamten Prozess behalten möchte, ab.
Um entscheiden zu können, welcher Weg der am besten passende ist, müssen wir aber natürlich zunächst ein paar Wege und deren Vor- und Nachteile kennen. Dies soll dieser Artikel leisten. Wir schauen uns fünf Wege an und am Ende kann jeder für sich wählen, welchen Weg er gehen möchte.
Beispielanwendung
Als Basis für diesen Artikel dient uns eine Spring-Boot basierte Anwendung. Diese wurde mit https://start.spring.io erzeugt und fügt lediglich einen Controller hinzu, der unter / den Text Hello from Spring! ausgibt.
Als Build-Tool habe ich mich für Maven entschieden. Hierdurch ist auch per Default das spring-boot-maven-plugin konfiguriert. Zwar ergeben sich durch die Wahl von Spring-Boot und Maven spezielle Rahmenbedingungen, die meisten der im Folgenden vorgestellten Wege funktionieren jedoch unabhängig davon, auch beispielsweise mit Gradle oder anderen Frameworks.
Ohne größere Umwege wollen wir einfach mit einer ersten Variante starten.
Fat-JAR-Container
Nachdem jahrelang der Hauptweg zum Deployment von Java-Anwendungen ein
Applikations- oder Webserver war, sind in den letzten Jahren ausführbare
JAR-Dateien immer populärer geworden. Diese, auch Fat-JAR genannten, Dateien
enthalten neben unserem eigenen Anwendungscode auch sämtliche Abhängigkeiten und
können deswegen mit dem Aufruf java -jar mein-jar.jar
ausgeführt werden.
Bei unserer Beispielanwendung sorgt das spring-boot-maven-plugin automatisch
dafür, dass als Ergebnis eines Builds unterhalb von target eine ausführbare
JAR-Datei entsteht. Neben unserem Anwendungscode und unseren Abhängigkeiten
enthält dieses noch einen von Spring-Boot zur Verfügung gestellten ClassLoader.
Dieser ist notwendig, da Java per Default keine JAR-Dateien, die sich selbst in
JAR-Dateien befinden, laden kann. Somit können wir nach einem Build des Projekts
durch ./mvnw verify
mit dem Befehl
java -jar target/spring-container-1.0.0-SNAPSHOT.jar
unsere Anwendung lokal
starten.
Für unser erstes Image wollen wir genau diesen Mechanismus nutzen. Wir kopieren
das Fat-JAR in unser Image und sorgen dafür, dass beim Start des Containers
java -jar
ausgeführt wird. Hierzu schreiben wir das in Listing 1 gezeigte
Dockerfile.
Als Basis-Image nutzen wir die aktuellste Version des vom AdoptOpenJDK-Projekt bereitgestellten Java 11-Images. Anschließend erstellen wir einen eigenen Ordner /app für unsere Anwendung und wechseln den Nutzer auf daemon, um nicht als root ausgeführt zu werden. Weiterhin kopieren wir das vorher per Maven gebaute Fat-JAR hinzu und spezifizieren den Startbefehl und auf welchem Port unsere Anwendung lauscht.
Um nun mittels Docker ein Image zu erzeugen, führen wir den Befehl
docker build -t spring-container .
aus. Sollte das Basis-Image lokal noch
nicht vorhanden sein, wird dieses automatisch heruntergeladen. Anschließend
werden die in der Datei vorhandenen Instruktionen, eine nach der anderen,
ausgeführt und es entsteht ein Image mit dem Namen spring-container. Dieses
Image lässt sich nun lokal per Docker mit dem Befehl
docker run -p 8000:8080 spring-container
ausführen. Anschließend sollte die
Anwendung unter http://localhost:8000 erreichbar sein.
Dieser Weg funktioniert für jede ausführbare JAR-Datei, egal welches Framework und welches Build-Tool verwendet wird. Allerdings erfordert er zwei separate Aufrufe. Können wir unser Image nicht direkt mit Maven bauen?
Container mit Maven bauen
Um uns den separaten docker-Befehl zu sparen und alles direkt mit Maven zu bauen, können wir beispielsweise das docker-maven-plugin von fabric8 nutzen. Hierzu ergänzen wir unsere pom.xml um die in Listing 2 gezeigte Plug-in-Definition.
Nun können wir das Goal docker:build verwenden, um ein Image zu erstellen. Das Plug-in nutzt hierzu das bereits vorhandene Dockerfile und nach dem Build ist ein Image unter dem Namen spring-container-fabric8 vorhanden.
Allerdings erfordert das Plug-in, dass die zu verpackende JAR-Datei bereits
existiert. Deswegen rufen wir das Goal idealerweise immer mit dem Goal verify
zusammen, also ./mvnw verify docker:build
, auf.
Neben dem Bauen von Images bietet uns das Plug-in noch weitere Goals, zum Beispiel zum Starten von Containern, an. Zudem ist es auch möglich, anstelle des Dockerfile die Instruktionen direkt in der pom.xml zu pflegen. Listing 3 zeigt, wie dies für unser bisheriges Dockerfile aussehen würde.
Mich persönlich hat der zusätzliche Aufruf von docker bisher nie gestört. Zumeist muss ich lokal die Anwendung auch nicht als Container verpacken, da ich diese lokal aus der IDE heraus starten kann. Möchten wir allerdings mit nur einem Befehl für unseren Build auskommen, kann der Einsatz dieses Plug-ins sinnvoll sein. Ich persönlich würde hierbei den Weg mit separatem Dockerfile bevorzugen, da die XML basierte Konfiguration in der pom.xml doch etwas geschwätzig ist.
Im Grunde haben wir bereits jetzt ein fertiges und funktionierendes Image erstellt. Somit könnte der Artikel an dieser Stelle eigentlich bereits enden. Allerdings gibt es da ein kleines Detail, an das wir noch denken sollten.
Layer-Caching
Konkret geht es bei diesem Detail um das Caching von Filesystem-Schichten. Das Dateisystem von Docker-Containern besteht aus mehreren Schichten. Jeder Layer beinhaltet dabei seine Differenz zum vorherigen Layer. Zur Laufzeit werden dann alle Schichten übereinandergelegt und es entsteht eine finale Sicht auf das Dateisystem.
Damit die Anwendung zur Laufzeit schreiben kann, wird zudem beim Starten des Containers ein neuer Layer hinzugefügt. Somit ist sichergestellt, dass die aus dem Image stammenden Schichten unveränderlich sind und wiederverwendet werden können.
Beim Bauen des Images entsteht dabei, grob gesagt, ein Layer für jede
Instruktion des Dockerfile. Da jede dieser Schichten unveränderlich ist,
werden beim Aufruf von docker build
nur die Instruktionen wirklich ausgeführt,
die sich verändert haben beziehungsweise alle darauffolgenden Instruktionen. Bei
einer ADD
- oder COPY
-Instruktion wird zur Erkennung die Hashsumme der
Datei(en) genommen.
Ein optimales Caching von Schichten hilft vor allem dabei, die Übertragungsmenge und damit auch -zeit der Schichten beim Push oder Pull zu verringern. In den Best Practices für Dockerfile finden sich einige Hinweise, um das Caching optimal auszunutzen. Generell ist dabei die Idee, große Schichten, die sich seltener ändern, weiter oben zu definieren, und Schichten, die sich häufig ändern, ans Ende zu hängen.
Für unsere Beispielanwendung heißt das konkret, dass wir unseren Anwendungscode von den Abhängigkeiten trennen sollten. Das Fat-JAR ist in dieser Beispielanwendung bereits knapp 18 MB groß. Ich habe aber auch schon Fat-JARs mit mehr als 100 MB gesehen. Dabei macht unser Code nur ein paar KB aus, der Großteil entsteht durch die mit eingepackten JAR-Dateien unserer Abhängigkeiten.
Da sich die Anzahl beziehungsweise Version unserer Abhängigkeiten deutlich seltener ändert als der Anwendungscode und diese zudem den Großteil des Plattenplatzes ausmachen, bietet es sich an, als ersten Layer die Abhängigkeiten und anschließend unseren Anwendungscode ins Image zu packen.
Maven-Dependency-Plugin
Ein Weg, dies zu tun, ist der Einsatz des maven-dependency-plugin. Mit diesem können wir unsere Abhängigkeiten herunterladen und anschließend den gesamten Ordner target/dependency als eigenen Layer ins Image hinzufügen. Listing 4 zeigt die Konfiguration des Plug-ins und Listing 5 das angepasste Dockerfile.
Im Gegensatz zu vorher erzeugen wir einen zusätzlichen Ordner /app/lib, in den wir unsere Abhängigkeiten kopieren, und haben den Startbefehl so geändert, dass dieser Ordner in den Classpath aufgenommen wird. Zusätzlich müssen noch zwei weitere kleine Anpassungen in der pom.xml gemacht werden. Zum einen müssen wir den Scope der spring-boot-devtools-Abhängigkeit von runtime auf provided ändern, damit diese nicht mit im fertigen Image landet. Außerdem muss noch das Property spring-boot.repackage.skip auf true gesetzt werden, damit das Fat-JAR nicht mehr automatisch gebaut wird und die JAR-Datei nur noch unseren Anwendungscode beinhaltet.
Bauen wir nun, mit oder ohne fabric8 docker-maven-plugin, ein Image für unsere Anwendung, können wir, sofern wir die Abhängigkeiten nicht ändern, ab dem zweiten Build einen Output ähnlich zu Listing 6 sehen.
Bis Schicht 6 werden die Schichten aus dem Cache genommen und nicht neu gebaut. Erst die JAR-Datei mit unserem Anwendungscode sorgt dafür, dass sich die Schichten ändern müssen. Somit würden, sollten wir dieses Image nun in eine Container-Registry pushen, nur noch die drei Schichten 6, 7 und 8 übertragen werden. Da diese in Summe nur wenige KB groß sind, sollte dies sehr schnell gehen.
Der Vorteil, das maven-dependency-plugin zu nutzen, besteht darin, dass dieser Weg mit jeder Art von Java-Anwendung, die über eine eigene main-Methode verfügt, funktioniert. Zudem sind weiterhin alle Schritte sehr explizit und setzen wenig implizites Wissen voraus. Dafür sollten wir Wissen über Docker und dessen Caching besitzen, da wir neben der Anwendung auch das Dockerfile warten müssen.
Jib
Obwohl ich am vorherigen Ansatz schätze, dass er sehr explizit ist und ich die volle Kontrolle über alles habe, kann ich verstehen, dass andere Menschen und Situationen andere Prioritäten fordern.
Eine Lösung, die mit weniger Konfiguration, dafür aber mehr impliziten Dingen
auskommt, ist Jib. Jib wurde von Google dafür gebaut, mit wenig Wissen über
Docker ein optimales Image zu erzeugen. Dazu läuft Jib mit im Build-Prozess und
muss nicht als Extra-Schritt ausgeführt werden. Listing 7 zeigt die
Konfiguration des Maven-Plug-ins, das anschließend beim Aufruf von
./mvnw verify jib:dockerBuild
ausgeführt wird.
Wie auch beim vorherigen Weg müssen wir zusätzlich den Scope der spring-boot-devtools auf provided ändern, damit diese nicht mit im fertigen Image landen.
Als Basis-Image nutzt Jib hier, die auch von Google bereitgestellten, „Distroless“ Docker Images. Dies sind spezielle Images, die ohne die normalerweise vom Betriebssystem bereitgestellten Dinge wie Shells oder andere Tools auskommen. Im Falle des Java-Distroless Images sind hier lediglich das JDK und dessen Abhängigkeiten vorhanden. Dies hat den Vorteil, dass die Images relativ klein sind und sicherheitstechnisch weniger Angriffsfläche bieten.
Wollen wir bei der Nutzung von Jib sogar auf Docker verzichten, müssen wir das Maven-Goal jib:build nutzen und zusätzlich eine Container-Registry samt Zugangsdaten konfigurieren.
Diese Variante punktet durch den geringen Konfigurationsaufwand, ein schmales Image und einer schnellen Gesamtzeit beim Bauen. Allerdings wird uns auch ein Teil der Kontrolle abgenommen und wir müssen uns darauf verlassen, dass Jib alles richtig macht und dessen Defaults auch zu uns passen.
Spring-Boot Buildpacks
Bei einer Spring-Boot basierten Anwendung wird uns ab Version 2.3 ein Jib ähnlicher Weg geboten, der auf Buildpacks basiert. Hierzu können wir ohne weitere Konfiguration das Maven-Goal spring-boot:build-image aufrufen.
Während des Ausführens erkennt Buildpacks nun von selbst, welche Art von Anwendung wir paketieren wollen, und leitet daraus ab, wie das Image idealerweise zu bauen ist. Die ursprüngliche Idee hierzu ist bereits 2011 von Heroku für deren Platform-as-a-Service genutzt worden. Zum Zeitpunkt des Schreibens befindet sich Spring-Boot 2.3 zwar noch in der Entwicklung, die Chance ist jedoch hoch, dass es mit Erscheinen des Artikels bereits ein fertiges Release gibt.
Der Vorteil an dieser Lösung besteht darin, dass diese direkt in Spring-Boot integriert ist. Deswegen müssen wir nichts zusätzlich konfigurieren und haben keine weitere Abhängigkeit. Im Gegenzug geben wir aber auch hier Kontrolle ab. Im Gegensatz zu Jib ist der interne Prozess, um das Image zu erstellen, durch das Zusammenspiel der Buildpacks ein wenig komplizierter.
Fazit
Wir haben uns insgesamt fünf Wege angeschaut, um eine Java-Anwendung als Container-Image zu verpacken. Das Spektrum reicht vom Verpacken eines Fat-JARs mittels Dockerfile bis zu einem ausgefeilten Build-Prozess mit automatischer Erkennung von Aspekten beim Einsatz von Buildpacks.
Ich persönlich bevorzuge einen möglichst expliziten Weg unter Berücksichtigung von Schichten, da ich gerne eine hohe Kontrolle über die Schritte im Build-Prozess behalten möchte. Das vorhandene Spektrum sollte jedoch eigentlich für jeden Geschmack und die meisten Anwendungsfälle mindestens einen passenden Weg bieten. Wenn nicht, lassen sich mit Sicherheit noch weitere Variationen der vorgestellten Wege finden.
Wie immer freue ich mich über Fragen, Anregungen oder Kritik über die bekannten Kontaktmöglichkeiten.