Das Symptom: der Cluster ist voll, aber nichts arbeitet
Das Gespräch läuft fast immer gleich. Der Virtualisierungs-Cluster ist bei 80 bis 90 Prozent Speicherbelegung, das Monitoring meldet gelb, die Beschaffung von zwei weiteren Hosts liegt schon im Freigabelauf. Gleichzeitig kann niemand eine einzige VM benennen, deren Anwendung tatsächlich am Speicher hängt. Die Auslastung ist hoch, die Arbeit ist es nicht.
Der Grund steckt nicht in der Applikation, sondern im Modell. Eine VM bekommt Arbeitsspeicher zugewiesen, ein Container belegt ihn. Das klingt nach einer Formulierungsfrage und ist in Wirklichkeit die ganze Differenz: zugewiesener Speicher ist für den Host verbraucht, unabhängig davon, ob im Gast jemals ein Byte davon benutzt wird. Belegter Speicher ist messbar an der Arbeit, die er trägt.
Dazu kommt der zweite Posten, der in keiner Sizing-Tabelle auftaucht: jede VM bringt ein vollständiges Betriebssystem mit. Einen eigenen Kernel, eigene Page Tables, eigenes systemd, eigenen Monitoring-Agent, eigenen Log-Shipper, eigenen Paketcache. Bei vierzig VMs betreibt man vierzig Kopien derselben Grundlast, um vierzig Instanzen derselben Anwendung zu fahren.
Was eine VM wirklich belegt
Eine VM mit 4 GB kostet den Host nicht 4 GB, sondern mehr. Und sie gibt davon nichts zurück, sobald der Gast den Speicher einmal angefasst hat. Vier Posten summieren sich:
- Die Zuweisung selbst. Bei aktiviertem Preallocation oder Hugepage-Backing ist der Speicher ab dem Start weg. Ohne Preallocation wächst die Belegung nach Bedarf, aber nur in eine Richtung: einmal berührte Gastseiten bleiben beim QEMU-Prozess, weil der Host nicht wissen kann, dass der Gast sie längst freigegeben hat.
- Das Gast-Betriebssystem. Ein schlank installiertes Debian oder RHEL mit
systemd, SSH, Monitoring-Agent und Log-Shipper liegt im Betrieb realistisch bei 400 bis 600 MB. Nicht dramatisch, aber es multipliziert sich mit der Zahl der VMs. - Der Page Cache im Gast. Das ist der unterschätzte Posten. Ein Linux-Kernel betrachtet freien Speicher als verschwendeten Speicher und füllt ihn mit Dateisystem-Cache. Genau das ist im Gast das richtige Verhalten und für den Host fatal: aus dessen Sicht sind die Seiten benutzt. Eine VM mit 4 GB, deren Anwendung 700 MB braucht, meldet nach einer Woche Laufzeit fast 4 GB belegt, davon über 2 GB Cache für Dateien, die vielleicht nie wieder gelesen werden.
- Der Hypervisor je Gast. Der QEMU-Prozess selbst, Shadow- bzw. verschachtelte Page Tables, Geräteemulation, vhost-Puffer. Je VM grob 80 bis 200 MB, abhängig von Gerätemodell und Speichergröße.
Bei ungünstiger Konfiguration kommt ein fünfter Posten dazu: doppelter Cache. Läuft eine virtuelle Platte mit cache=writeback, liegen dieselben Blöcke im Page Cache des Gasts und im Page Cache des Hosts. Deshalb ist cache=none (O_DIRECT am Host) für Produktionsgäste der Standard, nicht aus Performance-Gründen allein, sondern weil es eine ganze Cache-Ebene einspart.
# Was der Host fuer eine VM wirklich haelt. rss ist die Wahrheit,
# actual nur der aktuell erlaubte Ballon-Wert.
virsh dommemstat db01 --live
# actual 4194304 <- der VM zugewiesen (KiB)
# rss 4098112 <- was der Host tatsaechlich fuer sie haelt
# available 4062336 <- was der Gast an Gesamtspeicher sieht
# unused 182400 <- was im Gast frei ist: 178 MB von 4 GB
# usable 2611200 <- frei PLUS reclaimbarer Cache: 2,5 GB
# Der Vergleich, der die Luecke zeigt: usable ist der Speicher, den
# der Gast hergeben koennte, aber nicht hergibt.
for d in $(virsh list --name); do
echo -n "$d "
virsh dommemstat "$d" | awk '/^(actual|rss|usable)/ {printf "%s=%dMB ", $1, $2/1024}'
echo
done
rss mit dem, was die Anwendung im Gast an anonymem Speicher hält. Die Differenz ist der Preis des Modells. Sie liegt bei typisch dimensionierten VMs im Bereich von 60 bis 80 Prozent der Zuweisung, und sie steigt mit der Laufzeit, weil der Gast-Cache sich füllt.
Was ein Container belegt: nichts, was er nicht anfasst
Ein Container ist keine kleine VM. Er ist ein normaler Prozess auf dem Host-Kernel, dem über Namespaces eine eigene Sicht und über cgroups eine Ressourcengrenze gegeben wurde. Daraus folgt der ganze Speichervorteil, und zwar an vier Stellen.
Ein Kernel für alle
Der Kernel läuft einmal. Es gibt eine Prozesstabelle, einen Scheduler, einen Satz Page Tables pro Prozess statt pro Gast, einen Netzwerkstack. Auch das Host-Userland existiert einmal. Der Posten, der im VM-Modell mit der Zahl der Instanzen multipliziert wird, ist im Container-Modell eine Konstante.
Ein Page Cache für alle
Alle Container teilen den Page Cache des Hosts. Fünfzig Container aus demselben Image lesen dieselben Inodes der Read-only-Layer, also liegen libc, die JVM oder der Python-Interpreter genau einmal im Speicher, unabhängig davon, wie oft der Container läuft. Bei VMs ist das ausgeschlossen: fünfzig Gäste bringen fünfzig separate Kopien derselben Bibliothek mit, weil die Seiten in fünfzig getrennten Adressräumen liegen.
Ein Limit ist keine Reservierung
Das ist der Kern der Sache. memory.max in cgroup v2 ist eine Obergrenze, keine Zuteilung. Ein Container mit memory.max=4G, dessen Prozess 700 MB anonymen Speicher hält, kostet den Host 700 MB. Das Limit kostet null. Man kann Spitzenreserven großzügig setzen, ohne sie zu bezahlen. Genau das ist im VM-Modell unmöglich, dort ist die Reserve der Preis.
Wer umgekehrt eine echte Reservierung braucht, hat sie auch: memory.min schützt einen Bereich hart gegen Reclaim, memory.low weich. Die Reservierung wird damit zur Ausnahme für die zwei kritischen Dienste, statt zum Default für alles.
Cache ist rückholbar, und der Kernel weiß es
Der Page Cache eines Containers zählt in memory.current mit, ist aber jederzeit räumbar. Steigt der Druck, nimmt der Kernel die Seiten zurück, ohne dass ein Prozess stirbt. Beim Gast-Cache in einer VM kann er das nicht, weil er die Seite nur als benutzten Gastspeicher sieht, nicht als Cache. Das ist derselbe physische Speicher mit einem völlig anderen Verhalten unter Druck.
# cgroup v2: Limit, aktuelle Belegung, historischer Hoechstwert
cd /sys/fs/cgroup/system.slice/docker-abc123.scope
cat memory.max memory.current memory.peak
# 4294967296 <- Limit: kostet nichts
# 1043726336 <- aktuell belegt: 995 MB
# 1512136704 <- Spitze seit Start: 1,4 GB
# Die Aufschluesselung entscheidet, was davon echter Bedarf ist
grep -E '^(anon|file|slab|kernel_stack|sock) ' memory.stat
# anon 715653120 <- 682 MB: das ist der echte Bedarf
# file 298844160 <- 285 MB Cache: raeumbar, kein Bedarf
# slab 19226624
# kernel_stack 3244032
# PSI zeigt Speicherdruck, bevor der OOM-Killer es tut
cat memory.pressure
anon, nicht memory.current. current enthält den Cache und läuft in einem gesunden System immer gegen das Limit, das ist normal und kein Alarm. Auch docker stats rechnet deshalb inactive_file heraus, bevor es eine Zahl anzeigt. Wer nach current dimensioniert, baut die VM-Überdimensionierung in YAML nach.
Ein Host, drei Rechnungen
Rechnen wir es konkret. Der Host hat 512 GB RAM. Darauf sollen 96 gleichartige Dienste laufen, jeder mit einem gemessenen Arbeitsset von rund 700 MB anonymem Speicher und Spitzen um 1,2 GB. Die Anwendung ist in allen drei Modellen identisch, es ändert sich ausschließlich, was um sie herum gebaut wird.
Die Kupferfläche im ersten Modell ist der ganze Punkt: 269 GB, mehr als die Hälfte des Hosts, sind zugewiesen und tragen keine Arbeit. Sie sind im Gast als Page Cache oder freier Speicher gebunden, der Host kann sie nicht vergeben, und keine Beschaffung löst das Problem, weil die nächsten Hosts mit derselben Quote arbeiten.
Der zweite Blick lohnt auf die Balken: die teal-farbene Fläche links, die echten Nutzdaten der Anwendung, ist in allen drei Modellen exakt gleich groß. Der Faktor kommt nicht daher, dass Container die Anwendung schrumpfen. Er ist die Differenz zwischen zugewiesen und benutzt.
Und hier kommt der ehrliche Zwischenruf: die 4 GB im dritten Balken sind eine Entscheidung, keine Naturkonstante. Wer VMs mit 1,5 GB baut, halbiert die Lücke. Zwei Dinge bleiben aber auch dann bestehen. Erstens zahlt man weiterhin 96 Gast-Kernel samt 96 Kopien der Grundlast. Zweitens verliert man mit der knappen Dimensionierung genau die Kopfreserve, die der Grund für die großzügige Zuweisung war: eine VM, deren Anwendung in eine Spitze von 1,2 GB läuft, beginnt bei 1,5 GB zu swappen oder trifft den OOM-Killer im Gast. Beim Container ist derselbe Puffer als Limit gratis.
| Posten | VM je Dienst | Container | Warum |
|---|---|---|---|
| Nutzdaten der Anwendung | 67 GB | 67 GB | gleiche Software, gleiches Arbeitsset |
| Gast-Betriebssysteme | 48 GB | entfällt | 96 Kopien der Grundlast gegen keine |
| Kernel und Host-Grundlast | 8 GB | 8 GB | in beiden Modellen einmal |
| Zugewiesen, ungenutzt | 269 GB | 0 GB | Limit statt Reservierung |
| Hypervisor bzw. Runtime | 14 GB | 3 GB | kein QEMU-Prozess je Dienst |
| Page Cache | 96 getrennte Caches | ein geteilter Cache | gemeinsame Image-Layer |
| Nicht rückholbar belegt | 406 GB | 78 GB | Faktor 5,2 |
Bei dieser Dichte verschiebt sich der Engpass. 96 Container in 78 GB heißt nicht, dass 500 Dienste auf den Host passen: irgendwann limitieren CPU, Netzwerk-Sockets, Dateideskriptoren oder die Ausfalldomäne. Genau das ist der gewünschte Zustand. Der Speicher hört auf, die bindende Grenze zu sein, und die Kapazitätsplanung dreht sich um Größen, die man mit Lasttests belegen kann.
Messen statt schätzen
Bevor irgendetwas migriert wird, wird gemessen, und zwar am bestehenden VM-Bestand. Die Frage ist nicht, wie viel RAM eine VM hat, sondern wie viel ihre Anwendung braucht. Drei Ausgaben genügen.
Frage 1: Wie groß ist das Arbeitsset wirklich?
Im Gast interessiert nur der anonyme Speicher der Anwendung. free -m ist dafür die falsche Ausgabe, weil sie den Cache mitzählt und deshalb bei jeder länger laufenden VM alarmierend aussieht.
# Anonymer Speicher aller Prozesse: das ist der echte Bedarf.
# available in der zweiten Zeile ist die interessante Zahl,
# nicht used.
free -m
grep -E '^(AnonPages|Cached|Committed_AS|MemAvailable)' /proc/meminfo
# Pro Dienst, mit korrekt aufgeteiltem Shared Memory (PSS)
smem -k -c "pss uss command" -r | head -15
systemd-cgtop -m --order=memory
# Spitze ueber die Zeit, nicht der Momentwert: cgroup v2 merkt sich das
cat /sys/fs/cgroup/system.slice/app.service/memory.peak
Frage 2: Wie viel Zuweisung liegt clusterweit brach?
Diese Zahl ist die Grundlage jedes Gesprächs über Beschaffung. Sie kommt vom Hypervisor, nicht aus dem Gast, und sie braucht den Balloon-Treiber im Gast, sonst liefert virsh keine usable-Werte.
# Brachliegende Zuweisung ueber alle laufenden Gaeste summieren.
# usable = im Gast frei plus reclaimbarer Gast-Cache.
for d in $(virsh list --name); do
virsh dommemstat "$d" | awk -v n="$d" '
/^actual/ {a=$2} /^usable/ {u=$2}
END {printf "%-20s zugewiesen %6d MB brach %6d MB (%d %%)\n",
n, a/1024, u/1024, (a?100*u/a:0)}'
done
# Proxmox: Zuweisung gegen tatsaechliche Host-Belegung
qm list | awk 'NR>1 {s+=$4} END {print "zugewiesen:", s, "MB"}'
free -m
Frage 3: Was würde der Container-Weg kosten?
Das ist keine Schätzung, sondern eine Messung mit einem einzigen Kandidaten. Ein Dienst wird containerisiert, mit einem großzügigen Limit gestartet, unter Produktionslast beobachtet, danach werden memory.peak und anon abgelesen. Multipliziert mit der Zahl gleichartiger Dienste ergibt das eine belastbare Zahl statt eines Prospektversprechens.
| Beobachtung | Interpretation | Nächster Schritt |
|---|---|---|
| usable über 50 % von actual | VM ist deutlich zu groß | Kandidat für Container oder Downsizing |
| usable nahe 0, rss = actual | VM nutzt ihren Speicher | hier gewinnt die Migration wenig RAM |
| AnonPages klein, Cached groß | Cache füllt die Zuweisung | Bedarf ist der Anon-Wert, nicht used |
| memory.peak weit unter Limit | Limit ist reine Reserve | so lassen, es kostet nichts |
| memory.pressure steigt | echter Druck, Reclaim läuft | Limit anheben oder Dienst verteilen |
Ballooning und KSM: Reparatur am falschen Modell
Die Hypervisor-Welt hat für dieses Problem Werkzeuge, und sie funktionieren. Nur sind sie Gegenmaßnahmen gegen eine Eigenschaft der Architektur, nicht deren Behebung. Wer sie einschaltet, sollte wissen, was er dafür zahlt.
| Mechanismus | Was er tut | Was er kostet |
|---|---|---|
| virtio-balloon | zieht ungenutzte Gastseiten zurück | reagiert in Sekunden, nicht Mikrosekunden; braucht einen kooperierenden Gast |
| free page reporting | Gast meldet freie Seiten laufend | nur mit neuerem Kernel und Treiber; hilft nicht gegen Gast-Cache |
| KSM | dedupliziert identische Seiten | dauerhafte CPU-Last durch Scannen, Latenzspitzen beim Copy-on-Write, Seitenkanal-Risiko zwischen Mandanten |
| Host-Swap | lagert kalte Gastseiten aus | der Gast weiß nichts davon und trifft die falschen Entscheidungen |
| Transparent Huge Pages | senkt TLB-Druck | arbeitet gegen KSM und kann Speicher aufblähen |
# Bringt KSM auf diesem Host ueberhaupt etwas?
grep -H '' /sys/kernel/mm/ksm/{run,pages_sharing,pages_shared,full_scans}
# pages_sharing x 4 KiB = eingesparter Speicher.
# Unter ~5 % des Hosts ist die CPU-Last dafuer schwer zu begruenden.
awk '{printf "KSM spart %d MB\n", $1*4/1024}' /sys/kernel/mm/ksm/pages_sharing
# Ballooning wirkt nur mit Treiber im Gast. Fehlt er, ist actual == rss.
virsh qemu-monitor-command db01 --hmp 'info balloon'
Das entscheidende Problem an beidem: es sind Regelkreise mit Verzögerung. Der Ballon gibt Speicher zurück, wenn Druck entsteht, nicht bevor er entsteht. Genau in dem Moment, in dem der Host knapp wird, beginnt er, Gästen Seiten zu entziehen, deren Kernel darauf mit Reclaim und im schlechten Fall mit Swapping reagiert. Die Speicherknappheit wird damit in eine Latenzspitze in der Anwendung übersetzt, und zwar unter Last. Der Container-Weg hat diesen Regelkreis nicht, weil er den Speicher nie belegt hat.
Derselbe Fehler, nur in YAML
Der Vorteil ist kein Naturgesetz der Container, sondern eine Folge der Konfiguration. Man kann ihn vollständig verschenken, und in Kubernetes-Clustern passiert genau das regelmäßig: requests werden gesetzt wie VM-Größen.
requests.memory ist die Zahl, gegen die der Scheduler bucht. Sie ist die Reservierung. limits.memory ist die Obergrenze und landet als memory.max im cgroup. Wer aus Gewohnheit requests: 4Gi für einen Dienst mit 700 MB Arbeitsset schreibt, hat die VM-Reservierung nachgebaut, inklusive der brachliegenden Kapazität, nur ohne die Isolation dafür zu bekommen. Das ist der schlechteste Punkt von beiden Modellen.
# Falsch: request wie eine VM-Groesse. Der Scheduler bucht 4 Gi,
# benutzt werden 700 MB. Die Differenz ist verlorene Kapazitaet.
resources:
requests: { memory: 4Gi }
limits: { memory: 4Gi }
# Richtig: request am gemessenen Arbeitsset plus kleiner Puffer,
# limit grosszuegig fuer die Spitze. Das Limit kostet nichts.
resources:
requests: { memory: 900Mi } # anon aus memory.stat plus ~25 %
limits: { memory: 2Gi } # memory.peak plus Reserve
requests gleich limits bewusst. Diese Klasse gehört in die Garantierte-Klasse, gerne auch weiter in eine VM. Der Fehler ist nicht die Reservierung, sondern die Reservierung als Standard für alles.
Dieselbe Regel gilt jenseits von Kubernetes. Ein MemoryMax in einer systemd-Unit oder ein --memory am Container-Start kostet nichts, solange es nicht erreicht wird. Sobald jemand anfängt, mit memory.min Bereiche festzunageln, ist er zurück im Reservierungsmodell und sollte dafür einen Grund nennen können.
Der ehrliche Teil: wo die VM richtig bleibt
Wer aus dem Speicherargument die Regel „nie wieder VMs“ macht, macht einen zweiten Fehler nach dem ersten. Es gibt Fälle, in denen das Reservierungsmodell nicht der Preis ist, sondern das Produkt.
- Ein anderer Kernel. Container teilen den Kernel des Hosts. Windows, ein Legacy-System mit eigener Kernelversion, ein Gast mit exotischen Modulen oder eigenem Scheduler-Patch: das geht nicht als Container, und keine Rechnung ändert das.
- Isolation als Zusage. Der Container ist durch Namespaces und Seccomp vom Kernel getrennt, nicht von der Hardware. Die Angriffsfläche ist die Syscall-Schnittstelle eines gemeinsamen Kernels. Wo fremde Mandanten oder fremder Code laufen, ist die VM-Grenze die belastbarere Zusage, und ihr Speicherpreis ist dann gekaufte Sicherheit.
- Nachweispflichten. Wenn ein Prüfer, ein Kunde oder eine Zertifizierung eine dokumentierte Trennung auf Hypervisor-Ebene verlangt, ist das eine Anforderung und keine technische Meinung. Sie steht über der Effizienzrechnung.
- Ausfalldomäne. Ein Kernel-Panic in einer VM trifft eine VM. Auf einem Container-Host trifft er alles, was darauf läuft. Dasselbe gilt für Kernel-Updates: ein Reboot des Container-Hosts ist ein Reboot aller Dienste.
- Zustandsbehaftete Systeme mit Gerätezugriff. GPU-Passthrough, spezielle PCIe-Karten, Lizenzdongles, Systeme mit erwarteten Live-Migrationen: hier ist der Hypervisor der Ort, an dem diese Mechanik existiert.
Dazwischen liegt eine Zone, die man kennen sollte. MicroVMs wie Firecracker oder Cloud Hypervisor senken den Aufwand je Gast erheblich, weil sie die Geräteemulation radikal reduzieren, und Ansätze wie Kata Containers setzen die Container-Schnittstelle auf eine VM-Grenze. Sie verschieben die Kurve deutlich, aber sie heben den Kernpunkt nicht auf: es bleibt ein eigener Kernel, ein eigener Page Cache und eine eigene Speicherzuweisung je Instanz.
Betriebsregeln, die den Speicher freihalten
Die Migration muss nicht groß sein, um zu wirken. Sie muss an den Diensten anfangen, bei denen die Lücke zwischen Zuweisung und Bedarf am größten ist, und das sind fast immer die vielen kleinen, gleichartigen.
| Regel | Warum |
|---|---|
| Sizing gegen anonymen Speicher, nicht gegen used | Page Cache ist kein Bedarf, sondern Nutzung freier Kapazität |
| Brachliegende Zuweisung clusterweit berichten | usable je Gast summiert ist die Zahl, die eine Beschaffung entscheidet |
| Viele gleichartige Dienste zuerst migrieren | dort multipliziert sich die eingesparte Grundlast |
| Limits großzügig, Reservierungen sparsam | ein Limit kostet nichts, eine Reservierung kostet den ganzen Betrag |
| Eine VM je Dienst als Anti-Muster behandeln | zahlt VM-Kosten und liefert nur Container-Isolation, wenn ohnehin geteilt wird |
| KSM nur mit gemessenem Nutzen und nie mandantenübergreifend | CPU-Last und Seitenkanal gegen den eigentlichen Zweck des VM-Modells |
| Ausfalldomäne des Container-Hosts explizit festlegen | ein Kernel für alle heißt auch ein Reboot für alle |
| VM-Ebene für Kernel, Mandanten und Nachweise, Container für Dienste | jede Ebene trägt, was nur sie kann |
actual und Bedarf einmal clusterweit summiert, hat meistens die nächsten zwei Hosts schon im Rack stehen. Sie sind nur falsch aufgeteilt.
precipice.tech