ML
Docker

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.

June 19, 202610 min readDockerLinux

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 stop means PID 1 is ignoring SIGTERM. 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/sh as PID 1 and swallows signals.
  • PID 1 has no default signal actions. Even with exec form you must register a SIGTERM handler, or the process can only be SIGKILL'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 (or tini/dumb-init) so zombies get reaped — your app as PID 1 won't do it.
SharePostLinkedIn

Reader Discussion

7 replies// weighed in

TopNewestAuthor
Add to the thread
Disagree, agree harder, or share your own experience…
Email instead →markdown okbe kind
  1. Highlighted by author
    Arnab Mitra· DevOps · FintechFrom experience

    the COPY-package.json-first trick is one of those things you only learn after you've burned 90 minutes of CI per PR for a month. our build dropped from 4.5min to 38s with that one reorder.

    Jun 21, 2026·2 days later
  2. Fenna Vermeer· Senior SWEStory

    the jlink section made me laugh out loud — we did the EXACT same thing, shaved 18MB, then shipped a heap dump tool that needed jdk.management and woke ourselves up at 3am. distroless really is the sweet spot.

    Jun 24, 2026·5 days later
  3. Ravi Subramanian· Platform EngAsks

    Q — anyone moved to BuildKit cache mounts (RUN --mount=type=cache,target=/root/.m2)? we tested it and it's a step beyond multi-stage for repeatable builds, but the syntax is intimidating to onboard new devs.

    Jun 25, 2026·6 days later
  4. Mei Lin· Full StackFrom experience

    .dockerignore is mandatory not optional — copying that to the wall. our junior shipped an image with .git inside and accidentally exposed our deploy keys. one-line fix, six-month security review.

    Jun 22, 2026·3 days later
  5. Quân Đỗ🇻🇳 HCMC· Backend LeadAgrees

    Distroless thay cho alpine cho Java app là 1 quyết định mà mình tiếc đã không làm sớm. CVE alert giảm 80%, kích thước tương đương alpine, không có shell tức là không có exec attack surface. Highly recommend.

    Jun 23, 2026·4 days later
  6. Isabella Costa· Junior EngineerKind words

    saved this. sharing at standup tomorrow — we've had exactly this problem for 2 sprints and nobody on the team had framed it this way 🙏

    Jun 21, 2026·2 days later
  7. Kenta Yamada· Tech LeadAsks

    would love a war-story follow-up. principles are clear; the actual debugging session is where the interesting stuff lives. there's a real shortage of "here's the dashboard, here's the thread we pulled, here's where we got stuck for 90 mins" content.

    Jun 23, 2026·4 days later

Worked on something similar? Email ducminhldm@gmail.com — I read every one. The good ones become future posts.

Comments seeded · live discussion via email