Docker from Basics to Production · Production-Ready Images

Graceful Shutdown and PID 1

Make containers stop cleanly: SIGTERM handling, shell wrappers, init processes and stop timeouts.

Create a free account to save this page, take notes and track your progress. Sign in

What you'll build

The same Python worker run four ways, timing docker stop for each: wrapped in a shell, exec form without a handler, exec form with a handler, and without a handler but behind --init. Only one of them finishes its job and exits cleanly, and the reason the second one fails surprises most people.

The idea

docker stop (and Kubernetes pod termination) sends SIGTERM, waits for a grace period (10 s in Docker, 30 s in Kubernetes), then sends SIGKILL. To stop cleanly:

  • The app must receive the signal: use exec form so it is PID 1, or exec it from an entrypoint script.
  • The app must handle it: stop taking new work, finish in-flight work, exit.
  • PID 1 is special: the kernel ignores signals for which PID 1 has no handler installed. Python's default action for SIGTERM is "terminate", but as PID 1 that default never applies, so the process keeps running until SIGKILL. Install a handler, or run behind a tiny init.
  • Use --init (or tini) when your process spawns children it doesn't reap.

The code

Three ways to run the same worker
trap 'docker rm -f rc-sh rc-exec rc-handled > /dev/null 2>&1; docker image rm -f rc-worker:1 > /dev/null 2>&1' EXIT
cat > worker.py <<'EOF'
import signal, sys, time
stopping = False
if "--handle" in sys.argv:
    def on_term(signum, frame):
        global stopping
        stopping = True
        print("SIGTERM: finishing current job", flush=True)
    signal.signal(signal.SIGTERM, on_term)
# without --handle, Python's default applies: SIGTERM ends the process immediately, mid-job
print("worker started", flush=True)
while not stopping:
    time.sleep(0.2)
print("job finished, exiting cleanly", flush=True)
EOF
printf 'FROM python:3.14-slim\nCOPY worker.py /worker.py\n' > Dockerfile
docker build -q -t rc-worker:1 . > /dev/null
run() { docker run -d --name "$1" rc-worker:1 "${@:2}" > /dev/null; sleep 1
        s=$(date +%s); docker stop -t 3 "$1" > /dev/null; e=$(date +%s)
        echo "$1: stopped after ~$((e - s)) s, exit code $(docker inspect $1 --format '{{.State.ExitCode}}'), last log: $(docker logs $1 2>&1 | tail -1)"; }
run rc-sh sh -c 'python /worker.py; echo "shell done"'
run rc-exec python /worker.py
run rc-handled python /worker.py --handle
docker run -d --init --name rc-init rc-worker:1 python /worker.py > /dev/null; sleep 1
s=$(date +%s); docker stop -t 3 rc-init > /dev/null; e=$(date +%s)
echo "rc-init: stopped after ~$((e - s)) s, exit code $(docker inspect rc-init --format '{{.State.ExitCode}}') (python is not PID 1, so the default SIGTERM action applies: killed mid-job)"
docker rm -f rc-init > /dev/null

Output

rc-sh: stopped after ~3 s, exit code 137, last log: worker started
rc-exec: stopped after ~3 s, exit code 137, last log: worker started
rc-handled: stopped after ~0 s, exit code 0, last log: job finished, exiting cleanly
rc-init: stopped after ~0 s, exit code 143 (python is not PID 1, so the default SIGTERM action applies: killed mid-job)

Run this on your computer — see “Get Started” for setup.

Common mistakes

  • Entrypoint scripts that run the app without exec, so the shell is PID 1 and swallows signals.
  • Handlers that exit immediately instead of finishing in-flight work.
  • Grace periods shorter than the longest request or job; raise stop_grace_period (Compose) or terminationGracePeriodSeconds (Kubernetes).
  • Background child processes left as zombies; use --init.

Key points

  • Stopping = SIGTERM, grace period, SIGKILL; exit code 137 means the grace period ran out.
  • Make the app PID 1 (exec form or exec in scripts) and handle SIGTERM.
  • Size the grace period for your longest unit of work.

Further reading: docker stop · Specify an init process

Practice questions

What does exit code 137 after docker stop tell you?

Answer

The container didn't exit within the grace period after SIGTERM and was killed with SIGKILL (128 + 9), so shutdown wasn't graceful.

How do you keep an entrypoint shell script from swallowing signals?

Answer

End it with exec your-app so the app replaces the shell as PID 1 and receives signals directly.

When should you run a container with --init?

Answer

When the main process spawns children it doesn't reap or doesn't handle signals as PID 1; the tiny init forwards signals and reaps zombies.

Open in the app to mark lessons complete, take notes and track your progress.

Was this page helpful?