Blog

Java Heap Settings in Docker Containern

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}

Ab JDK 8u131+ und JDK 9 gibt es eine experimentelle VM-Option, die es der JVM-Ergonomie ermöglicht, die Speicherwerte von CGroups zu lesen. Um es einzuschalten, müssen Sie die Parameter -XX:+UnlockExperimentalVMOptions und -XX:+UseCGroupMemoryLimitForHeap auf der JVM explizit einstellen. Hier ein Beispiel einer Dockerdatei mit den 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

Ausgeführt als 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}

Die JVM weiss, dass der Container auf 600M begrenzt ist und allokierteinen maximalen Heap von ~150MB. Genau 1/4 des Containerspeichers, genau wie auf der JDK Seite definiert.

Ab Java 10 enthält die JVM nun alle notwendigen Verbesserungen, um in einem Container zu laufen. Aufgrund dieser Verbesserungen werden die Flags -XX:+UnlockExperimentalVMOptions und -XX:+UseCGroupMemoryLimitForHeap nicht mehr benötigt.

Führen Sie die Anwendung z.B. mit dem JDK 10 Image aus:

$ docker run -it --name mycontainer -p 8080:8080 -m 600M rafabene/java-container:openjdk10

Beachten Sie, dass der Befehl „curl http://docker-machine-ip:8080/api/memory“ nicht mehr fehlschlägt und Sie die folgende Meldung „Allocated more than 80% (145.0 MiB) of the max allowed JVM memory size (145.0 MiB)%“ sehen. 145MB ist 1/4 der 600M-Grenzwerte, die wir für diesen Container definiert haben.

Fazit

Arbeitet man noch mit Java 8 oder 9, muss man unbedingt den XmX Wert der JVM manuell setzen, wenn man keine Überraschungen erleben will. Ab Java 10 weiss die JVM, dass sie innerhalb eines Containers läuft und die 1/4 Regel ist in den meisten Fällen ok. Es ist aber immer eine gute Idee den Heap explizit zu setzen.

 

Masiar IghaniJava Heap Settings in Docker Containern