Java Virtual Machine und Container: ein Dream Team mit Startschwierigkeiten

Das problemlose Miteinander von Java Virtual Machine und Containern setzt eine gezielte Konfiguration voraus, damit Anwendungen effizient und fehlerfrei laufen.

In Pocket speichern vorlesen Druckansicht 21 Kommentare lesen

(Bild: PHOTOCREO Michal Bednarek/Shutterstock)

Lesezeit: 21 Min.
Von
  • Sascha Selzer
Inhaltsverzeichnis

Die Programmiersprache Java ist in der Softwareentwicklung fest etabliert – genauso wie Container-Technologien. In der täglichen Arbeit treffen Entwicklerinnen und Entwickler oft beides zusammen an. Als Container noch relativ neu waren, lief das Zusammenspiel allerdings nicht ganz reibungsfrei. Startete der JVM-Prozess (Java Virtual Machine) als Container, ignorierte er die in der Konfiguration eingestellten Grenzen für Ressourcen wie CPU und Speicher. Er betrachtete schlicht sämtliche Ressourcen des Hosts als Grundlage, um etwa die Größe des Heap zu bestimmen, die dann zwangsläufig größer war als die dem Container zur Verfügung stehende. Das führte zu den bekannten OOM Kills (Out-Of-Memory), da der Linux-Kernel rigoros Prozesse stoppt, die mehr Ressourcen verbrauchen, als ihnen zugewiesen sind.

Erst seit Java 9 (und später rückportiert auf Java 8 Update 191), erkennt die JVM die Container-Limits und berechnet die Heap-Größe korrekt.

Erfahrene Entwicklerinnen und Entwickler wissen, dass die Default-Einstellung für die Heapgröße (1/4 des verfügbaren Speichers) nicht für den Betrieb in Containern geeignet ist und verwenden daher Einstellungen wie -XX:MaxRAMPercentage. um die verfügbaren Ressourcen möglichst effektiv zu nutzen. Vor unerwartetem Verhalten ihrer Java-Anwendung in Containern sind sie dennoch nicht gefeit. Hohe Latenzen, Performance-Einbrüche oder auch Abstürze durch überlaufenden Speicher sind keine Seltenheit.

Viele dieser Probleme lassen sich besser verstehen, wenn man hinter die Kulissen schaut, wie Container eigentlich funktionieren und wie sich die JVM an bestimmte Gegebenheiten anpasst. Als erstes lohnt sich ein Blick darauf, wie der Linux-Kernel eigentlich Limits für Container verwaltet.

Der Linux-Kernel ist für die Interaktion mit der darunter liegenden Hardware zuständig und stellt den Zugriff darauf für Applikationen zur Verfügung, beispielsweise auf CPU und Speicher. Darüber hinaus kann er den Zugriff auf diese Ressourcen für einzelne Prozesse limitieren. Für die Steuerung und Limitierung der Ressourcennutzung verwendet der Kernel das cgroups-Feature (control groups), das seit 2008 zur Verfügung steht.

Die cgroups bilden eine hierarchische Struktur, in der sich Prozesse zusammenfassen lassen, damit sie sich bestimmte Ressourcen teilen können (siehe Abbildung 1). Zu den per cgroups steuerbaren Ressourcen zählen unter anderem:

  • CPU
  • Speicher
  • Netzwerk
  • Festplatten I/O

Hierarchische Darstellung der cgroups v1 (Abb. 1).

Die Gruppen und Strukturen sind wie in Linux üblich als Ordner und Dateien im Filesystem abgebildet. Startet man mit Docker einen Container und setzt Limits für CPU und Speicher, sind die passenden cgroups-Einstellungen im Filesystem ersichtlich.

Als Beispiel dient der folgende Aufruf:

docker run -d --memory 256m --cpus 2 traefik/whoami

cd693d893c47737ca5e10c9ac1f550fcceb51cdf5203c9b68b0f11483ed9957c

Die Ausgabe zeigt die ID des Containers und auch die zugehörige cgroup. All verfügbaren cgroups befinden sich unter /sys/fs/cgroup mit eigenen Unterordnern pro Ressourcen-Typ.

ls -l /sys/fs/cgroup/
total 0
dr-xr-xr-x 6 root root  0 Jun  1 11:02 blkio
lrwxrwxrwx 1 root root 11 Jun  1 11:02 cpu -> cpu,cpuacct
dr-xr-xr-x 6 root root  0 Jun  1 11:02 cpu,cpuacct
lrwxrwxrwx 1 root root 11 Jun  1 11:02 cpuacct -> cpu,cpuacct
dr-xr-xr-x 3 root root  0 Jun  1 11:02 cpuset
...
dr-xr-xr-x 6 root root  0 Jun  1 11:02 memory
lrwxrwxrwx 1 root root 16 Jun  1 11:02 net_cls -> net_cls,net_prio
dr-xr-xr-x 3 root root  0 Jun  1 11:02 net_cls,net_prio
lrwxrwxrwx 1 root root 16 Jun  1 11:02 net_prio -> net_cls,net_prio
...

Für den angelegten Container findet man unter diesen Ordnern dann auch die konfigurierten Limits. Da im Beispiel etwa die CPU limitiert wurde, findet man einen passenden Ordner mit der ID unterhalb des cpu-Ordners:

ls -l /sys/fs/cgroup/cpu/docker/cd693d893c47737ca5e10c9ac1f550fcceb51cdf5203c9b68b0f11483ed9957c/
total 0
-rw-r--r-- 1 root root 0 Jun  1 11:14 cgroup.clone_children
-rw-r--r-- 1 root root 0 Jun  1 11:08 cgroup.procs
-rw-r--r-- 1 root root 0 Jun  1 11:08 cpu.cfs_period_us
-rw-r--r-- 1 root root 0 Jun  1 11:08 cpu.cfs_quota_us
-rw-r--r-- 1 root root 0 Jun  1 11:14 cpu.shares
-r--r--r-- 1 root root 0 Jun  1 11:14 cpu.stat
-rw-r--r-- 1 root root 0 Jun  1 11:14 cpu.uclamp.max
-rw-r--r-- 1 root root 0 Jun  1 11:14 cpu.uclamp.min
-r--r--r-- 1 root root 0 Jun  1 11:14 cpuacct.stat
-rw-r--r-- 1 root root 0 Jun  1 11:14 cpuacct.usage
-r--r--r-- 1 root root 0 Jun  1 11:14 cpuacct.usage_all
-r--r--r-- 1 root root 0 Jun  1 11:14 cpuacct.usage_percpu
-r--r--r-- 1 root root 0 Jun  1 11:14 cpuacct.usage_percpu_sys
-r--r--r-- 1 root root 0 Jun  1 11:14 cpuacct.usage_percpu_user
-r--r--r-- 1 root root 0 Jun  1 11:14 cpuacct.usage_sys
-r--r--r-- 1 root root 0 Jun  1 11:14 cpuacct.usage_user
-rw-r--r-- 1 root root 0 Jun  1 11:14 notify_on_release
-rw-r--r-- 1 root root 0 Jun  1 11:14 tasks

Docker legt alle cgroups für Container, die per docker gestartet werden, unter einem eigenen Ordner an, sodass diese nicht in Konflikt mit anderen cgroups geraten. Welche Prozesse zu einer cgroup gehören, lässt sich in der Datei cgroup.procs nachschauen:

cat /sys/fs/cgroup/cpu/docker/cd693d893c47737ca5e10c9ac1f550fcceb51cdf5203c9b68b0f11483ed9957c/cgroup.procs

3632

Die eingestellten Limits stehen in cpu.cfs_period_us

cat /sys/fs/cgroup/cpu/docker/cd693d893c47737ca5e10c9ac1f550fcceb51cdf5203c9b68b0f11483ed9957c/cpu.cfs_quota_us

200000

Die Quota ist in Mikrosekunden angegeben und entspricht umgerechnet den im Beispiel definierten zwei CPUs. Um genau zu sein, sagt die Quota aus, dass in einem CPU-Zyklus, der standardmäßig mit 100.000 Mikrosekunden definiert ist, Prozesse in dieser cgroup 200.000 Mikrosekunden CPU-Zeit verwenden dürfen. Das heißt, die Angabe der CPUs wird umgerechnet in cpu.cfs_quota_us/cpu_cfs_period_us.

Dementsprechend befinden sich die Speicher-Limits unter dem memory-Ordner:

cat /sys/fs/cgroup/memory/docker/cd693d893c47737ca5e10c9ac1f550fcceb51cdf5203c9b68b0f11483ed9957c/memory.limit_in_bytes

268435456

Die Ordnerstruktur für Speicher ist identisch zu der für CPUs aufgebaut. Aus Sicht des Kernels sind das getrennte cgroups, die aber denselben Namen/ID tragen. Das macht die cgroups sehr flexibel. Denn theoretisch lassen sich Prozesse in verschiedenen cgroups für unterschiedliche Ressourcentypen zusammenfassen oder auch trennen: für Speicher könnten sich zwei Prozesse eine cgroup teilen, für CPU aber getrennte Limits und cgroups haben.

Darüber hinaus sind cgroups hierarchisch strukturiert, sodass sich bestimmte Limits auf höherer Ebene definieren und dann in den unteren Ebenen weiter zerteilen lassen. Als Beispiel dient die CPU-cgroup von docker:

cat /sys/fs/cgroup/cpu/docker/cpu.cfs_quota_us

-1

Für dockergibt es standardmäßig kein Limit, es lässt sich aber eines setzen. Ist für die cgroup beispielsweise 200.000 gesetzt, dann könnten alle Container, die per Docker gestartet und als Unterordner unter dem docker-Ordner angelegt werden, zusammen nie mehr als zwei CPUs verwenden. Diese hohe Flexibilität ist aber in den meisten Fällen nicht notwendig und macht die Verwaltung nur unnötig komplex. Daher steht mit cgroups v2 eine neue Implementierung parallel bereit, die in Zukunft die alte ablösen soll.