Skip to content

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

  1. The readiness probe (/ready on the operational port) starts returning 503. The load balancer removes the pod from rotation.
  2. A configurable drainDelay (default 10s) gives the LB time to actually catch up.
  3. The HTTP server stops accepting new connections.
  4. In-flight requests finish, bounded by drainTimeout (default 30s).
  5. Plugin and provider shutdown(fn) callbacks run in reverse boot order (DB pools close, queue workers stop accepting new jobs).
  6. The process exits with code 0 — or SIGKILL if hardTimeout (default 45s) is exceeded.

Wiring it up

ts
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:

text
nwire endpoint "my-app" listening
   data:   http://0.0.0.0:3000
   probes: http://0.0.0.0:9400/live · /ready

Probes 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.

ts
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

ProbeQuestionFailure 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:

yaml
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: http

Timing rules of thumb

Traffic profiledrainDelaydrainTimeouthardTimeoutterminationGracePeriodSeconds
Internal API, low traffic2s10s15s20s
Public API, moderate10s30s45s60s
Long uploads15s120s150s180s

If you're regularly bumping against drainTimeout, the work probably belongs in a queue worker — see Queues.

Zero-downtime rollouts

yaml
strategy:
  type: RollingUpdate
  rollingUpdate:
    maxSurge:       100%
    maxUnavailable: 0

All 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

MIT licensed.