12k
All articles

Cómo apagar un servidor Node de forma controlada

Apagado ordenado de Node con SIGTERM: maneja la readiness, drena solicitudes con server.close, cierra recursos en orden y evita 502.

OpenReplay Team
OpenReplay Team
Cómo apagar un servidor Node de forma controlada

Un apagado controlado de Node gestiona SIGTERM en un orden fijo: hacer fallar el readiness check, esperar a que el balanceador de carga deje de enrutar tráfico, drenar las peticiones en vuelo con server.close(), cerrar los recursos en orden de dependencia y, por último, salir.

Si ya haces casi todo eso y aun así ves errores 502 y conexiones reiniciadas en cada despliegue, el problema suele no estar en el propio handler. Hay tres cosas a su alrededor que tienden a provocarlo: la señal nunca llega a Node, el drenado nunca termina, o el orquestador mata el pod antes de que el drenado haya concluido. Este artículo construye un handler pequeño, recorre esos tres casos y termina con un test que puedes ejecutar.

Puntos clave

  • Sin un handler de SIGTERM, Node sale de inmediato y toda petición en vuelo acaba como una conexión reiniciada o una respuesta truncada.
  • Espera (await) a server.close() antes de cerrar el pool de la base de datos; cerrar el pool primero convierte cada consulta en vuelo en un error.
  • Desde Node 19.0.0, server.close() cierra por sí mismo las conexiones keep-alive inactivas; el antiguo bloqueo del tipo «el callback de close nunca se dispara» es historia.
  • Un CMD en forma de shell en el Dockerfile significa que la shell, y no Node, recibe SIGTERM, por lo que el código del handler nunca se ejecuta.
  • Configura un temporizador de salida forzada dentro del proceso, por debajo de terminationGracePeriodSeconds, para que el proceso elija su propia salida en lugar de recibir SIGKILL.

¿Qué ocurre sin un handler de SIGTERM?

La respuesta por defecto de Node ante SIGTERM es terminar el proceso, según la documentación de eventos de señal. Todo lo que estuviera en vuelo en ese instante queda cortado a mitad de respuesta: el cliente ve un reset o un cuerpo parcial, y el balanceador de carga registra un 502. En session replay este fallo tiene una forma reconocible: una acción del usuario que gira y luego falla, agrupada en una ventana estrecha que coincide con un despliegue y no con nada que hiciera el usuario. Esa vista del lado del cliente es normalmente donde se detecta el bug por primera vez.

El orden correcto de un apagado controlado en Node

Un handler correcto hace cinco cosas en orden: activar un flag de apagado para que el readiness falle, esperar brevemente a que el balanceador de carga reaccione, esperar a server.close() para que las peticiones en vuelo terminen, cerrar los recursos restantes y salir. El await de ese cierre es la parte que importa. Un handler que llama a server.close() sin esperar su callback, limpia recursos y sale ha recreado exactamente el fallo que pretendía evitar.

  1. Activa un flag de apagado para que el readiness probe devuelva 503.
  2. Espera brevemente a que el balanceador de carga deje de enrutar tráfico nuevo.
  3. Espera a server.close() para que terminen las peticiones en vuelo.
  4. Cierra los recursos restantes en orden de dependencia.
  5. Finaliza el proceso.
// server.js (Express)
const { setTimeout: sleep } = require('node:timers/promises');

let shuttingDown = false;

app.get('/health/live', (req, res) => res.sendStatus(200));
app.get('/health/ready', (req, res) => res.sendStatus(shuttingDown ? 503 : 200));

async function shutdown() {
  if (shuttingDown) return;
  shuttingDown = true;                    // readiness now returns 503

  await sleep(LB_WAIT_MS);                // matches your preStop sleep

  await new Promise((resolve, reject) =>
    server.close((err) => (err ? reject(err) : resolve()))
  );

  await closeResources();                 // next section
  process.exit(0);
}

process.on('SIGTERM', shutdown);
process.on('SIGINT', shutdown);

¿Por qué puede quedarse atascado el drenado?

En cualquier versión soportada de Node, server.close() detiene las nuevas conexiones, cierra los sockets keep-alive inactivos y espera a que terminen las conexiones con una petición en vuelo. La advertencia, muy repetida, de que los sockets keep-alive inactivos impiden que el callback de close se dispare describe el comportamiento de Node anterior a la 19.0.0, así que ese bloqueo ya no ocurre en versiones soportadas. Si tienes que ejecutar runtimes más antiguos, llama a server.closeIdleConnections() (añadido en Node 18.2.0) inmediatamente después de iniciar el cierre, no antes, para evitar una condición de carrera con conexiones recién creadas.

Lo que sigue atascando un drenado es trabajo legítimamente activo: handlers lentos, server-sent events y sockets promocionados a otro protocolo. Para eso existe el temporizador de salida forzada que veremos más abajo.

Cierra los recursos en orden de dependencia

Cierra primero el servidor HTTP, luego los workers de colas y los trabajos en segundo plano, después Redis y por último el pool de la base de datos. El orden sigue la cadena de dependencias: los handlers de peticiones y los trabajos usan Redis y el pool, así que cerrar el pool mientras siguen ejecutándose convierte cada consulta en vuelo en un error, lo que echa por tierra el drenado que acabas de esperar.

async function closeResources() {
  await worker.close();   // BullMQ or similar: finish the active job
  await redis.quit();
  await pool.end();       // pg pool last: nothing queries after this
}

Los trabajos que duran más que el periodo de gracia necesitan checkpointing para poder reanudarse, no drenado.

¿Qué ajustes del contenedor rompen el apagado controlado?

Node debe ser el proceso que realmente recibe la señal; si no, nada de lo anterior importa. La referencia de Dockerfile es explícita: la forma de shell ejecuta tu comando bajo /bin/sh -c, que no reenvía señales a su hijo, por lo que un CMD en forma de shell implica que el SIGTERM de docker stop nunca llega a Node.

# Broken: /bin/sh -c receives SIGTERM, node never does
CMD node server.js

# Correct: node is the signal target
CMD ["node", "server.js"]

Si necesitas un proceso init que recoja los procesos zombis, tini reenvía las señales a su hijo, así que el handler sigue ejecutándose. Ten en cuenta que docker stop escala a SIGKILL después de 10 segundos por defecto en Linux, configurable con -t.

En Kubernetes, configura terminationGracePeriodSeconds (30 por defecto) por encima de tu tiempo total de drenado, y añade un sleep en preStop: la eliminación del endpoint se evalúa en paralelo con SIGTERM y se propaga de forma asíncrona vía EndpointSlices, por lo que las peticiones siguen llegando durante un instante después de la señal. Mantén el liveness probe en verde mientras el readiness falla; un liveness probe conectado al mismo endpoint que está fallando provocará que el contenedor se reinicie a mitad del drenado.

terminationGracePeriodSeconds: 30   # > preStop + LB wait + drain + cleanup
lifecycle:
  preStop:
    exec:
      command: ["sleep", "5"]       # LB_WAIT_MS should match

Elige tu salida antes de que lo haga SIGKILL

Arma un temporizador de salida forzada al inicio del handler, configurado por debajo del periodo de gracia del orquestador, para que un drenado atascado termine con tu línea de log y tu código de salida en lugar de con un SIGKILL. Como último recurso, llama a server.closeAllConnections() (añadido en Node 18.2.0), que derriba todas las conexiones abiertas, incluidas las que aún están atendiendo una petición. Las conexiones que han cambiado a otro protocolo sobreviven a esta llamada, por lo que los WebSockets necesitan su propia difusión de close frame.

const forceExit = setTimeout(() => {
  server.closeAllConnections();
  process.exit(1);
}, GRACE_MS - 2_000);   // grace period minus a buffer, never a fixed default
forceExit.unref();

¿Cómo demuestras que el apagado funciona?

El test de aceptación es mecánico: lanza el servidor, dispara una petición contra una ruta deliberadamente lenta, envía SIGTERM a mitad de vuelo y comprueba que la respuesta sigue llegando con un 200 y que el proceso sale con código 0.

// verify-shutdown.js
const { spawn } = require('node:child_process');
const assert = require('node:assert');

const child = spawn('node', ['server.js'], { stdio: ['ignore', 'pipe', 'inherit'] });
child.stdout.on('data', async (chunk) => {
  if (!chunk.toString().includes('listening')) return;
  const pending = fetch('http://localhost:3000/slow'); // route awaits ~2s
  setTimeout(() => child.kill('SIGTERM'), 100);
  const res = await pending;
  assert.equal(res.status, 200);
  const code = await new Promise((r) => child.on('exit', r));
  assert.equal(code, 0);
  console.log('graceful shutdown verified');
});

Si esto pasa en local pero los despliegues siguen perdiendo peticiones, los sospechosos que quedan están en la capa del contenedor: un CMD en forma de shell o un periodo de gracia menor que tu presupuesto de drenado.

El handler en sí son cuarenta líneas; la fiabilidad viene del orden y del entorno que lo rodea. Integra el test en CI para que la próxima refactorización no pueda reintroducir en silencio el bug de reset-en-despliegue que acabas de arreglar.

Preguntas frecuentes

¿Puede un proceso de Node.js capturar o gestionar SIGKILL?

No. Node se niega a registrar un listener para SIGKILL, y la señal termina el proceso en todas las plataformas, diga lo que diga tu código, por lo que no se ejecuta ninguna limpieza. SIGSTOP tampoco puede escucharse. Por eso importa el temporizador de salida forzada dentro del proceso: debe dispararse antes de que expire el periodo de gracia del orquestador, de modo que el proceso drene y salga con SIGTERM en lugar de ser matado sin oportunidad de responder.

¿Cuál es la diferencia entre closeIdleConnections y closeAllConnections?

Ambos métodos se añadieron en Node 18.2.0. closeIdleConnections solo cierra los sockets que están inactivos entre peticiones, así que cualquier cosa a mitad de una petición se deja terminar. closeAllConnections es el contundente: derriba todas las conexiones abiertas, incluidas las que aún están atendiendo una petición, aunque las conexiones que han cambiado a otro protocolo como WebSocket sobreviven. Desde Node 19.0.0, server.close limpia por sí solo las conexiones inactivas, por lo que closeIdleConnections solo merece la pena si todavía das soporte a runtimes antiguos.

¿Espera process.exit a las peticiones en vuelo o al trabajo asíncrono pendiente?

No. process.exit cierra el proceso de inmediato y descarta cualquier trabajo asíncrono que siga en cola, incluida la salida que no haya terminado de escribirse en stdout o stderr. Por eso el handler de apagado espera a server.close y a la limpieza de recursos antes de llamar a exit; llamar a exit antes recrea el fallo de peticiones perdidas que el handler existe para evitar. Fuera de un handler, es preferible asignar process.exitCode y dejar que el proceso termine de forma natural.

¿Funcionan los handlers de SIGTERM en Windows?

No de la misma manera. Windows no tiene señales POSIX, y la documentación de Node.js indica que SIGTERM no está soportada ahí, aunque tu código pueda registrar un listener para ella. Ctrl+C sí genera SIGINT en todas las plataformas, y por eso el handler también escucha SIGINT para el desarrollo local. Prueba la ruta de drenado con SIGTERM dentro de un contenedor Linux, donde docker stop y Kubernetes entregan realmente la señal.

Understand every bug

Uncover frustrations, understand bugs and fix slowdowns like never before with OpenReplay — self-hosted, with full data ownership.

Star on GitHub

We use cookies to improve your experience. By using our site, you accept cookies.