Guía9 min

Graceful shutdown de un agente IA: SIGTERM antes del kill

Resumen

Cómo no cortar a mitad de un tool call cuando hay deploy: SIGTERM en Node, timeout de docker stop, kill_timeout en Fly, deploys de Railway y por qué Vercel Functions no te da el mismo hook. Cierra el webhook, termina el turno, luego muere.

DockerRailway
Señal de apagado ordenado frente a un proceso de agente que termina un turno

Qué resuelve

Esta pieza se queda en la decisión práctica: qué instalar, qué riesgo agrega y cómo aplicarlo sin romper operación.

Un deploy no espera a que el modelo termine de llamar sendMessage. Manda SIGTERM. Si no lo atrapas, Docker espera unos segundos y manda SIGKILL: turno a medias, webhook duplicado, sqlite a medias. Healthcheck dice “vivo”; shutdown dice “muere limpio”. Fuentes oficiales consultadas el 3 de septiembre de 2026.

Healthchecks cubren liveness. Rollback cubre el binario. Esta guía cubre los 10–30 segundos entre la señal y la muerte.

La regla

Al SIGTERM: deja de aceptar trabajo nuevo, termina el turno en curso (o lo marca para retry), cierra HTTP, process.exit(0). Si no sales a tiempo, el orquestador te mata igual.

Serverless (Vercel Functions, Workers CPU) no te garantiza ese hook. Ahí el límite es maxDuration / CPU time, no un shutdown ordenado.

Node: el mínimo

let closing = false;
for (const sig of ["SIGTERM", "SIGINT"]) {
  process.on(sig, () => {
    closing = true;
    server.close(() => process.exit(0));
    setTimeout(() => process.exit(1), 25_000).unref();
  });
}

En el handler del webhook: si closing, responde 503 para que Telegram reintente. No lances un tool largo si ya estás cerrando.

PID 1 en Docker: usa node directo o un init que reenvíe señales (tini). npm start a veces se come el SIGTERM.

Docker

docker stop manda SIGTERM (salvo que el contenedor tenga otro STOPSIGNAL) y, pasado el timeout, SIGKILL. El flag es -t / --timeout. Si no hay --stop-timeout al crear el contenedor, el daemon usa 10 s en Linux y 30 s en Windows. Un tool de 20 s no cabe en el default de Linux. Sube --timeout / stop_grace_period en Compose a lo que tarde un turno más 5 s, no a 5 minutos “por si acaso”. --timeout -1 espera indefinido: no lo uses en un agente de producción, un hang te deja el deploy colgado. El flag no es --time.

Compose documenta el ciclo start/stop. El proceso tiene que escuchar la señal: si el binario es un shell wrapper, la señal no llega a Node. PID 1 tiene que ser el runtime, no sh -c.

Fly.io

Por defecto Fly manda SIGINT, no SIGTERM. En fly.toml pon kill_signal = "SIGTERM" si tu handler de Node escucha esa señal. kill_timeout default 5 s, máximo 300 s (best-effort: prepárate a un corte más corto). Sube el timeout si el agente cierra sqlite o drena la cola. Rolling deploy mata la Machine vieja cuando la nueva pasa health — si el health es “el proceso arrancó” y no “listo para webhooks”, cortas turnos. Encadena con readiness, no solo liveness.

Railway

Un deploy nuevo reemplaza el proceso. Railway manda la señal de parada al servicio anterior. El mismo handler de SIGTERM aplica. No asumas drain eterno: si el build nuevo está listo, el viejo tiene una ventana corta.

Vercel / Workers

No hay proceso largo que reciba SIGTERM entre deploys. Hay límite de duración de la invocación. Si el agente hace 4 tools en una Function, o cabe en maxDuration o parte el trabajo (cola, cron). Un “shutdown” aquí es devolver 504 a tiempo, no server.close().

Tabla

Señal de parada y ventana de gracia por plataforma

RuntimeSeñalVentana típicaQué hacer
Docker / ComposeSIGTERM → SIGKILL10 s defaultstop_grace_period + handler
FlySIGINT default; kill_signal + kill_timeout5 s default, máx 300 sSIGTERM + drenar, luego exit
Railwaystop del deploycortaigual que Node + 503
Vercel FunctionstimeoutmaxDurationno dependas de SIGTERM
WorkersCPU / wallFree vs Paidun request, no un daemon

Errores comunes

Turno cortado a mitad porque el proceso ignoró SIGTERM

SíntomaCausaFix
Telegram reenvía el mismo update 3 veceskill a mitad de sendMessage503 en shutdown + idempotencia
sqlite corrupto al redeploySIGKILL con WAL suciodb.close() en SIGTERM
Señal ignoradanpm como PID 1CMD ["node","server.js"] o tini
Fly mata a los 5 sSIGINT default + kill_timeout 5 skill_signal = "SIGTERM" y timeout ≥ un turno
Function 5044 tools en una invocacióncola / cron, no un request eterno

Un turno de agente no es un request HTTP corto. Si el modelo tarda 12 s y Docker te da 10 s, el WAL de sqlite y el sendMessage quedan a medias aunque el handler de SIGTERM exista. Mide un turno real (p95, no el happy path) y pon la gracia por encima de ese número. Si el p95 es 40 s, o acortas el turno (cola, un tool por invocación) o subes el timeout; no esperes que el orquestador adivine.

Relación con el resto

Checklist

  • process.on("SIGTERM") y SIGINT cierran el server
  • Webhook responde 503 si closing
  • PID 1 es Node o tini, no un shell huérfano
  • stop_grace_period / kill_timeout ≥ un turno
  • sqlite / cola se cierran antes del exit
  • En Vercel no esperas SIGTERM: cabe en maxDuration

FAQ

¿SIGKILL se puede atrapar? No. Si llegaste ahí, perdiste. Alarga la gracia o acorta el turno.

¿PM2 / systemd? Misma idea: KillMode y timeout. El handler de Node no cambia.

¿Workers con Durable Objects? El aislamiento es por request/DO, no por server.close(). No copies el snippet de Docker.

El ensayo: kill -TERM <pid> en local mientras un webhook duerme 8 s. Debes ver 503 o el turno terminar; no un kill sucio.

Siguiente paso: si ya mueres limpio y prod igual queda mudo, el hostname o el health — dominio custom y healthchecks. Sin runtime: curso.