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