PID 1: Why Your Container Ignores docker stop and Breeds Zombies
Shell-form CMD, the kernel's special rules for PID 1, and why a ten-second pause on every docker stop traces back to a signal nobody forwarded.
You run docker stop, watch it hang for ten seconds, then the container dies hard. Your app's graceful-shutdown code never ran, in-flight requests were cut, and the logs show nothing. The cause is almost never your shutdown handler — it is which process became PID 1, and the kernel's special rules for it.
1. PID 1 is not a normal process
The first process in a container's PID namespace is PID 1, and the Linux kernel treats it differently in two ways that matter here. First, the default actions for signals are disabled for PID 1: a normal process that receives SIGTERM with no handler is killed by the kernel's default action, but PID 1 with no handler for SIGTERM simply ignores it. Second, PID 1 inherits orphaned processes and is responsible for reaping them.
docker stop sends SIGTERM, waits (default 10s, the --time grace period), then sends SIGKILL. If PID 1 has no SIGTERM handler, the term is ignored and you always pay the full grace period before the unstoppable SIGKILL. That ten-second hang is the tell.
2. Shell form is the usual culprit
This is where most people get bitten without realising it. These two are not equivalent:
# shell form — runs /bin/sh -c "node server.js"
CMD node server.js
# exec form — runs node directly
CMD ["node", "server.js"]
With the shell form, PID 1 is /bin/sh, and your node process is its child at some other PID. docker stop signals PID 1 (the shell), which by default does not forward signals to its children. So node never sees SIGTERM at all — it gets SIGKILL'd when the grace period ends, and your process.on("SIGTERM", ...) is dead code.
With the exec form, node is PID 1 and receives the signal directly. Now your handler can run — provided you wrote one, because remember rule 1: as PID 1 there is no default kill action to fall back on.
3. Even exec form needs a handler
Being PID 1 means the graceful path is entirely on you. A minimal correct shutdown:
const server = app.listen(3000);
process.on("SIGTERM", () => {
console.log("SIGTERM received, draining...");
server.close(() => process.exit(0)); // stop accepting, finish in-flight
setTimeout(() => process.exit(1), 9000); // hard cap under the 10s grace
});
Note the timeout cap below Docker's grace window — if your drain genuinely can't finish in time, exit yourself rather than letting SIGKILL cut you mid-write.
4. The zombies you didn't know you had
The second PID 1 duty is reaping. When a child process exits, it stays in the table as a zombie until its parent calls wait() to collect the exit status. Orphaned children get re-parented to PID 1, so a real init reaps them automatically. Your node or python app as PID 1 generally does not — so any process that spawns short-lived children (a shell-out, a worker that forks) slowly leaks zombie entries until the PID table is exhausted.
$ docker exec web ps -eo pid,stat,comm | grep Z
47 Z defunct
52 Z defunct # accumulating — nobody is reaping
5. Give PID 1 to a real init
The clean fix for both problems is to not make your app PID 1. Docker ships a tiny init for exactly this:
# reaps zombies AND forwards signals to your app
docker run --init my-image
# or bake it in:
# ENTRYPOINT ["tini", "--"]
# CMD ["node", "server.js"]
--init inserts tini as PID 1; it forwards SIGTERM to your process and reaps orphans. You still write the SIGTERM handler, but you stop fighting the PID 1 semantics just to get the signal delivered in the first place.
Rules of thumb
- A ten-second pause on every
docker stopmeans PID 1 is ignoringSIGTERM. Look there before touching your shutdown code. - Use exec-form
CMD ["node","server.js"], never shell-form, unless you genuinely need a shell — shell form hides your app behind/bin/shas PID 1 and swallows signals. - PID 1 has no default signal actions. Even with exec form you must register a
SIGTERMhandler, or the process can only beSIGKILL'd. - Cap your drain logic just under the grace period and exit yourself; don't rely on landing exactly inside the 10s window.
- If anything in the container forks short-lived children, run with
--init(ortini/dumb-init) so zombies get reaped — your app as PID 1 won't do it.