Kubernetes SIGTERM: The Rolling Update That Cut In-Flight Requests
The Deployment rolled. New pods became Ready. Old pods went Terminating. Grafana showed a spike of 502s that lasted the length of the rollout, then vanished. The process logged Application is shutting down and dropped the request that was still on the wire. A rolling update sends SIGTERM to a pod the Service may still be using. The default grace period is a timer, not a drain.
The liveness post is which probe kills you while you are running. This is the order Kubernetes uses when it stops you, and the three numbers that have to add up or SIGKILL finishes the request.
What the kubelet does, in order
You kubectl rollout a Deployment, a ReplicaSet scales in, or a node drains. The API server sets deletionTimestamp. From that instant the pod is Terminating. Two things then happen at the same time:
- The EndpointSlice controller drops the pod from the Service. kube-proxy, the cloud load balancer, and every ingress data plane have to notice. That is not a barrier the kubelet waits on.
- The kubelet starts the termination sequence on the node.
The sequence on the node is:
preStop hook (if you defined one)
→ SIGTERM to PID 1
→ wait until the process exits, or until the grace period runs out
→ SIGKILL the container's cgroup
The grace period is terminationGracePeriodSeconds on the Pod spec. It defaults to 30. The countdown starts when the kubelet begins termination, before preStop runs. preStop is not extra time. It is the first slice of the same 30 seconds.
# The sleep is not in addition to 30. It is 15 of the 30.
terminationGracePeriodSeconds: 30
lifecycle:
preStop:
exec:
command: ["sleep", "15"]
sleep 30 with a 30-second grace period means: when sleep returns, the timer is already at zero and the next signal is SIGKILL. The process never sees SIGTERM.
If the image is distroless, sleep is not there. preStop fails immediately, SIGTERM follows, and you are in the original race with a failed hook in the event log. Distroless needs a binary you shipped, or an httpGet preStop against a path the process already serves.
The race the sleep is for
EndpointSlice removal is a controller reconcile. kube-proxy programs iptables or nftables after it watches that change. An ingress controller, a service mesh sidecar, and a cloud NLB each have their own cache. SIGTERM does not wait for any of them.
So this happens, in production, on a cluster that is otherwise healthy:
t=0 pod marked Terminating
t=0 kubelet starts preStop / SIGTERM
t=0..3 a client still has this pod in its endpoint set
POST /orders is in flight, or a keep-alive connection
sends the next GET on the same socket
t=0.2 process received SIGTERM, closed the listener
the in-flight POST gets ECONNRESET
preStop: sleep 15 exists to hold the process up and still serving while those data planes drop the pod. It is a delay, not a health check. Fifteen seconds is a common first value because EndpointSlice plus kube-proxy plus one ingress usually converge inside it. It is not a promise. A slow ingress, or an HTTP/1.1 keep-alive from a client that never saw the slice update, still lands on a Terminating pod.
The sleep only helps if the process keeps answering HTTP until SIGTERM. Do not start draining in preStop and in the SIGTERM handler unless you know which one owns the drain. There is no Kubernetes API the kubelet will block on that means "every kube-proxy has programmed this deletion."
SIGTERM has to reach the process that is listening
PID 1 receives the signal. If PID 1 is a shell, the Node or .NET process is a child.
# Shell form: /bin/sh is PID 1. SIGTERM may never reach node.
CMD npm start
# Exec form: node is PID 1. SIGTERM lands on the process you wrote.
CMD ["node", "server.js"]
command: ["sh", "-c", "node server.js"] in the Pod spec is the same trap. exec node server.js inside that shell replaces the shell. Without exec, you are waiting for the grace period to expire and then SIGKILL'ing a Node that never ran its handler.
A process that does receive SIGTERM still has to drain. Node's default with no listener is to exit. An HTTP server does not install a drain for you:
const server = app.listen(8080);
function drain(signal) {
server.close((err) => {
process.exit(err ? 1 : 0);
});
if (typeof server.closeIdleConnections === "function") {
server.closeIdleConnections();
}
}
process.on("SIGTERM", () => drain("SIGTERM"));
process.on("SIGINT", () => drain("SIGINT"));
server.close() refuses new connections and waits for existing ones. Keep-alive sockets count as existing. Without closeIdleConnections() (Node 18.2+) a client that holds the socket open holds the process open until SIGKILL.
ASP.NET Core's generic host already listens for SIGTERM and calls StopAsync. The catch is HostOptions.ShutdownTimeout, which defaults to five seconds, independent of the Pod's 30. Kestrel aborts what is still running at five seconds and the process exits "cleanly" while Kubernetes still had 25 seconds on the clock.
builder.Services.Configure<HostOptions>(options =>
{
options.ShutdownTimeout = TimeSpan.FromSeconds(20);
});
That 20 has to fit in terminationGracePeriodSeconds minus preStop. If preStop sleeps 15 and the grace period is 30, the process has about 15 seconds of SIGTERM. A 20-second ShutdownTimeout is cut by SIGKILL. Set the host timeout below the remaining grace, not equal to the Pod spec.
Liveness during drain is a SIGKILL
The kubelet does not stop liveness probes because the pod is Terminating. If your SIGTERM handler closes the HTTP server first, the next /live fails. Enough failures and the kubelet kills the container. You do not get the rest of the grace period. A "graceful" shutdown lasts two seconds.
Keep /live up until you are actually exiting. Fail /ready as soon as you do not want new traffic — Terminating already told the EndpointSlice controller to drop you. The handler that SELECT 1s the database must not be on the liveness path here either: a drain plus a slow query is a failed probe plus SIGKILL.
livenessProbe:
httpGet:
path: /live
port: 8080
periodSeconds: 10
failureThreshold: 3
readinessProbe:
httpGet:
path: /ready
port: 8080
periodSeconds: 5
failureThreshold: 1
terminationGracePeriodSeconds: 45
lifecycle:
preStop:
exec:
command: ["sleep", "15"]
/live stays 200 while Kestrel or server.close() finishes in-flight work. /ready can go 503 the moment SIGTERM arrives. Do not lower liveness to failureThreshold: 1 and periodSeconds: 2 on a service you also drain; that turns one slow close into an immediate kill.
The three numbers have to fit
| Budget | What it pays for | Typical mistake |
|---|---|---|
preStop duration | Dataplane: EndpointSlice, kube-proxy, ingress | sleep longer than the grace period |
Process drain (ShutdownTimeout, server.close) | In-flight requests after SIGTERM | Default 5s in ASP.NET, or no handler in Node |
terminationGracePeriodSeconds | Both of the above, in one countdown | Left at 30 while preStop is 30 |
terminationGracePeriodSeconds ≥ preStop + drain + a few seconds of slack
A 15-second preStop and a 20-second drain need a grace period of about 45, not 30. The Deployment's progressDeadlineSeconds and minReadySeconds do not extend this. maxUnavailable: 0 only waits until the new pod is Ready before it starts this sequence on the old one. It does not make SIGTERM wait for kube-proxy. A hung preStop.httpGet spends the whole budget before SIGTERM; keep the hook short.
What this is not
A liveness-versus-readiness mixup on a steady pod. That is the probes post. Here the pod is already dying. The probe mistake that matters is failing liveness because you started to die.
A slot swap. Azure App Service slots move routing at the front door. Kubernetes deletes endpoints and hopes every data plane noticed.
A cancelled GitHub Actions job. A cancelled runner is also SIGTERM, and concurrency is how you stop murdering a deploy job. It does not configure the cluster.
If the rollout is green and the 502s track old replicas down to zero, look at PID 1, the grace-period arithmetic, and whether /live still answers after SIGTERM. The Deployment replaced the pods. It did not drain the sockets you still had open.
Keep reading
GitHub Actions GITHUB_TOKEN: The Push That Never Started the Next Workflow
A git push with GITHUB_TOKEN does not start other workflows. That is the anti-recursion rule. Use a GitHub App token, or fire workflow_dispatch yourself.
GitHub Actions Concurrency: The Group That Cancelled the Production Deploy
A shared concurrency group plus cancel-in-progress will cancel a production deploy when CI starts. Split the groups. Pending runs still get dropped.
GitHub Actions OIDC: Azure Login Without a Client Secret
Federated credentials replace AZURE_CLIENT_SECRET. Match issuer, audience, and the subject GitHub actually emits — environment jobs do not use the branch claim.
Kubernetes Liveness Probes That Restart a Process That Was Fine
Liveness restarts the container. Readiness pulls it out of the Service. A startup probe holds both off until the process has booted. Most outages come from using the wrong one.
GitHub Rewrote the Copilot Runtime in Rust With Agents. The Playbook Is the Story.
832,378 lines of production Rust, 128 pull requests, 135 releases in fourteen and a half weeks, about $120,000 in tokens. What GitHub's TypeScript-to-Rust port of the Copilot agent runtime teaches about shipping a rewrite without a cutover.
The Pipeline Was Green Because the Test Stage Never Ran
How an Azure DevOps condition and a skipped stage produce a green build that did not test anything, and the check to add so that cannot ship.
Newsletter
New posts, straight to your inbox
One email when a new post goes up. Double opt-in, unsubscribe anytime. Not a daily digest.
Comments
- No comments yet. Be the first.