avoiding zombie processes in containers
A container without a real init process can accumulate zombie processes and mishandle signals, two problems that look harmless at first glance but can destabilize long running production systems over weeks. tini and dumb-init solve both problems with just a few kilobytes of extra code.
Table of Contents
- 1. The PID 1 problem in containers
- 2. How zombie processes actually appear
- 3. The second problem: broken signal forwarding
- 4. tini: the de facto standard for minimal init processes
- 5. tini in detail: subreaper mode and debugging
- 6. dumb-init: Yelp's alternative
- 7. docker run --init: the built in alternative
- 8. When an init process is actually necessary
- 9. tini, dumb-init, and --init side by side
- 10. Summary
- 11. FAQ
1. The PID 1 problem in containers
Every Linux process has a parent process, and when a child process terminates, its parent must collect its exit status via wait() so the kernel can free the associated resources. If that never happens, the terminated process lingers in the process table as a so called zombie: no longer an active process, but an entry that occupies memory and shows up in ps with status Z. On a normal Linux system, PID 1, the init process, automatically reaps orphaned zombies whose original parent has already terminated itself.
Inside a container, this init process is usually absent, since the application's main process runs directly as PID 1. If that process itself spawns child processes, for example a shell that in turn invokes a helper tool, and does not explicitly take care of reaping them, zombies accumulate. Most applications were never written to take on this init responsibility, since in a classic environment they never had to run as PID 1.
2. How zombie processes actually appear
A typical example: a cron style Node.js or PHP process regularly invokes an external command line tool via child_process.exec or exec(), for instance for image conversion with ImageMagick or to call a backup script. As long as the application itself correctly collects the exit code of every spawned child, everything is fine. If that collection is missing, for example because a library spawns the child through a poorly managed process pool, a zombie is left behind as soon as the child terminates.
This becomes especially problematic with shell scripts as an entry point that themselves spawn and terminate further processes without taking care of reaping them, simply because they were never built to act as an init process. In a long running container that runs for weeks without a restart and regularly spawns such subprocesses, zombies accumulate slowly but steadily until the system's maximum process count (pid_max) is eventually reached and no new processes can be started at all.
# Find zombie processes in a running container
docker exec mycontainer ps aux | awk '$8=="Z" {print}'
# Count the number of zombies
docker exec mycontainer sh -c "ps -eo stat | grep -c '^Z'"
3. The second problem: broken signal forwarding
Besides reaping zombies, PID 1 has a second special job: signals like SIGTERM without a registered handler are not treated with the default behavior that applies to ordinary processes when they arrive at PID 1. If a shell runs as PID 1, for example because CMD was written in shell form, that shell does receive the SIGTERM, but it typically does not automatically forward it to its child processes, so the actual application never learns about the termination request.
A minimal init process like tini or dumb-init fixes exactly this problem by running as PID 1 itself, correctly forwarding received signals to the actual application process, and reliably reaping its child processes. That means it takes on exactly the two core responsibilities of a full init system like systemd, but in a tiny, container tailored footprint of a few hundred kilobytes instead of a full systemd stack.
4. tini: the de facto standard for minimal init processes
tini is a very small init program written in C, specifically built for containers, and by now even officially integrated into the Docker daemon. It launches the actual application process as its only child, correctly forwards every signal it receives, and reliably reaps any zombie processes that arise in the container, no matter how deeply nested the process hierarchy below it is.
It can be added either by explicitly installing the tini binary in the Dockerfile and using it as ENTRYPOINT, or, since Docker 1.13, much more simply through the runtime option --init, which injects tini automatically without it needing to be part of the image. For many standard cases, the runtime option is entirely sufficient; for images that need to be reproducible regardless of the Docker version, explicit installation in the Dockerfile is the more robust choice.
# Dockerfile: install tini explicitly and use it as init
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 in detail: subreaper mode and debugging
By default, tini runs as a classic PID 1 process and reaps only its direct and indirect children within its own process hierarchy. In rarer setups, for example when a small supervisor program inside the container itself spawns several independent process trees, it can make sense to run tini as a so called subreaper. For that purpose there is the TINI_SUBREAPER environment variable, which instructs tini to use the corresponding kernel mechanism PR_SET_CHILD_SUBREAPER and thereby reliably reap orphaned grandchild and great-grandchild processes as well.
For troubleshooting, tini also offers a verbose mode, enabled via the -v flag on invocation, or repeated as -vv for even more detailed output. In verbose mode, tini logs every received signal and every reaped process to standard error, which is excellent for verifying whether a suspected zombie process is actually being collected, or whether a signal was forwarded to the application as expected.
# Start tini in verbose mode to see signal and reap events
docker run --rm -it \
--entrypoint /sbin/tini \
mironsoft/app:latest \
-vv -- node server.js
# Enable subreaper mode for nested process trees
docker run -e TINI_SUBREAPER=1 --init mironsoft/app:latest
6. dumb-init: Yelp's alternative
dumb-init, developed by Yelp, follows the same approach as tini and solves the same two problems: correct signal forwarding and reliable zombie reaping. Functionally the two tools barely differ, but dumb-init offers a few extra configuration options, such as explicitly rewriting certain signals before forwarding them to the child, which can be useful in niche cases with unusual signal handling requirements.
In practice, tini has become the de facto standard, not least because it is integrated directly into Docker and usable via --init without any image changes at all. dumb-init remains a solid, actively maintained alternative nonetheless, particularly for teams that already have experience with it or specifically need one of its extra configuration options.
# Dockerfile: dumb-init as an alternative to 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: the built in alternative
Since Docker 1.13, there has been a runtime option --init that makes Docker automatically use a minimal, internally bundled version of tini as PID 1, without the image itself having to bring anything for it. The actual application process then runs as a child of this built in tini and benefits from the same correct signal forwarding and the same zombie reaping as with an explicit installation.
The advantage of this approach lies in its simplicity: a single extra flag on docker run, or init: true in the Compose file, is enough, with no Dockerfile change required. The downside is that this setting has to be applied by whoever runs the container and does not travel with the image automatically, which is easy to forget when an image is handed off to third parties or run in orchestrator environments without explicit configuration.
# docker-compose.yml: enable the built in tini via init
services:
worker:
image: mironsoft/image-processor:latest
init: true # equivalent to docker run --init
# Equivalent via CLI:
# docker run --init -d mironsoft/image-processor:latest
8. When an init process is actually necessary
Not every container strictly needs tini or dumb-init. If the application itself never spawns child processes of its own, for example a simple Go binary or a Node.js server that works entirely in process, no zombies can arise at all, since there are no child processes to be collected. In that case, the main motivation for an init process is more about correct signal forwarding, in case the application itself as PID 1 does not register its own handler.
An init process becomes nearly indispensable, though, as soon as the application spawns subprocesses, for example a PHP script that calls external tools via exec(), a Node process using child_process, or a shell wrapper script orchestrating several background processes. Multi process setups within a single container, such as supervisor managed applications with several worker processes, also practically require a real init process to reliably avoid zombies.
9. tini, dumb-init, and --init side by side
All three approaches solve the same two core problems but differ in integration and flexibility. Explicitly installing tini or dumb-init in the Dockerfile makes the image self contained: it behaves correctly regardless of which flags it is later started with, which matters especially for publicly distributed images or base images used by other teams. docker run --init, by contrast, shifts that responsibility onto whoever runs the container.
For internal projects where the Docker version and start command are under your own control, --init is often the more pragmatic choice, since it avoids Dockerfile changes altogether. For images that get distributed or used by third parties, explicit installation as ENTRYPOINT is recommended instead, so correct behavior is guaranteed no matter how the container is later started. The table below summarizes the differences.
| Approach | Integration | Signal forwarding | Zombie reaping | Recommended for |
|---|---|---|---|---|
| tini (in the Dockerfile) | Installed as ENTRYPOINT | Yes, correct | Yes, reliable | Distributed/public images |
| dumb-init (in the Dockerfile) | Installed as ENTRYPOINT | Yes, with rewrite options | Yes, reliable | Teams with special signal requirements |
| docker run --init | Set at runtime by the caller | Yes, correct (uses tini internally) | Yes, reliable | Internal projects, full control over start flags |
| No init process | None | Only if the app has its own handler | No, zombies possible | Only processes that never spawn subprocesses |
Mironsoft
Container infrastructure, CI pipelines and deployment automation
Docker setups that hold up across the team and in production?
We review existing Dockerfiles and Compose stacks for security gaps, bloated images and fragile build pipelines, then build a container infrastructure that builds fast, runs securely and stays understandable across the team.
Dockerfile Review
Systematically optimizing multi-stage builds, layer caching and image size.
Security Audit
Hardening container isolation, secrets handling and image scanning against real attack surfaces.
CI/CD Integration
Building build pipelines, registries and deployment strategies for reproducible releases.
10. Summary
tini and dumb-init: Key Takeaways
PID 1 problem
PID 1 does not automatically reap zombies or forward signals, unlike a real init system.
tini
Minimal init process integrated into Docker, the de facto standard for containers.
dumb-init
Functionally equivalent alternative from Yelp with extra signal rewrite options.
docker run --init
Built in runtime alternative with no Dockerfile change, responsibility rests with the caller.