Docker Best Practices: Keine “FAT JARs” mehr!

Dieser Beitrag unserer Blog-Serie beleuchtet, warum die so häufig genutzten “Fat” oder “Uber” Jars und auch WAR-Dateien schlecht für die Bereitstellung mit Docker sind und welche sinnvollen Alternativen existieren.

Dieser Artikel ist Teil der Serie “Docker Best Practices”. Hier die Links auf die bisherigen Beiträge:

WAR-Dateien werden vom startenden Application Server in der Regel ausgepackt und der Inhalt als einzelne Dateien bereitgestellt. Es ist zwar der einfachste Weg, eine Web-Anwendung bereitzustellen, allerdings ist es auch sehr unnötig, dass dieses Auspacken jeder neu gestartete Container ausführen muss. Es lässt sich verhindern, indem das WAR in ausgepacktem Zustand zum Image hinzugefügt oder via Script beim Build in den Server deployt wird.

Diese Optimierung mag klein erscheinen, aber alle Änderungen, die Docker im laufenden Container am Dateisystem machen muss, finden je Container x-mal isoliert statt. Wären diese Änderungen im Image bereits vorbereitet, würden alle laufenden Container eines solchen Image auf einer Maschine sogar physisch die gleichen Dateien aus dem Cache des Docker-Hosts nutzen. Je mehr bzw. je größer die Dateien und je mehr Container desto größer die Einsparung.

Ebenso kann es aussehen, wenn wir mit Java die üblichen Fat- oder Uber-Jars ausliefern. Es werden mehrere Services betrieben – alle mit ihrer eigenen Java VM und jeder Service liefert alle Abhängigkeiten als Fat Jar mit.

Für die Java-Prozesse (jeder mit einer verschiedenen Java-Version) auf dieser Maschine bedeutet dies, 240 MB Jar-Files zu laden. Dabei gilt für die meisten Anwendungen folgendes:

  • Java-Versionen und das Basis-Image sollten sich vereinheitlichen oder zumindest reduzieren lassen.
  • Das sich eigentlich ändernde Artefakt, üblicherweise ein Jar, ist sehr klein (<1MB, wenn nicht nur wenige hundert KB groß).
  • Die Änderungen sind nicht nur auf einer Maschine relevant, sondern in heutigen Projekten wird mit jedem Commit und jedem Branch ein solches Image hergestellt und durch die Infrastruktur transportiert. Dieses multipliziert sich völlig unnötig auf.
  • Dependencies ändern sich deutlich seltener, sind aber viel größer. Das Gleiche gilt für die hoffentlich zentral gemanagten Basis-Images die sich zumindest während der Entwicklungsphase eines Projektes viel seltener ändern, als die einzelnen Services.

Im Idealfall produziert ein MicroService-Build also nur ein kleines JAR-File, und viele Services beziehen sich auf möglichst das gleiche Basis-Image mit den Dependencies. Dann sieht das für eine Docker-Maschine viel freundlicher aus. Es werden physisch nur noch 64MB geladen – auch wenn jede VM im Container immer noch unabhängig arbeitet.

Wie kann man dieses Image nun herstellen, ohne sich mit Maven & Co. unnötig zu verbiegen? Wir wollen natürlich trotzdem, dass die Dependencies im Ergebnis immer korrekt sind, auch wenn das Basis-Image diese nicht alle oder sogar zu viel mitbringt. Es sei das Basis-Image mit Java gegeben und die Dependencies liegen in einem eigenen Layer im Verzeichnis /app-lib. Dazu kann z. B. das maven-dependencies-plugin genutzt werden. Das Goal “copy-dependencies” kopiert alle Dependencies in ein Verzeichnis.
So kann das Basis-Image (blau gestrichelter Kasten oben) mit einem eigenen Dockerfile erstellt werden:

$ cat Dockerfile-basis
FROM java:13
COPY target/dependency/* /app-lib/

$ mvn clean dependency:copy-dependencies
...
[INFO] ------------------------------------------------------------------------
[INFO] BUILD SUCCESS
[INFO] ------------------------------------------------------------------------
...
$ IMG=my.registry.de/base/dependencies:13-$(git rev-parse --short HEAD)
$ docker build . -t $IMG
$ docker push $IMG

Das Dockerfile der Anwendung kann dann aussehen wie folgt:

$ cat Dockerfile
FROM my.registry.de/base/dependencies:13-831b804
COPY target/app.jar /app.jar
CMD [ "java","-classpath", "/app.jar:/app-lib/*", "my.main.Class" ]

Mit dem Build-Cache von Docker funktioniert dieser Prozess sogar auch in einem Dockerfile, wenn immer auf der gleichen Maschine gebaut wird. Docker erkennt, dass sich die Dateien nicht verändert haben und nutzt die bereits gebauten Layer der Images aus dem Cache erneut.

Die sichere Lösung

In einer Cloud-Umgebung ist der Docker-Cache eventuell nicht nutzbar, weil sich die ausführende Maschine permanent ändert. Dann muss man die Abhängigkeiten in ein eigenes Basis-Image legen. Das ist aber fehleranfällig, wenn jemand die Abhängigkeiten ändert, aber nicht das Basis-Image.

Auch das lässt sich automatisieren. Der Build wird dann aber etwas komplexer: Für das eigentliche Image nutzen wir jetzt einen MultiStage-Build, da wir zuerst die aktuellen mit den alten Dependencies abgleichen müssen, ohne diesen Vergleich im Ergebnis-Image haben zu wollen.

  1. Das Basis-Image enthält die Dependencies zum Build-Zeitpunkt des Basis-Images.
  2. Beim Maven-Build werden die aktuellen Dependencies erneut kopiert und in das Verzeichnis /app-lib-current kopiert.
  3. Über ein Script vergleicht man /app-lib mit /app-lib-current und kopiert die zusätzlichen Dependencies nach /app-lib-new. Dabei treten neue (zu kopieren), überflüssige (zu löschen) und identische Dateien (keine Aktion notwendig) auf. Letztere sollten die häufigsten sein.
  4. Unnötige Dependencies die in /app-lib aber nicht mehr in /app-lib-current auftauchen, werden in ein Löschskript mit rm-Befehlen eingetragen.
  5. Jetzt starten wir die zweite Stage, beginnen erneut mit dem Basis-Image und kopieren/app-lib-new nach /app-lib, was in diesem Beispiel aus 2 MB neuen Dependencies plus dem Löschskript besteht.
  6. Dann starten wir das Löschskript. Der dadurch entstehende Layer enthält nur die Information über die gelöschten Dateien und hat daher niemals eine nennenswerte Größe.
  7. Am Ende wird das eigentliche Jar der App hinzugefügt.

Warum dieser Aufwand

Mit sinkenden Kosten für Speicherplatz stellt sich natürlich die Frage, warum man diese Optimierungen durchführen sollte. Dazu können die folgenden Argumente angeführt werden:

  • Docker isoliert einzelne Prozesse auch bzgl. deren Dateisystem. Wenn wir es schaffen, dass diese aus gemeinsam genutzten Images kommen, werden physisch die gleichen Dateien auf dem Host genutzt.
  • Mit MicroServices steigt die Anzahl der Container massiv an, was früher gemeinsam in eine Laufzeitumgebung installiert wurde bringt mit Docker sein komplettes Dateisystem mit. Der Host betreibt damit schlimmstenfalls mehrere Versionen der Libraries, Java-VMs und der Anwendungsabhängigkeiten.
  • In Cloud-Infrastrukturen ist die Startzeit eines Services für die Plattform durchaus ein relevanter Faktor. Dieser wurde in einem konkrekten Beispiel durch das MicroService-Layout um 25% verbessert.
  • Äußerst relevant ist die Optimierung außerdem für die Entwicklung. Dank CI/CD mit automatischen Deployments und Jenkins Multibranch-Pipeline-Builds steigt die Anzahl der Builds eines Teams plötzlich massiv an.
  • On-Premise wird man vielleicht doch nach dem Speicherbedarf der CI/CD-Umgebung gefragt und kann hier durch die genannten Maßnahmen massiv einsparen. 10 Services mit Entwicklung in 4 Branches mit je 10 Builds zu jeweils 60MB sind anhand des Beispiels oben normalerweise 24 GB Docker Images – optimiert nur 80MB.

Call to action

An dieser Stelle statt des üblichen Fazits ein “Call to action”. Mit “docker pull <image>” sowie “docker history <image>” sind die letzten 3 Images einer Anwendung schnell geprüft. Wenn sich viele und vor allem große Layer mit jedem Build ändern, was bei “docker history” am Datum der Layer erkennbar ist, dann kann hier optimiert werden.

Short URL for this post: https://wp.me/p4nxik-3r0
This entry was posted in Java Runtimes - VM, Appserver & Cloud and tagged , . Bookmark the permalink.

2 Responses to Docker Best Practices: Keine “FAT JARs” mehr!

  1. Pingback: Docker Best Practices: Sortierte Layer | techscouting through the java news

  2. Pingback: Docker Best Practices: Alle Wege führen nach Docker | techscouting through the java news

Leave a Reply