Optimierung Container-basierter Java-Anwendungen

Wie lassen sich stabile Docker-Images für Java-Anwendungen erstellen und diese robust und performant in Containern betreiben? Ein Tutorial.

In Pocket speichern vorlesen Druckansicht 83 Kommentare lesen

(Bild: Da Da Diamond/Shutterstock.com)

Lesezeit: 13 Min.
Von
  • Jochen Christ
Inhaltsverzeichnis

Eine Java-Anwendung wird zur Laufzeit in einer Java Virtual Machine (JVM) ausgeführt. In einem Docker-Container muss also eine Java-Laufzeitumgebung (JRE) vorhanden sein. Ein großer Vorteil von Container-Images ist, dass Entwicklerinnen und Entwickler die benötigte Java-Version genau definieren und testen können.

Seit der geänderten Lizenz- und eingeschränkten Support-Politik seitens Oracle haben einige Hersteller (z. B. IBM, SAP und Azul) sowie Cloud-Anbieter (bspw. Amazon, Alibaba und Microsoft) eigene JDK-Varianten entwickelt. Diese pflegen Security-Patches nach und ergänzen das JDK (Java Development Kit) beispielsweise um eigene Root-Zertifikate. Welches JDK sollte man im Docker-Image also verwenden? Das Oracle JDK darf ohne kostenpflichtige Lizenzierung auf keinen Fall in Produktion eingesetzt werden. Für die Referenzimplementierung des freien OpenJDK gibt es nach sechs Monaten keine Security-Updates mehr, auch nicht für die LTS-Versionen (Long-Term Support).

Empfehlenswert sind OpenJDK-Builds und Docker-Base-Images von AdoptOpenJDK (bzw. Eclipse Adoptium), einem Zusammenschluss mehrerer Java User Groups und Firmen, mit dem Ziel, verlässliche Builds des OpenJDK für unterschiedliche Plattformen anzubieten. Zudem eignen sich die LTS-Versionen (derzeit Version 11) für den produktiven Einsatz, da hierfür langfristig Security-Fixes bereitgestellt werden.

# Dockerfile
FROM adoptopenjdk:11.0.10_9-jre-hotspot
RUN useradd app
USER app
CMD ["java", "-jar", "/app.jar"]
COPY target/*.jar /app.jar

Das obige Listing zeigt ein einfaches Dockerfile mit der JRE (Java Runtime Environment) von AdoptOpenJDK. Während zum Kompilieren noch das komplette JDK benötigt wird, reicht die JRE zur Laufzeit aus. Aus Sicherheitsgründen wird die Anwendung nicht als root-User gestartet. Um Layer-Caching zu verwenden, wird der COPY-Befehl zudem erst ganz zum Schluss ausgeführt. Dieses Dockerfile lässt sich so in Produktion durchaus verwenden, wenngleich man es, wie im Folgenden beschrieben, noch weiter optimieren kann. Die Einfachheit und Verständlichkeit dieser Version hat aber durchaus auch ihre Vorzüge.

AdoptOpenJDK nutzt Ubuntu (bzw. Windows Server Core) als zugrunde liegendes Base-Image, und das Image hat eine beachtliche Größe von 247 MByte, was aufgrund des Cachings der Docker Layer in der Praxis aber kein Problem darstellen sollte. Wer hier optimieren muss, sollte einen Blick auf Googles distroless (Docker-Image: gcr.io/distroless/java:11) mit 197 MByte werfen.

Im Container sollte für das Ausführen der Anwendung die gleiche Version des JDK (Distribution, Version und Architektur) wie bei der Kompilierung und den Tests genutzt werden, um zur Laufzeit keine Überraschungen beispielsweise durch veränderte Klassenversionen oder Änderungen im Classpath zu erhalten.

In den bisherigen Beispielen wurde die Java-Anwendung (app.jar) in einem vorherigen Schritt (in der Regel durch eine Continuous Integration Pipeline) erstellt. Durch Einführung von Multi-Stage Docker Builds kann dieser Schritt aber auch im Rahmen des Docker-Build-Prozesses erfolgen, hier als Beispiel das Dockerfile mit Maven:

# maven build
FROM adoptopen:11.0.10_9-jdk-hotspot as build
WORKDIR /app
COPY mvnw .
COPY .mvn .mvn
COPY pom.xml .
RUN ./mvnw dependency:go-offline

COPY src src
RUN ./mvnw package

# docker image
FROM adoptopenjdk:11.0.10_9-jdk-hotspot
RUN useradd app
USER app
CMD ["java", "-jar", "/app.jar"]
COPY --from=build /app/target/*.jar /app.jar

Die Angabe der gleichen JDK- beziehungsweise JRE-Version stellt sicher, dass die Build- und Laufzeitversionen zusammenpassen. Der Zwischenschritt RUN dependency:go-offline sorgt übrigens dafür, dass die Maven-Dependencies nur bei einer geänderten pom.xml neu geladen werden, da Docker beim Build sogenannte Intermediate Layers verwendet. Das beschleunigt den Build-Prozess in der Regel erheblich.