There has been an error in my update
You press docker stop, and instead of fading away in a flash, the container honestly hangs for exactly ten seconds, and then dies with a jerk. The application hasn’t had time to finish writing the file, close the database connection, or release the lock. Familiar? This isn’t a daemon bug and not disk slowdown. This is your process not receiving a signal at all — it just sat through the timeout until Docker forcibly sent a SIGKILL.
Let’s trace the entire signal path step by step: who sends what to whom, where exactly the SIGTERM gets lost, and how to fix it in one line in a Dockerfile. I’m not rehashing someone else’s blog — all facts are verified against the official Docker and Compose Specification documentation, and I reproduced the signal behavior in the terminal, with commands shown below so you can try it yourself.
Who needs this. Anyone who keeps something with state inside a container: PostgreSQL, Redis, queues, an application with open connections, file buffers. If your service after a restart sometimes doesn’t come up quite right or there’s nothing in the logs where you’d expect a “graceful shutdown,” this is your case.
How the container stop actually works
First, the basic mechanics are needed before anything else. When you call docker stop, the following happens exactly (quoting the documentation):
The main process inside the container will receive
SIGTERM, and after a grace period,SIGKILL.
That is, the daemon sends a polite request to terminate to the container’s main process (the one inside with PID 1). It’s given time to clean up. If the process hasn’t exited within the allotted time — it is killed with SIGKILL, which cannot be intercepted or ignored; the kernel simply removes the process.
How long is this grace period? By default:
- Linux containers — 10 seconds;
- Windows containers — 30 seconds.
That’s where those ten seconds come from. You can change it: docker stop -t 30 mycontainer will give 30 seconds, -t 0 means immediate SIGKILL with no courtesy, and -t -1 will make the daemon wait indefinitely. In Compose, the same lives in stop_grace_period.
An important detail: the timeout is a maximum wait time, not a fixed pause. If the process properly handles SIGTERM and exits at the second, docker stop will return at the second. A container that consistently hangs for the full 10 seconds is almost always a symptom that the signal did not reach the application. A healthy service shuts down faster than the timeout.
The big catch: what PID 1 is and why it’s special
Inside the container the main process has PID 1. And PID 1 in Linux has a special privilege that most only learn about when something goes wrong.
The kernel does not apply default signal actions to PID 1. For a normal process, SIGTERM without a handler means “terminate immediately” — this is the kernel’s default behavior. But PID 1 is protected: if the process with PID 1 has no explicit signal handler, that signal is simply ignored. Historically this was done so you couldn’t accidentally kill init and take down the whole system.
The consequence for containers is harsh: if your app is running as PID 1 and inside it hasn’t set up a SIGTERM handler, then docker stop sends the signal — and it falls into a void. The app doesn’t terminate, the timeout hits, and SIGKILL arrives.
I checked this literally. I started a process as PID 1 in a separate PID namespace and sent it SIGTERM:
unshare -rpf --mount-proc sleep 30 &
# find the pid of `sleep` and send it a polite signal
kill -TERM <pid>
#result: the process is alive. SIGTERM is ignored because this is PID 1 without a handler
kill -KILL<pid>
# only SIGKILL removes it
sleep does not set a handler for SIGTERM, so as PID 1 it ignores it. The exact same thing will happen to your service if it doesn’t handle the signal itself.
Hence the practical rule: PID 1 is no place for a process without signal handlers. Either the application knows how to catch SIGTERM (most serious servers do), or there should be a proper init above it that can handle it for you (see below). Merely “relying on default behavior” doesn’t work here — PID 1 has no default behavior.
The second trap: shell form turns your app into a child of /bin/sh
Even if the application properly handles SIGTERM, there is a chance the signal never reaches it — because of how you wrote CMD or ENTRYPOINT.
There are two forms to write these instructions. Compare:
# shell form — string
CMD myapp --config /etc/app.conf
# exec form — JSON array
CMD ["myapp", "--config", "/etc/app.conf"]
Looks almost the same, but works fundamentally differently. Shell form always starts via a command shell — effectively Docker runs /bin/sh -c "myapp --config /etc/app.conf". And what follows: the PID 1 inside the container is not your application, but /bin/sh. The application becomes its child process with some PID 7.
Now docker stop sends SIGTERM to PID 1, i.e., to sh. And a classic sh started as sh -c waits for its only child to finish and does not forward the signals it receives. The signal goes to the shell and it dies there. Your application, which is ready to catch SIGTERM, never sees it.
I reproduced this. A shell with a child, then an exec form:
# Branch A: shell form. sh -c waits for sleep and DOES NOT forward the signal
sh -c 'sleep 40' &
kill -TERM %1
# result: the shell is killed, but the sleep 40 remains alive — the signal didn’t reach it
# Branch B: exec form. exec replaces the shell process
sh -c 'exec sleep 40' &
kill -TERM %1
# result: sleep received SIGTERM and terminated — the shell in the chain is gone
The difference is exactly the word exec. In the first case sleep survived the signal, in the second case it died as it should. In a container, the first case is your shell form CMD, the second is the exec form.
A common mistake that combines both traps:
ENTRYPOINT ./start.sh
Here PID 1 is /bin/sh, launching start.sh, and inside start.sh the last line is, say, node server.js without exec. It becomes three links: sh → start.sh → node. SIGTERM hits sh, it doesn’t forward it, node never learns about the stop. Timeout, SIGKILL, lost data — every time.
Symptom → cause → remedy
I’ve distilled the diagnostics into a table. Find your symptom, look at the cause, and what to do.
| Symptom | Cause | Remedy |
|---|---|---|
| The container always dies exactly after 10 seconds | SIGTERM never reaches the application |
exec-form CMD/ENTRYPOINT; exec "$@" in the script |
| There’s no line about completion in the logs | The signal handler exists, but the shell intercepted the signal | Remove /bin/sh from the chain — use exec form |
| The application catches the signal but stays silent | It’s started as PID 1 and has no handler |
Add init (--init) or add a handler to the code |
The container has zombie processes <defunct> |
No one to reap children — PID 1 is not init |
--init / init: true in Compose |
| Child processes outlive the stop | The signal does not propagate to the process group | init with group signal distribution (tini -g) |
Next — details for each remedy.
Remedy 1. Exec form — free and almost always enough
The most common fix is simple: rewrite CMD/ENTRYPOINT to the JSON array form.
# was
CMD python -m myservice
# now
CMD ["python", "-m", "myservice"]
Now the /bin/sh disappears from the chain, PID 1 is the Python process itself, and SIGTERM from docker stop goes straight to it. If the application can handle the signal (Django/Gunicorn, Node, nginx, Postgres — they can), the problem is solved completely.
Checklist for “exec form”.
CMD ["binary", "arg1", "arg2"]— each argument as a separate array element.- Quotes — double quotes, this is JSON. Single quotes (
['binary']) won’t be accepted by Docker. - Do you need environment variable substitution (
$HOME,$PORT)? It’s done by the shell, not present in exec form. Then either explicitlyCMD ["sh","-c","exec myapp --port $PORT"](note theexec!) or substitute the value in the entrypoint script.
Remedy 2. Proper entrypoint script: don’t forget exec
Wrapper scripts are common: wait for the database, apply migrations, then start the service. But the last command must be run with exec, otherwise the script stays as PID 1, and the service becomes its child, returning us to the same signal-stop problem.
#!/bin/sh
set -e
# preparation: wait for DB, migrations, etc.
until pg_isready -h "$DB_HOST"; do sleep 1; done
python manage.py migrate
# IMPORTANT: exec replaces the script process with the service process.
# Now the service itself becomes PID 1 and receives signals directly.
exec python -m myservice "$@"
Without exec in the last line the chain remains triple again (sh → script → service) and SIGTERM is lost. With exec — the script dissolves and the service takes its place. One verb solves everything.
Remedy 3. Init process when the app doesn’t handle signals
Sometimes you can’t rewrite the application: it doesn’t install a SIGTERM handler and, as PID 1, ignores it. Or it spawns children and doesn’t reap them, accumulating zombies. For both cases there is a standard init.
Docker, since version 1.13, includes the tini (the docker-init), and it is enabled with one flag:
docker run --init myimage
What init does in place of PID 1 (a quote from the tini README): it guarantees that “SIGTERM properly terminates your process even if you didn’t explicitly install a signal handler for it.” That is, init catches the signal itself and forwards it to your application. Plus it does reaping — collects terminated orphaned children so the container doesn’t accumulate <defunct> processes.
In Compose the same:
services:
app:
image: myimage
init: true
From the Compose specification: “If unset containers are stopped by Compose by sending SIGTERM.” The exact init binary depends on the platform, but the idea is the same.
The -g flag in tini makes it send the signal to the entire process group, not only to the direct child. Useful when the application itself splinters into several processes and some survive the stop. A classic example from the docs: docker run --rm krallin/ubuntu-tini sh -c 'sleep 10' and Ctrl-C — nothing happens because the shell waits for the child and doesn’t forward the signal.
Remedy 4. STOPSIGNAL — when the app needs a signal other than SIGTERM
Some programs historically expect a different signal for a graceful shutdown. Classic example — nginx, whose graceful shutdown is SIGQUIT. For such cases there is the STOPSIGNAL instruction in the Dockerfile and the --stop-signal option for docker run:
STOPSIGNAL SIGQUIT
Now docker stop will send SIGQUIT to this container instead of SIGTERM. If STOPSIGNAL is not set, the default is SIGTERM. In Compose the analog is stop_signal; from the specification: “If unset containers are stopped by Compose by sending SIGTERM”.
The key principle to keep in mind: the signal must reach the process that can handle it, and that process must be PID 1 (or under the right init). Everything else are edge cases of this rule. Exec form removes the extra shell, exec in a script removes an extra script, --init provides a handler where the app doesn’t have one, STOPSIGNAL selects the correct signal. Keep the chain to PID 1 short and intentional — and the “ten seconds to a clean stop” will disappear.
How to check it in under a minute
No need to wait for an incident. Diagnostics — three commands.
1. Who is your PID 1?
docker exec mycontainer ps -o pid,comm
If the first process is sh, bash or the name of your startup script, and not the service itself — the signal is most likely being lost.
2. Does the application actually respond to SIGTERM?
# in one terminal, watch the logs
docker logs -f mycontainer
# in another — politely ask it to stop and time it
time docker stop mycontainer
Look at two things: whether you see a line in the logs about termination (graceful shutdown, closing connections) and how long the time took. An instant stop with a log entry is great. Exactly 10 seconds of silence — the diagnosis is confirmed.
3. Are there zombie processes piling up?
docker exec mycontainer ps -el | grep -c defunct
Growing numbers of <defunct> — a sign that an init with reaping is needed.
A related point that is often forgotten: the stop timeout is also a data durability issue. If the database or queue process receives SIGKILL instead of SIGTERM, it doesn’t have time to flush buffers and close files properly. It might be acceptable once, but regular SIGKILL on the state store sooner or later leads to corrupted data or a long recovery on the next startup. Proper delivery of SIGTERM is not about log aesthetics, but about the integrity of what you store.
In short, takeaways
docker stopsendsSIGTERMto the processPID 1, waits for 10 seconds (Linux) and then issuesSIGKILL.- PID 1 ignores signals without an explicit handler — that’s the kernel rule.
- Shell form of
CMD/ENTRYPOINTinserts/bin/shas the first process, and it does not forward signals to the application. - Fixes in order of increasing effort: exec form →
exec "$@"in a script →--init/init: true→STOPSIGNAL. - A quick check:
ps -o pid,comm,time docker stop, defunct counter.
Related forum topics: Guide: Docker networking for self-hosted setups, Home server: roadmap from first container to resilience.
Sources
- Docker Docs —
docker container stop(timeouts, signals,-t/--signal). - Docker Docs — Dockerfile reference (exec- and shell-forms of
CMD/ENTRYPOINT,STOPSIGNAL). - Compose Specification — Services (
stop_grace_period,stop_signal,init). - krallin/tini — purpose of init, signal forwarding and reaping,
-gflag. - Personal terminal experiments (
unshare,sh -cvsexec), reproduced in the text.
Do your containers shut down instantly or do they wait out the timeout? Run time docker stop on your most heavily loaded service and tell us which process ends up as PID 1. Do you use --init by default — or do you believe the application should catch signals itself?
