Graceful Shutdown for Node: Drain Correctly on Kubernetes and ECS

Graceful Shutdown for Node: Drain Correctly on Kubernetes and ECS

A rolling deploy that SIGKILLs in-flight POSTs is just a distributed partial-failure generator. Kubernetes preStop + terminationGracePeriodSeconds and ECS stop timeouts only help if your Node process actually stops accepting, drains HTTP, nacks queue ownership cleanly, and exits 0 before the grace window. The unfair advantage is a single shutdown orchestrator every entrypoint shares — not ad-hoc process.on('SIGTERM') snippets per service.

⚡ TL;DR: On SIGTERM: fail readiness immediately, stop queue consumers, server.close(), await in-flight with a deadline, flush telemetry, exit. Align K8s preStop sleep with ALB deregistration and ECS stopTimeout. Pair with Node Watch Mode in Staging and Lambda Alias Traffic Shifting for complementary deploy safety.

One orchestrator, hard deadlines

// src/shutdown.ts
import type { Server } from "node:http";

type Closer = () => Promise<void>;
const closers: Closer[] = [];
let shuttingDown = false;

export function onShutdown(fn: Closer) { closers.push(fn); }
export function isShuttingDown() { return shuttingDown; }

export function installShutdown(server: Server, budgetMs = 25_000) {
  const run = async (signal: string) => {
    if (shuttingDown) return;
    shuttingDown = true;
    console.log(JSON.stringify({ msg: "shutdown_start", signal }));
    readiness.set(false); // ✅ ALB/K8s stop new traffic
    const timer = setTimeout(() => {
      console.error(JSON.stringify({ msg: "shutdown_timeout" }));
      process.exit(1);
    }, budgetMs);
    try {
      await new Promise<void>((res) => server.close(() => res()));
      for (const c of closers) await c();
      clearTimeout(timer);
      process.exit(0);
    } catch (err) {
      console.error(err);
      process.exit(1);
    }
  };
  process.on("SIGTERM", () => void run("SIGTERM"));
  process.on("SIGINT", () => void run("SIGINT"));
}
// ❌ Ignoring SIGTERM — K8s waits, then SIGKILL mid-request
// no handler at all

Stop consumers before HTTP drain finishes

// src/sqs-consumer.ts
import { onShutdown } from "./shutdown.js";

let running = true;
onShutdown(async () => {
  running = false; // ✅ stop long-polling
  await inFlight.drain(); // wait for handlers with AbortSignal
});

export async function loop(poll: () => Promise<Message[]>) {
  while (running) {
    const batch = await poll();
    await Promise.all(batch.map((m) => handle(m, AbortSignal.timeout(10_000))));
  }
}

Align platform timers

Platform Knob Typical
K8s preStop: sleep 5–10 Let endpoint slice propagate
K8s terminationGracePeriodSeconds 30–60 ≥ app budget
ALB deregistration delay Match preStop
ECS stopTimeout ≥ app budget + buffer
Node shutdown budget Leave 5s for SIGKILL margin
# deployment.yaml excerpt
lifecycle:
  preStop:
    exec:
      command: ["sh", "-c", "sleep 10"]
terminationGracePeriodSeconds: 45

Closing checklist

✅ Dos
– ✅ Fail readiness first on SIGTERM
– ✅ server.close() + await in-flight with deadline
– ✅ Stop queue/socket consumers explicitly
– ✅ Align preStop, deregistration, and stopTimeout
– ✅ Test with chaos: kill pods under load, measure 5xx

❌ Don’ts
– ❌ Don’t exit 0 while requests are still writing bodies
– ❌ Don’t set grace period shorter than app budget
– ❌ Don’t keep readiness green during drain
– ❌ Don’t forget open DB pools and telemetry flush

Related reading

Last updated on September 11, 2026


Discover more from CheatCoders

Subscribe to get the latest posts sent to your email.

Comments

No comments yet. Why don’t you start the discussion?

Leave a Reply