Zombie-Prozesse in Containern vermeiden
Ein Container ohne echten Init-Prozess kann Zombie-Prozesse ansammeln und Signale falsch verarbeiten, zwei Probleme, die auf den ersten Blick harmlos wirken, aber langlaufende Produktivsysteme über Wochen hinweg destabilisieren können. tini und dumb-init lösen beide Probleme mit wenigen Kilobyte Zusatzcode.
Inhaltsverzeichnis
- 1. Das PID-1-Problem in Containern
- 2. Wie Zombie-Prozesse konkret entstehen
- 3. Das zweite Problem: fehlerhafte Signalweiterleitung
- 4. tini: Der De-facto-Standard für minimale Init-Prozesse
- 5. tini im Detail: Subreaper-Modus und Debugging
- 6. dumb-init: Die Alternative von Yelp
- 7. docker run --init: Die eingebaute Alternative
- 8. Wann ein Init-Prozess wirklich notwendig ist
- 9. tini, dumb-init und --init im direkten Vergleich
- 10. Zusammenfassung
- 11. FAQ
1. Das PID-1-Problem in Containern
Jeder Linux-Prozess hat einen Elternprozess, und wenn ein Kindprozess endet, muss der Elternprozess dessen Exit-Status per wait() abholen, damit der Kernel die zugehörigen Ressourcen freigeben kann. Geschieht das nicht, bleibt der beendete Prozess als sogenannter Zombie in der Prozesstabelle stehen: kein aktiver Prozess mehr, aber ein Eintrag, der Speicher belegt und in ps als Status Z sichtbar ist. Auf einem normalen Linux-System übernimmt PID 1, der Init-Prozess, automatisch das Reaping verwaister Zombies, deren ursprünglicher Elternprozess bereits selbst beendet wurde.
In einem Container fehlt dieser Init-Prozess in der Regel, denn der Hauptprozess der Anwendung läuft direkt als PID 1. Startet dieser Prozess selbst Kindprozesse, etwa eine Shell, die wiederum ein Hilfsprogramm aufruft, und kümmert sich nicht explizit um deren Reaping, sammeln sich Zombies an. Die meisten Anwendungen wurden nie dafür geschrieben, diese Init-Aufgabe zu übernehmen, weil sie in einer klassischen Umgebung nie als PID 1 laufen mussten.
2. Wie Zombie-Prozesse konkret entstehen
Ein typisches Beispiel: Ein Cron-artiger Node.js- oder PHP-Prozess ruft regelmäßig über child_process.exec beziehungsweise exec() ein externes Kommandozeilenwerkzeug auf, etwa zur Bildkonvertierung mit ImageMagick oder zum Aufruf eines Backup-Skripts. Solange die Anwendung selbst den Exit-Code jedes gestarteten Kindprozesses korrekt abholt, ist alles in Ordnung. Fehlt diese Abholung, etwa weil eine Bibliothek das Kind über einen unsauber verwalteten Prozess-Pool startet, bleibt ein Zombie zurück, sobald das Kind endet.
Problematisch wird es vor allem bei Shell-Skripten als Einstiegspunkt, die selbst wiederum weitere Prozesse starten und beenden, ohne sich um deren Reaping zu kümmern, weil sie schlicht nicht dafür gebaut wurden, als Init-Prozess zu fungieren. In einem langlaufenden Container, der über Wochen ohne Neustart läuft und regelmäßig solche Unterprozesse startet, sammeln sich Zombies langsam aber stetig an, bis irgendwann die maximale Prozessanzahl des Systems (pid_max) erreicht wird und keine neuen Prozesse mehr gestartet werden können.
# Zombie-Prozesse in einem laufenden Container aufspueren
docker exec meincontainer ps aux | awk '$8=="Z" {print}'
# Anzahl der Zombies zaehlen
docker exec meincontainer sh -c "ps -eo stat | grep -c '^Z'"
3. Das zweite Problem: fehlerhafte Signalweiterleitung
Neben dem Reaping von Zombies hat PID 1 noch eine zweite Sonderaufgabe: Signale wie SIGTERM ohne registrierten Handler werden bei PID 1 nicht mit dem Standardverhalten behandelt, das für gewöhnliche Prozesse gilt. Läuft eine Shell als PID 1, etwa weil CMD in Shell-Form geschrieben wurde, empfängt diese Shell zwar das SIGTERM, leitet es aber typischerweise nicht automatisch an ihre Kindprozesse weiter, sodass die eigentliche Anwendung von der Beendigungsanfrage nie erfährt.
Ein minimaler Init-Prozess wie tini oder dumb-init behebt genau dieses Problem, indem er selbst als PID 1 läuft, empfangene Signale korrekt an den eigentlichen Anwendungsprozess weiterleitet und dessen Kindprozesse zuverlässig reaped. Damit übernimmt er exakt die beiden Kernaufgaben eines vollwertigen Init-Systems wie systemd, jedoch in einem winzigen, für Container zugeschnittenen Umfang von wenigen hundert Kilobyte statt eines vollständigen Systemd-Stacks.
4. tini: Der De-facto-Standard für minimale Init-Prozesse
tini ist ein sehr kleines, in C geschriebenes Init-Programm, das speziell für Container entwickelt wurde und mittlerweile sogar offiziell in den Docker-Daemon integriert ist. Es startet den eigentlichen Anwendungsprozess als sein einziges Kind, leitet alle empfangenen Signale korrekt weiter und reaped zuverlässig alle Zombie-Prozesse, die im Container entstehen, unabhängig davon, wie tief verschachtelt die Prozesshierarchie darunter ist.
Die Einbindung erfolgt entweder durch explizites Installieren des tini-Binaries im Dockerfile und Verwendung als ENTRYPOINT, oder, seit Docker 1.13, deutlich einfacher über die Laufzeitoption --init, die tini automatisch injiziert, ohne dass es Teil des Images sein muss. Für viele Standardfälle reicht die Laufzeitoption völlig aus, für reproduzierbare Images unabhängig von der Docker-Version ist die explizite Installation im Dockerfile aber die robustere Wahl.
# Dockerfile: tini explizit installieren und als Init nutzen
FROM node:20-alpine
RUN apk add --no-cache tini
COPY . /app
WORKDIR /app
RUN npm ci --omit=dev
ENTRYPOINT ["/sbin/tini", "--"]
CMD ["node", "server.js"]
5. tini im Detail: Subreaper-Modus und Debugging
Standardmäßig läuft tini als klassischer PID-1-Prozess und reaped ausschließlich seine direkten und indirekten Kinder innerhalb der eigenen Prozesshierarchie. In selteneren Konstellationen, etwa wenn innerhalb des Containers ein eigenes kleines Supervisor-Programm mehrere unabhängige Prozessbäume startet, kann es sinnvoll sein, tini als sogenannten Subreaper laufen zu lassen. Dafür existiert die Umgebungsvariable TINI_SUBREAPER, die tini anweist, den entsprechenden Kernel-Mechanismus PR_SET_CHILD_SUBREAPER zu nutzen und dadurch auch verwaiste Enkel- und Urenkelprozesse zuverlässig zu reapen.
Für die Fehlersuche bietet tini außerdem einen Verbose-Modus, aktivierbar über das Flag -v beim Aufruf oder wiederholt als -vv für noch detailliertere Ausgaben. Im Verbose-Modus protokolliert tini jedes empfangene Signal und jeden gereapten Prozess auf der Standardfehlerausgabe, was sich hervorragend eignet, um zu überprüfen, ob ein vermuteter Zombie-Prozess tatsächlich abgeholt wird oder ob ein Signal wie erwartet an die Anwendung weitergeleitet wurde.
# tini im Verbose-Modus starten, um Signal- und Reap-Vorgaenge zu sehen
docker run --rm -it \
--entrypoint /sbin/tini \
mironsoft/app:latest \
-vv -- node server.js
# Subreaper-Modus fuer verschachtelte Prozessbaeume aktivieren
docker run -e TINI_SUBREAPER=1 --init mironsoft/app:latest
6. dumb-init: Die Alternative von Yelp
dumb-init, entwickelt von Yelp, verfolgt denselben Ansatz wie tini und löst dieselben zwei Probleme: korrekte Signalweiterleitung und zuverlässiges Zombie-Reaping. Funktional unterscheiden sich beide Werkzeuge kaum, dumb-init bietet aber einige zusätzliche Konfigurationsoptionen, etwa das explizite Umschreiben bestimmter Signale, bevor sie an das Kind weitergeleitet werden, was in Nischenfällen mit exotischem Signal-Handling hilfreich sein kann.
In der Praxis hat sich tini als De-facto-Standard durchgesetzt, nicht zuletzt weil es direkt in Docker integriert ist und über --init ganz ohne Image-Änderung nutzbar ist. dumb-init bleibt trotzdem eine solide, aktiv gepflegte Alternative, besonders für Teams, die bereits Erfahrung damit haben oder eine der zusätzlichen Konfigurationsoptionen konkret benötigen.
# Dockerfile: dumb-init als Alternative zu tini
FROM python:3.12-slim
RUN apt-get update && apt-get install -y --no-install-recommends dumb-init \
&& rm -rf /var/lib/apt/lists/*
COPY . /app
WORKDIR /app
ENTRYPOINT ["dumb-init", "--"]
CMD ["python", "worker.py"]
7. docker run --init: Die eingebaute Alternative
Seit Docker 1.13 gibt es die Laufzeitoption --init, mit der Docker automatisch eine minimale, intern gebündelte Version von tini als PID 1 einsetzt, ohne dass das Image selbst irgendetwas dafür mitbringen muss. Der eigentliche Anwendungsprozess läuft dann als Kind dieses eingebauten tini und profitiert von derselben korrekten Signalweiterleitung und demselben Zombie-Reaping wie bei einer expliziten Installation.
Der Vorteil dieser Variante liegt in ihrer Einfachheit: Ein einziges zusätzliches Flag beim docker run beziehungsweise ein init: true in der Compose-Datei genügt, ganz ohne Dockerfile-Änderung. Der Nachteil ist, dass diese Einstellung vom jeweiligen Aufrufer des Containers gesetzt werden muss und nicht automatisch im Image mitkommt, was bei Weitergabe eines Images an Dritte oder in Orchestrator-Umgebungen ohne explizite Konfiguration leicht vergessen wird.
# docker-compose.yml: eingebautes tini per init aktivieren
services:
worker:
image: mironsoft/image-processor:latest
init: true # entspricht docker run --init
# Aequivalent per CLI:
# docker run --init -d mironsoft/image-processor:latest
8. Wann ein Init-Prozess wirklich notwendig ist
Nicht jeder Container braucht zwingend tini oder dumb-init. Wenn die Anwendung selbst niemals eigene Kindprozesse startet, etwa ein einfacher Go-Binary oder ein Node.js-Server, der ausschließlich In-Process arbeitet, entstehen gar keine Zombies, weil es keine Kindprozesse gibt, die abgeholt werden müssten. In diesem Fall ist die Hauptmotivation für einen Init-Prozess eher die korrekte Signalweiterleitung, falls die Anwendung selbst als PID 1 keinen eigenen Handler registriert.
Ein Init-Prozess wird hingegen fast unverzichtbar, sobald die Anwendung Subprozesse startet, etwa ein PHP-Skript, das über exec() externe Tools aufruft, ein Node-Prozess mit child_process, oder ein Shell-Wrapper-Skript, das mehrere Hintergrundprozesse orchestriert. Auch bei Multi-Prozess-Setups innerhalb eines einzigen Containers, etwa Supervisor-verwaltete Anwendungen mit mehreren Worker-Prozessen, ist ein echter Init-Prozess praktisch Pflicht, um Zombies zuverlässig zu vermeiden.
9. tini, dumb-init und --init im direkten Vergleich
Alle drei Ansätze lösen dieselben zwei Kernprobleme, unterscheiden sich aber in Integration und Flexibilität. Die explizite Installation von tini oder dumb-init im Dockerfile macht das Image self-contained: Es funktioniert korrekt, unabhängig davon, mit welchen Flags es später gestartet wird, was besonders für öffentlich verteilte Images oder Basis-Images für andere Teams wichtig ist. docker run --init verlagert diese Verantwortung dagegen auf den Aufrufer des Containers.
Für interne Projekte, bei denen Docker-Version und Startbefehl unter eigener Kontrolle stehen, ist --init oft die pragmatischste Wahl, weil sie Dockerfile-Änderungen erspart. Für Images, die weitergegeben oder von Dritten verwendet werden, empfiehlt sich hingegen die explizite Installation als ENTRYPOINT, damit korrektes Verhalten garantiert ist, egal wie der Container später gestartet wird. Die folgende Tabelle fasst die Unterschiede zusammen.
| Ansatz | Integration | Signalweiterleitung | Zombie-Reaping | Empfohlen für |
|---|---|---|---|---|
| tini (im Dockerfile) | Als ENTRYPOINT installiert | Ja, korrekt | Ja, zuverlässig | Verteilte/öffentliche Images |
| dumb-init (im Dockerfile) | Als ENTRYPOINT installiert | Ja, mit Umschreibe-Optionen | Ja, zuverlässig | Teams mit speziellen Signal-Anforderungen |
| docker run --init | Zur Laufzeit vom Aufrufer gesetzt | Ja, korrekt (nutzt tini intern) | Ja, zuverlässig | Interne Projekte, eigene Kontrolle über Start-Flags |
| Kein Init-Prozess | Keine | Nur wenn App eigenen Handler hat | Nein, Zombies möglich | Nur Prozesse ohne Subprozess-Erzeugung |
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
tini und dumb-init: Das Wichtigste auf einen Blick
PID-1-Problem
PID 1 reaped Zombies und leitet Signale nicht automatisch weiter, anders als bei echten Init-Systemen.
tini
Minimaler, in Docker integrierter Init-Prozess, De-facto-Standard für Container.
dumb-init
Funktional gleichwertige Alternative von Yelp mit zusätzlichen Signal-Umschreibe-Optionen.
docker run --init
Eingebaute Laufzeit-Alternative ohne Dockerfile-Änderung, Verantwortung liegt beim Aufrufer.