Warum Exit-Code 137 kein Bug im eigenen Code sein muss
Ein Container, der plötzlich mit Exit-Code 137 stirbt, ohne Panic, ohne Log-Ausgabe, ohne erkennbaren Fehler, ist eines der frustrierendsten Docker-Probleme überhaupt. In den allermeisten Fällen steckt der Linux-OOM-Killer dahinter, und das Problem liegt selten im Code, sondern in falsch geschätzten Memory-Limits.
Inhaltsverzeichnis
- 1. Wie der OOM-Killer in Containern auslöst
- 2. Exit-Code 137 richtig entschlüsseln
- 3. Kernel-Logs auswerten: dmesg und journalctl
- 4. Realen Speicherverbrauch messen statt raten
- 5. Verhalten mit und ohne Swap
- 6. Sprachspezifische Fallen: JVM und Node.js
- 7. Kontinuierliches Monitoring statt einmaliger Messung
- 8. Präventive Maßnahmen im Zusammenspiel
- 9. Troubleshooting-Checkliste bei OOM-Verdacht
- 10. Zusammenfassung
- 11. FAQ
1. Wie der OOM-Killer in Containern auslöst
Der Out-Of-Memory-Killer ist ein Kernel-Mechanismus, der einspringt, wenn Speicher angefordert wird, der nicht mehr verfügbar ist, und kein Prozess bereit ist, freiwillig Speicher freizugeben. Ohne Container-Kontext wird der OOM-Killer erst aktiv, wenn dem gesamten System der Speicher ausgeht, ein seltenes und meist dramatisches Ereignis. Innerhalb eines Containers mit gesetztem Memory-Limit ändert sich das grundlegend: Der OOM-Killer greift schon dann ein, wenn nur die cgroup dieses einen Containers ihr Limit erreicht, unabhängig davon, wie viel freier Speicher auf dem restlichen Host noch verfügbar ist.
Das bedeutet konkret, dass ein Host mit vielen Gigabyte freiem Arbeitsspeicher einen einzelnen Container trotzdem killen kann, sobald dessen individuelles memory.max erreicht ist. Der Kernel wählt dabei innerhalb der betroffenen cgroup einen Prozess anhand eines Scores aus, dem sogenannten oom_score, und beendet genau diesen Prozess mit einem SIGKILL-Signal. In den meisten Docker-Containern läuft nur ein Hauptprozess, wodurch praktisch immer die gesamte Anwendung im Container beendet wird, nicht nur ein einzelner Thread oder Worker.
2. Exit-Code 137 richtig entschlüsseln
Der Exit-Code 137 wirkt auf den ersten Blick wie eine willkürliche Zahl, ist aber eine feste Konvention in Unix-Systemen: Exit-Codes über 128 signalisieren, dass ein Prozess durch ein Signal beendet wurde, berechnet als 128 plus die Signal-Nummer. SIGKILL trägt die Signal-Nummer 9, und 128 plus 9 ergibt exakt 137. Ein Prozess, der mit Exit-Code 137 endet, wurde also nicht regulär beendet und hatte auch keine Chance, sauber herunterzufahren, weil SIGKILL im Gegensatz zu SIGTERM nicht abgefangen oder verzögert werden kann.
Wichtig ist dabei die Unterscheidung: Nicht jeder Exit-Code 137 stammt zwingend vom OOM-Killer, theoretisch kann auch ein manuelles docker kill oder ein Orchestrator diesen Code erzeugen. Docker selbst liefert aber ein zuverlässiges Unterscheidungsmerkmal: docker inspect zeigt im Feld State.OOMKilled explizit true an, wenn der Container tatsächlich vom OOM-Killer beendet wurde. Dieses Feld sollte bei jedem unerwarteten Container-Absturz mit Code 137 als erste Diagnosequelle geprüft werden, noch vor der Anwendungslog-Analyse.
# Exit-Code und OOM-Status eines beendeten Containers pruefen
docker inspect --format \
'ExitCode={{.State.ExitCode}} OOMKilled={{.State.OOMKilled}}' \
mein-container
# Beispielausgabe:
# ExitCode=137 OOMKilled=true
3. Kernel-Logs auswerten: dmesg und journalctl
Auch wenn docker inspect bestätigt, dass der OOM-Killer aktiv war, liefert das Kernel-Log selbst deutlich mehr Kontext: welcher genaue Prozess getötet wurde, wie viel Speicher zum Zeitpunkt des Kills belegt war, und welche cgroup betroffen war. Auf dem Host lässt sich das über dmesg oder, auf systemd-Systemen, über journalctl -k einsehen. Die relevante Zeile enthält typischerweise die Formulierung Killed process, gefolgt von der Prozess-ID, dem Prozessnamen und dem belegten Speicher in Kilobytes.
Ein Blick in diese Logzeile zeigt oft mehr als erwartet: Manchmal wird nicht der Hauptprozess der Anwendung getötet, sondern ein Kind-Prozess oder ein temporär gestarteter Hilfsprozess, was auf ein anderes zugrunde liegendes Problem hindeuten kann, etwa ein Speicherleck in einem Subprozess statt in der Hauptanwendung. Auch der genaue Zeitpunkt des Kills, abgeglichen mit Anwendungsmetriken oder Lastspitzen, hilft oft dabei, den Auslöser einzugrenzen, etwa einen Batch-Job, der kurzzeitig deutlich mehr Speicher als üblich beansprucht.
# Kernel-Log nach OOM-Ereignissen durchsuchen
dmesg -T | grep -i "killed process"
# Auf systemd-Systemen alternativ ueber journalctl
journalctl -k --since "1 hour ago" | grep -i oom
# Beispielausgabe:
# Out of memory: Killed process 48213 (node) total-vm:1235412kB,
# anon-rss:524288kB, file-rss:0kB, shmem-rss:0kB
4. Realen Speicherverbrauch messen statt raten
Der häufigste Grund für OOM-Kills ist nicht ein Speicherleck, sondern ein zu knapp geschätztes Limit, das nie gegen den tatsächlichen Verbrauch der Anwendung validiert wurde. Statt einen Wert wie --memory=256m zu raten, weil er in einem Tutorial stand, sollte der reale Speicherbedarf zunächst ohne Limit oder mit einem großzügigen Limit über einen repräsentativen Zeitraum gemessen werden, idealerweise unter realistischer Last, nicht im Leerlauf direkt nach dem Start.
Das Kommando docker stats zeigt den aktuellen Verbrauch live an, eignet sich aber schlecht, um kurzzeitige Lastspitzen zu erfassen, weil es nur Momentaufnahmen liefert. Zuverlässiger ist ein Blick in die cgroup-Datei memory.peak, die den höchsten je erreichten Speicherwert seit Erstellung der cgroup festhält, unabhängig davon, wann genau dieser Spitzenwert auftrat. Erst wenn dieser Spitzenwert über mehrere Tage oder mehrere typische Lastzyklen hinweg bekannt ist, lässt sich ein Limit mit einem sinnvollen Sicherheitspuffer von typischerweise 20 bis 30 Prozent darüber setzen.
# Live-Verbrauch beobachten (Momentaufnahme, kein Spitzenwert)
docker stats mein-container --no-stream
# Zuverlässiger: den tatsaechlichen Spitzenwert seit Containerstart lesen
CID=$(docker inspect --format '{{.Id}}' mein-container)
cat /sys/fs/cgroup/system.slice/docker-${CID}.scope/memory.peak
# Ueber laengeren Zeitraum mitloggen (z.B. jede Minute)
while true; do
echo "$(date -Iseconds) $(cat /sys/fs/cgroup/system.slice/docker-${CID}.scope/memory.current)"
sleep 60
done >> memory-log.txt
5. Verhalten mit und ohne Swap
Ob Swap aktiviert ist, verändert das OOM-Verhalten spürbar. Ohne zusätzlichen Swap-Anteil, also mit identischem Wert für --memory und --memory-swap, greift der OOM-Killer sehr schnell und ziemlich hart, sobald das physische Limit erreicht ist. Mit zusätzlichem Swap-Spielraum kann der Kernel zunächst Speicherseiten auf die Swap-Partition auslagern, bevor er den OOM-Killer aktiviert, was den harten Absturz zwar verzögert, aber gleichzeitig zu drastisch schlechterer Performance führt, weil Swap-I/O um Größenordnungen langsamer ist als RAM-Zugriff.
In den meisten produktiven Container-Umgebungen ist Swap für Container bewusst deaktiviert oder stark eingeschränkt, gerade weil ein Container, der anfängt zu swappen, meist ohnehin schon in einem fehlerhaften Zustand steckt und ein schneller, sauberer Neustart über eine Restart-Policy oft die bessere Lösung ist als ein zäh dahinsiechender, swappender Container. Für latenzkritische Anwendungen wie Datenbanken oder Caches gilt diese Empfehlung besonders stark, weil unvorhersehbare Swap-Latenzen dort weit schädlicher sind als ein sauberer, sofortiger Neustart.
6. Sprachspezifische Fallen: JVM und Node.js
Bestimmte Laufzeitumgebungen erkennen Container-Memory-Limits nicht automatisch oder erst seit relativ neuen Versionen, was zu besonders tückischen OOM-Kills führt. Ältere JVM-Versionen vor Java 10 berechneten die Standard-Heap-Größe anhand des gesamten Host-Speichers, nicht anhand des cgroup-Limits, wodurch eine JVM in einem Container mit 512-Megabyte-Limit versuchen konnte, einen Heap von mehreren Gigabyte zu allokieren. Moderne JVMs ab Java 10 sind cgroup-aware, sollten aber trotzdem explizit über Flags wie -XX:MaxRAMPercentage konfiguriert werden, um Sicherheitsmargen für Nicht-Heap-Speicher wie Thread-Stacks und Metaspace einzuplanen.
Node.js hat ein ähnliches, aber umgekehrtes Problem: Die V8-Engine begrenzt den Heap standardmäßig unabhängig vom Container-Limit, oft auf einen für kleine Container zu hohen Wert, wodurch die Anwendung erst sehr spät merkt, dass sie an eine Grenze stößt, während der Node-Prozess durch zusätzlichen Speicher außerhalb des V8-Heaps, etwa Buffer-Objekte, längst über das cgroup-Limit hinausgewachsen ist. Hier hilft es, --max-old-space-size explizit und deutlich unterhalb des Container-Limits zu setzen, um der Anwendung selbst kontrollierte Fehlerbehandlung statt eines harten OOM-Kills zu ermöglichen.
# JVM: Heap explizit als Prozentsatz des Container-Limits konfigurieren
docker run -d --memory=1g myapp:latest \
java -XX:MaxRAMPercentage=75.0 -XX:+UseContainerSupport -jar app.jar
# Node.js: Heap-Limit deutlich unter dem Container-Limit setzen
docker run -d --memory=512m mynodeapp:latest \
node --max-old-space-size=384 server.js
7. Kontinuierliches Monitoring statt einmaliger Messung
Eine einmalige Messung des Speicherverbrauchs reicht selten aus, weil sich das Nutzungsverhalten einer Anwendung über Wochen und Monate ändert, etwa durch wachsende Datenmengen, neue Features oder veränderte Nutzerzahlen. Deshalb lohnt sich dauerhaftes Monitoring über Tools wie cAdvisor oder den Node Exporter in Kombination mit Prometheus, die Speicherverbrauch, OOM-Ereignisse und Restart-Zähler über die Zeit hinweg aufzeichnen und in Grafana visualisierbar machen.
Besonders wertvoll ist dabei die Metrik container_oom_events_total, die cAdvisor direkt aus den Kernel-cgroup-Statistiken ableitet, sowie Alerts, die bereits bei 80 oder 90 Prozent Speicherauslastung eines Limits auslösen, lange bevor der tatsächliche OOM-Kill eintritt. Damit verschiebt sich das Vorgehen von reaktivem Debugging nach einem Absturz zu proaktivem Nachjustieren von Limits, bevor Nutzer überhaupt einen Ausfall bemerken.
8. Präventive Maßnahmen im Zusammenspiel
Die wirksamste Prävention kombiniert mehrere der besprochenen Maßnahmen: realistische Limits basierend auf gemessenen Spitzenwerten statt geratenen Werten, sprachspezifische Heap-Konfiguration, die deutlich unterhalb des Container-Limits bleibt, kontinuierliches Monitoring mit frühzeitigen Alerts, und eine sinnvolle Restart-Policy wie on-failure oder unless-stopped, damit ein einzelner OOM-Kill nicht zu dauerhaftem Ausfall führt, sondern der Container automatisch neu startet, während parallel die eigentliche Ursache untersucht wird.
Zusätzlich hilft es, Memory-Reservierungen von Memory-Limits zu unterscheiden, sofern die eingesetzte Orchestrierung, etwa Docker Swarm oder Kubernetes, das unterstützt: Eine Reservierung stellt sicher, dass ein Container immer mindestens so viel Speicher bekommt, wie für den Normalbetrieb nötig ist, während das Limit die absolute Obergrenze für Ausreißer definiert. Diese Kombination verhindert sowohl Ressourcenknappheit im Normalbetrieb als auch unkontrolliertes Wachstum bei Fehlfunktionen.
9. Troubleshooting-Checkliste bei OOM-Verdacht
Bei einem Container-Absturz mit Exit-Code 137 lohnt sich ein systematisches Vorgehen statt wildem Herumprobieren: erst docker inspect auf das Feld OOMKilled prüfen, dann dmesg nach der genauen Kill-Meldung durchsuchen, anschließend memory.peak der cgroup mit dem gesetzten memory.max vergleichen, und zuletzt die Anwendungslogs auf ungewöhnliche Aktivität kurz vor dem Absturz prüfen, etwa einen Batch-Import oder eine ungewöhnlich große Anfrage.
Die folgende Tabelle fasst die wichtigsten Diagnosewerkzeuge mit ihrem jeweiligen Zweck zusammen, als schnelle Referenz für den nächsten Container, der ohne erkennbaren Grund mit Exit-Code 137 beendet wird. Wer diese Reihenfolge einhält, findet die Ursache in aller Regel innerhalb weniger Minuten, statt tagelang im Anwendungscode nach einem nicht existierenden Bug zu suchen.
| Werkzeug | Zeigt | Befehl | Wann nutzen |
|---|---|---|---|
| docker inspect | OOMKilled true/false, Exit-Code | docker inspect --format '{{.State.OOMKilled}}' | Erster Check nach jedem Absturz |
| dmesg / journalctl | Genauer Prozess, Speicher zum Kill-Zeitpunkt | dmesg -T | grep -i 'killed process' | Details zum konkreten Kill-Ereignis |
| memory.peak | Höchster je erreichter Speicherwert | cat .../memory.peak | Limit realistisch dimensionieren |
| docker stats | Aktueller Live-Verbrauch | docker stats --no-stream | Grober Überblick, keine Spitzenwerte |
Mironsoft
Container-Infrastruktur, CI-Pipelines und Deployment-Automatisierung
Docker-Setups, die im Team und in Produktion tragfähig bleiben?
Wir prüfen bestehende Dockerfiles und Compose-Stacks auf Sicherheitslücken, aufgeblähte Images und fragile Build-Pipelines und bauen daraus eine Container-Infrastruktur, die schnell baut, sicher läuft und im Team nachvollziehbar bleibt.
Dockerfile-Review
Multi-Stage-Builds, Layer-Caching und Image-Größe systematisch optimieren.
Security-Audit
Container-Isolation, Secrets-Handling und Image-Scanning gegen echte Angriffsflächen absichern.
CI/CD-Integration
Build-Pipelines, Registries und Deployment-Strategien für reproduzierbare Releases aufbauen.
10. Zusammenfassung
OOM-Killer in Containern: Das Wichtigste auf einen Blick
Exit-Code 137
128 plus SIGKILL (Signal 9), fast immer ein Hinweis auf einen OOM-Kill.
Erste Diagnose
docker inspect auf State.OOMKilled prüfen, danach dmesg für Details.
Größter Fehler
Memory-Limits raten statt über memory.peak realistisch zu messen.
Sprachfallen
JVM und Node.js brauchen explizite Heap-Konfiguration unterhalb des Container-Limits.