DEV Community

jidonglab
jidonglab

Posted on

Docker Stop Takes 10 Seconds: Your App Is PID 1 and Ignores SIGTERM

I typed docker compose down, then sat and watched the spinner. One container. Ten seconds. Every single time.

For months I figured Docker was just slow at stopping things. It isn't. docker stop usually takes 10 seconds because your app is running as PID 1, it never reacts to SIGTERM, and Docker waits out the full grace period before it kills the process with SIGKILL. Your "graceful shutdown" never ran. Your in-flight requests got cut off mid-response.

The part that actually surprised me: the fix most blog posts give you, "use the exec form of CMD", doesn't fix it for Node or Python on its own.

TL;DR

  • docker stop sends SIGTERM, waits 10 seconds by default, then sends SIGKILL. If it takes about 10 seconds, your process ignored SIGTERM.
  • The Linux kernel doesn't apply default signal actions to PID 1 in a namespace. If PID 1 hasn't installed a SIGTERM handler, SIGTERM does nothing.
  • Node and Python don't install a SIGTERM handler by default, so node server.js or python app.py as PID 1 ignores docker stop.
  • Exit code 137 (128 + 9) means SIGKILL. Exit code 143 (128 + 15) means the process actually honored SIGTERM.
  • Fix: run with --init (or init: true in Compose), use the exec form of CMD, and handle SIGTERM in your code so connections drain.

Why does docker stop take 10 seconds?

docker stop takes 10 seconds because Docker sends SIGTERM, waits for the default 10-second timeout, and then sends SIGKILL when the process is still alive. The delay you're seeing is the timeout itself. Nothing is slow. Docker is waiting for your app to exit, and your app never heard the request.

You can reproduce it in under a minute. Here's the smallest server I could write:

// 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

And the Dockerfile most of us have written at some point:

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:

docker build -t pid1-demo .
docker run -d --name demo pid1-demo
time docker stop demo
docker inspect demo --format '{{.State.ExitCode}}'
Enter fullscreen mode Exit fullscreen mode

On my machine: real 0m10.3s and exit code 137. That 137 is the confession. The shell adds 128 to the signal number, and 9 is SIGKILL. Docker gave up waiting and shot the process.

What makes PID 1 special in a Docker container?

PID 1 gets no default signal behavior from the kernel. For a normal process, SIGTERM's default action is "terminate". PID 1 (the init process) is protected from that: a signal only has an effect on it if the process explicitly installed a handler for that signal.

On your laptop, PID 1 is systemd or launchd, and it's written to handle this. Inside a container, PID 1 is whatever your CMD or ENTRYPOINT started. Usually that's your app, which was never written to be an init system.

So the chain goes like this:

  1. docker stop sends SIGTERM to PID 1.
  2. PID 1 is node. Node registers no SIGTERM handler unless you add one.
  3. Kernel: no handler, it's PID 1, so the signal is dropped.
  4. Ten seconds pass. SIGKILL arrives. SIGKILL can't be ignored, even by PID 1.

Python behaves the same way. It installs a handler for SIGINT (that's where KeyboardInterrupt comes from) but not SIGTERM. Run python app.py as PID 1 and it'll sit there through every docker stop, just like Node.

Does the shell form of CMD make it worse?

Yes, the shell form CMD node server.js adds a layer. Docker wraps it as /bin/sh -c "node server.js". Depending on the shell, sh either stays alive as PID 1 with your app as its child, or it execs straight into your app. In the first case, sh doesn't forward SIGTERM to its child. In the second, your app is PID 1 with no handler. You lose both ways.

Check which one you have:

docker top demo
Enter fullscreen mode Exit fullscreen mode

If you see /bin/sh -c node server.js as the top process, the shell is PID 1. If you see only node server.js, the shell exec'd away. The stop time is 10 seconds either way.

CMD npm start is the worst version of this. That gives you npm, plus the shell npm uses to run your script, plus your app: three processes between the signal and your code. The common advice is to never use npm as the container's main process, and I've stopped doing it.

Why doesn't the exec form of CMD fix it?

The exec form CMD ["node", "server.js"] removes the shell, which is correct. But it makes Node PID 1 directly, and Node as PID 1 still has no SIGTERM handler. Change only the CMD line, rebuild, and time docker stop still prints 10 seconds.

This is the step most guides get wrong. They show the exec form, call it fixed, and move on. It's necessary but not sufficient, because the signal still reaches a process that ignores it.

You need one of two things: a real init process at PID 1, or a SIGTERM handler in your app. Ideally both.

How do I make docker stop instant?

Put a tiny init process at PID 1 with --init, use the exec form of CMD, and add a SIGTERM handler that closes your server. Here are all three, from least to most effort.

Fix 1: --init (one flag)

docker run -d --init --name demo pid1-demo
Enter fullscreen mode Exit fullscreen mode

Docker injects a minimal init (tini) as PID 1. Tini forwards signals to your process and reaps zombie processes. Your app is no longer PID 1, so SIGTERM falls back to its normal default action: terminate.

In Compose, it's one line per service:

services:
  api:
    build: .
    init: true
Enter fullscreen mode Exit fullscreen mode

With just this change, time docker stop demo dropped from 10.3s to well under a second, and the exit code went from 137 to 143 (128 + 15, SIGTERM). The process actually heard the request.

If you can't control the run flags (some platforms don't expose them), bake tini into the image instead:

FROM node:20-alpine
RUN apk add --no-cache tini
WORKDIR /app
COPY server.js .
ENTRYPOINT ["/sbin/tini", "--"]
CMD ["node", "server.js"]
Enter fullscreen mode Exit fullscreen mode

Fix 2: handle SIGTERM yourself

--init makes the process die fast. It doesn't make it die well. Default termination is still abrupt: open connections get dropped and pending writes get lost. For anything serving traffic, add a handler:

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

process.on("SIGTERM", () => {
  console.log("SIGTERM received, draining");
  server.close(() => process.exit(0));
  setTimeout(() => process.exit(1), 8000).unref();
});
Enter fullscreen mode Exit fullscreen mode

server.close() stops accepting new connections and waits for in-flight ones to finish. The 8-second fallback exits before Docker's 10-second SIGKILL, so you control how it ends. Keep-alive connections can hold close() open, so that timeout isn't optional.

Python equivalent, same idea:

import signal, sys

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

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

With a handler installed, your app works correctly even as PID 1, because the kernel now has a handler to call.

Fix 3: exec in your entrypoint script

If you use a shell entrypoint to set up env vars or run migrations, the last line decides everything:

#!/bin/sh
set -e
./migrate.sh
exec "$@"
Enter fullscreen mode Exit fullscreen mode

Without exec, the script stays PID 1 and your app becomes a child that never receives SIGTERM. With exec, the shell replaces itself with your app. I found a missing exec in two of my own entrypoint scripts while writing this post.

Should I just raise the stop timeout instead?

No. docker stop -t 30 or stop_grace_period: 30s in Compose only makes Docker wait longer before it sends SIGKILL. If your process ignores SIGTERM, a longer timeout just makes you wait longer for the same hard kill. Raise the timeout only after you have a SIGTERM handler, and only when you know draining takes more than 10 seconds.

The same rule applies on Kubernetes. The kubelet sends SIGTERM, waits terminationGracePeriodSeconds (30 by default), then SIGKILLs. A pod that ignores SIGTERM takes the full 30 seconds on every rollout. Multiply that by your replica count and you've found where your slow deploys went.

A 30-second audit for your own containers

Run this against any container you own:

time docker stop <name>
docker inspect <name> --format '{{.State.ExitCode}}'
Enter fullscreen mode Exit fullscreen mode
  • About 10 seconds and 137: PID 1 is ignoring SIGTERM. Add init: true today.
  • Under a second and 143: signal received, default termination. Add a handler if the container serves traffic.
  • Under a second and 0: your handler ran and exited cleanly. You're done.

My Compose stack has nine services. Before this, docker compose down took around 10 seconds because Compose stops independent services in parallel and every one of them hit the timeout. After adding init: true and handlers to the three HTTP services, it's about a second. Saving nine seconds sounds small until you count how many times a day you restart your stack.

So why does docker stop take 10 seconds?

docker stop takes 10 seconds because your app runs as PID 1 inside the container, and the kernel ignores SIGTERM for PID 1 unless the process installed a handler for it. Node and Python don't install one by default, so Docker waits out its 10-second grace period and sends SIGKILL, which shows up as exit code 137. Switching to the exec form of CMD alone doesn't fix it. Run the container with --init (or init: true in Compose) so a real init process forwards the signal, use exec at the end of entrypoint scripts, and add a SIGTERM handler that closes your server so shutdown is both fast and graceful.


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

Top comments (0)