Skip to content
Blog

Signal-Safe Entrypoints with tini and dumb-init

Why PID 1 in a container ignores SIGTERM and leaks zombies, and how to fix it with docker run --init, tini, or dumb-init, with worked examples.

Published on • October 16, 2026

AI Assistant

Two container problems look unrelated until you know the kernel rule behind them: docker stop hangs for the full grace period, and ps inside a long-running container shows a pile of <defunct> processes. Both come from the same place. The first process in a container becomes PID 1, and Linux gives PID 1 duties that no ordinary process signs up for. This post explains those duties, reproduces the failures, and shows the fix with tini, dumb-init, and Docker’s built-in --init flag.

What the kernel expects of PID 1

Inside a container’s PID namespace, process 1 is the init system, whether it wanted the job or not. Two behaviors matter.

Signals with default actions are ignored. Docker’s docker run reference states it directly: a process running as PID 1 inside a container is treated specially by Linux, and it ignores any signal with the default action. The process does not terminate on SIGINT or SIGTERM unless it is coded to handle them. On a normal system the kernel falls back to the default action (terminate) when a process has no handler; for PID 1 it does not. So docker stop, which sends SIGTERM to the main process, gets silence, waits out the grace period (10 seconds by default for Linux containers, settable with --stop-timeout or docker stop -t), and then escalates to SIGKILL. Your graceful shutdown hook never runs.

Orphaned children get re-parented to you. When a parent dies before its child, the child is re-parented to PID 1, which is supposed to wait() on it to collect the exit status. A process that exits before its parent waits becomes a zombie, staying in the process table as defunct until someone reaps it. Most programs do not wait() on random processes that happen to become attached to them, so containers that fork short-lived children accumulate zombies rooted at PID 1. Over time they can starve the system of PIDs.

The common denominator: a shell form entrypoint. CMD ./my_wrapper.sh makes Docker run /bin/sh -c ./my_wrapper.sh, so the shell is PID 1. Shells do not forward signals to their children by default, and they do not reap grandchildren.

Reproduce both failures

# Dockerfile.bad
FROM debian:bookworm-slim
COPY worker.sh /usr/local/bin/worker.sh
CMD ./worker.sh
#!/bin/bash
# worker.sh: spawns children that exit immediately
while true; do
  bash -c 'exit 0' &
  sleep 1
done
$ docker build -t zombie-demo .
$ docker run -d --name z1 zombie-demo
$ docker exec z1 ps -eo pid,ppid,stat,cmd | head
  PID  PPID STAT CMD
    1     0 Ss   /bin/sh -c ./worker.sh
    7     1 Z    [bash] <defunct>
   12     1 Z    [bash] <defunct>

Zombies keep appearing because PID 1 is a shell that never calls wait() on them. Now the signal half:

$ time docker stop z1
z1
real    0m10.03s        # full grace period, then SIGKILL

Ten seconds every time, no graceful shutdown, and if you had cleanup to do (flush a buffer, deregister from a queue) it never happened.

The fix, option one: Docker’s own —init

Docker 1.13 and later ships an init for exactly this case:

$ docker run -d --init --name z1 zombie-demo
$ docker exec z1 ps -o pid,ppid,stat,cmd
  PID  PPID STAT CMD
    1     0 Ss   docker-init -- /bin/sh -c ./worker.sh
    7     1 Ss   /bin/sh -c ./worker.sh

--init inserts a tiny init as PID 1 that forwards signals to the child and reaps processes when the container exits. The docs describe it as superior to running a full init such as systemd or sysvinit inside a container. The binary is the first docker-init found in the Docker daemon’s system path, and it is backed by tini. It is free to try and per-invocation: nothing in your image changes.

The limitation is reach: --init is a docker run option. Anything that launches containers without passing it (some orchestrators, hand-rolled run scripts) will not get the fix.

The fix, option two: bake tini into the image

tini is the simplest init you could think of: it spawns a single child, waits for it to exit, and in the meantime reaps zombies and performs signal forwarding. Its stated benefits map exactly onto our two failures: it protects you from software that accidentally creates zombie processes (which can, over time, starve your entire system for PIDs), and it ensures the default signal handlers work, so SIGTERM properly terminates your process even if you did not install a handler.

FROM python:3.12-slim
RUN apt-get update && apt-get install -y --no-install-recommends tini \
 && rm -rf /var/lib/apt/lists/*
ENTRYPOINT ["/usr/bin/tini", "--"]
CMD ["python", "/app/server.py"]

Other ways to get the binary:

# Alpine
RUN apk add --no-cache tini          # lands at /sbin/tini

# Any distro, pinned release
ENV TINI_VERSION=v0.19.0
ADD https://github.com/krallin/tini/releases/download/${TINI_VERSION}/tini /tini
RUN chmod +x /tini
ENTRYPOINT ["/tini", "--"]

Details that bite people:

  • Always include the --. Without it, tini parses your program’s arguments as its own and you get tini: invalid option -- 'c'. Arguments for tini itself go before it: /tini -v -- /your/program.
  • Chain an existing entrypoint instead of replacing it: ENTRYPOINT ["/tini", "--", "/docker-entrypoint.sh"].
  • tini exits with its child’s exit code, so docker inspect still tells you how the app died. If your app exits 143 after SIGTERM and you need a clean zero, remap it with -e 143.
  • -g (or TINI_KILL_PROCESS_GROUP) makes tini signal the whole child process group. Without it, docker run ... sh -c 'sleep 10' plus Ctrl-C sends SIGINT to the shell, which ignores it while waiting, and nothing happens.
  • -s (or TINI_SUBREAPER) registers tini as a subreaper for the rare case where it cannot run as PID 1 (Linux 3.4+). tini warns you if it is neither PID 1 nor a subreaper.
  • The dynamically linked binary is about 10 KB; the static one is under 1 MB. -v up to -vvv increases verbosity when debugging.

The fix, option three: dumb-init

dumb-init is a minimal process supervisor written in C and shipped as a small static binary. It runs as PID 1, immediately spawns your command as a child, and proxies all received signals to a session rooted at that child. Because your actual process is no longer PID 1, default signal handlers apply again and your program behaves as expected.

FROM debian:bookworm-slim
RUN apt-get update && apt-get install -y --no-install-recommends dumb-init \
 && rm -rf /var/lib/apt/lists/*
ENTRYPOINT ["/usr/bin/dumb-init", "--"]
CMD ["python", "/app/server.py"]

Distinctive features compared with tini:

  • Process-group signals by default. It establishes a session rooted at the child and signals the entire group, which is what saves you from shell scripts that never forward SIGTERM. Opt out with --single-child or DUMB_INIT_SETSID=0 if you want tini-like direct-child-only behavior.
  • Signal rewriting: --rewrite 15:3 turns SIGTERM into SIGQUIT for apps that need a different stop signal; --rewrite 2:0 drops a signal entirely.
  • Shebang usage: #!/usr/bin/dumb-init /bin/sh gives ordinary scripts init behavior outside Docker too.
  • Debian and Ubuntu package it (apt install dumb-init, Debian stretch+ and Ubuntu bionic+); prebuilt static binaries and a PyPI package also exist.

Whichever init you pick, use the JSON/exec form of CMD and ENTRYPOINT. Shell form puts a shell in front of your init (or instead of it), and you are back to square one. Same rule for wrapper scripts: finish with exec so the real process replaces the shell, for example exec gosu postgres "$@", the pattern the Postgres official image uses so the application becomes PID 1 and receives Unix signals.

When you do not need an init

An init earns its keep only when something inside the container forks and exits, or when your main process lacks a SIGTERM handler. You can skip it when:

  • PID 1 is a single long-lived process that never spawns children: no orphans, no zombies. A static Go binary with signal.Notify handling SIGTERM/SIGINT is the textbook case.
  • Your runtime installs handlers: an app that explicitly traps SIGTERM and exits satisfies the “coded to do so” condition in the kernel rule.
  • You are running something ephemeral: docker run --rm my-image some-cli finishes and exits; reaping never becomes an issue.

Verify rather than assume: run the container, then docker stop it and check that the command returns immediately instead of after 10 seconds, and run ps for a while to confirm no <defunct> entries pile up. When either check fails, add --init (fastest) or bake tini/dumb-init into ENTRYPOINT (most portable).

Key Takeaways

  • PID 1 ignores signals whose default action is termination, so docker stop sends SIGTERM, waits the default 10-second grace period, and then SIGKILLs the container.
  • PID 1 must also reap orphans; a shell-form CMD never calls wait(), so short-lived children show up as <defunct> zombies.
  • docker run --init inserts a docker-init binary backed by tini: zero image changes, but only where the flag is passed.
  • Bake tini (ENTRYPOINT ["/tini", "--"], always with --) or dumb-init (ENTRYPOINT ["/usr/bin/dumb-init", "--"]) into images for coverage across every launcher; use the exec form of CMD/ENTRYPOINT and exec in wrapper scripts.
  • Single-process apps that trap SIGTERM and never fork can run without an init; confirm with a docker stop timing test.

References