Run docker stop on a container whose app has no SIGTERM handler and watch what happens: nothing, for exactly ten seconds, and then the container dies hard. The app never saw the signal at all. Not "received it and ignored it" — the kernel refused to deliver it. This is the single most misunderstood behavior in container land, and it comes from a rule about PID 1 that predates Docker by decades.

The kernel rule: PID 1 has no default signal handlers

On a normal Linux process, SIGTERM with no handler installed means the default disposition applies: the process terminates. PID 1 — the init process of a PID namespace, which is what your container entrypoint becomes — plays by a different rule. From pid_namespaces(7):

Only signals for which the "init" process has established a signal handler can be sent to the "init" process by other members of the PID namespace. This restriction applies even to privileged processes, and prevents other members of the PID namespace from accidentally killing the "init" process.

The same restriction applies to processes in ancestor namespaces — which is where the container runtime lives when it executes your docker stop. The two exceptions are SIGKILL and SIGSTOP: when sent from an ancestor PID namespace, "these signals are forcibly delivered," and the namespace's init cannot catch them.

The logic is historical self-defense. If init dies, the system is done — in a PID namespace, the kernel reacts to PID 1's death by SIGKILLing every other process in the namespace, and any later fork() in that namespace fails with ENOMEM. So the kernel won't let a stray signal take init down via a default disposition. The consequence for containers: a process that handles SIGTERM behaves identically as PID 1 and as PID 2000; a process that relies on the default disposition is unkillable by SIGTERM when it's PID 1.

This is why docker stop hangs. The Docker docs state the contract plainly: "The main process inside the container will receive SIGTERM, and after a grace period, SIGKILL." The default grace period is 10 seconds for Linux containers (30 for Windows), overridable with -t, and the stop signal itself can be changed with STOPSIGNAL or --stop-signal. If your PID 1 has no TERM handler, the first signal is discarded by the kernel and the SIGKILL ten seconds later is doing all the work — no connection draining, no flushing, no cleanup. Every deploy is a crash.

The shell-form trap: your app isn't even PID 1

There's a second, sneakier way to lose the signal. Write this in a Dockerfile:

dockerfile
ENTRYPOINT node server.js

and Docker wraps it: the shell form "starts your ENTRYPOINT as a subcommand of /bin/sh -c, which does not pass signals. This means that the executable will not be the container's PID 1, and will not receive Unix signals" (Dockerfile reference). Now sh is PID 1. It probably has no TERM handler either, and even if the signal landed, as Yelp's engineers put it when they built dumb-init: "Signals sent to the shell won't be forwarded to the subprocess, and the shell won't exit until your process does."

Two fixes:

dockerfile
# exec form: node is PID 1, receives signals directly
ENTRYPOINT ["node", "server.js"]

# or, if you need shell processing, replace the shell at the end
ENTRYPOINT ["/bin/sh", "-c", "exec node server.js \"$0\" \"$@\""]

exec makes the shell replace itself with your binary instead of forking it, so your process inherits PID 1 and the signal path is direct. The exec form is the one the Dockerfile reference calls "the preferred form" — and signals are the main reason.

The other job you just inherited: reaping zombies

Becoming PID 1 doesn't just change how signals reach you; it hands you init's responsibilities. When a process dies, its parent must call wait() to collect the exit status; until then the dead process is a zombie holding a slot in the PID table. When a parent dies before its children, the orphans get reparented — per pid_namespaces(7), to the init process of the PID namespace (unless some intermediate process registered itself with PR_SET_CHILD_SUBREAPER).

Real init systems wait() on these adopted orphans continuously. Your Node server does not — it has no idea it's supposed to. So in a long-running container where anything forks and orphans children (a shell spawning background jobs, a worker that fork-execs ffmpeg, a flaky library), zombies accumulate. Each one is tiny, but PIDs are a bounded resource: as the tini README warns, zombie processes can starve your entire system for PIDs, at which point nothing on the host can fork.

The 10 KB fix: run a real init as PID 1

You don't need systemd in a container. You need something that does exactly two things: forward signals down, and reap zombies. That's tini — "All Tini does is spawn a single child" — then it waits, reaps anything reparented to it, forwards the signals it receives to your process, and finally exits with your process's exit code. It's a static binary in the 10 KB range (under 1 MB statically linked).

The crucial side effect: once tini is PID 1, your app is no longer PID 1, so the kernel's normal default dispositions apply to it again. An app with no SIGTERM handler now terminates on SIGTERM, exactly as it would on your laptop. dumb-init (Yelp's equivalent) works the same way: it "registers signal handlers for every signal that can be caught" and forwards them to the processes below it, restoring default behavior for your actual workload.

You almost certainly already have tini available: since Docker 1.13 it ships inside Docker itself, enabled per-container with docker run --init. In Compose that's init: true on the service. In a Dockerfile:

dockerfile
ENTRYPOINT ["/tini", "--"]
CMD ["node", "server.js"]

If tini can't be PID 1 in your setup (say, another wrapper already owns it), it should be registered as a subreaper instead — tini -s or the TINI_SUBREAPER env var — so orphans still reparent to it rather than skipping past to the real init. Tini prints a warning when it's neither PID 1 nor a subreaper, because in that position it can't see the zombies it's supposed to collect.

A checklist that covers all of it

  1. Exec form, always. ENTRYPOINT ["binary", "arg"], never ENTRYPOINT binary arg. If you must go through a shell, end with exec.
  2. Handle SIGTERM in the app if you have shutdown work to do — stop accepting connections, drain, flush, exit. The handler is what makes graceful shutdown graceful; tini only delivers the signal.
  3. Run an init (--init, init: true, or explicit tini) whenever the workload forks, and consider it default hygiene even when it doesn't.
  4. Verify with docker stop and a stopwatch. Instant exit: signals flow. Ten-second pause: something in this list is broken. docker exec ps -ef showing sh -c as PID 1, or entries piling up, tells you which one.

The whole failure mode costs ten silent seconds per deploy and one lost shutdown path — and the fix is a JSON array in your Dockerfile and a 10 KB binary the runtime already ships.

References