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.

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

| Runtime | Señal | Ventana típica | Qué hacer |
|---|---|---|---|
| Docker / Compose | SIGTERM → SIGKILL | 10 s default | stop_grace_period + handler |
| Fly | SIGINT default; kill_signal + kill_timeout | 5 s default, máx 300 s | SIGTERM + drenar, luego exit |
| Railway | stop del deploy | corta | igual que Node + 503 |
| Vercel Functions | timeout | maxDuration | no dependas de SIGTERM |
| Workers | CPU / wall | Free vs Paid | un request, no un daemon |
Errores comunes

| Síntoma | Causa | Fix |
|---|---|---|
| Telegram reenvía el mismo update 3 veces | kill a mitad de sendMessage | 503 en shutdown + idempotencia |
| sqlite corrupto al redeploy | SIGKILL con WAL sucio | db.close() en SIGTERM |
| Señal ignorada | npm como PID 1 | CMD ["node","server.js"] o tini |
| Fly mata a los 5 s | SIGINT default + kill_timeout 5 s | kill_signal = "SIGTERM" y timeout ≥ un turno |
| Function 504 | 4 tools en una invocación | cola / 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
- Readiness vs liveness: healthchecks.
- Si el deploy nuevo está mal: rollback.
- Ver el kill en logs: logs.
- Quién dispara el deploy: CI/CD.
Checklist
-
process.on("SIGTERM")ySIGINTcierran 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.
Lecturas relacionadas
Sigue explorando Deploy y otras piezas para builders.



