Besonderheiten von JVM-Flags in Container-Umgebungen

René Wilby | 27.02.2024 Min. Lesezeit

Oder: Wie eine unbedachte Konfiguration den Arbeitsspeicher eines ganzen Worker-Nodes verbrauchen kann.

Java-basierte Anwendungen in Containern

Wird eine Java-basierte Anwendung in einem Container in einer Laufzeitumgebung wie Kubernetes oder OpenShift ausgeführt, können viele relevante Einflussfaktoren konfiguriert werden. Dazu zählen unter anderem die zu verwendende Java-Distribution und -Version sowie die verfügbaren CPU- und RAM-Ressourcen. Bezogen auf den Arbeitsspeicher können für Java-basierte Anwendungen über verschiedene JVM-Flags weitere Aspekte beeinflusst werden. Dieser Artikel legt im weiteren Verlauf den Fokus auf Java-basierte Microservices, die in einem Container ausgeführt werden, wie der Arbeitsspeicher für diese Microservices konfiguriert und aufgeteilt werden kann und was dabei zu beachten ist.

Arbeitsspeicher, Heap und Non-Heap in Containern

Der Arbeitsspeicher, der einem Container als Ressource zur Verfügung gestellt wird, kann nicht vollständig von dem im Container laufenden Microservice verwendet werden. Der verfügbare Arbeitsspeicher teilt sich auf den Heap- und Non-Heap-Bereich auf. Im Speicherbereich Heap werden die Java-Objekte und Threads der Anwendung erzeugt und verwaltet. Dieser Speicherbereich muss ausreichend groß dimensioniert werden, damit die Anwendung stabil ausgeführt werden kann und damit es nicht zu Out-of-Memory-Exceptions kommt. Der Speicherbereich Non-Heap ist für statische Objekte (Stack), Metadaten (Metaspace) und die JVM selbst vorgesehen. Das Größenverhältnis von Heap- und Non-Heap-Speicher ist abhängig von der konkreten Java-Anwendung. Meiner Erfahrung nach ist eine Aufteilung des verfügbaren Arbeitsspeichers in 75 % für den Heap-Speicher und 25 % für den Non-Heap-Speicher für Microservices durchaus sinnvoll.

Seit Version 10 von Java kann die JVM erkennen, ob sie in einem Container ausgeführt wird. Darüber hinaus kann sie in solchen Fällen den verfügbaren Arbeitsspeicher des Containers erkennen. Dies lässt sich auch leicht selber überprüfen. Mit einer lokalen Container-Laufzeitumgebung, wie bspw. Podman, kann folgender Befehl ausgeführt werden.

podman run --memory 1g --rm ghcr.io/graalvm/jdk-community:21 cat /sys/fs/cgroup/memory.max

Das Ergebnis des cat Befehls ist 1073741824. Der Wert hat die Einheit Bytes. Rechnet man diesen Wert in GB um, kommt man auf 1 GB. Dies passt zum verwendeten Parameter --memory 1g. Bei älteren Images muss der Wert ggf. aus der Datei /sys/fs/cgroup/memory/memory.limit_in_bytes ausgelesen werden.

Besonderheit 1: Im Standard nur 25 % für den Heap

Im nächsten Schritt kann man sich nun anschauen, wie sich der verfügbare Arbeitsspeicher auf den Heap- bzw. Non-Heap-Speicher aufteilt. Dazu kann der folgende Befehl ausgeführt werden:

podman run --memory 1g --rm ghcr.io/graalvm/jdk-community:21 java -XX:+PrintFlagsFinal -version | grep 'MaxHeapSize\|MaxRAMPercentage'

Der Befehl liefert folgende Ausgabe:

size_t MaxHeapSize             = 268435456
double MaxRAMPercentage        = 25.000000
size_t SoftMaxHeapSize         = 268435456

Hierbei ist erkennbar, dass die maximale Größe des Heap-Speichers (MaxHeapSize) auf 256 MB beschränkt ist. Dies entspricht 25 % des verfügbaren Arbeitsspeichers und somit der Standardeinstellung der JVM. Der Microservice, der in dem Container ausgeführt wird, hat somit nur einen Bruchteil des Arbeitsspeichers zur Verfügung.

JVM-Flag MaxRAMPercentage

Mit Hilfe des JVM-Flags MaxRAMPercentage kann der maximal verfügbare Arbeitsspeicher für die JVM konfiguriert werden. Beim Start einer Java-Anwendung wird das JVM-Flag mit -XX:MaxRAMPercentage=<WERT> gesetzt. Der zuvor ausgeführte Befehl kann daher zum Beispiel wie folgt angepasst werden:

podman run --memory 1g --rm ghcr.io/graalvm/jdk-community:21 java -XX:+PrintFlagsFinal -XX:MaxRAMPercentage=75.0 -version | grep 'MaxHeapSize\|MaxRAMPercentage'

Der angepasste Befehl liefert nun folgende Ausgabe:

size_t MaxHeapSize             = 805306368
double MaxRAMPercentage        = 75.000000
size_t SoftMaxHeapSize         = 805306368

Es ist erkennbar, dass nun 75 % bzw. 768 MB des Arbeitsspeichers für den Heap-Speicher zur Verfügung stehen.

JVM-Flag MinRAMPercentage

Das vermeintliche Gegenstück zu MaxRAMPercentage könnte MinRAMPercentage sein. Der Name suggeriert, dass über dieses Flag festgelegt werden kann, wie viel Prozent des verfügbaren Arbeitsspeichers mindestens für den Heap-Bereich allokiert werden sollen. Auch hier lohnt sich ein Blick auf die Standardeinstellung der JVM. Dazu führt man den folgenden Befehl aus:

podman run --memory 1g --rm ghcr.io/graalvm/jdk-community:21 java -XX:+PrintFlagsFinal -version | grep 'MinHeapSize\|MinRAMPercentage\|MaxHeapSize\|MaxRAMPercentage'

Der Befehl liefert folgende Ausgabe:

size_t MaxHeapSize             = 268435456
double MaxRAMPercentage        = 25.000000
size_t MinHeapSize             = 8388608
double MinRAMPercentage        = 50.000000
size_t SoftMaxHeapSize         = 268435456

Die Ausgabe ist etwas irritierend. MinHeapSize hat den Wert 8 MB. Der Wert 50 % für MinRAMPercentage wird ignoriert. Warum ist das so?

Besonderheit 2: Bedeutung von MinRAMPercentage

MinRAMPercentage setzt die maximale(!) Größe des Heap-Speichers, wenn der verfügbare Arbeitsspeicher ungefähr 200 bis 250 MB, oder weniger ist. Steht dem Container mehr Arbeitsspeicher zur Verfügung, wird MinRAMPercentage ignoriert. Sehr irritierend! Auch in der Java-Community gibt es dazu Diskussionen.

Die nachfolgenden Befehle verdeutlichen die Auswirkungen von MinRAMPercentage in Verbindung mit dem verfügbaren Arbeitsspeicher.

Wenig Arbeitsspeicher und Standardwerte

Befehl:

podman run --memory 128m --rm ghcr.io/graalvm/jdk-community:21 java -XX:+PrintFlagsFinal -version | grep 'MinHeapSize\|MinRAMPercentage\|MaxHeapSize\|MaxRAMPercentage'

Ausgabe:

size_t MaxHeapSize             = 67108864
double MaxRAMPercentage        = 25.000000
size_t MinHeapSize             = 8388608
double MinRAMPercentage        = 50.000000
size_t SoftMaxHeapSize         = 67108864

MaxHeapSize beträgt 64 MB, obwohl MaxRamPercentage den Wert 25 % hat. MinHeapSize steht weiterhin bei 8 MB, obwohl MinRAMPercentage den Wert 50 % hat. MinRAMPercentage hat somit den Wert von MaxHeapSize bestimmt.

Wenig Arbeitsspeicher und individuelle Werte

Befehl:

podman run --memory 128m --rm ghcr.io/graalvm/jdk-community:21 java -XX:+PrintFlagsFinal -XX:MinRAMPercentage=25.0 -XX:MaxRAMPercentage=75.0 -version | grep 'MinHeapSize\|MinRAMPercentage\|MaxHeapSize\|MaxRAMPercentage'

Ausgabe:

size_t MaxHeapSize             = 33554432
double MaxRAMPercentage        = 75.000000
size_t MinHeapSize             = 8388608
double MinRAMPercentage        = 25.000000
size_t SoftMaxHeapSize         = 33554432

Obwohl MaxRAMPercentage explizit mit 75 % angegeben wurde, wird MaxHeapSize dennoch auf 32 MB gesetzt. Damit entspricht es den 25 %, die für MinRAMPercentage gesetzt wurden.

Viel Arbeitsspeicher und Standardwerte

Befehl:

podman run --memory 1g --rm ghcr.io/graalvm/jdk-community:21 java -XX:+PrintFlagsFinal -version | grep 'MinHeapSize\|MinRAMPercentage\|MaxHeapSize\|MaxRAMPercentage'

Ausgabe:

size_t MaxHeapSize             = 268435456
double MaxRAMPercentage        = 25.000000
size_t MinHeapSize             = 8388608
double MinRAMPercentage        = 50.000000
size_t SoftMaxHeapSize         = 268435456

MaxHeapSize hat den Wert 256 MB und entspricht somit den 25 % von MaxRAMPercentage. Der Wert von MinRAMPercentage wird ignoriert und die minimale Größe des Heap-Bereichs liegt weiterhin bei 8 MB.

Viele Arbeitsspeicher und individuelle Werte

Befehl:

podman run --memory 1g --rm ghcr.io/graalvm/jdk-community:21 java -XX:+PrintFlagsFinal -XX:MinRAMPercentage=25.0 -XX:MaxRAMPercentage=75.0 -version | grep 'MinHeapSize\|MinRAMPercentage\|MaxHeapSize\|MaxRAMPercentage'

Ausgabe:

size_t MaxHeapSize             = 805306368
double MaxRAMPercentage        = 75.000000
size_t MinHeapSize             = 8388608
double MinRAMPercentage        = 25.000000
size_t SoftMaxHeapSize         = 805306368

MaxHeapSize hat den Wert 768 MB und entspricht somit den 75 % von MaxRAMPercentage. Der Wert von MinRAMPercentage wird ignoriert und die minimale Größe des Heap-Bereichs liegt weiterhin bei 8 MB.

Die Beispiele zeigen deutlich auf, dass man auf die Verwendung von MinRAMPercentage verzichten sollte.

JVM-Flag InitialRAMPercentage

Als Alternative zu MinRAMPercentage für Container mit mehr Arbeitsspeicher bietet sich InitialRAMPercentage. Über dieses Flag kann bestimmt werden, wie groß der Heap-Bereich beim Start des Microservices sein soll. In dem folgenden Beispiel sollen initial 50 % und maximal 75 % des Arbeitsspeichers für den Heap-Bereich vorgesehen werden:

podman run --memory 1g --rm ghcr.io/graalvm/jdk-community:21 java -XX:+PrintFlagsFinal -XX:InitialRAMPercentage=50.0 -XX:MaxRAMPercentage=75.0 -version | grep 'InitialHeapSize\|InitialRAMPercentage\|MinHeapSize\|MinRAMPercentage\|MaxHeapSize\|MaxRAMPercentage'

Der Befehl liefert folgende Ausgabe:

size_t InitialHeapSize             = 536870912
double InitialRAMPercentage        = 50.000000
size_t MaxHeapSize                 = 805306368
double MaxRAMPercentage            = 75.000000
size_t MinHeapSize                 = 8388608
double MinRAMPercentage            = 50.000000
size_t SoftMaxHeapSize             = 805306368

Hier ist erkennbar, dass die initiale Größe des Heap-Bereichs mit 512 MB tatsächlich den gesetzten 50 % entspricht. Ohne die Angabe von -XX:InitialRAMPercentage=<WERT> liegt der Standardwert übrigens bei ca. 1,5 %, so dass InitialHeapSize dann den Wert 16 MB hätte.

Besonderheit 3: Container ohne Request-Limit

Für zustandslose Microservices, die in Containern ausgeführt werden, ist eine horizontale Skalierung, bspw. über einen Horizontal-Pod-Autoscaler sehr üblich. Zu diesem Zweck wird im Deployment ein Request-Limit für CPU und RAM festgelegt. Zusammen mit einem Threshold wird dann definiert, ab wie viel Auslastung eines laufenden Containers ein weiterer Container gestartet wird, der dann ebenfalls eingehende Requests verarbeiten kann. Es kann jedoch auch vorkommen, dass eine horizontale Skalierung nicht für jede Art von Microservice umgesetzt werden kann. In diesen Fällen kommt dann unter Umständen eine vertikale Skalierung des Containers in Betracht. Dabei könnte man auf die Idee kommen, für CPU und/oder RAM kein Request-Limit zu setzen. Somit könnte der Container bzw. die JVM des Microservices bei Bedarf mehr Speicher für den Heap-Bereich allokieren und diesen später wieder freigeben, wenn er nicht mehr benötigt wird. Doch wie verhalten sich die gezeigten JVM-Flags, wenn kein Request-Limit für den Arbeitsspeicher definiert ist? Dazu kann man den vorherigen Befehl ohne den Parameter --memory 1g ausführen:

podman run --rm ghcr.io/graalvm/jdk-community:21 java -XX:+PrintFlagsFinal -XX:InitialRAMPercentage=50.0 -XX:MaxRAMPercentage=75.0 -version | grep 'InitialHeapSize\|InitialRAMPercentage\|MinHeapSize\|MinRAMPercentage\|MaxHeapSize\|MaxRAMPercentage'

Der Befehl liefert folgende Ausgabe:

size_t InitialHeapSize             = 1031798784
double InitialRAMPercentage        = 50.000000
size_t MaxHeapSize                 = 1545601024
double MaxRAMPercentage            = 75.000000
size_t MinHeapSize                 = 8388608
double MinRAMPercentage            = 50.000000
size_t SoftMaxHeapSize             = 1545601024

MaxHeapSize hat in diesem konkreten Beispiel (Podman Desktop unter macOS) nun den Wert 1474 MB und InitialHeapSize hat den Wert 984 MB. Die Grundlage zur Berechnung der Heap-Size ist wieder die Datei /sys/fs/cgroup/memory.max (bzw. /sys/fs/cgroup/memory/memory.limit_in_bytes). Wird der Container ohne Request-Limit für den Arbeitsspeicher gestartet, enthält die Datei den Wert max. Das bedeutet, dass der gesamte verfügbare bzw. konfigurierte Arbeitsspeichers des Host bzw. Worker-Nodes als Grundlage zur Berechnung der initialen und maximalen Heap-Size herangezogen wird.

Wird also ein Container ohne Request-Limit für den Arbeitsspeicher gestartet und wird der Microservice mit dem JVM-Flag -XX:InitialRAMPercentage=50.0 gestartet, versucht die JVM 50 % des verfügbaren Arbeitsspeichers des Host bzw. Worker-Nodes für sich zu allokieren, unabhängig davon, ob der Microservice den Speicher tatsächlich benötigt, oder nicht. Dieses Verhalten ist in den meisten Fällen vermutlich eher nicht wünschenswert.

Fazit

Die gezeigten JVM-Flags warten mit einigen Überraschungen auf. Daher muss man vor ihrer Benutzung genau überlegen, welche Anforderungen der eigene Microservice hat und welche Ziele man verfolgt. Auf den Einsatz von MinRAMPercentage sollte man besser verzichten. Mit MaxRAMPercentage kann man zuverlässig die Obergrenze des Heap-Speichers festlegen. Der Standardwert von 25 % ist in der Regel zu niedrig angesetzt. Zu guter Letzt bietet InitialRAMPercentage die Möglichkeit, die initiale Größe des Heap-Speichers zu bestimmen. Dies sollte man jedoch nur dann in Erwägung ziehen, wenn der Container über ein Request-Limit für den Arbeitsspeicher verfügt.

Quellen:

Bildnachweis: pixabay