Skip to main content

10 Best Practices für Java-Container mit Docker

Artikel von
hero safe containers

24. August 2022

0 Min. Lesezeit

Anmerkung der Redaktion

Dieser Blogbeitrag wurde ursprünglich am 18. Februar 2021 veröffentlicht und wurde aktualisiert.

Sie möchten also eine Java-Anwendung erstellen und in einem Docker-Image ausführen? Ich helfe Ihnen dabei!

Dieses Cheat Sheet stellt Ihnen Best Practices für die Erstellung eines produktionsreifen Java-Containers vor. Im Beispiel erstellen wir anhand dieser Richtlinien einen Java-Container und konzentrieren uns darauf, ein optimiertes und sicheres Java-Container-Image für Ihre Anwendung zu erstellen. Dieses Cheat Sheet dient als Leitfaden, wenn Sie einen Java-Container mit Docker erstellen, aber auch, wenn Sie den Code anderer überprüfen. In beiden Fällen ist es wichtig, das Docker-Image, das Sie für den Produktiveinsatz vorgesehen haben, zu optimieren und abzusichern.

Cheat Sheet mit dem Titel „10 Best Practices zum Containerisieren von Java-Anwendungen mit Docker“, mit zehn Best Practices für sichere Java-Container-Images.
Cheat Sheet zum Containerisieren von Java-Anwendungen mit Docker

Zehn Best Practices

  1. Verwenden Sie explizite und deterministische Docker-Basis-Image-Tags („latest“ ist keine Version!)

  2. Installieren Sie im Java-Container-Image nur, was Sie für den Produktiveinsatz benötigen

  3. Finden und beheben Sie Sicherheitslücken in Ihrem Java-Docker-Image

  4. Verwenden Sie Multi-Stage-Builds, um die Größe des Produktions-Images weiter zu reduzieren

  5. Führen Sie Java-Anwendungen nicht als Root aus

  6. Behandeln Sie Ereignisse ordnungsgemäß, um eine Java-Anwendung sicher zu beenden

  7. Beenden Sie Java-Anwendungen kontrolliert

  8. Halten Sie unnötige Dateien mit .dockerignore aus Ihren Java-Container-Images heraus

  9. Stellen Sie sicher, dass Java Container berücksichtigt

  10. Seien Sie vorsichtig mit Tools zur automatischen Docker-Container-Generierung

Container-Sicherheit mit Fokus auf Entwickler

Snyk findet und behebt automatisch Schwachstellen in Container-Images und Kubernetes-Workloads.

So erstellen Sie KEIN Java-Container-Image

Beginnen wir mit einer einfachen Dockerfile für eine mit Maven erstellte Java-Anwendung. In vielen Artikeln sehen wir beim Erstellen eines Java-Containers etwas Ähnliches wie im folgenden Beispiel:

Die meisten Blogartikel beginnen und enden mit etwa den folgenden grundlegenden Dockerfile-Anweisungen für die Erstellung von Java-Docker-Images:

FROM maven
RUN mkdir /app
WORKDIR /app
COPY . /app
RUN mvn clean install
CMD "mvn" "exec:java"

Kopieren Sie das in eine Datei namens Dockerfile und erstellen und starten Sie das Image.

$ docker build . -t java-application
$ docker run -p 8080:8080 java-application

Das ist einfach und funktioniert. Allerdings steckt dieses Image voller Fehler! Sie sollten nicht nur wissen, wie Sie Maven richtig verwenden, sondern Java-Container auf keinen Fall wie im obigen Beispiel erstellen. 

Verbessern wir diese Dockerfile und optimieren sie Schritt für Schritt, damit ein effizientes und sicheres Docker-Image für Ihre Java-Anwendung entsteht.

1. Verwenden Sie explizite und deterministische Docker-Basis-Image-Tags

Wenn Sie mit Maven ein Java-Container-Image erstellen, liegt es nahe, als Basis das Maven-Image zu verwenden – Sie können es ganz einfach von Dockerhub abrufen und loslegen. Wissen Sie jedoch genau, was Sie mit diesem Basis-Image tatsächlich herunterladen? Sie können auf ein Docker-Image über dessen Tag verweisen. Wenn Sie kein Tag angeben, wird jedoch automatisch das implizite Tag latest verwendet. 

Das mag zunächst praktisch erscheinen, doch die Verwendung des standardmäßigen Maven-Images birgt einige potenzielle Probleme:

FROM maven

Das mag zunächst praktisch erscheinen, doch die Verwendung des standardmäßigen Maven-Images birgt einige potenzielle Probleme:

Ihre Docker-Builds sind nicht idempotent 

Das bedeutet, dass das Ergebnis beim erneuten Erstellen völlig anders ausfallen kann. Die aktuellen Images können sich von denen unterscheiden, die morgen oder nächste Woche aktuell sind. Dabei können die Versionen von Maven und dem JDK, auf denen Ihr Image basiert, aktualisiert werden. Dadurch unterscheidet sich der Bytecode Ihrer Anwendung und es kann zu unerwarteten Ergebnissen kommen. Bei der Neuerstellung eines Images möchten wir ein reproduzierbares, deterministisches Verhalten erreichen.

Das Maven-Docker-Image basiert auf einem vollständigen Betriebssystem-Image

Dadurch landen viele zusätzliche Binärdateien in Ihrem späteren Produktions-Image. Viele davon werden zum Ausführen Ihrer Anwendung nicht benötigt. Sie im Java-Container-Image zu haben, bringt jedoch einige Nachteile mit sich:

  • Ein größeres Image bedeutet längere Download- und Neuerstellungszeiten.

  • Die zusätzlichen Binärdateien können Sicherheitslücken enthalten. Jede anfällige Binärdatei stellt ein potenzielles Sicherheitsrisiko dar, das Sie nicht in Ihr System aufnehmen möchten.

Das Image maven:latest ist ziemlich groß und basiert regelmäßig auf Maven- und OpenJDK-Versionen, die sich innerhalb weniger Monate, Wochen oder sogar Tage ändern.

So können Sie das vermeiden:

  1. Verwenden Sie das kleinstmögliche Basis-Image, das Ihren Anforderungen entspricht. Überlegen Sie: Benötigen Sie ein vollständiges Betriebssystem mit all den zusätzlichen Binärdateien, um Ihr Programm auszuführen? Falls nicht, reicht vielleicht ein kleineres Image wie debian-slim oder sogar alpine genauso gut aus wie ein vollständiges Debian-Image.

  2. Geben Sie die Images möglichst genau an. Wenn Sie eine bestimmte Image-Version verwenden, können Sie das Verhalten bereits teilweise steuern und vorhersagen. Mit dem Image maven:3.6.3-jdk-11-slim können Sie sicher sein, dass Sie JDK 11 und Maven 3.6.3 verwenden. Der sechsmonatige JDK-Aktualisierungszyklus wirkt sich dann nicht mehr auf das Verhalten des Java-Containers aus. Noch genauer können Sie vorgehen, indem Sie den SHA256-Hash des Images verwenden. Mit diesem Hash stellen Sie sicher, dass Sie bei jeder Neuerstellung Ihres Images dasselbe Basis-Image verwenden. 

Aktualisieren wir unsere Dockerfile anhand dieser Erkenntnisse:

FROM maven:3.6.3-jdk-11-slim@sha256:68ce1cd457891f48d1e137c7d6a4493f60843e84c9e2634e3df1d3d5b381d36c
RUN mkdir /app
WORKDIR /app
COPY . /app
RUN mvn clean package -DskipTests

Weitere Informationen zur Auswahl sicherer Container-Images finden Sie in unserem Leitfaden zur Container-Sicherheit.

2. Installieren Sie im Java-Container-Image nur, was Sie für den Produktiveinsatz benötigen

Mit dem folgenden Befehl erstellen Sie Ihr Java-Programm – einschließlich aller Abhängigkeiten – im Container. Das bedeutet, dass sowohl der Quellcode als auch das Build-System Teil des Java-Produktionscontainers sind.

RUN mvn clean package -DskipTests

Java ist eine kompilierte Sprache. Daher benötigen wir nur das Artefakt, das Ihre Build-Umgebung erstellt, nicht den Code selbst. Das bedeutet auch, dass die Build-Umgebung nicht Teil Ihres Produktionscontainers sein sollte.

Zum Ausführen eines Java-Images benötigen wir außerdem kein vollständiges JDK; eine JRE genügt. Im Wesentlichen brauchen wir also ein Image mit einer JRE und dem kompilierten Java-Artefakt, sofern es sich um ein ausführbares JAR handelt.

Erstellen Sie Ihr Programm in Ihrer CI-Pipeline mit Maven (oder Gradle) und kopieren Sie die JAR-Datei in Ihr Produktions-Image, wie in der aktualisierten Dockerfile unten:

FROM openjdk:11-jre-slim@sha256:31a5d3fa2942eea891cf954f7d07359e09cf1b1f3d35fb32fedebb1e3399fc9e
RUN mkdir /app
COPY ./target/java-application.jar /app/java-application.jar
WORKDIR /app
CMD "java" "-jar" "java-application.jar"

3. Finden und beheben Sie Sicherheitslücken in Ihrem Java-Container-Docker-Image

Wir verwenden bereits ein kleines Image für meinen Java-Docker-Container. Allerdings weiß ich nicht, ob die Binärdateien in diesem Basis-Image Probleme enthalten. Testen wir unser Docker-Image mit der Snyk CLI. Sie können sich für ein kostenloses Snyk-Konto registrieren, um mitzumachen.

Sie können die CLI mit npm, brew oder scoop installieren oder die neueste Binärdatei von GitHub herunterladen. In diesem Beispiel installieren wir sie mit npm und authentifizieren uns mit unserem kostenlosen Konto:

$ npm install -g snyk
$ snyk auth
$ snyk container test openjdk:11-jre-slim@sha256:31a5d3fa2942eea891cf954f7d07359e09cf1b1f3d35fb32fedebb1e3399fc9e --file=Dockerfile`

Mit snyk container test können wir jedes gewünschte Docker-Image testen. Zusätzlich können wir die Dockerfile angeben, um bessere Empfehlungen zur Behebung zu erhalten.

Terminal mit einem Scan auf Abhängigkeitsschwachstellen: schwerwiegende Probleme in Debian-Paketen und insgesamt 58 gefundene Probleme.

Snyk hat 58 Sicherheitsprobleme in diesem Basis-Image gefunden. Die meisten davon betreffen Binärdateien, die Teil der Debian-Linux-Distribution sind.

Auf Grundlage dieser Informationen wechseln wir zu einem Alpine-basierten openjdk11:JRE-Image von adoptopenjdk und geben den genauen SHA-Hash an.

FROM adoptopenjdk/openjdk11:jre-11.0.9.1_1-alpine@sha256:b6ab039066382d39cfc843914ef1fc624aa60e2a16ede433509ccadd6d995b1f

Beim Testen dieser Version mit snyk container werden keine bekannten Sicherheitslücken in diesem Basis-Image angezeigt.

Auf ähnliche Weise können Sie Ihre Java-Anwendung testen, indem Sie im Stammverzeichnis Ihres Projekts snyk test ausführen. Ich empfehle Ihnen, bei der lokalen Entwicklung sowohl Ihre Anwendung als auch das erstellte Java-Container-Image zu testen. Automatisieren Sie außerdem denselben Test für das Image und Ihre Anwendung in Ihrer CI-Pipeline.

Denken Sie auch daran, dass im Laufe der Zeit neue Sicherheitslücken entdeckt werden. Sobald eine neue Sicherheitslücke bekannt wird, möchten Sie wahrscheinlich benachrichtigt werden.

Mit snyk monitor für Ihre Anwendung und snyk container monitor für Ihre Docker-Images bleiben Sie auf dem Laufenden. Wenn Sie die Version überwachen, die aktuell in der Produktion läuft, können Sie angemessen reagieren, sobald neue Sicherheitsprobleme gefunden werden.

Snyk-Container-Scan mit kritischen Log4j-Schwachstellen zur Remote-Codeausführung im Docker-Image ericsmalling/logstash
Beispiel für einen Snyk-Container-Scan, der eine Maven-Log4J-RCE-Schwachstelle erkennt

Alternativ können Sie auch Ihr Git-Repository mit Snyk verbinden, damit wir Sie dabei unterstützen können, Sicherheitslücken in diesem Teil Ihres SDLC zu finden und zu beheben.

Aktualisieren wir unsere aktuelle Dockerfile:

FROM adoptopenjdk/openjdk11:jre-11.0.9.1_1-alpine@sha256:b6ab039066382d39cfc843914ef1fc624aa60e2a16ede433509ccadd6d995b1f 
RUN mkdir /app
COPY ./target/java-application.jar /app/java-application.jar
WORKDIR /app
CMD "java" "-jar" "java-application.jar"

Weitere Informationen finden Sie in unserem Leitfaden zum Scannen von Docker-Containern auf Sicherheitslücken.

4. Verwenden Sie Multi-Stage-Builds für Ihren Java-Container

Weiter oben in diesem Artikel haben wir erklärt, dass wir unsere Java-Anwendung nicht in einem Container erstellen müssen – wir benötigen nur das Ergebnis dieses Builds. In manchen Fällen ist es jedoch praktisch, die Anwendung als Teil des Docker-Image-Build-Prozesses zu erstellen.

Zum Glück können Sie die Erstellung Ihres Docker-Images in mehrere Phasen aufteilen. Sie können ein Build-Image mit allen Tools erstellen, die Sie zum Erstellen Ihrer Anwendung benötigen, und anschließend in einer zweiten Phase das eigentliche Produktions-Image erstellen, das nur die Teile enthält, die Sie für den Produktiveinsatz benötigen.

FROM maven:3.6.3-jdk-11-slim@sha256:68ce1cd457891f48d1e137c7d6a4493f60843e84c9e2634e3df1d3d5b381d36c AS build
RUN mkdir /project
COPY . /project
WORKDIR /project
RUN mvn clean package -DskipTests

FROM adoptopenjdk/openjdk11:jre-11.0.9.1_1-alpine@sha256:b6ab039066382d39cfc843914ef1fc624aa60e2a16ede433509ccadd6d995b1f
RUN mkdir /app
COPY --from=build /project/target/java-application.jar /app/java-application.jar
WORKDIR /app
CMD "java" "-jar" "java-application.jar"

Verhindern Sie den Verlust vertraulicher Informationen

Wenn Sie Java-Anwendungen und Docker-Images erstellen, müssen Sie sich wahrscheinlich mit einem privaten Maven-Repository verbinden. Normalerweise speichern Sie die entsprechenden Angaben in der Datei settings.xml auf Ihrem lokalen Rechner oder in Ihrer Build-Umgebung. Bei Multi-Stage-Builds können Sie die Datei settings.xml sicher in Ihren Build-Container kopieren. Die Einstellungen mit den Anmeldedaten gelangen nicht in Ihr Produktions-Image. Wenn Sie Anmeldedaten als Befehlszeilenargumente verwenden müssen, können Sie das ebenfalls sicher im Build-Image tun. Sie gelangen nicht in das Produktions-Image.

Mit Multi-Stage-Builds können Sie mehrere Phasen erstellen und nur das Ergebnis in Ihre finalen Produktions-Images kopieren. Wenn Sie Code kompilieren und Images erstellen getrennt behandeln, können Sie sich darauf konzentrieren, im finalen Container-Image nur das bereitzustellen, was zum Ausführen der Anwendung erforderlich ist. So stellen Sie sicher, dass kein Quellcode, keine Compiler und keine anderen Inhalte in Ihre bereitgestellten Umgebungen gelangen.

Übrigens: Sehen Sie sich die Ausgabe von docker history für das erstellte Java-Container-Image an:

$ docker history java-application

Die Ausgabe enthält nur Informationen zum Produktions-Container-Image, nicht zum Build-Image.

Terminalfenster mit dem Docker-Verlauf für ein Java-Anwendungsimage, einschließlich Image-Layern, Erstellungszeitpunkten, Befehlen und Größen.

5. Führen Sie Container nicht als Root aus

Beim Erstellen eines Docker-Containers sollten Sie im Rahmen der Java-Container-Sicherheit das Prinzip der geringsten Berechtigung anwenden (das heißt, nur die tatsächlich benötigten Berechtigungen gewähren). Falls es einem Angreifer gelingt, in Ihre Anwendung einzudringen, möchten Sie nicht, dass er auf alles zugreifen kann. 

Mehrere Sicherheitsebenen tragen dazu bei, den möglichen Schaden eines Angriffs zu begrenzen, falls Ihr System kompromittiert wird. Daher müssen Sie sicherstellen, dass Sie Ihre Anwendung nicht als Root-Benutzer ausführen.

Hier gibt es nur ein Problem: Wenn Sie einen Docker-Container erstellen, wird er standardmäßig als Root ausgeführt. Das ist zwar während der Entwicklung praktisch, aber in Ihren Produktions-Images sollten Sie darauf verzichten. Nehmen wir an, ein Angreifer erhält aus irgendeinem Grund Zugriff auf ein Terminal oder kann Code ausführen. Dann verfügt er über erhebliche Berechtigungen im laufenden Container und kann möglicherweise über Dateisystem-Bind-Mounts mit unangemessen hohen Zugriffsrechten auch auf Host-Dateisysteme zugreifen.  

Die Lösung ist ziemlich einfach. Sehen Sie in der Dokumentation zu Ihrem Basis-Image nach. Wenn es bereits einen Benutzer mit geringeren Berechtigungen enthält, verwenden Sie diesen einfach, indem Sie Ihrer Dockerfile eine Zeile mit USER und dem entsprechenden Benutzer hinzufügen. Andernfalls erstellen Sie einen eigenen Benutzer mit eingeschränkten Berechtigungen, um Ihre Anwendung auszuführen. Testen Sie unbedingt, ob dieser Benutzer die Anwendung ausführen kann. 

Aktualisieren wir unsere Dockerfile entsprechend. Beachten Sie, dass wir uns nur im zweiten Teil des Multi-Stage-Container-Builds um den Benutzer kümmern müssen.

FROM maven:3.6.3-jdk-11-slim@sha256:68ce1cd457891f48d1e137c7d6a4493f60843e84c9e2634e3df1d3d5b381d36c AS build
RUN mkdir /project
COPY . /project
WORKDIR /project
RUN mvn clean package -DskipTests

FROM adoptopenjdk/openjdk11:jre-11.0.9.1_1-alpine@sha256:b6ab039066382d39cfc843914ef1fc624aa60e2a16ede433509ccadd6d995b1f
RUN mkdir /app
RUN addgroup --system javauser && adduser -S -s /bin/false -G javauser javauser
COPY --from=build /project/target/java-application.jar /app/java-application.jar
WORKDIR /app
RUN chown -R javauser:javauser /app
USER javauser
CMD "java" "-jar" "java-application.jar"

Hinweis: Sie können einen anderen Benutzer auch über die Befehlszeile oder in der Konfiguration Ihres Orchestrators festlegen.  Der Docker-CLI-Befehl run verfügt beispielsweise über einen Parameter -u. Kubernetes-Pod-Spezifikationen bieten dieselbe Einstellung über das Feld SecurityContext:runAsUser. Wird der Benutzer jedoch in der Build-Spezifikation angegeben, lässt sich der Vorgang wiederholen.

6. Behandeln Sie Ereignisse ordnungsgemäß, um eine Java-Docker-Webanwendung sicher zu beenden

In vielen Beispielen sehen wir den verbreiteten Fehler, die Build-Umgebung zum Starten der containerisierten Java-Anwendung zu verwenden.

Wir haben bereits besprochen, warum Maven oder Gradle in unseren Java-Docker-Container gehören. Es gibt jedoch weitere Gründe, Dinge wie diese zu vermeiden:

  • CMD “mvn” “exec:java”

  • CMD [“mvn”, “spring-boot run”]

  • CMD “gradle” “bootRun”

  • CMD “run-app.sh”

Wenn eine Anwendung in Docker ausgeführt wird, läuft die erste Anwendung als Prozess mit der Prozess-ID 1 (PID 1). Der Linux-Kernel behandelt PID 1 auf besondere Weise. Normalerweise ist der Prozess mit PID 1 der Init-Prozess. Wenn wir unsere Java-Anwendung mit Maven ausführen, wie können wir sicher sein, dass Maven Signale wie SIGTERM an den Java-Prozess weiterleitet? Das können wir nicht!

Wenn Sie Ihren Docker-Container stattdessen wie im Beispiel unten ausführen, erhält Ihre Java-Anwendung PID 1. So wird sichergestellt, dass Signale korrekt weitergeleitet werden.

CMD "java" "-jar" "application.jar"

Beachten Sie, dass die Befehle docker kill ... und docker stop ... nur Signale an den Container-Prozess mit PID 1 senden. Wenn Sie ein Shell-Skript ausführen, das Ihre Java-Anwendung startet, sollten Sie beachten, dass eine Shell-Instanz wie /bin/sh Signale nicht an untergeordnete Prozesse weiterleitet. Ihre Anwendung erhält daher niemals das Signal SIGTERM.

Wichtig zu wissen: Unter Linux hat PID 1 auch einige zusätzliche Aufgaben. Diese werden im Artikel Docker and the PID 1 zombie reaping problem sehr anschaulich beschrieben. In manchen Fällen möchten Sie nicht PID 1 sein, weil Sie nicht wissen, wie Sie mit diesen Problemen umgehen sollen. Eine gute Lösung ist die Verwendung von dumb-init.

RUN apk add dumb-init
CMD "dumb-init" "java" "-jar" "java-application.jar"

Wenn Sie Ihren Docker-Container so ausführen, belegt dumb-init PID 1 und kümmert sich um alle Aufgaben. Ihr Java-Prozess muss sich nicht mehr darum kümmern.

Unsere aktualisierte Dockerfile sieht nun etwa so aus:

FROM maven:3.6.3-jdk-11-slim@sha256:68ce1cd457891f48d1e137c7d6a4493f60843e84c9e2634e3df1d3d5b381d36c AS build
RUN mkdir /project
COPY . /project
WORKDIR /project
RUN mvn clean package -DskipTests

FROM adoptopenjdk/openjdk11:jre-11.0.9.1_1-alpine@sha256:b6ab039066382d39cfc843914ef1fc624aa60e2a16ede433509ccadd6d995b1f
RUN apk add dumb-init
RUN mkdir /app
RUN addgroup --system javauser && adduser -S -s /bin/false -G javauser javauser
COPY --from=build /project/target/java-code-workshop-0.0.1-SNAPSHOT.jar /app/java-application.jar
WORKDIR /app
RUN chown -R javauser:javauser /app
USER javauser
CMD "dumb-init" "java" "-jar" "java-application.jar"

7. Java-Webanwendungen ordnungsgemäß herunterfahren

Wenn Ihre Anwendung ein Signal zum Herunterfahren erhält, sollten idealerweise alle Prozesse ordnungsgemäß beendet werden. Je nachdem, wie Sie Ihre Anwendung entwickeln, kann ein Interrupt-Signal (SIGINT) oder CTRL + C dazu führen, dass der Prozess sofort beendet wird.

Das ist möglicherweise nicht erwünscht, denn dadurch kann es zu unerwartetem Verhalten oder sogar zu Datenverlust kommen. 

Wenn Sie Ihre Anwendung als Teil eines Webservers wie Payara oder Apache Tomcat ausführen, kümmert sich der Webserver höchstwahrscheinlich um ein ordnungsgemäßes Herunterfahren. Das gilt möglicherweise auch für bestimmte Frameworks, mit denen Sie eine ausführbare Anwendung erstellen können. Spring Boot verfügt beispielsweise über eine eingebettete Tomcat-Version, die das Herunterfahren übernimmt.

Wenn Sie eine eigenständige Java-Anwendung erstellen oder manuell ein ausführbares JAR erstellen, müssen Sie sich selbst um diese Interrupt-Signale kümmern. 

Die Lösung ist ganz einfach. Sie können der Laufzeitumgebung einen Shutdown-Hook hinzufügen, wie im Beispiel unten. Sobald ein Signal wie SIGINT empfangen wird, wird ein neuer Thread gestartet, der ein ordnungsgemäßes Herunterfahren übernehmen kann.

Runtime.getRuntime().addShutdownHook(new Thread() {
   @Override
   public void run() {
       System.out.println("Inside Add Shutdown Hook");
   }
});

Zugegeben, es geht dabei eher um ein allgemeines Thema für Webanwendungen als um Dockerfiles. In orchestrierten Umgebungen ist es jedoch umso wichtiger. Erfahren Sie mehr über die Container-Orchestrierung in unserem Leitfaden.

8. Unnötige Dateien mit .dockerignore aus Ihren Java-Container-Images heraushalten

Um bestimmte Dateien in Ihrem (öffentlichen) Git-Repository auszuschließen, können Sie die Datei .gitignore verwenden. Damit verhindern Sie, dass unnötige Dateien Ihr Git-Repository überladen. Außerdem hilft das Tool dabei, vertrauliche Dateien nicht versehentlich in ein öffentliches Repository gelangen zu lassen. 

Für Docker-Images gibt es etwas Ähnliches: die Datei .dockerignore. Wie die Ignore-Datei für Git ignoriert sie Dateien, die den darin angegebenen Mustern entsprechen. So wird verhindert, dass unerwünschte Dateien oder Verzeichnisse in Ihr Docker-Image kopiert werden.

Technisch funktioniert das so: Beim Start eines docker build-Befehls sendet das CLI-Tool den Inhalt des Build-Kontextverzeichnisses (standardmäßig das Verzeichnis mit der Dockerfile) an die Container-Laufzeitumgebung. Diese Kopie wird für die in der Dockerfile definierten Schritte verwendet. Dateien, die einem Muster in .dockerignore entsprechen, werden nicht kopiert und stehen daher während des Builds für die Befehle COPY oder ADD nicht zur Verfügung. Das beschleunigt auch Builds, da sich der Kontext deutlich schneller kopieren lässt, wenn Sie große Verzeichnisbäume wie .git ausschließen. Besonders deutlich ist der Effekt, wenn Sie mit einer Docker-Engine auf einem anderen Server über eine Netzwerkverbindung bauen.

Das vereinfachte Beispiel in diesem Artikel geht davon aus, dass wir mit einem einzelnen ausführbaren JAR arbeiten. Das ist keineswegs die einzige Möglichkeit, eine Java-Anwendung bereitzustellen. Bei eigenständigen Java-Anwendungen erstellen wir möglicherweise kein Fat-JAR, das alle Abhängigkeiten im Artefakt enthält. Je nach Kontext müssen wir vielleicht vollständige Verzeichnisse mit JARs, von denen unsere Anwendung abhängt, oder andere Dateien kopieren. Vertrauliche Informationen sollen nicht versehentlich in unsere Docker-Images gelangen – insbesondere dann nicht, wenn wir diese Images öffentlich veröffentlichen. 

Hier ein Beispiel für eine .dockerignore:

.dockerignore
**/*.log
Dockerfile
.git
.gitignore

Die Vorteile der Datei .dockerignore:

  • Schließt Abhängigkeiten aus, die Sie nur zu Testzwecken verwenden.

  • Schützt Sie vor dem Offenlegen von Geheimnissen, etwa wenn Zugangsdaten aus den Dateien .env oder aws.json in das Java-Docker-Image gelangen. Beachten Sie, dass auch Debug-Logdateien Geheimnisse oder vertrauliche Informationen enthalten können, die Sie nicht offenlegen möchten.

  • Hält Ihr Docker-Image sauber und kompakt und verkleinert es dadurch. Außerdem hilft das, unerwartetes Verhalten zu verhindern.

  • Beschleunigt Docker-Build-Vorgänge, indem das zu kopierende Build-Kontextverzeichnis verkleinert wird.

9. Sicherstellen, dass Java Container unterstützt

Die Java Virtual Machine (JVM) ist eine erstaunliche Technologie. Sie passt sich an das System an, auf dem sie ausgeführt wird. Durch verhaltensbasiertes Tuning werden beispielsweise Heap-Größen dynamisch optimiert. In älteren Versionen wie Java 8 und Java 9 erkannte die JVM jedoch keine vom Container festgelegten CPU- oder Speicherlimits. Die JVM dieser älteren Java-Versionen sah den gesamten Speicher und alle CPUs des Hostsystems. Im Grunde werden die Einstellungen der Docker-Gruppe ignoriert.

Seit der Veröffentlichung von Java 10 ist die JVM containerfähig und erkennt die durch Container festgelegten Beschränkungen. Das Feature UseContainerSupport ist ein JVM-Flag und standardmäßig aktiviert. Die mit Java 10 eingeführte Container-Unterstützung wurde auf Java-8u191 zurückportiert. 

Bei Versionen vor Java 8 können Sie versuchen, die Heap-Größe manuell mit dem Flag -Xmx zu begrenzen. Das ist jedoch mühsam. Außerdem entspricht die Heap-Größe nicht dem gesamten Speicher, den Java verwendet. Für Java-8u131 und Java 9 ist die Container-Unterstützung experimentell. Sie müssen die experimentellen JVM-Optionen und das Speicherlimit-Flag aktivieren.

-XX:+UnlockExperimentalVMOptions 
-XX:+UseCGroupMemoryLimitForHeap

Am besten aktualisieren Sie auf eine neuere Java-Version als 10, damit die Container-Unterstützung standardmäßig aktiviert ist. Leider setzen viele Unternehmen weiterhin stark auf Java 8. Daher sollten Sie entweder in Ihren Docker-Images auf eine neuere Java-Version aktualisieren, vorzugsweise auf die aktuelle LTS-Version, oder mindestens Java 8u191 oder höher verwenden. 

Das klingt nicht speziell nach Java-Container-Sicherheit, oder? Doch wenn Sie genauer darüber nachdenken, ist Verfügbarkeit eines der drei Merkmale der CIA-Triade der Informationssicherheit.

10. Vorsicht bei Tools zur automatischen Docker-Container-Erstellung

Wenn Sie im Internet stöbern, stoßen Sie wahrscheinlich auf großartige Tools und Plugins für Ihr Build-System. Neben diesen Plugins gibt es auch einige hervorragende Tools, mit denen Sie Java-Docker-Container erstellen und auf Wunsch sogar automatisch veröffentlichen können.

Aus Entwicklersicht klingt das großartig, denn Sie müssen sich neben der eigentlichen Anwendungsentwicklung nicht auch noch um die Pflege von Dockerfiles kümmern. 

Ein Beispiel für ein solches Plugin ist JIB. Sie müssen nur das Build-Plugin konfigurieren, wie unten gezeigt, und mvn jib:dockerBuild aufrufen:

<plugin>
   <groupId>com.google.cloud.tools</groupId>
   <artifactId>jib-maven-plugin</artifactId>
   <version>2.7.1</version>
   <configuration>
       <to>
           <image>myimage</image>
       </to>
   </configuration>
</plugin>

Damit wird mühelos ein Docker-Image mit dem angegebenen Namen erstellt. 

Ähnliches ist mit Spring Boot ab Version 2.3 möglich, indem Sie das Maven-Ziel mvn aufrufen:

mvn spring-boot:build-image

In beiden Fällen erstellt das System automatisch ein Java-Docker-Container-Image für mich. Zugegeben, diese Container sind auch relativ klein. Das liegt daran, dass sie eclipse-temurin oder Buildpacks als Image-Basis verwenden. Aber egal, wie groß Ihr Image ist: Woher wissen Sie, ob diese Container sicher sind? Sie müssen sie genauer untersuchen, und selbst dann wissen Sie nicht, ob dieser Zustand auch künftig erhalten bleibt.

Ein Scan beider Images mit snyk container zeigt mehrere Sicherheitslücken in den Basis-Images, die diese beiden Systeme verwenden. Darüber hinaus wissen wir nicht, ob die Benutzerberechtigungen korrekt festgelegt sind – neben all den anderen Punkten, die wir hier bereits besprochen haben.

Ich sage nicht, dass Sie diese Tools nicht verwenden sollten, wenn Sie Java-Docker-Images erstellen. Wenn Sie diese Images jedoch veröffentlichen möchten, sollten Sie alle Aspekte der Java-Container-Sicherheit gründlich untersuchen. Ein Container-Scan ist ein guter Anfang. Wenn Sie sich für ein alternatives Tool entscheiden, untersuchen Sie unbedingt die Standardeinstellungen wie Basis-Images und Standardbenutzer und überschreiben Sie diese mit den für Ihre Anwendung passenden Werten. Diese Einstellungen explizit festzulegen – so wie Sie es auch in einer Dockerfile tun würden – ist immer die sicherere Entscheidung.

Zusammenfassung

Damit ist unser Leitfaden zur sicheren Containerisierung von Java-Docker-Anwendungen abgeschlossen. Dabei haben wir Leistungs- und Sicherheitsoptimierungen berücksichtigt, um Java-Docker-Images für den Produktionseinsatz zu erstellen!

Diese weiterführenden Ressourcen empfehle ich Ihnen ausdrücklich:

Docker for Java developers: How not to fail your security

Container-Sicherheit mit Fokus auf Entwickler

Snyk findet und behebt automatisch Schwachstellen in Container-Images und Kubernetes-Workloads.

Häufig gestellte Fragen

Wie prüfe ich die Java-Version in einem Docker-Container?

Eine zuverlässige Möglichkeit, die in einem Docker-Container-Image installierte Java-Version zu prüfen, besteht darin, darin den Befehl java -version auszuführen:

docker run --rm -it eclipse-temurin:11 java -version
openjdk version "11.0.16" 2022-07-19
OpenJDK Runtime Environment Temurin-11.0.16+8 (build 11.0.16+8)
64-Bit-Server-VM von OpenJDK Temurin-11.0.16+8 (build 11.0.16+8, gemischter Modus)

Wenn für das Image ein entrypoint festgelegt ist, der dies verhindert, können Sie ihn wie in diesem Beispiel gezeigt mit dem Parameter --entrypoint überschreiben:

docker run --rm -it --entrypoint java tomcat:8 -version
opnjdk version "17.0.4" 2022-07-19
OpenJDK Runtime Environment Temurin-17.0.4+8 (build 17.0.4+8)
OpenJDK 64-Bit Server VM Temurin-17.0.4+8 (build 17.0.4+8, mixed mode, shareing)

Wie installiere ich Java in einem Docker-Container?

Am besten installieren Sie Java in Ihren Container-Images, indem Sie ein offizielles Basis-Image verwenden, das die benötigte Version enthält. Zum Beispiel:

FROM eclipse-temurin:11.0.16_8-jdk
COPY myapp.jar /myapp.jar
...


Wie ändere ich die Java-Version in einem Docker-Container?

Am besten ändern Sie die Java-Version in Ihrem Container-Image, indem Sie das Basis-Image auf die gewünschte Version aktualisieren. Ändern Sie dazu einfach die FROM-Zeile in Ihrer Dockerfile auf den passenden Tag für die benötigte Version. Erstellen Sie das Image anschließend mit einem geeigneten Tag neu und übertragen Sie es in Ihre Image-Registry. Aktualisieren Sie schließlich Ihr Deployment, damit es den neuen Tag verwendet.