SIGTERM during deploys: giving in-flight requests time to finish Intermediate
Estimated reading time: 8 min read
The problem
Section titled “The problem”A deploy replaces an old API Pod with a new one. The old Pod is handling a create-order request: it holds a database connection and is waiting on the payment gateway. If the process is killed immediately:
- The client sees the connection drop, even though the payment succeeded downstream.
- A client retry without an idempotency key → risk of double payment.
Conversely, if the old Pod keeps accepting requests and waits indefinitely, the rollout drags on and the Pod is force-killed anyway.
Graceful shutdown means: stop accepting new work → let in-flight work finish within a deadline → close resources → exit.
It does not guarantee every request completes (node death, crashes, deadline exceeded), so business-level idempotency is still needed. But for normal deploys and scale-downs, it sharply reduces the number of requests cut off midway.
Core concepts
Section titled “Core concepts”When a Pod is deleted, two things happen in parallel, not synchronized:
| Flow | What happens | Notes |
|---|---|---|
| Traffic | The Pod’s endpoint in the EndpointSlice turns terminating, ready = false → kube-proxy/ingress stop sending new traffic |
Takes a few seconds to propagate; clients may still hold old connections |
| Process | Run preStop (if any) → send SIGTERM → when the grace period ends, SIGKILL |
Default terminationGracePeriodSeconds = 30 |
The most important consequence: SIGTERM arrives before traffic has fully stopped. If the app closes its listener as soon as it receives SIGTERM, requests still being routed to the Pod get connection refused.
The Pod starts terminating; the grace period begins and the endpoint is marked not ready.
Time budget: everything comes out of one grace period
Section titled “Time budget: everything comes out of one grace period”preStop is not added on top; it eats into the same grace period as the drain. Example with a 30-second budget:
0s 5s 25s 30s├───────────┼────────────────────────────┼──────────┤│ preStop │ SIGTERM → drain requests │ cleanup │ SIGKILL│ sleep 5s │ server.close() waits │ close ││ │ │ pool │└───────────┴────────────────────────────┴──────────┘ old traffic stop accepting new, wait flush logs, still arrives for in-flight to hit 0 close DBRule: terminationGracePeriodSeconds ≥ preStop + max drain time + cleanup margin. Measure the real drain time (p99 of the longest request); don’t pick an arbitrary number.
Configuring it correctly
Section titled “Configuring it correctly”1. Kubernetes: wait for traffic changes to propagate before sending SIGTERM
spec: terminationGracePeriodSeconds: 30 containers: - name: api lifecycle: preStop: exec: command: ["sleep", "5"] # wait for the endpoint to be removed from the load balancerAn exec sleep hook works when the image has a sleep binary.
Kubernetes also has a native sleep action (preStop: { sleep: { seconds: 5 } }, where the kubelet waits itself, no binary needed in the image): alpha in v1.29, beta and on by default since v1.30, stable since v1.34 (feature gate PodLifecycleSleepAction); check the version of the cluster you use.
2. Container: make sure Node receives SIGTERM
# Right: exec form, node is PID 1 and receives SIGTERM directlyCMD ["node", "dist/main.js"]
# Wrong: shell form or npm start, the signal may never reach node# CMD npm startThis is the most common cause of “graceful shutdown doesn’t run”: a wrapper process (sh -c, npm) does not forward SIGTERM, so the app never learns it is about to stop and gets SIGKILL after 30 seconds.
If you need an init process to reap zombies and forward signals, use tini or docker run --init.
3. Node application: drain with a hard stop
process.once('SIGTERM', () => { ready = false; // readiness returns 503 const hardStop = setTimeout(() => process.exit(1), 20_000); hardStop.unref(); // hard stop, does not keep the process alive
server.close(async () => { // wait for in-flight requests await dbPool.end(); process.exitCode = 0; });});preStop already handles waiting for propagation, so the listener can be closed right away here. Without preStop, wait a few seconds before server.close().
The code is still a sketch: it lacks closing consumers/workers, error handling in the callback, and WebSocket handling (upgraded sockets must be tracked and closed by you). With NestJS, use enableShutdownHooks() and lifecycle hooks, then verify the shutdown order.
Draining in Node: two calls to tell apart
Section titled “Draining in Node: two calls to tell apart”| Call | What it does | When to use it |
|---|---|---|
server.close() |
Stops accepting new connections and waits for running requests to finish. Since Node 19 it also closes idle (keep-alive) connections | The default on SIGTERM |
server.closeAllConnections() |
Forcibly closes connections, may cut running requests | Only at the final deadline, after close() |
server.close() only closes the HTTP server. Database pools, broker consumers, workers, crons and long-lived sockets must be stopped separately, in order: stop taking new jobs → finish or release jobs in hand → flush logs/telemetry → close connections.
With queues, acking before a durable write can lose jobs and acking after can cause redelivery, so handlers must still be idempotent.
Real-world example
Section titled “Real-world example”Run the state simulation (no real Pods or signals):
npx --prefix examples ts-node examples/src/graceful-shutdown/shutdown-demo.tsExpected output: old request started → shutdown reports it stopped accepting → new request rejected → old request completed → drained. Lower the deadline to 5 ms to see deadline reached before the old request finishes. In the demo, the deadline only reports state and does not kill the task; real Kubernetes sends SIGKILL.
Trade-offs
Section titled “Trade-offs”Benefits
- Fewer requests cut off midway during normal rollouts or scale-downs.
- A clear moment to stop accepting work, drain and close resources.
- Drain time can be measured, so the grace period is chosen from data.
Limits
- A long grace period slows rollouts and holds resources longer; too short and work gets cut.
- No protection against crashes, node loss or forced kills.
- HTTP, queues, WebSockets and databases each have their own lifecycle; one SIGTERM handler doesn’t cover them all.
Production checklist
Section titled “Production checklist”- The Dockerfile uses exec form (
CMD ["node", ...]), notnpm startor shell form. preStopwaits a few seconds for the endpoint to be removed;terminationGracePeriodSecondsexceedspreStop+ drain + cleanup combined.- The SIGTERM handler runs only once (
process.once), has a hard time limit and does not callprocess.exit()immediately. - Readiness switches to failing when shutdown begins (health probes).
- Close in order: HTTP → consumers/workers → pools → flush logs/telemetry.
- Outbound calls have timeouts; important writes have idempotency keys (webhooks & idempotency).
- Test rollouts with slow requests and keep-alive connections on staging, and track
drain timeandrequests cutas metrics.
Common mistakes
Section titled “Common mistakes”Summary
Section titled “Summary”Self-check: (1) Does preStop add time beyond the grace period? (2) Has server.close() closed the database pool? (3) Why can a request still arrive after the Pod has started terminating?