doing it right in containerized apps
docker stop sends a SIGTERM and waits, yet many applications never respond to it and get hard killed once the deadline passes. Understanding how signal forwarding, PID 1, and the grace period interact is the key to avoiding dropped connections and data loss during deployments.
Table of Contents
- 1. What actually happens when a container is stopped
- 2. Why PID 1 plays a special role inside containers
- 3. The shell form trap: CMD and signal forwarding
- 4. Implementing graceful shutdown in Node.js applications
- 5. Implementing graceful shutdown in PHP applications (PHP-FPM)
- 6. docker stop -t: configuring the grace period
- 7. docker stop vs. docker kill: an important difference
- 8. Detecting and avoiding SIGKILL
- 9. How it all fits together: a complete flow
- 10. Summary
- 11. FAQ
1. What actually happens when a container is stopped
When docker stop is invoked, Docker first sends a SIGTERM signal to the container's main process, that is, to PID 1 inside the container namespace. This signal is a polite request: please terminate yourself and clean up along the way. The process gets a certain window of time, the so called grace period, to respond. If it does not respond in time, Docker sends a SIGKILL, which terminates the process immediately with no chance to clean up anything.
This exact sequence is where many containerized applications fall apart: they do not react to SIGTERM at all, either because the process itself never registers a signal handler, or because the signal never reaches it in the first place. The outcome is always the same regardless of the exact cause: the container appears to sit there unaffected for the entire grace period, until SIGKILL arrives and open database transactions, in flight HTTP requests, or file writes get abruptly cut off.
2. Why PID 1 plays a special role inside containers
On a normal Linux system, PID 1 is the init process, such as systemd, which is specifically built to handle signals correctly and forward them to child processes. The Linux kernel treats PID 1 differently from every other process, though: for signals without a registered handler, no default behavior is applied. An ordinary process without its own handler would be terminated by SIGTERM automatically, but PID 1 silently ignores the signal in that case unless the application explicitly registers its own handler for it.
Inside a container, the main process is practically always PID 1, because Docker does not insert its own init process unless you explicitly configure it to. So if you start node server.js or php-fpm directly as CMD in the Dockerfile, that process runs as PID 1 and has to handle signals itself. Many frameworks and runtimes, however, were never designed to run as an init process, and therefore ship with no SIGTERM handler out of the box.
# Check PID 1 inside the container
docker exec mycontainer ps -o pid,comm -p 1
# Show a running process's signal handlers (SigCgt field, hex bitmask)
docker exec mycontainer cat /proc/1/status | grep Sig
3. The shell form trap: CMD and signal forwarding
One especially common cause of an ignored SIGTERM lies in how CMD and ENTRYPOINT are written in the Dockerfile. If the command is written in shell form, for example CMD node server.js, Docker internally runs it as /bin/sh -c "node server.js". That makes the shell PID 1 instead of the application, and most shells like sh or bash do not forward received signals to their child processes by default.
The fix is to use the exec form of CMD, written as a JSON array: CMD ["node", "server.js"]. This starts the process directly, with no enclosing shell, and it receives SIGTERM immediately as PID 1. This difference looks cosmetic at first glance, but in practice it decides whether a graceful shutdown ever gets a chance to trigger at all.
# WRONG: shell form, the shell becomes PID 1, does not forward SIGTERM
CMD node server.js
# CORRECT: exec form, the application itself becomes PID 1
CMD ["node", "server.js"]
# The same applies to ENTRYPOINT
ENTRYPOINT ["php-fpm", "--nodaemonize"]
4. Implementing graceful shutdown in Node.js applications
In Node.js, a SIGTERM handler can be registered with process.on. It is important that the server does not shut down abruptly but instead stops accepting new connections while letting in flight requests finish. The HTTP server object provides a close method for exactly this behavior: it closes the listening socket immediately but waits for all already open connections to finish before invoking its callback.
In addition, database connection pools, message queue connections, and open file handles should be closed explicitly instead of relying on implicit cleanup at process exit. A safety net timeout is also worth having, so a single hanging request cannot block the entire shutdown indefinitely and delay the process all the way to SIGKILL.
# server.js (Node.js graceful shutdown)
const http = require('http');
const server = http.createServer(app);
server.listen(3000);
let shuttingDown = false;
function shutdown(signal) {
if (shuttingDown) return;
shuttingDown = true;
console.log(`Received ${signal}, shutting down gracefully...`);
server.close(async () => {
console.log('No more open HTTP connections.');
await dbPool.end(); // close database connections
await messageQueue.close(); // close queue connection
process.exit(0);
});
// Safety net: force exit after 10s if close() hangs
setTimeout(() => {
console.error('Shutdown timeout reached, forcing exit.');
process.exit(1);
}, 10000).unref();
}
process.on('SIGTERM', () => shutdown('SIGTERM'));
process.on('SIGINT', () => shutdown('SIGINT'));
5. Implementing graceful shutdown in PHP applications (PHP-FPM)
PHP-FPM behaves differently from a classic Node.js process, because it uses a short lived worker process per request instead of running a permanent event loop. The PHP-FPM master process already handles SIGTERM sensibly by default: it stops accepting new requests but waits for running workers to finish their current request before terminating itself. It is important to run PHP-FPM in the foreground as PID 1, with the --nodaemonize option, instead of using the classic daemon mode.
For long running PHP processes outside of FPM, for example a Swoole or ReactPHP based worker, the developer has to handle signals manually, similar to Node.js. The pcntl extension provides pcntl_signal for registering a callback for SIGTERM. It is crucial to enable pcntl_async_signals(true), so signals are caught asynchronously between processing steps instead of only at explicit pcntl_signal_dispatch calls.
# Dockerfile: PHP-FPM in the foreground as PID 1
FROM php:8.4-fpm-alpine
COPY php-fpm.conf /usr/local/etc/php-fpm.d/zz-custom.conf
CMD ["php-fpm", "--nodaemonize"]
6. docker stop -t: configuring the grace period
By default, docker stop waits ten seconds between SIGTERM and the final SIGKILL. This deadline can be adjusted via the -t (or --time) option, either for a single invocation or permanently through stop_grace_period in the Compose file. For applications that typically handle only short lived requests, ten seconds is usually enough. Applications with long running operations, such as file uploads, report generation, or WebSocket connections, may need considerably more time.
A grace period that is too short results in graceful shutdown logic that is correctly implemented but simply does not finish in time before SIGKILL strikes. A grace period that is too long, on the other hand, needlessly slows down deployments and rolling updates, because every container replacement then takes as long as the configured wait time, even when the application has long since finished shutting down. The right balance comes from the realistic maximum duration of the longest running operation plus a safety margin.
# docker-compose.yml
services:
api:
image: mironsoft/api:latest
stop_grace_period: 30s # SIGTERM, then wait up to 30s before SIGKILL
stop_signal: SIGTERM # default, but documented explicitly
# Equivalent via CLI:
# docker stop -t 30 api-container
7. docker stop vs. docker kill: an important difference
Besides docker stop, there is a second command, docker kill, that looks similar at first glance but behaves fundamentally differently. By default, docker kill sends a SIGKILL immediately, with no preceding SIGTERM and no grace period whatsoever. The container terminates instantly, regardless of whether requests are currently being handled or database transactions are still open. For production operation, that is almost always the wrong choice.
docker kill is still useful, but for a different purpose: as a last resort when a container simply refuses to respond despite a sent SIGTERM, for example because it is stuck in a deadlock, or for testing purposes where a hard failure similar to a power outage is deliberately being simulated. docker kill also supports sending a signal other than SIGKILL via the -s option, for example docker kill -s SIGTERM, but that runs immediately with no grace period, unlike docker stop, which actively waits for the process to end after sending the signal.
8. Detecting and avoiding SIGKILL
A container that is regularly terminated via SIGKILL instead of shutting down cleanly shows up in the docker events log and in the container's exit code, frequently as exit code 137, which corresponds to 128 plus signal 9. This value is a reliable diagnostic signal that the graceful shutdown path is not working, either because no handler is registered, because the shell form is blocking the signal, or because the grace period is set too short.
For systematic troubleshooting, it helps to test the shutdown process locally by running docker stop with a very short grace period against the container's live logs and observing whether the custom shutdown logic even gets invoked. If the expected log lines like received SIGTERM never show up, the problem usually lies with PID 1 signal handling or the shell form in CMD, not with the actual application logic.
9. How it all fits together: a complete flow
A clean shutdown sequence looks like this: the orchestrator or administrator calls docker stop, Docker sends SIGTERM to PID 1 inside the container. The application, correctly started as an exec form CMD, receives the signal through its registered handler, stops accepting new connections, lets existing requests finish, cleanly closes database pools and queue connections, and finally terminates itself with exit code 0, all within the configured grace period.
In load balancer backed setups, an additional layer comes into play: the load balancer should ideally take the container out of rotation even before SIGTERM is sent, for example through a readiness check that fails shortly before the stop, so new requests never even reach the dying container. This combination of application level signal handling, correct PID 1 configuration, and an appropriate grace period is the foundation of genuine zero downtime deployments.
| Cause | Symptom | Fix | Affected component |
|---|---|---|---|
| CMD in shell form | SIGTERM never reaches the app, exit code 137 | Write CMD as a JSON array (exec form) | Dockerfile |
| No signal handler registered | Container hangs until the grace period expires | Add process.on('SIGTERM') or pcntl_signal | Application code |
| Grace period too short | Shutdown logic starts but gets cut off | Increase stop_grace_period to match the longest operation | docker-compose.yml |
| Load balancer unaware of shutdown | Requests land on a dying container | Fail the readiness check before SIGTERM | Orchestration/LB |
Mironsoft
Container infrastructure, CI pipelines and deployment automation
Docker setups that hold up across the team and in production?
We review existing Dockerfiles and Compose stacks for security gaps, bloated images and fragile build pipelines, then build a container infrastructure that builds fast, runs securely and stays understandable across the team.
Dockerfile Review
Systematically optimizing multi-stage builds, layer caching and image size.
Security Audit
Hardening container isolation, secrets handling and image scanning against real attack surfaces.
CI/CD Integration
Building build pipelines, registries and deployment strategies for reproducible releases.
10. Summary
Graceful Shutdown in Containers: Key Takeaways
PID 1 problem
Without a registered handler, PID 1 ignores SIGTERM, unlike ordinary processes.
Exec instead of shell form
Writing CMD as a JSON array prevents a shell from swallowing the signal.
Explicit handling
Node.js and PHP need explicit code to close connections cleanly.
Tune the grace period
Match stop_grace_period or docker stop -t to the longest real operation.