Ein Container startete neu mit Reason: OOMKilled, Exit-Code 137. Das Log bricht mitten im Satz ab: Das SIGKILL kommt vom Kernel, kein Handler kann es abfangen. Das Dashboard zeigt derweil entspannte 60% Speicherauslastung. Diese Kombination ist das häufigste Szenario eines «stillen» Neustarts in Kubernetes, und die Reaktion «Limit erhöhen und hoffen» kuriert das Symptom genau bis zum nächsten Traffic-Peak.
Die Kette, die man auswendig kennen sollte
Es ist nicht Kubernetes, das tötet — das Urteil fällt der kernel OOM Killer. Das Memory-Limit aus der Pod-Spec wird in eine cgroup übersetzt; versucht der Prozess, darüber hinaus zu allozieren, wählt der Kernel ein Opfer und sendet SIGKILL. Der Container beendet sich mit Code 137 (128 + 9), das kubelet markiert ihn als OOMKilled und startet bei restartPolicy: Always nur den Container neu, nicht den Pod: Die IP bleibt, Sidecars laufen weiter, Volumes bleiben gemountet. Wiederholt sich das im Kreis, folgt CrashLoopBackOff mit exponentiellem Backoff bis zur Fünf-Minuten-Grenze.
Ein nützlicher Kontrast: Für CPU-Überschreitung tötet Kubernetes nicht — es drosselt über cgroup bandwidth. CPU-Überschreitung degradiert die Latenz, Memory-Überschreitung bricht den Prozess ohne Vorwarnung ab. Das sind zwei verschiedene Failure Modes, und sie in einem Alert zu vermischen ist ein Fehler.
Warum das Dashboard «nichts gesehen» hat
Der klassische Fall: Pods werden regelmäßig gekillt, doch der Speichergraph steigt nie über 65%. Die Ursache sind Spitzen von 200–500 ms Länge: lang genug, um das harte Limit zu durchschlagen, zu kurz für ein Scrape-Intervall von 15–30 Sekunden. Die Glättung erledigt den Rest: avg() über ein langes Fenster zeigt 320 Mi, wo der Peak 600 erreichte.
Die dritte Falle ist die falsche Metrik. Der Kernel trifft die OOM-Entscheidung anhand des Working Set, der Anker ist also container_memory_working_set_bytes, nicht der Heap der Anwendung. Spike-Detection:
max_over_time(container_memory_working_set_bytes[30s])
# korrelieren mit:
kube_pod_container_status_last_terminated_reason{reason="OOMKilled"}
Spitzen sind keine Bugs: das Parsen eines großen JSON, GC-Pausen, ein Schub gleichzeitiger Requests. Kombiniert mit auf Kante genähten Limits sind sie der Auslöser.
Chirurgie am lebenden Pod
Solange der Pod lebt — zwischen den Neustarts — läuft die Forensik über einen Ephemeral Container mit shared process namespace:
kubectl debug -it <pod> --image=nicolaka/netshoot --target=<container>
--target öffnet die Prozesse des Hauptcontainers: cat /proc/1/status | grep VmRSS zeigt den aktuellen Footprint, ls /proc/1/fd | wc -l fängt ein Descriptor-Leck. Danach: Diff zweier pmap -x 1-Snapshots im Abstand von fünf Minuten — welche Segmente gewachsen sind. Zeigt der Diff kein offensichtliches Wachstum, liefert eBPF memleak aus BCC Stack Traces aller nicht freigegebenen Allokationen. Alles ohne Neustart, ohne Image-Rebuild, und es funktioniert auch auf distroless.
Ground Truth — cgroups memory.stat
cadvisor-Metriken täuschen bei Shared Pages: libc und libssl, von mehreren Containern geteilt, zählt RSS für jeden voll. Das ehrliche Bild liefert die cgroup selbst:
cat /sys/fs/cgroup/memory.stat # cgroup v2
# anon — Heap und Stacks: der «echte» Speicher des Containers
# file — Page Cache: der Kernel gibt ihn frei, bevor er tötet
# shmem — zwischen Prozessen geteilt
Das reale OOM-Risiko ist memory.current − file − shmem — nur der Heap. PSS (das zeigt smem) teilt Shared Pages gleichmäßig zwischen Prozessen auf — ein ehrliches Maß für den Footprint pro Container. Und ein häufiger Fehlalarm: pgmajfault sind Major Page Faults, Symptom eines I/O-Bottlenecks, kein OOM-Signal; darauf gebaute Alerts fangen das Falsche.
Reparieren statt beten
Es läuft auf vier Züge hinaus. Limits vom p99 Working Set ableiten: limit = p99 × 1.5, bei OOM-Historie × 2.0; Ziel-Baseline 50% des Limits statt 70%. Im Alerting max_over_time über kurze Fenster statt avg(). Lasttests mit realistischen Bursts, nicht mit glattem synthetischem Traffic. Und seit Kubernetes 1.36 gibt es ein Frühsignal: kubelet_psi_memory_full (PSI, GA) — der Zeitanteil, in dem alle Threads auf Speicher warten; das ist ein OOM-Kandidat vor dem ersten SIGKILL.
OOMKilled hört auf, ein Mysterium zu sein, wenn die Untersuchung Schicht für Schicht vorgeht: kubectl describe — was passiert ist; PromQL mit der richtigen Auflösung — wann; pmap und memory.stat — wohin der Speicher ging; Right-Sizing — damit es nicht wieder passiert.