Blog · 14. September 2026 · 13 Min. Lesezeit

Kubernetes OOMKilled: erkennen und beheben

Laptop-Bildschirm mit einer rot hervorgehobenen Fehlermeldung
Foto: Markus Spiske / Pexels

Kubernetes OOMKilled bedeutet: Der Kernel hat einen Prozess deines Containers wegen Speichermangels beendet. Entweder überschreitet der Container sein eigenes Memory-Limit, oder dem Node geht der Speicher insgesamt aus, dann trifft es auch Container ohne eigenes Limit. Im Pod-Status steht danach Last State: Terminated, Reason: OOMKilled und Exit Code: 137. Erkennen kannst du das mit kubectl describe pod, den aktuellen Verbrauch zeigt kubectl top pod. Beheben heißt in den meisten Fällen: Werte an den gemessenen Bedarf anpassen, ein Memory-Leak in der Anwendung finden oder die Laufzeitumgebung so einstellen, dass sie das Container-Limit kennt. Das Limit einfach zu verdoppeln ist ein Pflaster, keine Lösung.

Ich betreibe seit 2017 Kubernetes-Cluster, heute vor allem k3s auf Hetzner Cloud mit Prometheus, Grafana und Loki, und bin Kubestronaut der CNCF. OOMKilled gehört zu den Meldungen, die mir im Betrieb immer wieder begegnen, obwohl der Code seit Wochen unverändert ist. Alles zu Kubernetes sammle ich auf der Seite Kubernetes.

Was OOMKilled bedeutet und was Exit Code 137 sagt

OOM steht für „Out of Memory". Ein Container mit Memory-Limit läuft in einer Cgroup, die der Linux-Kernel überwacht. Übersteigt der Speicherverbrauch das Limit und gerät der Kernel unter Speicherdruck, beendet der OOM-Killer den Prozess mit dem Signal SIGKILL. Exit Code 137 ist nach Unix-Konvention 128 plus 9, und 9 ist die Nummer von SIGKILL. Der Container bekommt keine Chance zum Aufräumen: keine offenen Verbindungen schließen, keine Puffer schreiben, kein Shutdown-Hook.

Der zweite Weg wird leicht übersehen. Geht dem Node der Speicher aus, bevor der kubelet Pods räumen kann, greift derselbe OOM-Killer eine Ebene höher zu, und dann trifft es auch Container ganz ohne Memory-Limit. Die Kubernetes-Dokumentation zur Node-pressure Eviction (Stand September 2026) beschreibt das im Abschnitt zum Out-of-Memory-Verhalten des Nodes. Im Pod-Status steht in beiden Fällen Reason: OOMKilled, welcher Fall vorlag, verraten die Events des Nodes.

Die Kubernetes-Dokumentation zu Ressourcen (Stand September 2026) formuliert das vorsichtiger, als viele annehmen: „memory limits are enforced reactively. A container may use more memory than its memory limit, but if it does, it may get killed." Ein Container kann das Limit also kurz überschreiten, ohne sofort zu sterben. Sobald der Kernel Speicherdruck bemerkt, ist es aber vorbei. Für CPU gilt das nicht: Ein CPU-Limit wird durch Drosselung durchgesetzt, der Prozess wird langsamer, aber nie beendet.

Wenn der Container neu gestartet werden darf, startet ihn der kubelet nach dem OOM-Kill wieder, beim ersten Mal sofort, danach mit wachsender Wartezeit. Passiert das mehrfach hintereinander, landet der Pod in CrashLoopBackOff: Pods richtig debuggen. OOMKilled ist deshalb oft nur das erste Symptom einer Kette.

OOMKilled in Kubernetes erkennen: drei kubectl-Befehle

Der erste Blick geht auf die Restarts. kubectl get pods -n <namespace> zeigt in der Spalte RESTARTS, welcher Pod auffällig oft neu gestartet wurde. Der zweite Befehl liefert den Grund. kubectl describe pod zeigt im Abschnitt Containers den letzten Zustand des Containers:

kubectl describe pod api-7d9c6b5f8-x2k4q -n shop

Der entscheidende Ausschnitt in der Ausgabe sieht so aus (gekürzt):

Containers:
  api:
    State:          Running
      Started:      Wed, 19 Nov 2026 09:14:02 +0100
    Last State:     Terminated
      Reason:       OOMKilled
      Exit Code:    137
      Started:      Wed, 19 Nov 2026 09:02:41 +0100
      Finished:     Wed, 19 Nov 2026 09:13:58 +0100
    Ready:          True
    Restart Count:  4
    Limits:
      memory:  256Mi
    Requests:
      cpu:     100m
      memory:  256Mi

Drei Dinge liest du hier ab: den Grund (OOMKilled), den Exit Code (137) und das Limit, an dem der Container gestorben ist (256Mi). Die Zeitstempel verraten, wie lange der Container bis zum Tod gelaufen ist. Elf Minuten wie hier sprechen für einen wachsenden Verbrauch, nicht für einen Fehler beim Start. Weitere Befehle für den Alltag habe ich in kubectl Befehle: die wichtigsten im Alltag zusammengestellt.

Der dritte Befehl zeigt den aktuellen Verbrauch. kubectl top braucht einen laufenden metrics-server im Cluster, laut Kubernetes-Anleitung zu Memory-Ressourcen (Stand September 2026) ist das die Voraussetzung. In k3s ist er standardmäßig dabei.

kubectl top pod -n shop --containers
POD                   NAME   CPU(cores)   MEMORY(bytes)
api-7d9c6b5f8-x2k4q   api    38m          241Mi

241Mi bei einem Limit von 256Mi: Der Container steht kurz vor dem nächsten OOM-Kill. kubectl top zeigt aber nur einen Augenblick. Ob der Verbrauch stabil hoch oder stetig wachsend ist, siehst du erst in einer Zeitreihe.

Sechs Ursachen für OOMKilled und ihr Fix

In der Praxis lassen sich die Ursachen auf sechs Muster eindampfen. Die Tabelle ordnet jeder Ursache das Symptom, die Prüfung und den Fix zu:

Ursache Symptom Prüfung Fix
Limit zu knapp geschätzt OOMKilled kurz nach dem Start oder bei jeder Lastspitze kubectl top mehrfach, Verbrauch liegt dauerhaft nahe am Limit Limit auf die gemessene Spitze plus Puffer setzen
Memory-Leak in der Anwendung Verbrauch steigt linear, OOMKilled in gleichmäßigen Abständen Speicherkurve in Grafana über Stunden ansehen Leak in der Anwendung beheben; ein höheres Limit kauft nur Zeit
Laufzeit kennt das Limit nicht Eine ältere oder abgeschaltete Container-Erkennung reserviert mehr Heap, als der Container hat Version und Heap-Einstellungen der Laufzeit im Container gegen das Limit prüfen Container-Erkennung aktiv lassen oder die Heap-Grenze ausdrücklich unter das Container-Limit setzen
Lastspitze ohne Skalierung OOMKilled nur zu Stoßzeiten, sonst ruhig Zeitpunkte der Restarts mit dem Traffic vergleichen HPA auf Basis von Requests, Limit mit Puffer für einen einzelnen Pod
Kein Limit, Node unter Druck Mehrere Pods auf demselben Node sterben gleichzeitig, auch solche ohne eigenes Limit kubectl describe node, Events und Kernel-Log des Nodes Requests und Limits für alle Container setzen, kritische Pods als Guaranteed
Sidecar oder Init-Container vergessen Der Hauptcontainer ist unauffällig, ein Nebencontainer stirbt kubectl describe pod, alle Container durchsehen Jedem Container im Pod eigene Requests und Limits geben

Der Fall „Laufzeit kennt das Limit nicht" ist der tückischste, weil die Anwendung selbst nichts falsch macht: Sie fragt das Betriebssystem nach dem verfügbaren Speicher, bekommt den ganzen Node gemeldet und richtet ihren Heap danach ein. Der Kernel sieht dagegen nur die Cgroup.

Das ist heute die Ausnahme, nicht die Regel. Aktuelle Laufzeiten erkennen Cgroup-Grenzen von sich aus: Für die JVM ist UseContainerSupport laut Oracle-Dokumentation zum Befehl java (Stand September 2026) unter Linux standardmäßig aktiv. Übrig bleiben drei Stolperstellen: alte Laufzeit-Versionen, Images mit ausdrücklich abgeschalteter Erkennung (-XX:-UseContainerSupport) und eine fest verdrahtete Heap-Größe über dem Container-Limit. Prüfe deshalb erst Version und Einstellungen, bevor du am Limit drehst. Setzt du die Heap-Grenze selbst, gehört sie mit Abstand unter das Container-Limit, weil neben dem Heap auch Threads und Puffer Speicher brauchen.

Requests und Limits richtig setzen

Ein Pod ohne Memory-Limit kann nicht wegen seines eigenen Limits OOMKilled werden. Er kann aber den ganzen Node in Speichernot bringen, und dann greift der OOM-Killer auf Node-Ebene zu, gegen die Nachbarn und gegen ihn selbst. Deshalb gehören Requests und Limits in jedes Deployment, das in Produktion läuft. Requests setze ich in meinen Clustern so gut wie überall, weil der Scheduler darüber plant: Ohne Request weiß er nicht, wie viel Platz ein Pod braucht, und packt den Node zu voll. So sieht ein sauberer Block aus:

apiVersion: apps/v1
kind: Deployment
metadata:
  name: api
  namespace: shop
spec:
  replicas: 2
  selector:
    matchLabels:
      app: api
  template:
    metadata:
      labels:
        app: api
    spec:
      containers:
        - name: api
          image: registry.example.com/shop/api:2.4.1
          resources:
            requests:
              cpu: 100m
              memory: 384Mi
            limits:
              memory: 384Mi

Zwei Entscheidungen stecken darin. Erstens: Memory-Request und Memory-Limit sind gleich. Der Scheduler plant damit genau die Menge ein, die der Container auch maximal nutzen darf. Eine exklusive Reservierung ist das nicht. Requests sind Buchhaltung für die Planung, kein physisch abgetrennter Speicherbereich, und Speicherdruck auf dem Node bleibt möglich. Der Gewinn liegt woanders: Ein Container, der seinen Request nicht überschreiten kann, gerät bei Node-Druck seltener ins Visier der Räumung. Zweitens: Es gibt ein CPU-Request, aber kein CPU-Limit. CPU-Drosselung macht Anwendungen langsam, ohne dass es im Pod-Status auftaucht. CPU-Limits setze ich deshalb situativ, je nachdem, wie gut der Cluster ausgestattet und wie kritisch die Anwendung ist. Für weniger kritisch als Memory-Limits halte ich sie ohnehin: Ein gedrosselter Prozess läuft weiter, ein Prozess über dem Memory-Limit stirbt. Die Abwägung steht in Kubernetes Requests und Limits erklärt.

Die Kombination aus Requests und Limits bestimmt die QoS-Klasse des Pods. Laut der Dokumentation zu Pod-QoS-Klassen (Stand September 2026) bekommt ein Pod Guaranteed nur, wenn jeder Container Memory- und CPU-Request und -Limit hat und Request gleich Limit ist. Burstable hat mindestens ein Request oder Limit, erfüllt die Guaranteed-Bedingungen aber nicht. BestEffort hat gar nichts gesetzt. Das Beispiel oben ist Burstable, weil das CPU-Limit fehlt.

Die QoS-Klasse ist aber nicht das Sortierkriterium der Räumung. Die Dokumentation zur Node-pressure Eviction (Stand September 2026) nennt eine andere Reihenfolge: zuerst, ob ein Pod über seinen Requests liegt, dann die Pod Priority, dann die Nutzung relativ zu den Requests. Die QoS-Klasse wirkt indirekt, weil ein Guaranteed-Pod seine Requests im Normalfall nicht überschreitet und ein BestEffort-Pod keine Requests hat, über denen er liegen könnte. Deshalb trifft es BestEffort meist zuerst und Guaranteed zuletzt. Ein Ausfallschutz ist Guaranteed damit nicht, nur ein geringeres Risiko.

Noch ein Detail: Gibst du nur ein Limit an und kein Request, kopiert Kubernetes das Limit als Request. Auf kleinen Nodes, wie ich sie bei Hetzner betreibe, geht so schnell Platz verloren.

Ein Fall aus der Praxis: wie ein Scanner die kleinen Pods verdrängt hat

Wie eng das zusammenhängt, hat mir zuletzt ein Schwachstellen-Scanner gezeigt. Er startete acht Worker gleichzeitig und belegte dabei sehr viel Arbeitsspeicher. Getroffen hat es zuerst die Nachbarn: Kleinere Pods auf demselben Node wurden verdrängt und liefen in Out-of-Memory. Kurz darauf ging der Scanner selbst Out-of-Memory.

Aufgefallen ist das schnell, und behoben war es mit angepassten Werten für Request und Limit. Welche Werte die beteiligten Pods vorher hatten, habe ich nicht dokumentiert, deshalb schreibe ich keine Rekonstruktion dazu. Der Mechanismus dahinter ist der aus dem Abschnitt oben: Wer bei Speicherdruck zuerst geht, entscheidet sich daran, wie weit ein Pod über seinen Requests liegt.

Die Lehre formuliere ich so: Wer Request und Limit weit auseinander definiert, bekommt mehr Auslastung aus seinen Nodes, weil der Scheduler nur den Request einplant und das Limit Luft nach oben lässt. Er riskiert damit genau solche Verdrängungen, sobald mehrere Pods gleichzeitig ihre Spitze ziehen. Kleine Cluster mit knapp bemessener Kapazität sind dafür anfälliger, weil ich dort bewusst eng definiere.

OOMKilled früher erkennen: Prometheus, Grafana, Loki

kubectl top reicht für den akuten Fall, nicht für die Vorsorge. In meinen Clustern läuft kube-prometheus-stack, und die wichtigste Kurve für dieses Thema ist der Working Set eines Containers im Verhältnis zu seinem Limit. Die Abfrage dafür in Prometheus:

max by (namespace, pod, container) (container_memory_working_set_bytes{container!=""})
  / on (namespace, pod, container)
  max by (namespace, pod, container) (kube_pod_container_resource_limits{resource="memory"})

Der Wert liegt zwischen 0 und 1. Ein Alert bei dauerhaft über 0,9 verschafft dir Vorlauf, bevor der Kernel zuschlägt. Wie viel, hängt am Lastprofil: Bei langsam wachsendem Verbrauch sind es Stunden bis Tage, bei stark schwankender Last Sekunden. Nimm den Alert als Warnung, nicht als Sicherheitsnetz. Ein zweiter Alert auf kube_pod_container_status_last_terminated_reason{reason="OOMKilled"} meldet jeden Kill, auch den, der nachts um drei passiert und morgens schon wieder vom Neustart überdeckt ist. Wie ich den Stack auf k3s aufsetze, steht in Kubernetes Monitoring mit Prometheus.

Die Logs helfen beim Warum. Ein Container, der per SIGKILL stirbt, schreibt keine Abschiedszeile, aber die Minuten davor sind aufschlussreich: eine Anfrage mit ungewöhnlich großer Antwort, ein Batch-Job, der alle Datensätze auf einmal lädt, ein Cache ohne Obergrenze. Diese Zeilen sind nach dem Kill nicht verloren. Laut der Dokumentation zur Logging-Architektur (Stand September 2026) hält der kubelet einen beendeten Container mit seinen Logs vor, und kubectl logs --previous holt sie hervor. Es bleibt bei diesem einen, und mit dem Pod verschwinden die Logs vom Node. Für Aufbewahrung und Korrelation mit den Metriken sammle ich deshalb zentral mit Loki, beschrieben in Kubernetes Logging mit Loki und Grafana.

Ein Muster, das leicht in die Irre führt, ist die Verwechslung mit Probes: Eine Liveness-Probe schlägt fehl, weil der Container unter Speicherdruck nicht mehr rechtzeitig antwortet, und der Neustart wird der Probe zugeschrieben statt dem Speicher. Der Blick auf Last State klärt das. Mehr dazu in Liveness und Readiness Probes erklärt.

Häufige Fragen

Warum werden meine Pods OOMKilled und wie erkenne ich das früher?

Ein Pod wird OOMKilled, wenn der Kernel einen Prozess wegen Speichermangels beendet: entweder weil der Container sein eigenes Memory-Limit überschreitet oder weil dem Node der Speicher ausgeht. Früher erkennst du es mit einer Zeitreihe des Speicherverbrauchs im Verhältnis zum Limit, zum Beispiel in Grafana, und einem Alert ab 90 Prozent. Der Restart ist dann nicht mehr die erste Nachricht, wie viel Vorlauf der Alert dir verschafft, hängt aber am Lastprofil.

Was bedeutet Exit Code 137 in Kubernetes?

Exit Code 137 ist 128 plus 9, und 9 ist das Signal SIGKILL. In Verbindung mit Reason: OOMKilled heißt es, dass der Kernel den Container wegen Speichermangel beendet hat. Exit Code 137 ohne OOMKilled kann auch ein manuelles kill -9 oder ein zu langsames Beenden nach terminationGracePeriodSeconds sein.

Reicht es, das Memory-Limit zu erhöhen?

Nur, wenn das Limit zu knapp geschätzt war und der Verbrauch stabil ist. Steigt der Verbrauch über Stunden linear, verschiebt ein höheres Limit den nächsten Kill nur nach hinten, und du zahlst für Speicher, den ein Leak frisst. Miss vorher, dann entscheide.

Wie sammle ich Kubernetes-Logs am besten?

Zentral, außerhalb des Pods. Nicht weil der OOM-Kill die Logs sofort vernichtet: Der kubelet hält einen beendeten Container mit seinen Logs vor, kubectl logs --previous zeigt sie. Verloren sind sie erst, wenn der Pod den Node verlässt oder die Rotation zuschlägt. Für Aufbewahrung und für die Korrelation mit Metriken nutze ich Loki mit Grafana, gesammelt von einem Agenten auf jedem Node, gespeichert in Object Storage. Wichtig ist, dass Logs und Metriken im selben Dashboard nebeneinander stehen, sonst suchst du in zwei Werkzeugen nach demselben Zeitpunkt.

Wie überwache ich das Autoscaling-Verhalten?

Der Horizontal Pod Autoscaler schreibt seine Entscheidungen als Events, die kubectl describe hpa zeigt; Prometheus liefert dazu die Zeitreihen für Replikazahl und Auslastung. Für OOMKilled ist entscheidend, dass jedes Replikat für sich genug Speicher hat, weil der HPA neue Pods startet, aber ein einzelnes Replikat nicht vor dem Kill bewahrt. Mehr dazu in Kubernetes Autoscaling mit HPA.

Was ist der Unterschied zwischen OOMKilled und Evicted?

OOMKilled trifft einen einzelnen Container: Der Kernel beendet ihn wegen Speichermangels, weil er sein eigenes Limit überschreitet oder weil der Node insgesamt am Ende ist. Der kubelet startet ihn danach neu, sofern die Restart Policy es vorsieht. Evicted trifft einen ganzen Pod, den der kubelet vorsorglich räumt, bevor der Node in den Speichermangel läuft. Sortiert wird dabei nach der Nutzung oberhalb der Requests und der Pod Priority, nicht direkt nach der QoS-Klasse. Evicted-Pods werden nicht neu gestartet, sondern von ihrem Controller neu angelegt; ohne Controller bleibt der Pod geräumt.

Wie du weitermachst

Prüfe zuerst mit kubectl describe pod, ob wirklich OOMKilled im Last State steht. Dann nimm dir die Speicherkurve der letzten Tage vor und ordne den Fall einer der sechs Ursachen zu. Erst danach fasst du Requests und Limits an, und zwar mit Zahlen aus der Messung. Wenn der Pod bereits in der Neustart-Schleife hängt, hilft CrashLoopBackOff: Pods richtig debuggen beim Ausstieg; die Grundlagen zu Ressourcen findest du in Kubernetes Requests und Limits erklärt.

Ausführlich, mit Ressourcen, Probes, Skalierung und Monitoring im Zusammenhang, steht das in Kapitel 8 „Ready for Production" meines Kubernetes-Praxisbuchs bei Rheinwerk.

Kevin Welter

Kevin Welter

Entwickler, IT-Architekt, Fachbuchautor (Kubernetes, Cloud-Infrastrukturen) und Speaker. Betreibt sein Business mit einer KI-Belegschaft aus acht KI-Mitarbeitern und zeigt Selbstständigen in seiner Community, wie sie ihren ersten KI-Mitarbeiter einstellen.

Mehr über KI-Mitarbeiter

Kubernetes von den Grundlagen bis zum produktionsreifen Cluster

Zum Buch