Graceful shutdown + K8s probes
The story is the same on any container platform: when SIGTERM arrives, the pod has a few seconds to finish in-flight requests and disconnect cleanly. If it doesn't, the user sees a 502 and the load balancer keeps sending traffic until its health check finally notices.
@nwire/endpoint does the whole sequence for you. You declare the checks; the framework owns the order.
What happens on SIGTERM
- The readiness probe (
/readyon the operational port) starts returning503. The load balancer removes the pod from rotation. - A configurable
drainDelay(default 10s) gives the LB time to actually catch up. - The HTTP server stops accepting new connections.
- In-flight requests finish, bounded by
drainTimeout(default 30s). - Plugin and provider
shutdown(fn)callbacks run in reverse boot order (DB pools close, queue workers stop accepting new jobs). - The process exits with code 0 — or SIGKILL if
hardTimeout(default 45s) is exceeded.
Wiring it up
import { endpoint, defineCheck } from "@nwire/endpoint";
import { app } from "./app";
import { api } from "./api";
const dbCheck = defineCheck("db", async () => { await db.execute(sql`SELECT 1`); });
const redisCheck = defineCheck("redis", async () => { await redis.ping(); });
await endpoint("my-app", {
port: 3000,
shutdown: {
drainDelay: 10_000, // wait for LB to remove us
drainTimeout: 30_000, // wait for in-flight requests
hardTimeout: 45_000, // SIGKILL backstop
},
probes: {
port: 9_400, // default; override if the port is in use
},
})
.use(dbCheck)
.use(redisCheck)
.serve(app)
.serve(api)
.run();The boot banner confirms what's running:
nwire endpoint "my-app" listening
data: http://0.0.0.0:3000
probes: http://0.0.0.0:9400/live · /readyProbes are served on a separate operational HTTP server (lightship) so a thrashing data-port doesn't poison liveness signals. The probe paths /live and /ready are fixed by lightship; the port defaults to 9400 and is configured via probes: { port }.
defineCheck
A check is just an async function that throws if the dependency isn't healthy. The readiness probe runs every registered check and returns 200 only when they all pass.
const dbCheck = defineCheck("db", async () => {
const start = Date.now();
await db.execute(sql`SELECT 1`);
return { latencyMs: Date.now() - start };
});Keep them cheap — they run on every readiness poll (every few seconds). A slow check causes restart loops. Don't put expensive joins or external API pings here.
Liveness vs readiness
| Probe | Question | Failure means |
|---|---|---|
Liveness (/live on :9400) | Is the process alive? | kubelet restarts the pod |
Readiness (/ready on :9400) | Should new traffic come here? | kubelet stops routing; pod stays up |
Liveness is cheap (200 once the process is running). Readiness includes your defineCheck callbacks. Keep that distinction sharp: a flaky DB should fail readiness, not liveness — otherwise every transient outage costs you a pod restart.
Kubernetes manifests
A complete Deployment + Service for a typical web app:
apiVersion: apps/v1
kind: Deployment
metadata:
name: my-app
spec:
replicas: 3
strategy:
type: RollingUpdate
rollingUpdate:
maxSurge: 1
maxUnavailable: 0
template:
spec:
# Must exceed drainDelay + drainTimeout + buffer.
# For the defaults above (10s + 30s) → 60s gives 20s safety.
terminationGracePeriodSeconds: 60
containers:
- name: app
image: my-org/my-app:latest
ports:
- { name: http, containerPort: 3000 }
- { name: probes, containerPort: 9400 }
# Keeps the pod alive long enough for the endpoints
# controller to remove it from Service routing.
# Match this to drainDelay.
lifecycle:
preStop:
exec: { command: ["sleep", "10"] }
livenessProbe:
httpGet: { path: /live, port: probes }
periodSeconds: 10
failureThreshold: 3
readinessProbe:
httpGet: { path: /ready, port: probes }
periodSeconds: 5
failureThreshold: 2
# Don't kill on slow boot — DB connect, JWKS fetch, etc.
startupProbe:
httpGet: { path: /live, port: probes }
periodSeconds: 5
failureThreshold: 30
---
apiVersion: v1
kind: Service
metadata:
name: my-app
spec:
selector:
app: my-app
ports:
- name: http
port: 80
targetPort: httpTiming rules of thumb
| Traffic profile | drainDelay | drainTimeout | hardTimeout | terminationGracePeriodSeconds |
|---|---|---|---|---|
| Internal API, low traffic | 2s | 10s | 15s | 20s |
| Public API, moderate | 10s | 30s | 45s | 60s |
| Long uploads | 15s | 120s | 150s | 180s |
If you're regularly bumping against drainTimeout, the work probably belongs in a queue worker — see Queues.
Zero-downtime rollouts
strategy:
type: RollingUpdate
rollingUpdate:
maxSurge: 100%
maxUnavailable: 0All new pods come up before any old pod is removed. Combined with the readiness gate, traffic only shifts when the new pods are actually ready (DB connected, JWKS fetched, projection caches warm).
See also
endpoint()reference — full options- Observability — once the pod is healthy, what to look at when something's slow