CertGrid CertGrid
Concepts·Docker

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

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.

One Docker host is all this guide needs. Nothing here depends on a second machine, and the hardware above is modest on purpose - a 2 core, 4 GB VM runs everything in this path.
Server NameIP AddressOSRolesCPURAMHDD
DOCKER01192.168.0.21Ubuntu 26.04 LTSDocker Host2 Core4 GB50 GB

Before you start

  1. How docker stop is supposed to work

    docker stop sends 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 container

    Expected resultThe timeout flag, defaulting to ten seconds.

    Success conditionYou know the two signals involved. Everything below is about which one actually ends your container.

  2. The surprise: a trivial command takes the full ten seconds

    sleep has 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=137

    Expected 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.

  3. 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. sleep installs 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 137

    Expected 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.

  4. An init process is the general fix

    --init inserts 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,args

    Expected resultdocker-init as 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.

  5. 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' TERM

    Expected 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.

  6. 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 s

    Expected resultA stop that takes just over the timeout you set.

    Success conditionThe wait matches your timeout. In Compose the key is stop_grace_period.

  7. 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 shells exec it 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-tmo

    Expected 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 --init where the process spawns children.

Troubleshooting

Official sources