Saltar al contenido principal
Jordi Izquierdo Montalbán
Blog

Del portátil a una VPS con systemd (sin Docker) y un CI que se pone rojo con razón

Cómo despliego mis bots 24/7: usuario dedicado, límites de memoria medidos, backups con timer, aviso de caída y un CI con lockfile con hashes.

7 min de lectura -

Mis bots de Telegram vivían en mi portátil: si lo cerraba, dejaban de existir. Hoy corren en una VPS Hetzner CX23 (2 vCPU, 4 GB, Ubuntu 26.04, Núremberg, 7,25 € al mes sin permanencia). Este post cuenta cómo está montado, qué medí, qué se rompió y qué no he hecho. Escribo lo que está desplegado y marco lo que solo está escrito.

El servicio: lo mínimo que me da robustez

Cada bot es un servicio systemd con su propio usuario de Linux sin privilegios. El de reto (jimmy.service) se desplegó el 21/08/2026. Estas son las líneas que importan, copiadas de la unidad real:

[Unit]
StartLimitIntervalSec=60
StartLimitBurst=5
OnFailure=avisar-caida@%n.service
 
[Service]
Restart=on-failure
RestartSec=10
MemoryHigh=1000M
MemoryMax=1200M
CPUQuota=80%
NoNewPrivileges=true
PrivateTmp=true
ProtectSystem=full

El otro bot, agente-telegram, usa Restart=always, MemoryMax=1G, ProtectSystem=full y NoNewPrivileges=true, con el usuario correos-jimmy. Comparten máquina, así que cada uno tiene su usuario y su carpeta personal a 700: eso es lo que impide que uno lea el .env del otro.

El 16/09 hice la prueba que de verdad cuenta: comprobé que correos-jimmy no podía leer /home/bot-jimmy ni tenía sudo, y luego un sudo reboot real. Los dos servicios arrancaron solos con PID nuevo y el timer de copias se rearmó. En agente-telegram lo confirmé además escribiéndole por Telegram, con las marcas de tiempo en el log.

Por qué on-failure en reto y always en agente-telegram

En reto on-failure es deliberado. main_bot() sale con código 0 en dos casos: el cerrojo del proceso ya está cogido (hay otro vivo) y la clave de cifrado es inválida. Los dos piden que venga una persona, y reintentarlos en bucle solo taparía el problema.

En agente-telegram la razón es más simple y menos fina: quiero que, si el proceso muere por cualquier causa, systemd lo levante. No he razonado ahí, caso por caso, qué pasa si sale con código 0, y con Restart=always y RestartSec=10s reintentaría sin fin ante un token inválido.

Un detalle en el que es fácil caer: StartLimitBurst va en [Unit], no en [Service]. Puesto en [Service], systemd lo ignora con un aviso y te quedas sin límite de reintentos sin enterarte. En mi unidad de reto lo dejé en [Unit] desde el principio, y con cinco fallos en 60 segundos systemd deja de insistir, que es lo que quiero ante un token inválido. agente-telegram no tiene ese límite todavía.

La memoria: medir en vez de estimar

El primer MemoryMax=1G de agente-telegram lo puse sin medir y lo dejé escrito como provisional. En reto sí medí, con PSS y no a ojo:

SituaciónRAM
Sin límite, con el RAG vacío2.515 MB
Con MemoryHigh=1000M y MemoryMax=1200M, usándolo de verdad1.000 MB de pico + 92 MB de swap
En reposo, esperando mensajes172 MB

El 60 % de esos 2,5 GB era onnxruntime reservando de más "por si acaso". Con un techo puesto se aprieta y funciona igual, sin un solo OOM. Separé MemoryHigh (frena) de MemoryMax (mata) para dar margen antes de matar nada.

El tope también es aislamiento: sin él, el kernel sin RAM mata al proceso que más ocupa, que puede ser el bot inocente de al lado.

Para agente-telegram medí después, el 17/09: 81 MB al arrancar y 576 MB con pico de 586 tras usar voz y /noticias, alrededor del 57-58 % de su MemoryMax. Dejé el límite como estaba (y el comentario del .service aún dice "provisional", pendiente de actualizar con esta medición). Lo que no he medido es un día entero de uso continuo, que sería lo que diría si la memoria crece sola con las horas.

Backups con timer

Las copias de agente-telegram no son un cron suelto, son un timer de systemd:

[Timer]
OnCalendar=*-*-* 04:00:00
Persistent=true

Persistent=true ejecuta la copia pendiente si la máquina estaba apagada a las 04:00. Al activarlo el 16/09 una copia la disparó Persistent=true y otra la hice a mano justo después; comprobé en disco las dos, con permisos 700.

La herramienta copia las bases SQLite en caliente, conserva 7 copias y verifica la nueva con PRAGMA quick_check antes de purgar la más antigua.

Aquí corregí un fallo mío: la carpeta de destino tenía un valor por defecto dentro del proyecto y no estaba en .gitignore, así que un git add . habría subido a GitHub mis notas y mis conversaciones. Ahora sin RUTA_COPIAS_SEGURIDAD no se copia, y la herramienta rechaza cualquier destino dentro del proyecto. La de restauración se niega con el bot vivo (sale con código 3) y exige --confirmar.

Enterarme cuando se cae

systemd revive el proceso, pero no me avisa. Antes me enteraba al escribir al bot y no contestar. En reto, OnFailure= lanza un script que manda un mensaje por Telegram con las últimas líneas del log. Usa curl contra la API y no el bot, así que puede avisar de que el bot está muerto. Esto es solo de reto: agente-telegram aún no tiene OnFailure=.

Al probarlo rompiendo el servicio a propósito llegaron 5 mensajes seguidos, uno cada 10 segundos. Había dado por hecho que OnFailure= salta una vez, cuando systemd se rinde. Es falso: salta en cada intento fallido. Añadí un limitador de un aviso por unidad cada 15 minutos.

Hay una segunda red por si se pierde el primer aviso: vigilar-bots.timer comprueba cada día a las 10:00 (con Persistent=true), pero solo vigila jimmy.service.

Dependencias reproducibles y un CI que me para

reto tiene un requirements-lock.txt con 111 dependencias, transitivas incluidas, cada una con su hash SHA256, y el CI las instala con --require-hashes. Una versión fija dice qué paquete quieres; el hash fija el contenido. La VPS instala solo ese lock de ejecución. En agente-telegram sí instalé también el lock de desarrollo en la VPS, para pasar el arnés allí (1854 pruebas en verde, 3 saltadas).

Lo útil del CI es lo que me ha parado, siempre por motivos reales:

  • Un lockfile hecho en Windows sin uvloop. pip-compile evalúa los marcadores contra su intérprete, y en Windows uvloop no existe. El CI de Linux falló con "all requirements must have their versions pinned". Lo arreglé a mano y lo dejé explicado en la cabecera del lock. El CI de Linux lo cazó, pero tardé tres días en arreglarlo; una matriz Windows más Linux lo habría marcado en el mismo commit que generó el lock, y no la tengo.
  • Tests que dependían del .env de quien los corre. En mi máquina pasaban porque tenía un .env real; en GitHub Actions, sin él, reventaban.
  • CVEs en pip-audit. El paso tenía continue-on-error: true y salía siempre en verde, así que lo quité. El 06/09 se puso rojo con seis CVEs nuevas y las analicé una a una. Las tres de pypdf sí me afectan (un PDF fabricado gasta tiempo o memoria al extraer texto, y los PDF me llegan por Telegram), así que subí de 6.15.0 a 6.17.0. Las de chromadb no tienen parche y exigen modo servidor, y yo uso PersistentClient. Las ignoro con el ID y el motivo escritos en el propio ci.yml, y el comentario avisa de que si algún día levanto un servidor de Chroma esas líneas dejan de ser válidas.

El job de mutación es solo informativo (|| true) y solo se lanza a mano (workflow_dispatch): mide, pero no vigila. Prefiero decirlo a venderlo como puerta.

Las acciones de GitHub van fijadas por SHA, no por tag, porque un tag es móvil, y el workflow limita el token a lectura:

permissions:
  contents: read
# en cada paso que usa una acción
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262  # v4

Por qué systemd en lugar de Docker

Lo tengo justificado por escrito en el README: cada bot corre con su usuario de Linux, su carpeta a 700 y su tope de memoria. Un venv aísla librerías de Python, no la máquina, y el usuario más el tope sí. No digo que Docker no pueda hacer esto; digo que con dos bots y una persona, systemd resuelve el problema con menos piezas que mantener.

Para otros entornos tengo además un Dockerfile multi-stage en multi-agente (usuario no root, HEALTHCHECK).

Compartir