Skip to content
Blog

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:

  1. Sends SIGTERM (by default — override with --stop-signal) to the container’s PID 1.
  2. Waits for the grace period — 10 seconds by default, configurable per container with --stop-timeout (seconds) or in Compose with stop_grace_period.
  3. If the process is still alive, sends SIGKILL and 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.Notify works 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 SIGTERM by default in recent versions only when it’s the main process; libraries like close-server still need explicit handling for in-flight requests.
  • Java: JVMs install a SIGTERM handler for shutdown hooks — but if PID 1 ignores the signal (shell form), the hooks never run. Also remember the exit code: a JVM exiting after SIGTERM reports 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:

  1. Spawns your application as its single child.
  2. Forwards signals to it (so SIGTERM reaches the app even when you couldn’t fix the entrypoint).
  3. 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
  1. Is PID 1 the right process? docker top api — if you see /bin/sh -c ... or entrypoint.sh, signals stop at the shell. Switch to exec form or --init.
  2. Does the app handle SIGTERM at all? Test outside Docker: docker run --rm --init my-api timeout -s TERM 2s /app/server; echo $?.
  3. Is the grace period too short? Compare stop_grace_period against measured drain time. Long-running streams, large batch commits, and JVM shutdown hooks routinely need 20–60 seconds.
  4. 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.

Further reading