skills/kubernetes-graceful-shutdown/SKILL.md
Shutting down HTTP services in Kubernetes without dropping traffic — the SIGTERM/preStop dance, the EndpointSlice removal race, the `preStop sleep` pattern, terminationGracePeriodSeconds budgeting, and the Node.js + Go server skeletons. Grounded in kubernetes.io and language runtime docs.
npx skillsauth add curiositech/windags-skills kubernetes-graceful-shutdownInstall this skill globally with one command. Works with Claude Code, Cursor, and Windsurf.
3 of 9 scanners reported clean
Some scanners were skipped, did not run, or reported a non-clean status. Review each row below.
TL;DR: A pod receiving SIGTERM is not the same moment that traffic stops arriving. The kubelet and the Endpoints/Service controller run concurrently, so kube-proxy on every node may take seconds to remove your pod from its iptables rules. The fix is a
preStophook that sleeps for ~5–15 seconds before SIGTERM, giving the data plane time to converge, plus a signal handler in your app that drains in-flight work withinterminationGracePeriodSeconds.
| Symptom | Section | |---|---| | "Rolling deploy drops requests with connection refused" | The race | | "What does preStop actually do?" | Pod lifecycle | | "How long should preStop sleep be?" | The sleep recipe | | "Node.js shutdown skeleton" | Node.js | | "Go shutdown skeleton" | Go | | "Pod stuck Terminating forever" | Anti-patterns |
flowchart TD
A[Pod about to be deleted<br/>rolling deploy, scale-in, eviction] --> B[API server sets deletionTimestamp]
B --> C[/Concurrent: kubelet + EndpointSlice controller/]
C --> D[Kubelet runs preStop hook<br/>blocking, before SIGTERM]
C --> E[EndpointSlice marks endpoint terminating]
D --> F{preStop done?}
E --> G[kube-proxy on each node updates iptables<br/>+ external LB deregisters target]
F -->|Yes| H[Kubelet sends SIGTERM to PID 1]
H --> I{App drains in-flight requests<br/>within terminationGracePeriodSeconds?}
I -->|Yes, exit 0| J[Pod removed cleanly]
I -->|No, deadline exceeded| K[Kubelet sends SIGKILL]
K --> L[In-flight requests die mid-response]
The hazard is steps E + G racing against D + H. If your app stops accepting connections before G completes, kube-proxy still routes traffic to your pod's IP — connection refused.
From the Kubernetes EndpointSlices docs:
The
terminatingcondition indicates that the endpoint is terminating. For endpoints backed by a Pod, this condition is set when the Pod is first deleted (that is, when it receives a deletion timestamp, but most likely before the Pod's containers exit).
And:
With kube-proxy running on each Node and watching EndpointSlices, every change to an EndpointSlice becomes relatively expensive since it will be transmitted to every Node in the cluster.
Translation: when the API server stamps your pod with deletionTimestamp, the EndpointSlice controller marks the endpoint terminating immediately — but the actual update to iptables on every node, plus deregistration from any external load balancer (AWS NLB, GCP LB), takes time. Tens of milliseconds in a small cluster, several seconds in a large one or with a slow LB.
In that gap, traffic still arrives at your pod's IP. If your app has already started shutting down its listener, those new connections fail.
From the Container Lifecycle Hooks docs:
PreStop hooks are not executed asynchronously from the signal to stop the Container; the hook must complete its execution before the TERM signal can be sent.
If
terminationGracePeriodSecondsis 60, and the hook takes 55 seconds to complete, and the Container takes 10 seconds to stop normally after receiving the signal, then the Container will be killed before it can stop normally, sinceterminationGracePeriodSecondsis less than the total time (55+10).
Ordered sequence (verified across the lifecycle and EndpointSlice docs):
| Step | Actor | What happens | Timing |
|---|---|---|---|
| 1 | API server | Sets deletionTimestamp on Pod | t=0 |
| 2 | EndpointSlice controller | Marks endpoint terminating | t≈0 (concurrent with kubelet) |
| 3 | kube-proxy on each node | Receives EndpointSlice update, rewrites iptables | t = Δ₁ (varies per cluster size) |
| 4 | External LB | Re-syncs target groups (NLB, etc.) | t = Δ₂ (often seconds) |
| 5 | Kubelet | Runs preStop (blocking, synchronous) | t=0 onward |
| 6 | Kubelet | Sends SIGTERM to PID 1 | after preStop |
| 7 | App | Drains in-flight; closes server | within remainder of grace period |
| 8 | Kubelet | If app still running at deadline, SIGKILL | at terminationGracePeriodSeconds |
terminationGracePeriodSeconds defaults to 30 seconds.
The grace-period budget is shared between preStop and the post-SIGTERM drain. A 60-second grace with a 30-second preStop sleep leaves only 30 seconds to drain in-flight work. Size the budget accordingly.
The pattern: don't start draining until traffic has stopped arriving. Sleep gives the data plane time to converge while the app keeps serving normally.
spec:
containers:
- name: api
image: my-api:latest
lifecycle:
preStop:
exec:
command: ["/bin/sh", "-c", "sleep 15"]
terminationGracePeriodSeconds: 60 # must be > preStop sleep + drain time
Sleep duration rationale (operational rules of thumb — kubernetes.io documents the race but not the exact tuning):
| Sleep | When | |---|---| | 5s | Small cluster, kube-proxy iptables convergence only | | 10–15s | Typical: cloud LB target-group deregistration (AWS NLB, GCP LB, Azure LB) | | 30s+ | Large clusters / long LB health-check intervals / Istio with slow xDS push |
Tune by measuring: enable access logs, do a rolling deploy, count requests that landed after SIGTERM. The right sleep is the smallest value that brings that count to zero.
Kubernetes 1.29+ adds a native sleep action so you don't need /bin/sh:
lifecycle:
preStop:
sleep:
seconds: 15
The exec sleep form remains universally compatible with older clusters.
A complementary trick: have your app fail readiness before it stops accepting traffic. This makes EndpointSlice mark the pod NotReady faster on its own.
// Pseudocode
let ready = true
process.on('SIGTERM', () => { ready = false; /* drain... */ })
app.get('/readyz', (req, res) => res.status(ready ? 200 : 503).end())
But: this does not replace the preStop sleep. The readiness gate helps the next propagation cycle; the sleep covers the current in-flight traffic. Use both.
From the Node.js HTTP docs: server.close() "stops the server from accepting new connections and closes all connections connected to this server which are not sending a request or waiting for a response." Since v19.0.0, it closes idle keep-alive connections automatically. closeIdleConnections() (v18.2.0) and closeAllConnections() (v18.2.0) are the explicit hammers.
import http from 'node:http'
const server = http.createServer((req, res) => { /* ... */ })
server.listen(8080)
let shuttingDown = false
function shutdown() {
if (shuttingDown) return
shuttingDown = true
// Optional: flip readiness so endpoint is removed faster
// (your /readyz handler reads `shuttingDown`)
// Stop accepting new connections; finish in-flight requests
server.close((err) => {
process.exit(err ? 1 : 0)
})
// Kick idle keep-alive sockets so close() can resolve faster
server.closeIdleConnections() // Node 18.2+
// Hard deadline shorter than terminationGracePeriodSeconds
setTimeout(() => {
server.closeAllConnections() // Node 18.2+: forcibly drop active requests
process.exit(1)
}, 25_000).unref()
}
process.on('SIGTERM', shutdown)
process.on('SIGINT', shutdown)
Pitfall: when running Node under npm start or a shell wrapper, PID 1 is the shell, not Node — and the shell may not forward SIGTERM. Either run Node directly (CMD ["node", "server.js"] in the Dockerfile, no shell) or use a process supervisor like tini (--init in docker run, or set shareProcessNamespace: true carefully).
From the net/http docs: Server.Shutdown "gracefully shuts down the server without interrupting any active connections... by first closing all open listeners, then closing all idle connections, and then waiting indefinitely for connections to return to idle and then shut down."
package main
import (
"context"
"errors"
"log"
"net/http"
"os/signal"
"syscall"
"time"
)
func main() {
srv := &http.Server{Addr: ":8080", Handler: mux()}
// Canonical signal-to-context bridge (Go 1.16+)
ctx, stop := signal.NotifyContext(context.Background(),
syscall.SIGINT, syscall.SIGTERM)
defer stop()
go func() {
if err := srv.ListenAndServe(); err != nil &&
!errors.Is(err, http.ErrServerClosed) {
log.Fatalf("listen: %v", err)
}
}()
<-ctx.Done()
log.Println("shutdown signal received")
shutdownCtx, cancel := context.WithTimeout(context.Background(), 25*time.Second)
defer cancel()
if err := srv.Shutdown(shutdownCtx); err != nil {
log.Printf("graceful shutdown failed: %v", err)
_ = srv.Close() // hard close — drop in-flight
}
}
signal.NotifyContext is the idiomatic Go pattern; the returned context cancels on SIGTERM/SIGINT. Server.Close() is the immediate (non-graceful) variant; use it as the last-resort fallback.
| Anti-pattern | Why it bites | Fix |
|---|---|---|
| No preStop hook + immediate server.close() on SIGTERM | New connections fail during EndpointSlice / LB convergence | Add preStop: sleep N with N tuned to your LB |
| terminationGracePeriodSeconds: 30 (default) with a 25-second preStop sleep | Only 5 seconds left to drain — SIGKILL aborts in-flight work | Sum your budget: preStop + drain + safety margin |
| App ignores SIGTERM (no signal handler) | Pod hangs, gets SIGKILL'd at deadline | Always register handlers; for Node, beware shell PID 1 |
| Readiness probe stays green during shutdown | New traffic keeps arriving even after SIGTERM | Flip readiness on SIGTERM (with preStop sleep, not instead of) |
| kill -9 style hard exit on first SIGTERM | In-flight requests get connection reset | Use server.close() / srv.Shutdown(ctx) then exit |
| preStop sleep 60 with default 30s grace period | preStop never finishes — SIGKILL straight through | preStop must be << terminationGracePeriodSeconds |
| Calling shutdown logic but not stopping background workers (cron, queue consumers) | Workers keep popping jobs and dying mid-task | Cancel a shutdown context everywhere, not just HTTP |
| | Novice | Expert |
|---|---|---|
| First SIGTERM handler | process.exit() | server.close() + drain timeout + kill switch |
| preStop hook | None | 5-15s sleep tuned to LB convergence |
| terminationGracePeriodSeconds | Default 30 | Set explicitly = preStop sleep + drain budget + margin |
| Readiness probe | Always 200 | Flips to 503 on SIGTERM, complementing preStop |
| Verifying shutdown | "Looks fine, no errors in deploy" | Counts post-SIGTERM requests in access logs; should be 0 |
| Worker pods | Same shutdown as HTTP | Cancel shutdown context across HTTP, queue consumers, cron, DB pool |
Timeline test: roll a fresh deploy under sustained synthetic traffic. An expert deployment shows zero 5xx in client logs. A novice deployment shows a brief blip of connection-refused / ECONNRESET errors per pod replaced.
A graceful-shutdown change ships when:
preStop hook exists with explicit sleep duration; terminationGracePeriodSeconds > preStop_sleep + drain_budget. Verified by a kubectl plugin or a CI lint of manifests.connection refused / connection reset errors at the client.docker run --rm IMAGE pgrep -P 0 or kubectl exec./readyz after triggering SIGTERM, expect 503).terminationGracePeriodSeconds expires (shorter setTimeout / context.WithTimeout).postgres-connection-pooling for idle_in_transaction_session_timeout and pool-side concerns)redis-patterns-expert)kubernetes-rollout-strategies or argo-rollouts-canary)kubernetes-pdb-and-drains)service-mesh-shutdown-orderingterminating condition is set "before the Pod's containers exit"http.Server.close() and friends — closeIdleConnections() and closeAllConnections() added in v18.2.0net/http.Server.Shutdownos/signal.NotifyContextdata-ai
license: Apache-2.0 NOT for unrelated tasks outside this domain.
development
Use when designing caching strategies (cache-aside, write-through, write-behind), implementing distributed locks, building rate limiters, leaderboards, real-time streams (XADD/consumer groups), pub/sub, or tuning eviction policies. Triggers: thundering-herd on cache miss, dogpile on key expiry, Redlock vs SET-NX-PX choice, sliding-window rate limiter, hot-key on a single cluster slot, big-key blowup, MULTI/EXEC across slots, KEYS in production. NOT for Redis Cluster operations/admin (different domain), embedded KV (SQLite, leveldb), in-process LRU caches, or Memcached.
tools
Drawing the `'use client'` boundary correctly in React Server Components apps (Next.js App Router, RSC frameworks) — leaf-pushing, slot composition, serialization rules, and environment poisoning prevention. Grounded in react.dev and Next.js 16 docs.
development
Use when designing rate limiting for an API, choosing between token bucket / sliding window / leaky bucket / fixed window, implementing it in Redis, deciding edge (Cloudflare/Upstash) vs origin enforcement, sizing per-user vs per-IP vs per-endpoint quotas, returning the right 429 response with Retry-After, or fixing the boundary-burst bug in fixed-window limiters. Triggers: 429 too many requests, INCR + EXPIRE, ZADD + ZREMRANGEBYSCORE + ZCARD, X-RateLimit-Remaining header, Cloudflare WAF rate limiting rules, Upstash @upstash/ratelimit, leaky bucket shaping vs policing, distributed rate limiter consistency. NOT for DDoS mitigation specifically (different scale), CAPTCHA / bot management, full WAF design, or per-user quota billing.