EthicsComputerCourses
Join training +91 90532 10052

PID 1, Signals & Graceful Shutdown

Docker & DevOps · Lesson 21 of 39 Intermediate 8 min read Updated

Every deploy, restart and scale-down stops containers. If your app stops cleanly, users never notice. If it does not, requests in progress fail, a half-written order stays in the database, and every docker stop takes an annoying ten seconds. The difference is how your container handles signals, the short messages Linux sends to a process, such as "please stop" (SIGTERM) and "stop now" (SIGKILL).

This lesson explains what Docker does when it stops a container, why the first process in a container is special, and the four fixes that make shutdown fast and safe: exec form, exec in entrypoint scripts, an init process, and a SIGTERM handler in your app.

What docker stop really does

When you run docker stop (or docker compose down, or a deploy replaces a container), Docker:

  1. Sends the container's stop signal to process 1 inside it. By default this is SIGTERM.
  2. Waits for the process to exit. The default grace period is 10 seconds on Linux (30 seconds for Windows containers).
  3. If it is still running, sends SIGKILL, which the kernel enforces at once. The program cannot catch it, clean up or log anything.

The exit code tells you which ending happened: 143 (128 + 15) means the app exited because of SIGTERM, 137 (128 + 9) means it had to be killed. An app that handles SIGTERM itself often exits with 0.

Why PID 1 is special

The first process in a container gets process ID 1, the role an init system such as systemd plays on a normal Linux machine. The kernel treats PID 1 differently: signals that the process has no handler for are ignored instead of using their default action. On a normal machine, SIGTERM kills a program that has no handler. As PID 1 in a container, the same program just keeps running.

You can see this with sleep, which has no signal handlers:

Terminal
docker run -d --name sleeper alpine:3.22 sleep 300
time docker stop sleeper
Output
sleeper

real	0m10.412s
user	0m0.021s
sys	0m0.016s

Docker waited the full 10 seconds and then killed it (the timing is traced and will vary slightly). The same thing happens to many apps that do not install their own SIGTERM handler, and to apps hidden behind a shell that does not pass signals on.

Fix 1 and 2: let the signal reach your app

The most common cause of slow stops is a shell in front of the app. Shell-form CMD npm start makes /bin/sh process 1, as you saw in CMD, ENTRYPOINT, ARG & Other Instructions. npm start adds another layer: npm itself starts Node.js as a child. Use exec form and start the runtime directly: CMD ["node", "server.js"].

Entrypoint scripts have the same trap. Many images use a small script to prepare things (wait for a database, render a config file) before starting the app. The script runs as PID 1, so it must replace itself with the app using exec:

Terminal
#!/bin/sh
# docker-entrypoint.sh
set -e

if [ -n "$DB_PASSWORD_FILE" ]; then
  DB_PASSWORD="$(cat "$DB_PASSWORD_FILE")"
  export DB_PASSWORD
fi

python manage.py migrate --noinput

exec "$@"
Dockerfile
COPY --chmod=755 docker-entrypoint.sh /usr/local/bin/
ENTRYPOINT ["docker-entrypoint.sh"]
CMD ["gunicorn", "--bind", "0.0.0.0:8000", "hostel.wsgi"]

"$@" is the CMD passed to the script. exec replaces the shell process with gunicorn, so gunicorn becomes PID 1 and receives SIGTERM directly. Without exec, the shell stays as PID 1, gunicorn runs as its child, and the stop signal never reaches it. COPY --chmod=755 makes the script executable during the copy.

Fix 3: add a tiny init process

Some programs do not handle signals well, or start child processes and never clean them up. Finished child processes that nobody collects stay in the process table as zombies. A small init process solves both problems: it runs as PID 1, forwards signals to your app and collects finished children.

Docker includes one. Add --init to docker run, or init: true in Compose:

Terminal
docker run -d --name sleeper2 --init alpine:3.22 sleep 300
time docker stop sleeper2
Output
sleeper2

real	0m0.391s
user	0m0.019s
sys	0m0.014s

Now the init process (Docker's docker-init, which is based on the small open-source init called tini) is PID 1. It passes SIGTERM to sleep, which is no longer PID 1 and so dies immediately. Some images install tini themselves and use it as their ENTRYPOINT; the effect is the same.

Fix 4: handle SIGTERM in your app

Getting the signal is half the job. A graceful shutdown means: stop accepting new requests, finish the ones in progress, close database connections, then exit. In Node.js with Express:

JavaScript
const server = app.listen(3000, () => console.log("listening on 3000"));

function shutdown(signal) {
  console.log(`${signal} received, closing server`);
  server.close(async () => {
    await pool.end();
    console.log("clean exit");
    process.exit(0);
  });
  setTimeout(() => process.exit(1), 8000).unref();
}

process.on("SIGTERM", shutdown);
process.on("SIGINT", shutdown);

server.close stops accepting new connections and calls back when open ones finish. pool.end() closes the MySQL connection pool. The timer is a safety net: if something hangs, the app exits by itself after 8 seconds, before Docker's 10-second SIGKILL. SIGINT is the signal Ctrl+C sends, so the same code works when you run the app in a terminal. Production servers such as gunicorn, uvicorn and PHP-FPM already handle SIGTERM gracefully, as long as it reaches them.

Tuning the stop signal and grace period

Settingdocker runComposeDockerfile
Signal to send--stop-signal SIGQUITstop_signal: SIGQUITSTOPSIGNAL SIGQUIT
Grace period--stop-timeout 30stop_grace_period: 30s(none)
Init process--initinit: true(none)

Some programs use a different signal for a graceful stop. The official Nginx image, for example, sets STOPSIGNAL SIGQUIT, which Nginx treats as "finish current requests, then exit". Give long-running jobs, such as a worker that processes a 30-second PDF export, a grace period longer than their longest task. Kubernetes has the same idea with its own setting; see Rolling Updates & Rollbacks in the Kubernetes course.

Common mistakes

  • CMD npm start or CMD node server.js in shell form. The signal stops at npm or /bin/sh. Use CMD ["node", "server.js"].
  • Entrypoint scripts without exec "$@". The script stays PID 1 and the app never hears SIGTERM.
  • Catching SIGTERM and doing nothing. The app ignores the stop and is killed after 10 seconds anyway. Close the server and exit.
  • Cleanup that takes longer than the grace period. Raise stop_grace_period or make shutdown faster; SIGKILL cannot be handled.
  • Ignoring zombie processes in apps that start child processes (headless browsers, report generators). Use --init.

Key points

  • docker stop sends SIGTERM (or the image's STOPSIGNAL), waits 10 seconds on Linux, then sends SIGKILL; exit code 137 means the app had to be killed.
  • PID 1 ignores signals it has no handler for, so a program with no handler, or a shell in front of the app, causes slow stops.
  • Use exec-form CMD, and end entrypoint scripts with exec "$@".
  • --init or init: true adds Docker's tini-based init as PID 1 to forward signals and collect zombie processes.
  • Handle SIGTERM in your app: stop accepting work, finish requests, close connections, exit before the grace period ends.

Exercise

Meera's container pdf-worker always takes 10 seconds to stop and its logs never show her "shutting down" message. Its Dockerfile ends with ENTRYPOINT ["/start.sh"], and start.sh contains two lines: echo "starting worker" and node worker.js. Find the cause, fix the script, and give the worker 45 seconds to finish its current job in Compose.

Show solution
Terminal
#!/bin/sh
echo "starting worker"
exec node worker.js
YAML
services:
  pdf-worker:
    image: ghcr.io/meera-nair/pdf-worker:1.1.0
    stop_grace_period: 45s

Without exec, the shell running start.sh is PID 1 and does not pass SIGTERM to Node.js, so the handler never runs and Docker kills everything after 10 seconds. With exec, Node.js replaces the shell, receives the signal, prints its message and finishes the job within the 45-second grace period.

Quick Quiz

1. What does docker stop send first by default?

2. Why does sleep 300 as PID 1 ignore docker stop?

3. What does exec "$@" at the end of an entrypoint script do?

4. Which option adds a small init process that forwards signals and collects zombies?

Learn Docker & DevOps with a mentor

Free lessons get you started. Our Full Stack Web Development training adds real projects and code reviews from working developers.

  • Project-based learning
  • Mentor code reviews
  • Free counselling call