Docker PID 1, Signals and Graceful Shutdown
Why docker stop takes ten seconds and ends in exit 137, even for a process that should die instantly. The answer is a kernel rule about PID 1 that almost nobody is told, measured here four ways.
Containers and Images Guide 8 of 46 Intermediate
- OSUbuntu 26.04 LTS (resolute)
- Kernel7.0.0-29-generic
- Docker Engine29.7.2
- Architectureamd64
- TimeAbout 14 min
- Reviewed20 August 2026
Tested on the versions above. Timings are wall-clock from one host and vary by a few hundred milliseconds. The ten-second pattern and the exit codes are the point.
| Server Name | IP Address | OS | Roles | CPU | RAM | HDD |
|---|---|---|---|---|---|---|
| DOCKER01 | 192.168.0.21 | Ubuntu 26.04 LTS | Docker Host | 2 Core | 4 GB | 50 GB |
Before you start
- You can start and stop containers - guide 5 in this path.
- Reading exit codes and state - guide 7 in this path.
-
How docker stop is supposed to work
docker stopsends SIGTERM to the container's main process, waits ten seconds, and then sends SIGKILL. A well-behaved application catches SIGTERM, finishes what it is doing, closes connections and exits - and the stop is immediate. When a stop takes the full ten seconds, that handshake failed.bash # docker stop = SIGTERM, wait 10s, SIGKILLdocker stop --help | head -6# -t, --timeout int Seconds to wait before killing the containerExpected resultThe timeout flag, defaulting to ten seconds.
Success conditionYou know the two signals involved. Everything below is about which one actually ends your container.
-
The surprise: a trivial command takes the full ten seconds
sleephas no signal handler, and the default action for SIGTERM is to terminate - so this should stop instantly. It does not. It takes ten seconds and exits 137, which is SIGKILL. Something prevented SIGTERM from having any effect.bash Example session docker run -d --name cg-exec alpine:3.22 sleep 300docker exec cg-exec ps -o pid,argsPID COMMAND 1 sleep 300time docker stop cg-exec# 10.12 sdocker inspect cg-exec --format "exit={{.State.ExitCode}}"exit=137Expected resultTen seconds and exit 137, for a command that ought to die instantly.
Success conditionYou have reproduced the anomaly. Note the PID: the process is running as 1.
-
The rule nobody mentions: PID 1 has no default signal handlers
The kernel treats PID 1 specially. A process running as PID 1 does NOT get the default action for a signal - if it has not explicitly installed a handler, the signal is discarded.
sleepinstalls no handler, so as PID 1 it ignores SIGTERM entirely and only SIGKILL can end it. This is why so many containers take exactly ten seconds to stop, and it is a property of being PID 1 rather than anything Docker does.bash # same binary, same signal, different outcome purely because of the PIDdocker run -d --name cg-exec alpine:3.22 sleep 300# PID 1, no handler installed -> SIGTERM discarded -> SIGKILL after 10s -> exit 137Expected resultAn understanding rather than new output.
Success conditionYou can explain a ten-second stop without guessing. The fix is either to handle the signal, or to give PID 1 to something that does.
-
An init process is the general fix
--initinserts a tiny init as PID 1, which forwards signals to your process - where the normal default actions apply again - and reaps zombie children, the other job PID 1 is supposed to do. If your application cannot be changed, this is the one-flag answer.bash Example session docker run --rm --init alpine:3.22 ps -o pid,argsPID COMMAND 1 /sbin/docker-init -- ps -o pid,args 6 ps -o pid,argsExpected result
docker-initas PID 1 with your command as a child.Success conditionYour process is no longer PID 1, so ordinary signal behaviour returns. In Compose this is
init: true. -
Handling the signal yourself is the better fix
An init gets your container to stop; handling SIGTERM gets it to stop CORRECTLY. Only your application knows how to finish an in-flight request, flush a buffer or release a lock. A trap that does nothing still leaves you at ten seconds - the handler has to actually exit.
bash Example session # a handler that ignores the signal is no better than no handler:docker run -d --name cg-ignore alpine:3.22 sh -c "trap : TERM; while true; do sleep 1; done"time docker stop cg-ignore# 10.13 s, exit=137# what you want instead, in your own code:trap 'echo draining; exit 0' TERMExpected resultStill ten seconds, because the trap absorbs the signal without exiting.
Success conditionYou see that catching a signal is not the same as acting on it. A correct handler exits, and the container stops in well under a second with exit 0.
-
Shorten the grace period when ten seconds is wrong
The timeout is configurable per container at creation, or per command with
docker stop -t. Shortening it makes a stubborn container die faster; lengthening it gives a slow drain time to finish. Change it deliberately - a database mid-flush needs more time, not less.bash Example session docker run -d --name cg-tmo --stop-timeout 2 alpine:3.22 sh -c "trap : TERM; while true; do sleep 1; done"time docker stop cg-tmo# 2.12 s instead of 10.13 sExpected resultA stop that takes just over the timeout you set.
Success conditionThe wait matches your timeout. In Compose the key is
stop_grace_period. -
Exec form versus shell form
The usual advice is that exec form fixes signals and shell form breaks them - and it is half right. Shell form wraps your command in
/bin/sh -c, and a shell that stays running becomes PID 1 and does not forward signals to its children. Worth knowing: with a single simple command many shellsexecit directly, so your process becomes PID 1 after all - which is why the first example in this guide behaved identically in both forms. Do not rely on the shell optimising; write exec form and mean it.bash CMD ["nginx", "-g", "daemon off;"] # exec form - your binary is PID 1CMD nginx -g "daemon off;" # shell form - sh may stay as PID 1docker inspect NAME --format "{{.Config.Cmd}}"# [/bin/sh -c ...] means shell formdocker rm -f cg-exec cg-ignore cg-tmoExpected resultThe inspect output telling you which form an image actually uses.
Success conditionYou can identify shell form from the image config. Combine exec form, a real signal handler and
--initwhere the process spawns children.
Troubleshooting
Every container takes exactly ten seconds to stop
Why: SIGTERM is being discarded - PID 1 with no handler, or a shell that does not forward it. Ten seconds exactly is the default grace period elapsing.
Fix:Handle SIGTERM in the application, or run with
--init. Confirm with the exit code: 137 means it was killed, 0 means it shut down.bash docker inspect NAME --format "{{.State.ExitCode}}"Requests fail during every deployment
Why: The container is killed mid-request because it never drains. SIGKILL gives the application no chance to finish anything.
Fix:Catch SIGTERM, stop accepting new connections, finish in-flight work, then exit. Raise the grace period if draining legitimately takes longer.
bash docker run -d --stop-timeout 30 IMAGEDefunct or zombie processes accumulate inside a container
Why: PID 1 is also responsible for reaping orphaned children, and most application processes do not do it.
Fix:Run with
--initso a real init reaps them.bash docker exec NAME ps -o pid,stat,args | grep -w ZThe container ignores Ctrl+C in the foreground
Why: Ctrl+C sends SIGINT, and the same PID 1 rule applies - no handler, no effect.
Fix:Stop it from another terminal with
docker stop, and fix the handler.STOPSIGNALin the Dockerfile changes which signal is sent if your process listens for a different one.bash docker kill --signal=TERM NAME