Images für Java-Anwendungen bauen

Das Runde muss ins Eckige

Vielfach werden für den Betrieb von Java-Anwendungen Container verwendet. Das setzt voraus, dass die Anwendung als Container-Image zur Verfügung gestellt wird. Doch wie entsteht ein solches Image? Und worauf sollten wir achten? Dieser Artikel zeigt mehrere Wege, wie wir für eine Anwendung zu einem solchen Image gelangen können und worauf wir dabei achten sollten.

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.

FROM adoptopenjdk/openjdk11:jdk-11.0.7_10-alpine-slim

RUN mkdir -p /app && \
    chown -R daemon /app

USER daemon
WORKDIR /app

COPY ./target/spring-container-*.jar /app/spring-container.jar

CMD ["java", "-jar", "/app/spring-container.jar"]
EXPOSE 8080
Listing 1: Fat-JAR 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.

...
<plugin>
  <groupId>io.fabric8</groupId>
  <artifactId>docker-maven-plugin</artifactId>
  <version>0.33.0</version>
  <configuration>
    <images>
      <image>
        <name>spring-container-fabric8</name>
      </image>
    </images>
  </configuration>
</plugin>
...
Listing 2: Docker-Maven-Plug-in von fabric8

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.

...
<plugin>
  <groupId>io.fabric8</groupId>
  <artifactId>docker-maven-plugin</artifactId>
  <version>0.33.0</version>
  <configuration>
    <images>
      <image>
        <name>spring-container-fabric8</name>
        <build>
          <from>adoptopenjdk/openjdk11:jdk-11.0.7_10-alpine-slim</from>
          <runCmds>
            <run>mkdir -p /app &amp;&amp; chown -R daemon /app</run>
          </runCmds>
          <user>daemon</user>
          <workdir>/app</workdir>
          <assembly>
            <targetDir>/app</targetDir>
            <descriptorRef>artifact</descriptorRef>
          </assembly>
          <cmd>
            <exec>
              <arg>java</arg>
              <arg>-jar</arg>
              <arg>/app/${project.artifactId}-${project.version}.jar</arg>
            </exec>
          </cmd>
          <ports>
            <port>8080</port>
          </ports>
        </build>
      </image>
    </images>
  </configuration>
</plugin>
...
Listing 3: fabric8-Docker-Maven-Plug-in ohne Dockerfile

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.

...
<plugin>
  <artifactId>maven-dependency-plugin</artifactId>
  <executions>
    <execution>
      <goals>
        <goal>copy-dependencies</goal>
      </goals>
      <configuration>
        <includeScope>runtime</includeScope>
      </configuration>
    </execution>
  </executions>
</plugin>
...
Listing 4: Konfiguration des maven-dependency-plugin
FROM adoptopenjdk/openjdk11:jdk-11.0.7_10-alpine-slim

RUN mkdir -p /app/lib && \
    chown -R daemon /app

USER daemon
WORKDIR /app

COPY ./target/dependency/ /app/lib
COPY ./target/spring-container-*.jar /app/spring-container.jar

CMD [ "java", \
      "-classpath", \
      "/app/spring-container.jar:/app/lib/*", \
      "de.mvitz.spring.container.Application" ]
EXPOSE 8080
Listing 5: Dockerfile für maven-dependency-plugin

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.

Sending build context to Docker daemon19.84MB
Step 1/8 : FROM adoptopenjdk/openjdk11:jdk-11.0.7_10-alpine-slim
  ---> 6e24b2c53f87
Step 2/8 : RUN mkdir -p /app/lib && ...
  ---> Using cache
  ---> d38bca5bb573
Step 3/8 : USER daemon
  ---> Using cache
  ---> 5ec27e5709aa
Step 4/8 : WORKDIR /app
  ---> Using cache
  ---> caaba9b04bd9
Step 5/8 : COPY ./target/dependency/ /app/lib
  ---> Using cache
  ---> 25be6bb1c773
Step 6/8 : COPY ./target/spring-container-...
  ---> 9588c648d175
Step 7/8 : CMD [ "java", "-classpath", ...
  ---> Running in 4f0d36a08a5d
Removing intermediate container 4f0d36a08a5d
  ---> a5bca05c5b9c
Step 8/8 : EXPOSE 8080
  ---> Running in b6f6ecd02b3b
Removing intermediate container b6f6ecd02b3b
  ---> 7b0d65356b8f
Successfully built 7b0d65356b8f
Successfully tagged spring-container:latest
Listing 6: Docker-build-Ausgabe mit Schichten für Abhängigkeiten

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.

...
<plugin>
  <groupId>com.google.cloud.tools</groupId>
  <artifactId>jib-maven-plugin</artifactId>
  <version>2.1.0</version>
  <configuration>
    <to>
      <image>spring-container-jib</image>
    </to>
  </configuration>
</plugin>
...
Listing 7: Konfiguration des jib-maven-plugin

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.

TAGS

Comments

Please accept our cookie agreement to see full comments functionality. Read more