Entwicklern sollte bewusst sein, dass sich Java Prozesse in Docker Containern anders verhalten als wenn sie direkt auf einem Host ausgeführt werden. Wenn wir z.B. eine Anwendung mit java -jar mypplication-fat.jar” starten, dann passt die JVM einige Parameter selbständig an, um die bestmögliche Performance zu gewährleisten.
Man neigt dazu zu denken, dass Container genau wie virtuelle Maschinen sind, wo wir eine Reihe von virtuellen CPUs und virtuellen Speicher vollständig definieren können. Container sind eher ein Isolationsmechanismus, bei denen die Ressourcen (CPU, Speicher, Dateisystem, Netzwerk, etc.) für Prozesse voneinander getrennt sind. Diese Isolation wird durch eine Linux-Kernelfunktion namens cgroups ermöglicht. Einige Anwendungen, die Informationen aus der Ausführungsumgebung benutzen, wurden jedoch vor der Existenz von cgroups implementiert. Tools wie ‚top‘, ‚free‘, ‚ps‘, und selbst die JVM ist nicht dafür optimiert, innerhalb eines Containers einen stark eingeschränkten Linux-Prozess auszuführen. Schauen wir uns das mal an.
Das Problem
Zu Demonstrationszwecken habe ich einen Docker-Daemon in einer virtuellen Maschine mit 1 GB RAM mit „docker-machine create -d virtualbox -virtualbox-memory ‚1024‘ docker1024“ erstellt. Als nächstes habe ich den Befehl „free -h“ in drei verschiedenen Linux-Distributionen ausgeführt, die in einem Container mit 100 MB RAM und Swap als Grenze laufen. Das Ergebnis ist, dass alle VMs 995 MB Gesamtspeicher aufweisen.
Selbst in einem Kubernetes/OpenShift-Cluster ist das Ergebnis ähnlich. Ich habe einen Kubernetes Pod mit einer Speicherbegrenzung von 512MB (mit dem Befehl „kubectl run mycentos -image=centos -it -limits=’memory=512Mi'“) in einem Cluster mit 15GB RAM ausgeführt und der angezeigte Gesamtspeicher betrug 14GB.
Die Docker-Switches (-m, -memory und -memory-swap) und der kubernetes-Switch (-limits) weisen den Linux-Kernel an, den Prozess zu beenden, wenn er versucht, die angegebene Grenze zu überschreiten. Aber die JVM ist sich der Grenzen komplett nicht bewusst und wenn sie die Grenzen überschreitet, passieren seltsame Dinge!
Um den Prozess zu simulieren, der nach Überschreiten der angegebenen Speichergrenze beendet wird, können wir den WildFly Application Server in einem Container mit 50 MB Speicherlimit über den Befehl „docker run -it -name mywildfly -m=50m jboss/wildfly“ ausführen. Während der Ausführung dieses Containers können wir „docker stats“ ausführen, um das Containerlimit zu überprüfen.
Aber nach einigen Sekunden wird die Ausführung des Wildfly-Containers unterbrochen und eine Nachricht ausgegeben: *** JBossAS-Prozess (55) received KILL-Signal ***
Der Befehl „docker inspect mywildfly -f ‚{{json .state}}}'“ zeigt, dass dieser Container aufgrund einer OOM (Out of Memory) beendet wurde. Beachten Sie die OOMKilled=true im Container „state“.
Wie betrifft das Java Applikationen?
In diesem Beispiel läuft der Docker Daemon auf auf einer Maschine mit 1 GB RAM (mit „docker-machine create -d virtualbox -virtualbox-memory ‚1024‘ docker1024“ erstellt) und hat den Containerspeicher auf 150 Megabyte beschränkt. die Spring Boot Java-Anwendung wurde im Dockerfile mit dem Parameter -XX:+PrintFlagsFinal gestartet. Dieser Parameter ermöglicht es uns, die ersten relevante JVM Parameter auszulesen.
$ docker run -it --rm --name mycontainer150 -p 8080:8080 -m 150M rafabene/java-container:openjdk
Es gibt in dem Container einen Endpunkt unter „/api/memory/“, der den JVM-Speicher mit String-Objekten lädt. Damit simulieren wir eine Operation, welche viel Speicher verbraucht:
$ curl http://`docker-machine ip docker1024`:8080/api/memory
Dieser Endpunkt antwortet mit etwas ähnlichem wie: „Allocated more than 80% (219.8 MiB) of the max allowed JVM memory size (241.7 MiB)„.
Hier können wir mindestens zwei Fragen herausgreifen:
- Warum ist der maximal zulässige Speicher der JVM 241,7 MiB?
- Wenn dieser Container den Speicher auf 150 MB begrenzt, warum erlaubt er es Java, fast 220 MB zu allokieren?
Zuerst müssen wir uns daran erinnern, was die JVM 9 Ergonomie über „maximale Heapgröße“ sagt. Es besagt, dass es 1/4 des physikalischen Speichers allokiert, falls nichts anderes vorgegeben ist. Da die JVM nicht weiß, dass sie innerhalb eines Containers ausgeführt wird, kann die maximale Heap-Größe nahe 260 MB liegen. Da wir bei der Initialisierung des Containers das Flag -XX:+PrintFlagsFinal hinzugefügt haben, können wir diesen Wert überprüfen:
$ docker logs mycontainer150|grep -i MaxHeapSize uintx MaxHeapSize := 262144000 {product}
Zweitens müssen wir verstehen, dass wenn wir den Parameter „-m 150M“ in der Docker-Befehlszeile verwenden, der Docker-Daemon 150M im RAM und 150M im Swap bereitstellt. Dadurch kann der Prozess die 300M allokieren und es erklärt sich, warum unser Prozess keinen Kill vom Kernel erhalten hat.
Ist mehr Speicher die Lösung?
Eine häufige Lösung ist die Bereitstellung einer Umgebung mit mehr Speicherplatz, aber das wird die Situation noch verschlimmern.
Nehmen wir an, wir ändern unseren Daemon von 1GB auf 8GB (erstellt mit „docker-machine create -d virtualbox -virtualbox-memory ‚8192‘ docker8192“) und unseren Container von 150M auf 600M:
$ docker run -it --name mycontainer -p 8080:8080 -m 600M rafabene/java-container:openjdk
Beachten Sie, dass der Befehl „curl http://docker-machine-ip:8080/api/memory“ diesmal gar nicht zu Ende ausgeführt wird, da die berechnete MaxHeapSize für die JVM in einer 8GB-Umgebung 2092957696 Bytes (~ 2GB) beträgt. Überprüfen Sie dies mit „docker logs mycontainer|grep -i MaxHeapSize“ selbst.
Die Anwendung wird versuchen, mehr als 1,2 GB Arbeitsspeicher zuzuweisen, was mehr als die Grenze dieses Containers ist (600 MB im RAM + 600 MB im Swap) und der Prozess wird beendet.
Es ist klar, dass die Erhöhung des Speichers und die Möglichkeit, dass die JVM ihre eigenen Parameter setzt, nicht immer eine gute Idee ist. Wenn eine Java-Anwendung innerhalb von Containern ausgeführt wird, sollten wir die maximale Heap-Größe (Parameter -Xmx) selbst festlegen, basierend auf den Anwendungsanforderungen und den Containergrenzen.
Was ist die Lösung?
Eine leichte Änderung in der Dockerdatei ermöglicht es dem Benutzer, eine Umgebungsvariable anzugeben, die zusätzliche Parameter für die JVM definiert. Überprüfen Sie die folgende Zeile:
CMD java -XX:+PrintFlagsFinal $JAVA_OPTIONS -jar java-container.jar
Jetzt können wir die Umgebungsvariable JAVA_OPTIONS verwenden, um die Größe des JVM-Heaps zu bestimmen. 300MB scheinen für diese Anwendung ausreichend zu sein. Später können Sie die Logs überprüfen und den Wert von 314572800 Bytes (300MBi) sehen.
Für Docker können Sie die Umgebungsvariable mit dem Schalter „-e“ angeben.
$ docker run -d --name mycontainer8g -p 8080:8080 -m 600M -e JAVA_OPTIONS='-Xmx300m' rafabene/java-container:openjdk-env $ docker logs mycontainer8g|grep -i MaxHeapSize uintx MaxHeapSize := 314572800 {product}
In Kubernetes kann die Umgebungsvariable mit dem Switch “–env=[key=value]” gesetzt werden:
$ kubectl run mycontainer --image=rafabene/java-container:openjdk-env --limits='memory=600Mi' --env="JAVA_OPTIONS=-Xmx300m" $ kubectl logs $(kubectl get pods|grep mycontainer|awk '{ print $1 }'|head -1)|grep MaxHeapSize uintx MaxHeapSize := 314572800 {product}
Starting with JDK 8u131+ and JDK 9, there is an experimental VM option that allows JVM ergonomics to read the memory values of CGroups. To turn it on, you need to explicitly set the parameters -XX:+UnlockExperimentalVMOptions and -XX:+UseCGroupMemoryLimitForHeap on the JVM. Here is an example of a Dockerfile with the settings:
FROM openjdk:9 ADD target/java-container.jar /usr/src/myapp/ WORKDIR /usr/src/myapp EXPOSE 8080 CMD java -XX:+PrintFlagsFinal -XX:+UnlockExperimentalVMOptions -XX:+UseCGroupMemoryLimitForHeap -jar java-container.jar
Executed as a container:
$ docker run -d --name mycontainer8g-jdk9 -p 8080:8080 -m 600M rafabene/java-container:openjdk-cgroup $ docker logs mycontainer8g-jdk9|grep MaxHeapSize size_t MaxHeapSize = 157286400 {product} {ergonomic}
The JVM knows that the container is limited to 600M and allocates a maximum heap of ~150MB. Exactly 1/4 of the container memory, just as defined on the JDK page.
Starting with Java 10, the JVM now includes all the necessary improvements to run in a container. Due to these improvements, the flags -XX:+UnlockExperimentalVMOptions and -XX:+UseCGroupMemoryLimitForHeap are no longer needed.
Run the application with the JDK 10 image, for example:
$ docker run -it --name mycontainer -p 8080:8080 -m 600M rafabene/java-container:openjdk10
Notice that the command „curl http://docker-machine-ip:8080/api/memory“ no longer fails and you see the following message “ Allocated more than 80% (145.0 MiB) of the max allowed JVM memory size (145.0 MiB)% „. 145MB is 1/4 of the 600M limits we defined for this container.
Conclusion
If you are still working with Java 8 or 9, you absolutely have to set the XmX value of the JVM manually if you don’t want to experience any surprises. Starting with Java 10, the JVM knows that it is running within a container and the 1/4 rule is OK in most cases. However, it is always a good idea to set the heap explicitly.