Signal Handling and Graceful Container Shutdown in Docker
Why PID 1 ignores your SIGTERM, how docker stop's 10-second grace period really works, and how tini, exec-form ENTRYPOINT and healthchecks make shutdown graceful.
Published on • October 31, 2026
AI Assistant

docker stop hangs for exactly ten seconds, every time, and then the container dies with exit code 137. Your app “closed gracefully” on your laptop but never does in production. Clients get connection resets on every deploy. None of this is mysterious: Docker’s shutdown contract is small, the kernel’s PID 1 rules are older than Docker, and most images get one of them wrong.
The shutdown contract
When you run docker stop <container>, the daemon:
- Sends
SIGTERM(by default — override with--stop-signal) to the container’s PID 1. - Waits for the grace period — 10 seconds by default, configurable per container with
--stop-timeout(seconds) or in Compose withstop_grace_period. - If the process is still alive, sends
SIGKILLand the container is gone.
There is no negotiation and no cleanup step: SIGKILL cannot be caught, blocked, or ignored. Anything your application hasn’t flushed by then — open transactions, in-flight requests, unwritten files — is simply lost. So graceful shutdown means exactly one thing: PID 1 must handle SIGTERM and finish its work within the grace period.
Why PID 1 is special
Linux treats PID 1 differently from every other process. Two rules matter:
Signal dispositions. A process’s default action for SIGTERM, SIGINT, SIGHUP and friends is to terminate — but for PID 1, the kernel ignores signals whose default action would kill it, unless the process has explicitly installed a handler (or set SIG_IGN). Your app’s author may never have noticed, because when you run the same binary as a normal user, the default handler does its job. As PID 1, it doesn’t. The docker run documentation calls this out directly: a process running as PID 1 “doesn’t terminate on SIGINT or SIGTERM unless it’s coded to do so.”
Zombie reaping. PID 1 is the parent of everything else in the container’s PID namespace. When a grandchild exits, its parent may be gone — the orphan is re-parented to PID 1, which must wait() on it to reap the entry. A PID 1 that never reaps accumulates zombies until the container hits its pids limit or the host runs out of PIDs.
A shell-script entrypoint makes both problems worse. In the shell form of ENTRYPOINT/CMD:
CMD /app/start.sh # shell form → runs as /bin/sh -c "/app/start.sh"
/bin/sh becomes PID 1, starts your app as a child, and sh has no reason to forward signals. docker stop delivers SIGTERM to sh, your app never hears it, and after ten seconds SIGKILL takes both of them. The exec form runs your application as PID 1:
ENTRYPOINT ["/app/server"] # exec form → your server is PID 1
(For the full mechanics of the two forms, see ENTRYPOINT vs CMD.)
Write apps that handle SIGTERM
The application side of the contract:
import signal, sys
def shutdown(signum, frame):
print(f"got signal {signum}, draining...", flush=True)
stop_accepting() # fail readiness so the load balancer drains us
close_connections()
sys.exit(0) # exit 0 => clean shutdown in docker inspect
signal.signal(signal.SIGTERM, shutdown)
signal.signal(signal.SIGINT, shutdown) # Ctrl-C in foreground
Language runtimes differ in how much they give you for free:
- Go:
signal.Notifyworks fine — but only if your Go program is PID 1. A Go binary run under a shell wrapper still won’t see the signal. - Node: handles
SIGTERMby default in recent versions only when it’s the main process; libraries likeclose-serverstill need explicit handling for in-flight requests. - Java: JVMs install a
SIGTERMhandler for shutdown hooks — but if PID 1 ignores the signal (shell form), the hooks never run. Also remember the exit code: a JVM exiting afterSIGTERMreports 143 (128+15), which restart policies treat as a failure.
The readiness probe matters as much as the handler: flip readiness before closing sockets so your orchestrator or load balancer stops routing to you while you drain.
Give containers a real init: --init and tini
If your image runs anything that forks (a shell entrypoint, a process manager, cron, a language runtime that spawns children), add an init:
$ docker run -d --init --name api my-api:latest
Docker then mounts its bundled docker-init binary as PID 1 — which is tini under the hood. Tini does three things:
- Spawns your application as its single child.
- Forwards signals to it (so
SIGTERMreaches the app even when you couldn’t fix the entrypoint). - Reaps zombies for the life of the container.
It’s transparent — images that work without it work with it — and tiny (around 10 KB). You can bake it in instead:
FROM alpine:3.20
RUN apk add --no-cache tini
ENTRYPOINT ["/sbin/tini", "--"]
CMD ["/app/server"]
or ENTRYPOINT ["/tini", "--", "/docker-entrypoint.sh"] if you already have a shell entrypoint. The -- separates tini’s own options from your command; include it. Two behavioral notes: tini exits with its child’s exit code (so docker inspect still shows the app’s status), and by default it signals only its immediate child — tini -g kills the whole process group, which is what you want for sh -c '...' wrappers.
Healthchecks and stop ordering
Docker does not consult your HEALTHCHECK during docker stop. There is no “wait until healthy, then stop” and no automatic pre-stop hook — health status is for depends_on: condition: service_healthy, load balancers, and your own orchestration logic. The ordering you must build yourself:
services:
api:
image: my-api:latest
init: true
stop_grace_period: 30s
healthcheck:
test: ["CMD", "curl", "-fsS", "http://localhost:8080/healthz"]
interval: 10s
timeout: 3s
retries: 3
In Compose, stop_grace_period must exceed your drain time, and depends_on only controls startup order — on shutdown, Compose stops services in reverse dependency order only if you use depends_on (and it still won’t wait for your app to finish draining). In Kubernetes, the equivalent pieces are preStop, terminationGracePeriodSeconds, and readiness gates; the container-level signals are the same.
Debugging “the container takes 10 seconds to stop”
Work through it in order:
$ time docker stop api
# real 0m10.0s → SIGTERM was ignored or handled past the deadline
$ docker inspect -f 'Exit={{.State.ExitCode}} OOMKilled={{.State.OOMKilled}}' api
Exit=137 OOMKilled=false → SIGKILL after the grace period
Exit=0 → SIGTERM was handled cleanly
Exit=143 → SIGTERM received, app exited without a clean exit code
- Is PID 1 the right process?
docker top api— if you see/bin/sh -c ...orentrypoint.sh, signals stop at the shell. Switch to exec form or--init. - Does the app handle
SIGTERMat all? Test outside Docker:docker run --rm --init my-api timeout -s TERM 2s /app/server; echo $?. - Is the grace period too short? Compare
stop_grace_periodagainst measured drain time. Long-running streams, large batch commits, and JVM shutdown hooks routinely need 20–60 seconds. - Watch it happen:
$ docker stop -t 60 api & docker exec -it api sh -c 'apt-get install -y procps; sleep 1; ps aux'
If you see the app exiting immediately on docker stop, it has a handler. If nothing happens until SIGKILL, you have a PID 1 problem — which --init solves in one flag.