01Read the report
“The rollout was green” and “a few requests failed” can both be true.
Suppose support reports several report requests ending with connection resets during a rollout. The Deployment became available, error rates returned to baseline, and the team has no confirmed duplicate jobs. This is a useful starting report; it is not yet evidence that the process exited too early or that Kubernetes sent traffic to a terminating Pod.
Keep the questions separate: which requests failed, which Pod handled them, whether the process was terminating, whether that request had already been accepted, and what the client did next. A retry may succeed while the first attempt is still completing, which makes idempotency part of the investigation.
Compare a first diagnostic passReveal after making a prediction
- Competing explanations
- The process exited while work was active; new traffic reached a Pod during route convergence; a handler or dependency timed out; or the client abandoned and retried the request.
- Useful evidence
- Request IDs and durations, response/reset type, Pod UID, termination start and exit time, application signal logs, EndpointSlice conditions, and client retry records.
- What the lesson establishes
- The sample configuration defines a lifecycle to investigate. It does not claim a real cluster had these timings or that every client follows the same retry policy.
02Trace termination
Withdrawal and process shutdown overlap in time.
When a Pod is deleted, Kubernetes starts its termination grace countdown and begins
updating matching EndpointSlices while running any preStop hook. Terminating
endpoints can remain present with terminating: true and ready: false; the address need not disappear immediately. Consumers that
support draining can also inspect serving. For this single-container example,
the hook runs before the stop signal; after it completes, the container runtime sends the
stop signal (commonly SIGTERM). At grace expiry, remaining processes can be
forcefully terminated.
EndpointSlice updates and application shutdown are concurrent parts of the transition. Endpoint changes need to propagate through consumers and proxies; they do not form a guarantee that all traffic has stopped before the process receives its signal. This is why the handler should stop accepting new work promptly and remain robust to requests that arrive during convergence.
“Drain” has multiple meanings. Stop accepting new requests on a listener; let accepted requests finish; close or wait for keep-alive connections; separately handle upgraded or long-lived sessions. A web server’s graceful close may cover ordinary requests while WebSockets, streams, or external load balancers need their own protocol.
03Define the contract
Choose what happens to new work and active work.
| Request state | Shutdown behavior | Evidence to collect |
|---|---|---|
| Not accepted yet | Mark the instance unready and stop admitting work when shutdown begins. During routing convergence, return a deliberate retryable response or close safely. | Request arrival time, Pod UID, readiness and endpoint condition, response status. |
| Accepted and in flight | Allow bounded work to finish. At the deadline, cancel or close according to the application contract. | Request duration, completion/cancellation reason, drain deadline, client outcome. |
| Long-lived or upgraded | Define a separate reconnect, close-frame, or migration policy; ordinary HTTP server close behavior may not cover it. | Connection type, session age, close code, reconnect and duplicate-action behavior. |
Readiness indicates whether an instance should receive new work under the service’s routing contract. It is not a command that instantly empties every connection. A shutdown flag should be idempotent: Kubernetes lifecycle hooks can be retried, and repeated drain requests must not reopen service or extend the deadline without limit.
04Set the budget
Give the application room to finish, with a hard stop.
The example allows five seconds for the hook and up to twenty-five seconds for application shutdown inside a forty-second Pod grace period. Ten seconds remain for scheduling, signal delivery, and other termination overhead. Those are teaching values, not universal defaults: derive them from measured request durations, client deadlines, and the reliability cost of waiting.
If requests can validly take longer than the drain window, either revise the request contract or accept that some work will be cut off. Infinite waiting can strand capacity and block rollout progress. A deadline is a deliberate boundary, and its expiration should be observable.
apiVersion: apps/v1
kind: Deployment
metadata:
name: reports-api
spec:
replicas: 3
selector:
matchLabels:
app: reports-api
template:
metadata:
labels:
app: reports-api
spec:
# 5s preStop allowance + up to 25s active-request drain + 10s margin.
terminationGracePeriodSeconds: 40
containers:
- name: api
image: reports-api:reviewed-release
ports:
- name: http
containerPort: 8080
lifecycle:
preStop:
httpGet:
path: /drain
port: http
readinessProbe:
httpGet:
path: /readyz
port: http
periodSeconds: 3
timeoutSeconds: 1
failureThreshold: 1
05Implement the drain
Make shutdown explicit in the server and bounded by a deadline.
Both examples set a drain state on the hook and on signals, including when the hook did
not run. Repeated hook requests share the first wait. They then stop accepting new
connections and wait for ordinary in-flight work. The TypeScript version forces remaining
connections closed after its deadline and aborts its cancellable simulated work. Accepted
HTTP/1.1 responses include Connection: close during drain. Closing a socket
alone does not cancel a database write or external side effect; those require their own
cancellation and idempotency contract. The Go version passes a deadline context to http.Server.Shutdown and calls Close if graceful shutdown expires. The fixture uses WORK_MS, HOOK_MS, DRAIN_MS, and PORT for
local drills. Keep the defaults for the illustrated Deployment. Neither example automatically
solves WebSocket or external load-balancer draining.
import { createServer } from 'node:http';
import { setTimeout as delay } from 'node:timers/promises';
// Shorter values are useful for local drills; defaults match the Deployment.
function milliseconds(name: string, fallback: number) {
const value = Number(process.env[name] ?? fallback);
if (!Number.isSafeInteger(value) || value < 1 || value > 120_000)
throw new Error(`Invalid ${name}`);
return value;
}
const drainMs = milliseconds('DRAIN_MS', 25_000);
const hookMs = milliseconds('HOOK_MS', 5_000);
const workMs = milliseconds('WORK_MS', 3_000);
const port = Number(process.env.PORT ?? 8080);
let draining = false;
let hookWait: Promise<void> | undefined;
let stopping = false;
const activeWork = new Set<AbortController>();
const log = (event: string) => console.log(new Date().toISOString(), event);
const server = createServer(async (request, response) => {
const path = new URL(request.url ?? '/', 'http://local').pathname;
if (path === '/readyz') {
response.writeHead(draining ? 503 : 200).end();
return;
}
if (path === '/drain') {
draining = true;
log('drain requested');
// Repeat hooks share the first wait; they never restart the allowance.
// A hook timer must not keep the process alive after forced socket closure.
hookWait ??= delay(hookMs, undefined, { ref: false });
await hookWait;
if (!response.destroyed) response.writeHead(200).end('draining');
return;
}
if (path !== '/work') {
response.writeHead(404).end();
return;
}
if (draining) {
response.writeHead(503, { connection: 'close' }).end('instance is draining');
return;
}
const controller = new AbortController();
activeWork.add(controller);
response.on('close', () => controller.abort());
log('work accepted');
try {
await delay(workMs, undefined, { signal: controller.signal });
if (!response.destroyed)
response.writeHead(200, draining ? { connection: 'close' } : {}).end('request complete');
log('work complete');
} catch (error) {
if (!(error instanceof Error) || error.name !== 'AbortError') throw error;
log('work cancelled');
} finally {
activeWork.delete(controller);
}
});
server.listen(port, () => log(`listening ${port}`));
function shutdown() {
if (stopping) return;
stopping = true;
draining = true; // Signals must work even when the preStop hook did not run.
log('signal received; stop admission');
const forceStop = setTimeout(() => {
log('drain deadline expired');
for (const controller of activeWork) controller.abort();
server.closeAllConnections();
}, drainMs);
server.close(() => {
clearTimeout(forceStop);
log('server closed');
process.exitCode = 0;
});
}
process.on('SIGTERM', shutdown);
process.on('SIGINT', shutdown);
package main
import (
"context"
"log"
"net/http"
"os"
"os/signal"
"strconv"
"sync"
"sync/atomic"
"syscall"
"time"
)
func main() {
log.SetFlags(log.LstdFlags | log.Lmicroseconds)
var draining atomic.Bool
var hookOnce sync.Once
hookDone := make(chan struct{})
readMS := func(name string, fallback int) time.Duration {
value := fallback
if raw := os.Getenv(name); raw != "" {
parsed, err := strconv.Atoi(raw)
if err != nil || parsed < 1 || parsed > 120000 {
log.Fatalf("invalid %s", name)
}
value = parsed
}
return time.Duration(value) * time.Millisecond
}
hookDuration := readMS("HOOK_MS", 5000)
drainDuration := readMS("DRAIN_MS", 25000)
workDuration := readMS("WORK_MS", 3000)
port := os.Getenv("PORT")
if port == "" {
port = "8080"
}
mux := http.NewServeMux()
mux.HandleFunc("GET /readyz", func(w http.ResponseWriter, r *http.Request) {
if draining.Load() {
http.Error(w, "draining", http.StatusServiceUnavailable)
return
}
w.WriteHeader(http.StatusOK)
})
mux.HandleFunc("GET /drain", func(w http.ResponseWriter, r *http.Request) {
draining.Store(true)
hookOnce.Do(func() {
log.Print("drain requested")
go func() { time.Sleep(hookDuration); close(hookDone) }()
})
<-hookDone
w.WriteHeader(http.StatusOK)
})
mux.HandleFunc("GET /work", func(w http.ResponseWriter, r *http.Request) {
if draining.Load() {
http.Error(w, "instance is draining", http.StatusServiceUnavailable)
return
}
log.Print("work accepted")
select {
case <-time.After(workDuration):
_, _ = w.Write([]byte("request complete"))
log.Print("work complete")
case <-r.Context().Done():
log.Print("work cancelled")
return
}
})
server := &http.Server{Addr: ":" + port, Handler: mux}
serveErr := make(chan error, 1)
go func() { serveErr <- server.ListenAndServe() }()
signals := make(chan os.Signal, 1)
signal.Notify(signals, syscall.SIGTERM, os.Interrupt)
select {
case <-signals:
case err := <-serveErr:
if err != nil && err != http.ErrServerClosed {
log.Fatal(err)
}
return
}
draining.Store(true)
log.Print("signal received; stop admission")
ctx, cancel := context.WithTimeout(context.Background(), drainDuration)
defer cancel()
if err := server.Shutdown(ctx); err != nil {
log.Printf("grace period expired; close remaining connections: %v", err)
_ = server.Close()
}
if err := <-serveErr; err != nil && err != http.ErrServerClosed {
log.Fatal(err)
}
log.Print("server closed")
}
06Verify the rollout
Exercise the transition and follow one request end to end.
In a disposable environment, run a request that lasts longer than a few seconds while deleting its Pod, then repeat with a request near the configured deadline. Record whether it completed, was cancelled, or was retried. Also issue fresh requests during the transition. Repeat with a persistent connection if the service uses them.
Compare application timestamps to Pod events and EndpointSlice state, then verify the client-visible result. A port-forward-only test skips much of the Service routing path; it can validate the process behavior but not the whole traffic transition. Observe failed and successful requests so a temporary error spike is not hidden by the final rollout status.
Run the process-level drillNode 22 or Go · local terminal
Run from src/lib/content/lessons/graceful-shutdown-and-connection-draining.
Start the server in one terminal using one of the commands below. In a second terminal,
run curl -v http://localhost:8080/work; once the server logs “work
accepted”, send kill -TERM PID from a third terminal using that server process
ID.
WORK_MS=3000 node --experimental-strip-types server.ts # Or compile once, then run the Go process directly: go build -o /tmp/heyrian-shutdown server.go WORK_MS=3000 /tmp/heyrian-shutdown
The accepted request should finish, followed by “server closed”. Restart with WORK_MS=30000 to exceed the default 25-second application drain: expect a deadline log and interrupted client
response, not success. Run curl http://localhost:8080/drain twice while
serving; after drain begins, new /work requests receive 503 and readiness fails.
Repeated hooks share the original five-second allowance.
These commands use process signals, not Kubernetes routing. Stop each fixture before starting another on the same port. The examples do not persist data or use a real dependency. A process-level pass does not establish Service or load-balancer convergence.
07Keep the operating rule
Graceful means predictable completion or predictable cancellation.
Before shipping a shutdown change, name the request classes the service promises to finish, the deadline for each, the client retry and idempotency behavior, and the evidence that demonstrates the result. If the measured failures persist, keep the cause open: routing propagation, application close behavior, load-balancer policy, and client retries remain distinct places to investigate.
Follow the remaining resets
A rollout still shows occasional resets after the drain handler is added. What is the most useful next move?
Operational rule to carry forward: stop admitting new work, finish accepted work only within a measured budget, and verify what users and clients actually observe across the complete request path.
References: Kubernetes container lifecycle hooks, Pod lifecycle, Node.js HTTP server close, and Go HTTP server shutdown. Accessed 2026-10-01.
08Apply it
Take the next operating decision.
Repair a Dockerfile that keeps the stop signal from reaching its server, then fix a Pod termination budget. Inspect the evidence and plan how to verify accepted work and traffic withdrawal.