Optimierung von Docker-Images für Java

By Gerald Mücke | October 25, 2017

Optimierung von Docker-Images für Java

Docker ist eine beliebte Technologie zur Erstellung von Laufzeitumgebungen für Server und ganze Systeme. Docker-Images lassen sich leicht verteilen, bereitstellen und starten. Doch gerade bei der Verteilung profitiert man von schlanken Images - große Images benötigen Zeit zur Übertragung, besonders wenn dies häufig geschieht, kann dies einen echten Einfluss auf die Entwicklungsgeschwindigkeit haben. In diesem Artikel werde ich einige bewährte Verfahren zur Reduzierung oder Optimierung der Bildgröße vorstellen.

Docker ist eine sehr praktische Technologie zur Realisierung von unveränderlichen Servern. Das Docker-Image, das den Dienst enthält, wird in einem Playbook - der Docker-Datei - definiert. Anders als bei VMWare oder Virtual Box sind Docker-Images jedoch keine einzelnen, opaken Dateien, sondern bestehen aus Schichten. Jede Anweisung in der Docker-Datei, die Inhalt zum Dateisystem hinzufügt, löscht oder ändert, fügt eine weitere Schicht hinzu. Dies ist wichtig zu wissen, denn das Entfernen einer Datei aus dem Dateisystem entfernt sie nicht tatsächlich, es ist eher so, als würde man eine neue Reihe von 0 Bytes hinzufügen, um die Datei wieder zu löschen. Es ist ähnlich wie bei Mehrsitzungs-CDs, wo eine Datei von der Disk entfernt werden kann, aber physisch eingebrannt bleibt.

Bei der Verteilung wird für jede Schicht ein Hashwert berechnet und mit dem Hashwert einer zuvor übertragenen Schicht verglichen. Nur Schichten, die sich geändert haben, werden übertragen. Dies ist wichtig zu wissen, aber wir werden später darauf zurückkommen.

Kleine Images sparen Speicherplatz, was insbesondere in Build-Umgebungen mit häufigen Builds wichtig ist. Außerdem, da nur geänderte Schichten gespeichert werden, sollte die Größe jeder Schicht mit dem tatsächlichen Änderungssatz zusammenhängen. Dies spart nicht nur Speicherplatz, sondern beschleunigt auch den Entwicklungs- und Bereitstellungsprozess, da weit weniger Daten übertragen werden müssen. Der Einfluss ist umso größer, je häufiger die Anwendung gebaut und bereitgestellt wird.

Jetzt werfen wir einen Blick darauf, wie man kleine Images erstellt.

Verwenden Sie ein schlankes Basis-Image

Zuerst und am wichtigsten ist es, mit einer so schlanken Basis wie möglich zu beginnen. Natürlich könnten Sie mit allem von Grund auf beginnen

FROM scratch
...

Das gibt Ihnen die meisten Optionen, um ein schlankes Image zu erstellen, aber der Nachteil ist, dass Sie alles Nützliche selbst hinzufügen müssen.

Wenn Sie nicht alles selbst machen wollen, aber mit einer vernünftig kleinen Basis beginnen möchten, verwenden Sie ein Alpine-Image, das eine spezielle Linux-Distribution für die Erstellung schlanker Binärdateien für Container-Verwendung ist. Es kommt mit seinem eigenen Paketmanager. Der wichtigste Nachteil ist, dass es nicht den GCC-Compiler für die Erstellung der Binärdateien verwendet, sondern die musl libc, aber das sollte in den meisten Fällen kein Problem sein.

Für die Erstellung eines Images mit Alpine verwenden Sie eine seiner Varianten:

FROM alpine:latest
...

Eine Übersicht über die verschiedenen Größen von Docker-Basis-Images finden Sie in diesem Docker Base Image OS Size Comparison.

Wenn Sie ein alpine-basiertes Image mit Java-Unterstützung benötigen, hat Anapsix eine Reihe von Alpine-basierten Images mit verschiedenen Arten von Java-Unterstützung erstellt - mit JRE, mit JDK, mit unbegrenzter Verschlüsselungsunterstützung usw. - alle basierend auf der Oracle Java Distribution. In den meisten Fällen reichen die JRE-Versionen aus, um Ihre Systeme zu betreiben.

FROM anapsix/alpine-java:8_server-jre
...

Das ist ungefähr 48 MB groß.

Die Modularisierungsunterstützung von Java 9 ermöglicht die Erstellung einer modularisierten Laufzeitumgebung mit nur den Teilen des JRE, die Sie tatsächlich benötigen. Dies ermöglicht ein noch kleineres Image.

Das Basis-Image, das Sie für Ihren Dienst verwenden, wird in der Regel nur einmal oder jedes Mal übertragen, wenn Sie das Basis-Image aktualisieren. Die Größe ist also wichtig, aber nicht so wichtig wie die Größe des volatileren Inhalts.

Befehle verketten

Die zweite wichtige Technik besteht darin, Befehle, die entgegengesetzte Effekte haben (Erstellen und Entfernen einer Datei), zu verketten, um weniger und kleinere Schichten zu erzeugen.

Zum Beispiel anstatt:

RUN curl http://source/of/my/file.zip -o myfile.zip
RUN unzip myfile.zip
RUN rm myfile.zip

was dazu führen würde, dass das Dateisystem immer noch die gleiche Größe hat, als ob man die myfile.zip nicht entfernt hätte, würden Sie die Befehle mit && verketten und einen Backslash \ für eine bessere Lesbarkeit hinzufügen:

RUN curl http://source/of/my/file.zip -o myfile.zip && \
unzip myfile.zip && \
rm myfile.zip

Gleiches gilt für die Installation von Paketen mit einem Paketmanager. Jeder Paketmanager hält Zwischenspeicher oder temporäre Dateien, die nach der Installation entfernt werden können:

RUN apt-get update && \
apt-get install -y --no-install-recommends curl && \
apt-get autoremove && \
rm -rf /var/lib/apt/lists/*

Bauen Sie keine Fat-Jars!

Fat-Jars sind eine sehr bequeme Distributionsform für die Erstellung ausführbarer Jar-Dateien. Alle Abhängigkeiten werden in einer einzigen Jar-Datei zusammengeführt. Keine zusätzlichen Jar-Dateien werden benötigt, das Fat-Jar enthält alles, was es braucht. Sie müssen also nur eine einzige, dicke Jar-Datei verteilen. Für Docker-Images bedeutet das eine einzelne ADD myFat.jar-Anweisung.

Was ist also das Problem mit Fat-Jars?

Fat-Jars bringen eine Reihe von Problemen mit sich.

  • Fat-Jars könnten rechtliche Risiken mit sich bringen - die durch ordnungsgemäßes Lizenz-Check & Abhängigkeitsmanagement gemildert werden könnten.
  • Zusammenfügen oder Schattieren könnte Ihre Archive beschädigen, falls im Klassenpfad Ressourcen mit demselben Namen vorhanden sind. Dies ist besonders ein Problem, wenn einige Klassenpfad-Ressourcen konventionsgemäß benannt sind und nicht umbenannt werden können. Zum Beispiel eine beans.xml-Datei.
  • Sie verstoßen gegen das Prinzip der Trennung von Anliegen, indem sie Laufzeit und Geschäftslogik in einer einzigen Jar-Datei zusammenführen. Manche argumentieren, dass die Vermischung von Anliegen durch das Einsetzen in ein Docker-Image ohnehin vernachlässigt werden kann.

Besonders der letzte Punkt ist für Docker relevant. Beide Seiten des Arguments Pro vs. Contra Fat-Jars haben einen Punkt. Aber lassen Sie uns dieses Thema genauer betrachten.

Das wichtigste Problem bei Fat-Jars, die Laufzeit und Geschäftslogik vermischen, ist, dass die Laufzeit (Plattform oder Abhängigkeiten) sich weit weniger häufig ändert als die Geschäftslogik, besonders, aber nicht nur, während der Entwicklung. Außerdem macht die eigentliche Geschäftslogik nur einen winzigen Bruchteil im Vergleich zu den Abhängigkeiten aus, und Änderungen treten meist nur in diesem Bereich auf. Ein ganzes Fat-Jar könnte leicht auf mehrere Dutzend oder Hunderte von Megabyte anwachsen. Die Gesamtgröße ist typischerweise groß im Vergleich zur Geschäftslogik, die vielleicht nur ein paar Megabyte umfasst, manchmal sogar weniger als 1 MB.

Angesichts des geschichteten Dateisystems von Docker führt das Hinzufügen der eigentlichen Anwendung als Fat-Jar zum Image dazu, dass eine Schicht von mehreren MB hinzugefügt wird. Die Verteilung des Images erfordert die Übertragung der geänderten Schicht mit diesem Fat-Jar. Aber nur ein winziger Bruchteil des Fat-Jars, die Geschäftslogik, hat sich tatsächlich geändert.

Daher bringt die Trennung von Laufzeit und Geschäftslogik den Vorteil, dass, solange die Laufzeit stabil bleibt, nur die kleine Geschäftslogik zu einer Schicht hinzugefügt und verteilt werden muss.

Um Ihnen einen Eindruck von den Auswirkungen zu geben, habe ich zwei praktische Beispiele.

Ich habe einen Vert.x-Microservice erstellt. Das Image hatte die folgenden Schichten mit Größen:

  • anapsix/alpine-java: ~50 MB
  • Fat-Jar mit Vert.x, Abhängigkeiten und Geschäftslogik: ~8MB

So musste ich jedes Mal, wenn sich der Microservice änderte, 8MB verteilen. Das klingt nicht nach viel, es sei denn, Sie sind über 4G aus einem fahrenden Zug verbunden (was ich oft bin).

Nach der Trennung des Fat-Jars hatte ich die folgenden Schichten:

  • anapsix/alpine-java: ~50 MB
  • Abhängigkeiten: 7,2 MB
  • Geschäftslogik: 0,8 MB

Jetzt musste ich nur noch 0,8 MB übertragen, was selbst bei einer langsamen Verbindung nur eine Frage von Sekunden ist. Aber auch Entwickler mit festen Verbindungen werden den Unterschied bemerken, wenn sie ein solches Image mehrmals am Tag verteilen.

In einem anderen Fall, einem Kundenprojekt, war das Fat-Jar ~ 120 MB groß, mit nur 5 MB Geschäftslogik.

Im Falle einer Java EE-Anwendung oder eines Microservices kann es in Anwendungsplattform - den Java EE Application Server oder Java EE-Bibliotheken, die als separate Schicht Teil des Images sein können, und die Abhängigkeiten der Anwendung aufgeteilt werden. Die Schichten eines solchen Systems könnten sein:

  • das Betriebssystem (Basis-Image)
  • die Anwendungslaufzeit
  • Anwendungsabhängigkeiten
  • die Geschäftslogik

Weitere Informationen zu diesem Thema finden Sie in Building, Packaging und Distributing Java EE Applications in 2017

Nun, wie würden Sie eine solche Anwendung verpacken, unter Verwendung der vorhandenen Werkzeuge. Typischerweise verwenden Ihre Projekte entweder Maven oder Gradle. Ich gehe davon aus, dass Gradle ähnliche Plugins wie Maven hat, aber da Maven eine größere Verbreitung hat, zeige ich nur Maven-Beispiele.

Abhängigkeiten kopieren

Zuerst ist es gut, alle Laufzeitabhängigkeiten Ihres Dienstes an einem Ort zu haben. Das erreichen Sie mit dem Dependency-Plugin.


<plugin>
    <groupId>org.apache.maven.plugins</groupId>
    <artifactId>maven-dependency-plugin</artifactId>
    <version>3.0.2</version>
    <executions>
        <execution>
            <id>copy</id>
            <phase>package</phase>
            <goals>
                <goal>copy-dependencies</goal>
            </goals>
            <configuration>
                <includeScope>compile</includeScope>
                <outputDirectory>target/lib</outputDirectory>
            </configuration>
        </execution>
    </executions>
</plugin>

Dies legt alle Abhängigkeiten in einen lib-Ordner.

Skinny Jar erstellen

Als nächstes erstellen Sie ein Skinny-Jar (kein Fat-Jar) mit Ihrer Anwendung, aber geben eine Hauptklasse an, wie Sie es für ein Fat-Jar tun würden, damit Sie das Jar ausführbar machen (<mainClass>). Weiterhin fügen Sie alle Abhängigkeiten in die Manifest-Datei des Jars ein (<addClasspath>), wobei auf Ihren Lib-Ordner verwiesen wird (<classpathPrefix>).

Dies fügt jede Abhängigkeits-Jar-Datei in einen Klassenpfadabschnitt der Manifest-Datei ein, vorangestellt mit lib/.


<plugin>
    <groupId>org.apache.maven.plugins</groupId>
    <artifactId>maven-jar-plugin</artifactId>
    <version>3.0.2</version>
    <configuration>
        <finalName>my-skinny-service</finalName>
        <archive>
            <index>true</index>
            <manifest>
                <mainClass>com.example.Main</mainClass>
                <addClasspath>true</addClasspath>
                <classpathPrefix>lib/</classpathPrefix>
            </manifest>
        </archive>
    </configuration>
</plugin>

Um Ihr Jar auszuführen, sollte die Ordnerstruktur für Jar und Abhängigkeiten sein

/
+- my-skinny-service.jar
+-/ lib
| + dependency1.jar
| + dependency2.jar
| + ...

Erstellen der Dockerfile

Das Erstellen eines Docker-Images ist jetzt unkompliziert,

  • verwenden Sie ein Basis-Image (Basis-Schicht)
  • kopieren Sie die Abhängigkeiten (Laufzeitschicht)
  • kopieren Sie die Anwendung (Geschäftsschicht)
  • geben Sie den ausführbaren Befehl an
FROM anapsix/alpine-java:8_server-jre_unlimited

RUN ln -s /opt/jdk/bin/java /usr/bin/java

# kopieren Sie alle Abhängigkeiten
COPY ./target/lib/* /opt/service/lib/
# fügen Sie Ihr Skinny-Jar in einem separaten Schritt hinzu
ADD ./target/my-skinny-service.jar /opt/service/service.jar

EXPOSE 12345

CMD /usr/bin/java \
-jar /opt/service/service.jar

Der Kopierschritt erstellt eine separate Schicht, die sich nur ändert, wenn sich Ihre Abhängigkeiten bzw. deren Versionen ändern. Wenn Sie nur Änderungen in Ihrem Skinny-Jar haben, ist die Schicht ziemlich klein und das Pushen/Ziehen erfordert nur einige Kbytes des Skinny-Jars zu übertragen, anstatt eines ganzen Fat-Jars.

Wenn Sie viele externe Abhängigkeiten oder Projekt-Abhängigkeiten haben, die sich viel häufiger ändern als externe Abhängigkeiten, können Sie weiter verbessern, indem Sie die Laufzeitschicht mit Platzhaltern oder Regex-Mustern aufteilen.

Die folgenden Anweisungen kopieren zuerst alle Abhängigkeiten nicht beginnend mit einem bestimmten Präfix, indem sie einen regulären Ausdruck mit Ausschlussklassen verwenden (gefunden in dieser Diskussion). Der folgende Schritt kopiert alle Abhängigkeiten mit diesem Präfix, was in zwei verschiedenen Schichten resultiert.

# kopieren Sie alle außer Abhängigkeiten, die mit abc beginnen
COPY ./target/lib/[^a][^b][^c]* /opt/service/lib/

# kopieren Sie alle Abhängigkeiten, die mit abc beginnen
COPY ./target/lib/abc* /opt/service/lib/
...

Zusammenfassung

In diesem Artikel habe ich diskutiert, warum kleine Docker-Images Ihren Entwicklungs- und Testprozess verbessern und Techniken gezeigt, um die Bildgröße zu reduzieren und die Bildschichtung für die Verteilung zu optimieren, um der Häufigkeit von Änderungen am besten zu entsprechen.