DEV Community

jidonglab
jidonglab

Posted on

Why docker stop Takes 10 Seconds: PID 1 Is Ignoring Your SIGTERM

For months I believed docker stop was just slow. Every deploy, every docker compose down, every restart: type the command, watch the cursor blink, count to ten, get my prompt back.

Then I ran docker inspect on a stopped container and saw ExitCode: 137. That number means the process was killed with SIGKILL. My Node server had a graceful shutdown handler, logging and all. It never ran once. Docker had been asking my app to stop politely, my app had been ignoring it, and after ten seconds Docker shot it.

If docker stop takes 10 seconds for you too, it's almost certainly the same bug, and the fix takes about two minutes.

TL;DR

  • docker stop sends SIGTERM, waits 10 seconds by default, then sends SIGKILL. A stop that always lands at ~10s means SIGTERM did nothing.
  • Your app runs as PID 1 inside the container. Linux ignores signals sent to PID 1 unless the process has installed a handler for that signal. Node and Python don't install a SIGTERM handler by default.
  • Shell-form CMD node server.js makes it worse: PID 1 may be /bin/sh, which doesn't forward SIGTERM to your app.
  • Fix: use exec form (CMD ["node", "server.js"]), handle SIGTERM in your code, and run with --init (or init: true in Compose) so a tiny init process forwards signals.
  • Check with docker inspect -f '{{.State.ExitCode}}': 137 means SIGKILL. 0 or 143 means the signal actually worked.

Why does docker stop take exactly 10 seconds?

docker stop takes 10 seconds because that's its default grace period. Docker sends the container's stop signal (SIGTERM unless you changed STOPSIGNAL), waits up to 10 seconds for the main process to exit, and then sends SIGKILL, which can't be caught or ignored.

So the 10 seconds isn't Docker being slow. It's Docker waiting for an exit that's never coming. A healthy container stops in well under a second.

You can change the timer with docker stop -t 30 or stop_grace_period in Compose, but raising it only makes the wait longer. Lowering it to 1 makes things faster and kills your app harder. Neither fixes anything.

Why does PID 1 ignore SIGTERM in a container?

Linux gives PID 1 special treatment: signals that the process hasn't registered a handler for are dropped instead of triggering the default action. On a normal machine, the default action for SIGTERM is "terminate". For PID 1, the default action is "nothing". The kernel does this so a stray signal can't take down init and the whole system with it.

Inside a container, your app is PID 1 in its own PID namespace. It gets that same protection whether it wants it or not.

That's fine for real init systems, which install handlers for everything. It's terrible for application runtimes that lean on the default action:

  • Node.js doesn't register a SIGTERM handler unless you call process.on('SIGTERM', ...). As PID 1, it ignores SIGTERM and SIGINT. Ctrl+C in an attached docker run does nothing either.
  • Python installs a handler for SIGINT (that's where KeyboardInterrupt comes from) but not for SIGTERM. So as PID 1, Python responds to Ctrl+C but ignores docker stop.

The Node.js Docker image maintainers say this outright in their best-practices doc: Node wasn't designed to run as PID 1.

How do I reproduce the docker stop delay?

Reproduce it with a five-line server and a Dockerfile that looks like half the Dockerfiles on the internet.

server.js:

const http = require('http');
http.createServer((req, res) => res.end('ok\n')).listen(3000);
console.log('listening on 3000');
Enter fullscreen mode Exit fullscreen mode

Dockerfile:

FROM node:20-alpine
WORKDIR /app
COPY server.js .
CMD node server.js
Enter fullscreen mode Exit fullscreen mode

Build it, run it, stop it, and time the stop:

docker build -t sigtest .
docker run -d --name sigtest sigtest
time docker stop sigtest
docker inspect -f '{{.State.ExitCode}}' sigtest
docker rm sigtest
Enter fullscreen mode Exit fullscreen mode

On my machine: real 0m10.2s and exit code 137. That's 128 + 9, the shell convention for "killed by signal 9", which is SIGKILL.

Want to see who PID 1 actually is? While the container runs:

docker exec sigtest cat /proc/1/cmdline | tr '\0' ' '; echo
Enter fullscreen mode Exit fullscreen mode

With shell-form CMD, Docker wraps your command in /bin/sh -c "node server.js". Depending on the shell in your base image, PID 1 is either sh (which won't forward SIGTERM to Node) or Node itself, if the shell happened to exec it. In both cases the stop takes ten seconds, because even Node as PID 1 ignores the signal. Shell form just gives you two ways to lose instead of one.

Does switching to exec form fix it?

Exec form alone doesn't fix it. It removes the shell, so Node becomes PID 1 for certain, and Node as PID 1 still ignores SIGTERM. Here's what I measured across four variants of the same container:

Setup PID 1 docker stop time Exit code
CMD node server.js sh or node ~10s 137
CMD ["node", "server.js"] node ~10s 137
exec form + docker run --init docker-init under 1s 143
exec form + --init + SIGTERM handler docker-init under 1s 0

Exec form is still the right default, because it makes the setup predictable. The two rows that actually fix things are the ones with --init.

What does docker run --init actually do?

docker run --init starts a tiny init process (Docker ships tini as docker-init) as PID 1, and that process runs your app as its child. The init process forwards signals to the child and reaps zombie processes, which is the whole job of PID 1.

Now Node is PID 2. It's a normal process again, so SIGTERM gets its normal default action: terminate. That's the 143 in the table, 128 + 15, "killed by SIGTERM". Fast, but still abrupt: in-flight requests get dropped mid-response.

In Compose, it's one line per service:

services:
  api:
    build: .
    init: true
    stop_grace_period: 15s
Enter fullscreen mode Exit fullscreen mode

If you'd rather bake it into the image, install tini and set ENTRYPOINT ["/sbin/tini", "--"] (that path is where Alpine's tini package puts it). Same effect, no runtime flag to forget.

How do I make my app shut down gracefully?

Exit code 0 needs one more piece: a SIGTERM handler that stops accepting new work, finishes what's in flight, and exits on its own.

Node:

const http = require('http');
const server = http.createServer((req, res) => res.end('ok\n')).listen(3000);

function shutdown(signal) {
  console.log(`${signal} received, draining`);
  server.close(() => process.exit(0));
  // keep-alive sockets can hold close() open; don't outlive the grace period
  setTimeout(() => process.exit(1), 8000).unref();
}

process.on('SIGTERM', shutdown);
process.on('SIGINT', shutdown);
Enter fullscreen mode Exit fullscreen mode

Python:

import signal
import sys

def handle_sigterm(signum, frame):
    # close DB pools, flush queues, then:
    sys.exit(0)

signal.signal(signal.SIGTERM, handle_sigterm)
Enter fullscreen mode Exit fullscreen mode

Keep the fallback timer shorter than the grace period. If your drain needs 8 seconds and Docker gives you 10, you're fine. If your drain needs 20 and you didn't raise stop_grace_period, you're back to SIGKILL, just with extra steps.

Bonus: once you register a handler, Linux stops ignoring that signal even for PID 1. So a handler alone, without --init, also fixes the 10-second stop. I still add --init because it covers signals you didn't think to handle and reaps zombie child processes your code spawns.

What other setups hit this bug?

The pattern shows up anywhere something stands between Docker and your app:

  1. CMD ["npm", "start"]. Now npm is PID 1 and your app is its child. Whether SIGTERM reaches your app depends on npm's own signal handling, which isn't something I want my deploys to depend on. Run node directly.
  2. Entrypoint scripts. A docker-entrypoint.sh that sets up env vars and then runs node server.js as its last line leaves bash as PID 1. End the script with exec node server.js (or exec "$@") so your app replaces the shell.
  3. Kubernetes. Same signal, bigger timer. The kubelet sends SIGTERM and waits terminationGracePeriodSeconds, which defaults to 30. A pod that ignores SIGTERM takes 30 seconds to die on every rollout, and your users eat dropped connections at the end of it.
  4. Process managers inside containers. If you run something like a supervisor as PID 1, check that it forwards SIGTERM to its children and waits for them. Most do. Verify yours instead of assuming.

The two-minute checklist

  • Run time docker stop <container>. Anything near 10 seconds is the bug.
  • Check docker inspect -f '{{.State.ExitCode}}' <container>. 137 is SIGKILL.
  • Switch CMD and ENTRYPOINT to exec form.
  • Add init: true in Compose or --init to docker run.
  • Handle SIGTERM in the app, with a drain timeout shorter than the grace period.
  • Replace npm start with node, and put exec in front of the last line of entrypoint scripts.

So why does docker stop take 10 seconds?

docker stop takes 10 seconds because your app is PID 1 inside the container and has no SIGTERM handler, so Linux ignores the signal. Docker waits out its default 10-second grace period and then sends SIGKILL, which is why the container exits with code 137 and your shutdown logic never runs. Fix it by using exec-form CMD, running with --init (init: true in Compose) so a real init process forwards signals, and handling SIGTERM in your app so it drains and exits cleanly with code 0.


Written by the developer behind Preterview, an interview prep platform.

Top comments (0)