En entornos cloud distribuidos (Kubernetes, AWS ECS, Docker Swarm), los contenedores son entidades intrínsecamente efímeras. Se inicializan, se reprograman entre nodos del clúster y se desalojan continuamente durante eventos operativos ordinarios: escalado automático (Horizontal Pod Autoscaling, HPA), despliegues continuos mediante Rolling Updates o vaciado de nodos (Node Draining).
Si una aplicación no está diseñada para interceptar y gestionar adecuadamente las señales de terminación del orquestador, cualquier despliegue genera picos transitorios de errores para los usuarios:
502 Bad Gateway o 504 Gateway Timeout a los navegadores clientes.Lograr despliegues sin tiempo de inactividad (Zero-Downtime Deployments) y una alta resiliencia requiere la coordinación sincronizada de dos primitivas arquitectónicas: Sondas de salud (Healthcheck Probes) y Apagado elegante (Graceful Shutdown).
[Ciclo de vida del desalojo elegante de contenedores (Graceful Eviction)]
1. Orquestador (K8s/Docker) 2. Next.js App / Pod 3. Ingress / Balanceador
│ │ │
├────── 1. Envío de SIGTERM ───────►│ │
│ │ │
│ ├─ 2. Readiness = 503 Service Unavail│
│ │ │
├────── 3. Retirar Pod de los Endpoints del Servicio ───────────────────►│
│ │ │
│ │ 4. Tráfico redirigido a │
│ │ réplicas sanas │
│ │ │
│ ├─ 5. Pausa 3-5s (Drenar in-flight) │
│ │ │
│ ├─ 6. server.close() (detener TCP) │
│ │ │
│ ├─ 7. prisma.$disconnect() │
│ │ redis.quit() │
│ │ │
│ ├─ 8. process.exit(0) │
│ ▼ │
│◄───── 9. Contenedor finalizado limpiamente ────────────────────────────┘
│
(Si expira el margen de 30s: el orquestador emite un SIGKILL inapelable)La gobernanza del ciclo de vida del contenedor se apoya directamente en las señales estándar del sistema operativo POSIX:
El obstáculo principal al ejecutar Node.js dentro de un contenedor Docker es el problema del proceso inicial PID 1 (The PID 1 Init Problem).
En sistemas operativos Linux, el primer proceso ejecutado (PID 1) asume el rol de demonio supervisor del sistema (tradicionalmente init o systemd). El kernel de Linux aplica una semántica particular al PID 1:
SIGTERM, el proceso con PID 1 ignora la señal por completo a menos que el código haya registrado explícitamente un manejador activo mediante process.on('SIGTERM').CMD npm start o CMD node server.js), la shell /bin/sh o el binario de npm asumen el PID 1. Ni /bin/sh ni npm reenvían las señales POSIX entrantes a los procesos hijos de Node.js.Como resultado, al emitir un docker stop o ante el desalojo de un pod, el orquestador espera a que concluya el periodo de gracia (por defecto 10 segundos en Docker o 30 segundos en terminationGracePeriodSeconds en Kubernetes). Una vez transcurrido, el kernel ejecuta un SIGKILL forzado, destruyendo violentamente todas las conexiones TCP activas de los usuarios.
[Antipatrón: La shell envuelve a Node.js]
Docker Daemon ──SIGTERM──► [PID 1: /bin/sh] ──X (señal absorbida y descartada)
│
[PID 8: node server.js] (continúa operando sin enterarse)
│ (tras 10–30s de margen)
Docker Daemon ──SIGKILL──► [El kernel destruye el proceso: corte de sockets]
[Arquitectura de producción: Ejecución directa en Exec-Form]
Docker Daemon ──SIGTERM──► [PID 1: node server.js]
│
▼
Manejador explícito: process.on('SIGTERM')
• Invalidar sonda de Readiness (503)
• Drenar solicitudes HTTP en vuelo
• Cerrar pools de base de datos (Postgres, Redis)
• Salida limpia: process.exit(0)El segundo modelo mental divide la evaluación de la salud del contenedor en dos vectores ortogonales:
Liveness Probe (Sonda de vitalidad): "¿Sigue vivo el proceso Node.js y su V8 Event Loop sin bloqueos?". Si la respuesta no es 200, el orquestador declara el pod congelado o caído y procede a reiniciarlo.
Readiness Probe (Sonda de preparación): "¿Está esta instancia en condiciones de atender tráfico productivo real?". Verifica la conectividad con PostgreSQL y Redis. Si la base de datos experimenta saturación momentánea, o si el contenedor ha entrado en fase de apagado (Shutting Down), Readiness devuelve HTTP 503 y el balanceador retira inmediatamente el pod de la rotación sin reiniciarlo.
Durante el despliegue continuo de nuevas versiones, Kubernetes programa nuevos pods de réplica y desaloja los antiguos concurrentemente. Introducir una pausa inicial de sincronización de red de 3 a 5 segundos permite que los controladores Ingress y las tablas de enrutamiento de kube-proxy retiren los endpoints, eliminando por completo los errores 502 Bad Gateway en los navegadores clientes.
En pasarelas de pago o sistemas de facturación, la confirmación de un webhook bancario o una transacción de múltiples pasos puede tardar de 3 a 5 segundos en completarse. El mecanismo de Graceful Shutdown permite que las peticiones activas confirmen sus escrituras en PostgreSQL de forma segura, evitando discrepancias contables por el apagado repentino del proceso.
Al reiniciar 50 réplicas de Next.js simultáneamente sin un cierre ordenado, PostgreSQL retiene 50 sockets TCP en estados CLOSE_WAIT / TIME_WAIT. Las nuevas réplicas intentan abrir otras 50 conexiones, superando rápidamente el límite max_connections del motor de base de datos. La invocación explícita del cierre de pools previene caídas en cascada.
Para construir una arquitectura resiliente de nivel empresarial, implementamos un sistema coherente compuesto por un manejador de ruta en el App Router y un coordinador singleton de apagado en Node.js.
Node.js captura SIGTERM del orquestador. Registra la marca de tiempo de inicio y activa el temporizador guardián (watchdog).
La bandera isShuttingDown se establece en true. La ruta /api/health?type=ready responde inmediatamente con HTTP 503.
Pausa de 3 a 5 segundos para que los controladores Ingress y kube-proxy retiren la IP del pod de los endpoints activos.
Ejecución de server.close() para rechazar nuevos handshakes TCP, espera a peticiones en curso y cierre de Prisma y Redis.
Liberación de temporizadores watchdog y finalización limpia del proceso Node.js con código de salida 0.
/api/health#En el App Router de Next.js, creamos una ruta unificada que segrega limpiamente la lógica de Liveness y Readiness:
import { NextResponse } from 'next/server';
import { db } from '@/shared/lib/db';
import { redis } from '@/shared/lib/redis';
import { shutdownCoordinator } from '@/shared/lib/shutdown';
// Forzar ejecución dinámica y evitar optimizaciones estáticas de Next.js
export const dynamic = 'force-dynamic';
/**
* Endpoint multirrégimen de salud de contenedores:
* /api/health?type=live -> Liveness Probe (vitalidad del Event Loop)
* /api/health?type=ready -> Readiness Probe (conectividad de dependencias)
*/
export async function GET(request: Request) {
const { searchParams } = new URL(request.url);
const probeType = searchParams.get('type') || 'ready';
// 1. LIVENESS PROBE: Verifica que el Event Loop responda
if (probeType === 'live') {
return NextResponse.json(
{ status: 'alive', uptime: process.uptime() },
{ status: 200 }
);
}
// 2. READINESS PROBE: Verifica capacidad para procesar tráfico entrante
// Si el contenedor se encuentra en proceso de apagado, rechazar tráfico de inmediato
if (shutdownCoordinator.isShuttingDown) {
return NextResponse.json(
{ status: 'terminating', message: 'Container is shutting down' },
{ status: 503 }
);
}
const checks: Record<string, 'ok' | 'failed'> = {
postgres: 'failed',
redis: 'failed',
};
try {
// Ping a PostgreSQL con un límite estricto de timeout de 2 segundos
await Promise.race([
db.$queryRaw`SELECT 1`,
new Promise((_, reject) => setTimeout(() => reject(new Error('Postgres timeout')), 2000)),
]);
checks.postgres = 'ok';
} catch (err: unknown) {
console.error('[Healthcheck Error] PostgreSQL unreachable:', err instanceof Error ? err.message : err);
}
try {
// Ping a Redis (PING -> PONG) con un timeout estricto de 2 segundos
const pong = await Promise.race([
redis.ping(),
new Promise((_, reject) => setTimeout(() => reject(new Error('Redis timeout')), 2000)),
]);
if (pong === 'PONG') checks.redis = 'ok';
} catch (err: unknown) {
console.error('[Healthcheck Error] Redis unreachable:', err instanceof Error ? err.message : err);
}
const isHealthy = checks.postgres === 'ok' && checks.redis === 'ok';
return NextResponse.json(
{
status: isHealthy ? 'ready' : 'degraded',
timestamp: new Date().toISOString(),
checks,
},
{ status: isHealthy ? 200 : 503 }
);
}import type { Server } from 'node:http';
interface ShutdownTask {
name: string;
action: () => Promise<void>;
}
class ShutdownCoordinator {
private _isShuttingDown = false;
private httpServer: Server | null = null;
private tasks: ShutdownTask[] = [];
private isInitialized = false;
public get isShuttingDown(): boolean {
return this._isShuttingDown;
}
/**
* Registra el servidor HTTP nativo de Node.js para detener nuevos handshakes TCP
*/
public registerHttpServer(server: Server): void {
this.httpServer = server;
this.initSignalHandlers();
}
/**
* Registra tareas asíncronas de limpieza (cierre de pools de bases de datos y clientes de caché)
*/
public registerCleanupTask(name: string, action: () => Promise<void>): void {
this.tasks.push({ name, action });
}
private initSignalHandlers(): void {
if (this.isInitialized) return;
this.isInitialized = true;
// Registrar escuchadores para señales POSIX estándar de terminación
process.on('SIGTERM', () => this.handleSignal('SIGTERM'));
process.on('SIGINT', () => this.handleSignal('SIGINT'));
// Capturar excepciones no controladas y rechazos fatales
process.on('uncaughtException', (err) => {
console.error('[FATAL] Uncaught Exception:', err);
this.handleSignal('uncaughtException');
});
process.on('unhandledRejection', (reason) => {
console.error('[FATAL] Unhandled Promise Rejection:', reason);
});
}
private async handleSignal(signal: string): Promise<void> {
if (this._isShuttingDown) {
console.warn(`[Shutdown] Signal ${signal} received again. Termination already in progress.`);
return;
}
this._isShuttingDown = true;
console.log(`\n🛑 [Shutdown] Received signal ${signal}. Starting Graceful Shutdown sequence...`);
// TEMPORIZADOR GUARDIÁN (Watchdog): Fuerza el cierre si la limpieza se congela más de 25s.
// Debe terminar antes de que el orquestador envíe un SIGKILL inapelable (normalmente a los 30s).
const watchdog = setTimeout(() => {
console.error('💥 [Shutdown] Watchdog timeout reached (25s). Forcing termination with exit code 1.');
process.exit(1);
}, 25000);
// unref() asegura que este temporizador no mantenga abierto el Event Loop si las tareas terminan antes
watchdog.unref();
try {
// 1. PAUSA DE DRENAJE PARA SINCRONIZACIÓN DE INGRESS:
// Permitir que los controladores Ingress detecten el código 503 en la sonda de Readiness
// y retiren la IP de este pod de las tablas de enrutamiento activas
const ingressDrainDelayMs = 4000;
console.log(`⏳ [Shutdown] Pausing ${ingressDrainDelayMs}ms for Ingress endpoint synchronization...`);
await new Promise((resolve) => setTimeout(resolve, ingressDrainDelayMs));
// 2. DETENER TRÁFICO HTTP ENTRANTE:
// Dejar de aceptar nuevas conexiones en el socket HTTP
if (this.httpServer) {
console.log('🔒 [Shutdown] Closing HTTP server listener...');
await new Promise<void>((resolve, reject) => {
this.httpServer?.close((err) => {
if (err) {
console.error('❌ [Shutdown] Error closing HTTP server:', err);
return reject(err);
}
console.log('✅ [Shutdown] HTTP server closed successfully.');
resolve();
});
});
}
// 3. EJECUTAR TAREAS ASÍNCRONAS DE LIMPIEZA (PostgreSQL, Redis, Colas)
for (const task of this.tasks) {
console.log(`🔌 [Shutdown] Executing cleanup task: ${task.name}...`);
try {
await task.action();
console.log(`✅ [Shutdown] Cleanup task completed: ${task.name}`);
} catch (err: unknown) {
console.error(`❌ [Shutdown] Cleanup task failed: ${task.name}`, err instanceof Error ? err.message : err);
}
}
console.log('🎉 [Shutdown] Graceful shutdown completed cleanly. Terminating process.');
process.exit(0);
} catch (err: unknown) {
console.error('💥 [Shutdown] Critical failure during shutdown sequence:', err);
process.exit(1);
}
}
}
export const shutdownCoordinator = new ShutdownCoordinator();Configurar la directiva CMD del Dockerfile con gestores de paquetes: CMD ["npm", "start"] o CMD ["pnpm", "start"].
Por qué ocurre: Los desarrolladores perciben npm start como el comando más natural y directo para proyectos Node.js.
Consecuencia en producción: npm asume el PID 1 y actúa como un escudo impermeable contra las señales POSIX. Cuando Kubernetes emite SIGTERM, la utilidad npm no propaga la señal al proceso hijo de Node.js. Al cabo de 30 segundos de silencio, Kubernetes destruye el contenedor por la fuerza mediante SIGKILL, provocando la caída de sesiones y picos de errores 502 Bad Gateway.
# ❌ ANTIPATRÓN: npm absorbe y silencia las señales SIGTERM
CMD ["npm", "start"]
# ✅ CORRECTO: Ejecución directa del binario de Node sobre el servidor standalone
CMD ["node", "server.js"]Ejecutar una consulta SELECT 1 a PostgreSQL dentro de /api/health?type=live.
Por qué ocurre: Asumir erróneamente que una comprobación exhaustiva "de todo el sistema" ofrece mayor seguridad.
Consecuencia en producción: Si la base de datos PostgreSQL experimenta una sobrecarga transitoria y no responde dentro del tiempo de espera de la sonda, la Liveness Probe falla en todas las réplicas de la aplicación al mismo tiempo. Kubernetes asume que todas las instancias se han congelado e inicia un reinicio masivo simultáneo de toda la flota (Thundering Herd Reboot Loop), colapsando por completo el servicio.
// ❌ ERROR CATASTRÓFICO: Comprobación de base de datos en Liveness Probe
export async function GET() {
await db.$queryRaw`SELECT 1`; // ¡Si Postgres se satura, el contenedor es destruido!
return NextResponse.json({ status: 'ok' });
}
// ✅ CORRECTO: Liveness solo verifica el Event Loop; la base de datos se evalúa en Readiness
export async function GET(req: Request) {
const isLive = new URL(req.url).searchParams.get('type') === 'live';
if (isLive) return NextResponse.json({ status: 'alive' }, { status: 200 });
// Comprobación de base de datos únicamente para Readiness...
}Llamar a process.exit(0) de forma inmediata al interceptar process.on('SIGTERM').
Por qué ocurre: La creencia de que apagar la instancia con la mayor celeridad ahorra recursos computacionales.
Consecuencia en producción: Existe una latencia de propagación de 1 a 3 segundos entre que el orquestador emite SIGTERM y los balanceadores y proxies de Ingress (Kube-Proxy, AWS ALB) actualizan sus tablas de enrutamiento. Salir inmediatamente causa que peticiones HTTP ya en tránsito choquen contra sockets cerrados, generando errores de cliente Connection Reset by Peer.
// ❌ ANTIPATRÓN: Salida inmediata sin pausa de sincronización con Ingress
process.on('SIGTERM', () => {
server.close();
process.exit(0); // ¡Interrumpe peticiones de usuarios ya en tránsito en la red!
});
// ✅ CORRECTO: Esperar 3–5 segundos a la propagación del Ingress antes de cerrar
process.on('SIGTERM', async () => {
isShuttingDown = true; // Pone la sonda Readiness en 503
await sleep(4000); // Permite la sincronización de tablas de Ingress
server.close(); // Detiene nuevas conexiones TCP
await cleanup(); // Cierra pools de base de datos y caché
process.exit(0);
});Ejecutar server.close() sin imponer un tiempo límite acotado para el cierre de conexiones abiertas.
Por qué ocurre: Pasar por alto que el método server.close() espera a que todos los sockets de clientes activos se cierren antes de disparar su callback.
Consecuencia en producción: Si un cliente retiene una conexión de streaming abierta, un WebSocket o una petición externa bloqueada sin timeout, server.close() nunca resolverá su callback. La aplicación permanece congelada en fase de apagado hasta que el orquestador la elimina de forma abrupta mediante SIGKILL.
// ✅ CORRECTO: Temporizador guardián que fuerza la salida antes del SIGKILL
const watchdog = setTimeout(() => {
console.error('Watchdog timeout reached! Forcing process termination.');
process.exit(1);
}, 25000);
watchdog.unref(); // Permite que el proceso finalice de forma natural si las tareas concluyen antesEliminar envoltorios npm/sh en el CMD del Dockerfile. Emplear la sintaxis directa CMD ['node', 'server.js'].
Crear rutas independientes /api/health?type=live y /api/health?type=ready con timeouts estrictos de dependencias.
Cambiar la sonda Readiness a HTTP 503 tan pronto se recibe SIGTERM para cortar la entrada de nuevo tráfico.
Aguardar de 3 a 5 segundos para que Ingress y kube-proxy propaguen la retirada de endpoints antes de cerrar sockets.
Cerrar pools de conexiones de Prisma y Redis, cancelar temporizadores watchdog y salir limpiamente con process.exit(0).