03-runtime-networking-storage

PID 1, Signals and Health Checks

Why your container takes exactly ten seconds to stop, how exec form decides whether your app hears SIGTERM, and writing a health check that means something.

September 13, 2026
dockerpid1signalssigtermgraceful-shutdownhealthchecktini

The Ten-Second Tell

Run docker stop on your container and count. If it takes almost exactly ten seconds every time, you have a signal-handling bug — and the number is the giveaway, because ten seconds isn't your application being slow. It's Docker's default grace period expiring.

Here's what docker stop actually does:

SIGTERM is a request: finish up and exit. SIGKILL is not a request — the kernel terminates the process immediately, with no opportunity to close connections, flush buffers or finish in-flight requests.

A container that always takes the full grace period is one whose application never received the polite request at all.

Exec Form vs Shell Form

The cause is usually two lines that look equivalent and are not.

dockerfile
CMD node server.js                # shell form
CMD ["node", "server.js"]         # exec form

Shell form runs your command through /bin/sh -c. So the process tree inside the container is:

text
PID 1: /bin/sh -c "node server.js"
PID 7:   └── node server.js

Docker sends SIGTERM to PID 1 — which is /bin/sh. And sh does not forward signals to its children. Your Node process never learns anything happened. Ten seconds later SIGKILL arrives and kills the whole tree.

Exec form executes your binary directly, with no shell in between:

text
PID 1: node server.js

Now SIGTERM lands on your application, your handler runs, and the container stops in milliseconds.

🚨

This applies to ENTRYPOINT as well as CMD, and it's the single most common cause of slow container shutdown. Use exec form — the JSON array with double quotes — for anything long-running. Note that it requires double quotes: ['node', 'server.js'] is not valid JSON and will be treated as shell form.

The catch with exec form

Exec form doesn't invoke a shell, so you lose shell features — notably variable substitution:

dockerfile
CMD ["node", "server.js", "--port=$PORT"]    # $PORT is a literal string, not expanded

If you genuinely need shell processing, be explicit about it and keep control of signals by using exec, which replaces the shell rather than spawning a child under it:

dockerfile
CMD ["sh", "-c", "exec node server.js --port=$PORT"]

That exec keyword matters: without it you're back to a shell at PID 1 swallowing your signals. For anything beyond a trivial case, an entrypoint script is clearer:

bash
#!/bin/sh
set -e
# setup work here — migrations, config templating
exec node server.js "$@"      # exec replaces the shell, so the app becomes PID 1

Check yourself

A Dockerfile ends with ENTRYPOINT ./start.sh, where start.sh is a shell script that ends with the line `node server.js`. Deploys are slow and the app's shutdown logs never appear. What's the minimal fix?

Handling SIGTERM in Your Application

Getting the signal to your process is half of it. Your application has to do something sensible with it:

js
const server = app.listen(3000);
 
process.on("SIGTERM", () => {
  console.log("SIGTERM received, draining connections");
  server.close(() => {         // stop accepting new connections, finish in-flight ones
    db.end();
    process.exit(0);
  });
});

Without a handler, the default action for SIGTERM is immediate termination — which is still far better than being SIGKILLed, but drops in-flight requests. A graceful handler is what turns a deploy from "some users saw a 502" into "nobody noticed".

Tuning the grace period

The default is 10 seconds on Linux (30 on Windows containers). If your application legitimately needs longer to drain, say so:

bash
docker stop -t 30 myapp                       # one-off
docker run --stop-timeout 30 myapp            # baked into the container

And if your application listens for a different signal — some servers expect SIGQUIT for graceful shutdown — declare it in the image:

dockerfile
STOPSIGNAL SIGQUIT

Zombie Processes and --init

A separate PID 1 problem, for applications that spawn child processes.

In Linux, when a child exits, its parent must call wait() to collect the exit status. Until that happens the child remains as a zombie — a process table entry with no process behind it. Normally init (PID 1) adopts orphaned children and reaps them automatically.

Inside a container, your application is PID 1, and most applications weren't written to adopt and reap arbitrary orphans. Zombies accumulate, consuming process table entries inside the container's PID namespace.

The fix is a real init process:

bash
docker run --init myapp

This runs a minimal init (Docker bundles tini) as PID 1, which forwards signals to your application and reaps zombies properly. It's cheap insurance for anything that shells out or spawns workers.

💡

You don't need --init for a single-process application that never forks. You do want it for anything running a process manager, spawning worker subprocesses, or shelling out to tools. If docker exec <container> ps aux shows processes in Z/defunct state, that's your signal.

Health Checks

A running process is not the same as a working application. A server can be alive and stuck in a loop, deadlocked on a connection pool, or unable to reach its database — with the process perfectly healthy from the kernel's point of view.

HEALTHCHECK gives the container a health status alongside its running state:

dockerfile
HEALTHCHECK --interval=30s --timeout=3s --start-period=40s --retries=3 \
  CMD curl -fsS http://localhost:3000/health || exit 1

The contract is simple: exit 0 means healthy, exit 1 means unhealthy. (Exit code 2 is reserved — don't use it.) The options:

OptionDefaultWhat it does
--interval30sTime between checks
--timeout30sA check taking longer than this counts as a failure
--start-period0sGrace window at startup where failures don't count toward --retries
--start-interval5sCheck frequency during the start period (Engine 25.0+)
--retries3Consecutive failures before the container is marked unhealthy

Status begins as starting, becomes healthy on the first passing check, and flips to unhealthy after --retries consecutive failures.

--start-period is the option that prevents most false alarms. A JVM or heavy framework may take 40 seconds to boot; without a start period it fails three checks and is marked unhealthy before it ever finished starting. Set it generously — failures during that window don't count against you.

Two things to get right

Write a check that tests something real. A check that only proves the port is open tells you almost nothing — the process being up is what you already knew. A /health endpoint that verifies the application can reach its database catches the failure that actually takes you down.

But don't overcorrect: if your health check fails whenever a downstream dependency hiccups, one slow database marks your entire fleet unhealthy. Check what this container needs in order to serve requests, not the health of the whole system.

Know what the status does and doesn't do. Docker Engine records health status — it does not restart unhealthy containers. --restart reacts to the container exiting, not to it being unhealthy. The status becomes useful when something acts on it: Compose's depends_on with condition: service_healthy (the next phase covers this), or an orchestrator that reschedules unhealthy workloads.

bash
docker ps                                                  # STATUS column shows (healthy)
docker inspect --format '{{json .State.Health}}' myapp | jq # full history, with output from failed probes

Failed probe output is stored, which makes that last command the fastest way to see why a check is failing.

Check yourself

A team adds HEALTHCHECK to their API image and expects Docker to restart the container automatically when it goes unhealthy. It never restarts. Why?

Phase 3 in Four Sentences

  1. Each container has its own network namespace, so localhost is container-local and name resolution requires a user-defined network.
  2. The writable layer dies with the container — volumes for state, bind mounts for development source, and always verify the mount path matches where the application writes.
  3. Use exec form so your application is PID 1 and receives SIGTERM; add --init if it spawns children.
  4. Health checks report, they don't act — write one that tests something real and give it a start period.

What's Next

Phase 4 puts these pieces together with Docker Compose — including the version confusion in older tutorials, and the depends_on subtlety that makes local stacks flaky — and then builds a repeatable method for debugging containers that won't start, won't connect, or won't stop.