Java Virtual Machine und Container: ein Dream Team mit Startschwierigkeiten

Seite 2: Eine neue cgroup-Hierarchie vereinfacht die Verwaltung

Inhaltsverzeichnis

cgroups v2 ist seit dem Kernel 4.5 verfügbar und vereinfacht die Struktur, sodass sich in einer cgroup alle Ressourcen-Limits zusammenfassen lassen. Für das obige Beispiel lassen sich mit cgroups v2 alle Informationen unter folgendem Ordner finden:

ls -l /sys/fs/cgroup/system.slice/docker-cd693d893c47737ca5e10c9ac1f550fcceb51cdf5203c9b68b0f11483ed9957c.scope/
total 0
...
-rw-r--r-- 1 root root 0 Jun  1 11:54 cgroup.procs
-r--r--r-- 1 root root 0 Jun  1 11:55 cgroup.stat
...
-rw-r--r-- 1 root root 0 Jun  1 11:54 cpu.max
-rw-r--r-- 1 root root 0 Jun  1 11:55 cpu.pressure
-r--r--r-- 1 root root 0 Jun  1 11:54 cpu.stat
-rw-r--r-- 1 root root 0 Jun  1 11:54 cpu.weight
...
-rw-r--r-- 1 root root 0 Jun  1 11:55 io.max
-rw-r--r-- 1 root root 0 Jun  1 11:55 io.pressure
-r--r--r-- 1 root root 0 Jun  1 11:54 io.stat
-rw-r--r-- 1 root root 0 Jun  1 11:54 io.weight
...
-r--r--r-- 1 root root 0 Jun  1 11:55 memory.current
-r--r--r-- 1 root root 0 Jun  1 11:54 memory.events
-r--r--r-- 1 root root 0 Jun  1 11:55 memory.events.local
-rw-r--r-- 1 root root 0 Jun  1 11:54 memory.high
-rw-r--r-- 1 root root 0 Jun  1 11:54 memory.low
-rw-r--r-- 1 root root 0 Jun  1 11:54 memory.max
-rw-r--r-- 1 root root 0 Jun  1 11:54 memory.min
...

Neben der Vereinfachung und Vereinheitlichung der Hierarchie sowie der Umbenennung einiger Dateien in dem Ordner fällt aber die Unterstützung für einige Ressourcentypen weg, die sich mit v1 noch nutzen ließen. Diese Beschränkung fällt aber in der Regel kaum ins Gewicht. Die neue cgroup-Implementierung ist bei vielen Linux-Distributionen bereits standardmäßig aktiv, darunter:

  • Fedora (seit 31)
  • Arch Linux (seit April 2021)
  • openSUSE Tumbleweed (seit c. 2021)
  • Debian GNU/Linux (seit 11)
  • Ubuntu (seit 21.10)

cgroups sind jedoch nur eine Seite der Medaille. Applikationen, die in einem Container laufen, sind nicht auf magische Weise der Sicht auf die unterliegende Hardware beraubt, sondern müssen eigenständig erkennen, dass sie in einem Container laufen und aktiv die Limits auslesen. Dieses Verhalten bezeichnet man allgemein als "Container Awareness".

Nimmt man die JVM als Beispiel, so muss sie selbstständig erkennen, ob es sich um cgroup v1 oder v2 handelt. Laut cgroups man-page existiert die Datei /proc/cgroups, die beim Start-up ausgelesen werden kann. Der Inhalt sieht wie folgt aus:

cat /proc/cgroups
#subsys_name  hierarchy num_cgroups enabled
cpuset        0         167         1
cpu           0         167         1
cpuacct       0         167         1
blkio         0         167         1
memory        0         167         1
devices       0         167         1
freezer       0         167         1
net_cls       0         167         1
perf_event    0         167         1
net_prio      0         167         1
hugetlb       0         167         1
pids          0         167         1
rdma          0         167         1
misc          0         167         1

Diese Datei beschreibt, welche cgroups-Controller (Subsysteme pro Ressourcentyp) im Kernel kompiliert und wo diese in der cgroup-v1-Hierarchie aufgehängt sind. Die Hierarchie beinhaltet eine 0, wenn der Controller nicht in die v1-Hierarchie, sondern in v2 gemountet ist. Diese Information kann daher zur Unterscheidung dienen und wird auch aktiv von der JVM verwendet.

Nach der Unterscheidung muss der Prozess auf die Limits zugreifen. Die cgroup-Informationen sind innerhalb des Containers im Ordner /sys/fs/cgroup verfügbar. Um etwa das Speicher-Limit einer v2 cgroup auszulesen, lässt sich der folgende Befehl nutzen:

docker run –memory 256m ubuntu cat /sys/fs/cgroup/memory.max

268435456

Über diesen Weg kann die JVM dann Informationen wie die Heap-Größe oder die Threadanzahl korrekt berechnen, wenn sie in einem Container gestartet wird.

Die bisherigen Beispiele haben sich auf "einfache" Ressourcen beschränkt, etwa die maximale CPU-Zeit oder Speicher, die einem Prozess zugeteilt sind. Es gibt aber auch noch die sogenannten CPU Shares als Ressource, die Einfluss darauf haben kann, wie sich die JVM in einem Container verhält. CPU Shares sind ein Maß für die Gewichtung, wenn mehrere Prozesse um CPU-Zeit konkurrieren. Sie stellen kein Limit im eigentlichen Sinn dar, sondern beziehen sich lediglich auf das Verhältnis verschiedener Prozesse zueinander.

Beim Starten eines Containers beträgt der Standardwert für CPU Shares 1024, sofern Entwicklerinnen oder Entwickler keinen anderen definieren. Dieser Wert lässt zunächst keine Rückschlüsse darauf zu, wieviel CPU-Zeit ein Prozess verwenden kann. Läuft auf einem Server nur ein einziger Container, kann dieser die komplette CPU-Zeit nutzen. Startet auf demselben Server ein zweiter Container mit einem größeren CPU Share (beispielsweise 2048), hat das solange keine Auswirkung auf den ersten Prozess und dessen CPU-Zeit, wie ausreichend Ressourcen zur Verfügung stehen.

Die Gewichtung greift erst dann, wenn die Ressourcen knapp werden und der Kernel den Zugriff auf die CPU priorisieren muss. Dann wird dem Container mit dem größeren CPU Share mehr CPU-Zeit zugesprochen – im obigen Beispiel also genau doppelt so viel.

Verteilung der CPU-Zeit mit Hilfe von CPU Shares (Abb. 2).

Die CPU Shares lassen sich unter cgroup v1 wie folgt auslesen:

docker run ubuntu cat /sys/fs/cgroup/cpu/cpu.shares

1024

Unter cgroup v2 sieht das etwas anders aus:

docker run ubuntu cat sys/fs/cgroup/cpu.weight

100

Abgesehen von der Namensänderung von CPU Shares zu CPU Weight beträgt der Standardwert nun 100 statt 1024. Greifen Entwicklerinnen und Entwickler jedoch auf Docker CLI zurück, um Container zu starten, bleibt der Standardwert bei 1024 und auch der Begriff CPU Shares gilt unverändert. Docker übernimmt im Hintergrund das Umrechnen des Wertes für den CPU Share auf den passenden für CPU Weight mit der folgenden Formel:

cpu.weight = (1 + ((cpu.shares – 2) * 9999) / 262142)

Das Ergebnis lässt sich im Container ansehen:

docker run –cpu-shares 1024 ubuntu cat /sys/fs/cgroup/cpu.weight

39

Auch Kubernetes macht Gebrauch von CPU Shares respektive CPU Weight.

apiVersion: v1
kind: Pod
metadata:
  name: app
spec:
  containers:
  - name: app
    image: nginx
    resources:
      requests:
        cpu: 2

Im obigen Beispiel wird der CPU Request einer Applikation auf 2 gesetzt. Den Wert nutzt Kubernetes, um für die Platzierung der Applikation einen geeigneten Server zu wählen. Dabei wird der CPU-Request-Wert auch als CPU Share beim Starten des Containers gesetzt: 2*1024 = 2048.

Obwohl sich anhand dieser Angabe keine Aussage über die aktuell verfügbare CPU-Zeit machen lässt, zieht die JVM diese Information unter bestimmten Umständen als Grundlage zum Bestimmen der konkret verfügbaren CPUs heran. Das kann zu unerwartetem, bisweilen sogar sehr sonderbarem Verhalten führen.

Um das Gesamtbild zu verstehen, muss man sich auch anschauen, wie die JVM mit diesen Einstellungen umgeht.