BotNetwork

Apagado limpio para bots: manejar SIGTERM en Docker, systemd y PaaS

Cada despliegue, reinicio y reducción de escala termina con tu bot recibiendo una señal. Manejarla cuesta unas líneas y evita respuestas duplicadas, updates perdidos y estado corrupto.

Publicado 4 min de lectura

Qué pasa cuando el hosting detiene tu bot

Docker, systemd, Kubernetes y la mayoría de las plataformas siguen la misma secuencia:

  1. Envían SIGTERM al proceso principal: "por favor, detente".
  2. Esperan un periodo de gracia.
  3. Envían SIGKILL si el proceso sigue vivo. SIGKILL no se puede capturar; el proceso muere a mitad del trabajo.

Periodos de gracia por defecto según la documentación oficial:

Entorno Espera antes de SIGKILL Ajuste
docker stop 10 segundos --time, o stop_grace_period en Compose
systemd 90 segundos (DefaultTimeoutStopSec) TimeoutStopSec= en la unidad
Kubernetes 30 segundos terminationGracePeriodSeconds

Las plataformas administradas tienen sus propios valores; revisa la documentación de tu hosting y dale a tu código de apagado unos segundos menos de lo que permita.

Qué falla sin un handler

  • Respuestas duplicadas. Arranca una instancia nueva mientras la vieja sigue conectada. Con long polling de Telegram ambas compiten por los updates (la segunda recibe 409 Conflict); con Discord ambas sesiones reciben eventos y las dos responden.
  • Trabajo perdido. Llegó un mensaje, la respuesta iba a medias y SIGKILL la cortó.
  • Updates repetidos. Un bot de polling que no confirmó su último offset recibe los mismos updates tras reiniciar.
  • Archivos corruptos. Una escritura de SQLite o un archivo JSON de estado interrumpido a la mitad.

La receta de apagado

Al recibir SIGTERM:

  1. Deja de aceptar trabajo nuevo (detén el polling, cierra la conexión al gateway, que el servidor HTTP no acepte conexiones).
  2. Deja terminar los handlers en curso, con un tiempo límite.
  3. Vacía los logs, cierra la base de datos, confirma los mensajes de la cola.
  4. Sal con código 0.

Node.js

grammY (long polling):

import { Bot } from "grammy";
const bot = new Bot(process.env.BOT_TOKEN);
// ... handlers
const stop = () => bot.stop();          // termina el lote actual y luego resuelve start()
process.once("SIGINT", stop);
process.once("SIGTERM", stop);
await bot.start();
await db.close();                        // corre cuando el polling ya se detuvo

discord.js:

async function shutdown() {
  console.log("Apagando");
  await client.destroy();                // cierra la conexión al gateway
  await db?.close();
  process.exit(0);
}
process.once("SIGTERM", shutdown);
process.once("SIGINT", shutdown);

Servidores de webhook (Express, Fastify, Hono en Node): llama a server.close() para dejar de aceptar conexiones y deja que terminen las peticiones en curso antes de salir.

Python

python-telegram-bot lo resuelve por ti: Application.run_polling() y run_webhook() escuchan SIGINT, SIGTERM y SIGABRT por defecto (el parámetro stop_signals), dejan de pedir updates y ejecutan tu hook post_shutdown:

async def post_shutdown(app):
    await app.bot_data["db"].close()

app = Application.builder().token(TOKEN).post_shutdown(post_shutdown).build()
app.run_polling()

discord.py: registra un handler de SIGTERM que cierre el cliente.

import asyncio, signal

async def main():
    async with client:
        loop = asyncio.get_running_loop()
        loop.add_signal_handler(signal.SIGTERM, lambda: asyncio.create_task(client.close()))
        await client.start(TOKEN)

asyncio.run(main())

loop.add_signal_handler funciona en Unix, que es lo que corren las imágenes de Docker y los servidores Linux.

La trampa del PID 1 en Docker

Las señales van al PID 1 del contenedor. Si tu Dockerfile usa la forma shell:

CMD python -m bot.main        # /bin/sh es el PID 1

entonces /bin/sh recibe SIGTERM y puede no reenviarlo, tu handler nunca corre y Docker mata el bot a los 10 segundos. Usa la forma exec:

CMD ["python", "-m", "bot.main"]

Arrancar con npm start agrega otro proceso intermedio; prefiere CMD ["node", "index.js"]. Si tu proceso lanza procesos hijos, corre el contenedor con --init (o init: true en Compose) para que un init mínimo reenvíe señales y recoja procesos zombis. El Dockerfile completo está en desplegar un bot en Python con Docker.

systemd

[Service]
ExecStart=/opt/bot/.venv/bin/python -m bot.main
Restart=always
KillSignal=SIGTERM
TimeoutStopSec=20

Restart=always levanta el bot tras un fallo; TimeoutStopSec acota cuánto puede tardar en detenerse. Configuración completa en el tutorial de VPS en Vultr con systemd.

Despliegues sin solapamiento

Para bots de polling y gateway, la regla es una sola instancia corriendo. Si tu plataforma arranca la versión nueva antes de detener la vieja, desactiva el solapamiento para ese servicio o haz que la nueva espere a que la vieja se desconecte. Los bots con webhook son más fáciles: varias instancias pueden atender peticiones a la vez, así que los despliegues graduales son seguros mientras los handlers toleren duplicados. Más en mantener un bot en línea 24/7 y la guía de confiabilidad y límites.

Preguntas frecuentes

¿Cómo pruebo mi handler de apagado?

Corre el bot en Docker y ejecuta docker stop <contenedor>. Revisa que aparezca tu mensaje de apagado en los logs y que tarde menos que el periodo de gracia. En local, kill -TERM <pid> hace lo mismo.

¿El bot debería avisar "me desconecto"?

Normalmente no. Los reinicios son frecuentes y cortos; los avisos son ruido. Mejor regístralo en los logs.

¿Qué código de salida uso?

Sal con 0 tras un apagado solicitado. Un código distinto de cero le dice al supervisor que algo falló, lo que puede disparar alertas o esperas entre reinicios.

Fuentes