Apagado Elegante
Maneja SIGTERM y SIGINT deteniendo el nuevo trabajo, drenando las conexiones HTTP y cerrando los pools de la base de datos antes de salir en Node.js 24.
Receta
Tarjeta de receta de referencia rápida: lista para copiar y pegar.
import express from "express";
import { Pool } from "pg";
const app = express();
const pool = new Pool({ connectionString: process.env.DATABASE_URL });
const server = app.listen(Number(process.env.PORT ?? 3000), "0.0.0.0");
let shuttingDown = false;
app.get("/ready", (_req, res) => {
if (shuttingDown) return res.status(503).json({ status: "draining" });
res.json({ status: "ready" });
});
function shutdown(signal: string) {
shuttingDown = true;
console.log(JSON.stringify({ event: "shutdown_start", signal }));
server.close(async () => {
await pool.end();
console.log(JSON.stringify({ event: "shutdown_complete" }));
process.exit(0);
});
setTimeout(() => {
console.error(JSON.stringify({ event: "shutdown_forced" }));
process.exit(1);
}, 30_000).unref();
}
process.on("SIGTERM", () => shutdown("SIGTERM"));
process.on("SIGINT", () => shutdown("SIGINT"));Cuándo usarlo: Cada servicio HTTP de producción en Kubernetes, ECS, Cloud Run, PM2 o systemd.
Ejemplo de trabajo
// src/shutdown.ts
import type { Server } from "node:http";
import type { Pool } from "pg";
export type Closable = { close: () => Promise<void> };
export function registerGracefulShutdown(
server: Server,
resources: Closable[],
options: { timeoutMs?: number } = {}
) {
const timeoutMs = options.timeoutMs ?? 30_000;
let draining = false;
const shutdown = async (signal: string) => {
if (draining) return;
draining = true;
console.log(JSON.stringify({ event: "shutdown_start", signal }));
await new Promise<void>((resolve, reject) => {
server.close((err) => (err ? reject(err) : resolve()));
});
for (const r of resources) {
await r.close();
}
console.log(JSON.stringify({ event: "shutdown_complete" }));
process.exit(0);
};
const forceTimer = setTimeout(() => {
console.error(JSON.stringify({ event: "shutdown_timeout" }));
process.exit(1);
}, timeoutMs);
forceTimer.unref();
process.on("SIGTERM", () => void shutdown("SIGTERM"));
process.on("SIGINT", () => void shutdown("SIGINT"));
return {
isDraining: () => draining,
};
}
// src/main.ts
import express from "express";
import { Pool } from "pg";
import { registerGracefulShutdown } from "./shutdown.js";
const pool = new Pool({ connectionString: process.env.DATABASE_URL });
const app = express();
const shutdownCtl = registerGracefulShutdown(
app.listen(3000, "0.0.0.0"),
[{ close: () => pool.end() }]
);
app.get("/ready", (_req, res) => {
if (shutdownCtl.isDraining()) {
res.status(503).json({ status: "draining" });
return;
}
res.json({ status: "ready" });
});Lo que esto demuestra:
server.close()deja de aceptar; finaliza las solicitudes en cursopool.end()drena las conexiones de la base de datos limpiamente- La preparación falla durante el drenaje para que los balanceadores de carga dejen de enviar tráfico
- La salida forzada después de 30 segundos coincide con el
terminationGracePeriodSecondstípico
Análisis Profundo
Cronología de la señal en Kubernetes
1. Pod marcado como Terminating
2. Endpoints eliminados del Servicio (la preparación falla)
3. Se ejecuta el hook preStop (suspensión opcional)
4. SIGTERM enviado al contenedor
5. El período de gracia transcurre -> SIGKILL
Alinea el tiempo de espera de apagado de la aplicación con el terminationGracePeriodSeconds del pod (tiempo de espera de la aplicación < período de gracia).
Apagado de Fastify
import Fastify from "fastify";
const app = Fastify();
app.addHook("onClose", async () => {
await pool.end();
});
const close = async () => {
await app.close();
process.exit(0);
};
process.on("SIGTERM", () => void close());app.close() activa los hooks onClose en orden inverso de registro.
Trabajo en curso más allá de HTTP
| Recurso | API de cierre |
|---|---|
PostgreSQL pg.Pool | pool.end() |
Redis ioredis | redis.quit() |
| Trabajador de BullMQ | worker.close() |
Cron setInterval | clearInterval + esperar último trabajo |
Rastrea los trabajos en segundo plano; server.close() por sí solo no espera a los trabajadores de la cola.
@godaddy/terminus
import { createTerminus } from "@godaddy/terminus";
createTerminus(server, {
signals: ["SIGTERM", "SIGINT"],
healthChecks: { "/health": () => Promise.resolve() },
onSignal: async () => { await pool.end(); },
});Agrupa las comprobaciones de estado y el apagado para las aplicaciones Express.
Errores comunes
- No hay fallo de preparación en el apagado - El balanceador de carga envía tráfico al pod moribundo. Solución: bandera
shuttingDownen/ready. server.close()sin tiempo de espera - se cuelga para siempre en keep-alive. Solución: temporizador de salida forzada; ajustakeepAliveTimeouten el servidor.- Cerrar el pool antes del drenaje HTTP - error en las solicitudes en curso. Solución:
server.close()primero, luego los pools. - SIGTERM ignorado en desarrollo - Ctrl+C envía SIGINT; maneja ambos.
- Los trabajadores siguen consumiendo después de que HTTP se detiene - procesamiento duplicado durante el despliegue. Solución: pausa los trabajadores en SIGTERM.
kill_timeoutde PM2 demasiado corto - eliminación forzada a mitad de la solicitud. Solución:kill_timeoutde 30 segundos - PM2 y systemd.
Alternativas
| Alternativa | Usar cuándo | No usar cuándo |
|---|---|---|
| Manejador manual de SIGTERM | Control total, cualquier framework | Quieres salud + drenaje con todo incluido |
| @godaddy/terminus | Servicios HTTP de Express | Fastify (usa app.close()) |
| Solo Kubernetes (sin manejador de aplicación) | Nunca | APIs de producción (siempre maneja SIGTERM) |
process.exit(0) inmediato | Trabajos por lotes | Servicios HTTP (causa 502s) |
Preguntas frecuentes
¿Cuánto tiempo debe durar el preStop sleep?
3-5 segundos suelen ser suficientes para la propagación de endpoints antes de SIGTERM. Ajusta con la documentación del balanceador de carga.
¿Cloud Run envía SIGTERM?
Sí, en el escalado de instancias y el despliegue de revisiones. Se aplica el mismo patrón de manejador.
¿Lambda necesita un apagado elegante?
Invocaciones de corta duración; callbackWaitsForEmptyEventLoop importa más que SIGTERM. Consulta Patrones de manejadores de Lambda.
¿Conexiones WebSocket?
Cierra el servidor WebSocket en la secuencia de apagado; notifica a los clientes con un marco de cierre. Puede necesitar un período de gracia más largo.
¿NestJS?
app.enableShutdownHooks() escucha SIGTERM y cierra los módulos que implementan OnModuleDestroy.
¿Qué código de salida en un apagado forzado?
Sale con 1 en caso de tiempo de espera para que el supervisor registre la terminación anormal y se activen las alertas.
Relacionado
- Despliegues sin tiempo de inactividad - despliegue + drenaje
- Sondas de salud y preparación - preparación durante el drenaje
- Despliegue de Kubernetes - preStop y período de gracia
- Conceptos básicos de operaciones en tiempo de ejecución - modelo de supervisión
- Mejores prácticas de operaciones en tiempo de ejecución - lista de verificación de la sección
Versiones de la pila: Esta página fue escrita para Node.js 24.18.0 (LTS activa), npm 10+, TypeScript 5.6+, Express 5, Fastify 5 y NestJS 11.