Ghosty
Blog

El runner que duerme — micro-VMs para un deploy de 22 segundos

Nuestro pipeline de deploy corre dentro de una micro-VM que hiberna entre un push y el siguiente. Cómo pasamos de destruirla cada vez a despertarla en 4 segundos, y las tres trampas que encontramos en el camino.

5 min de lecturaFixter
  • micro-VMs
  • infraestructura
  • CI/CD
  • Firecracker
  • deploy continuo
Aguafuerte de un tigre echado y dormido a la entrada de su guarida, en reposo pero listo para saltar
Tigre couché à l'entrée de son antre — Cleveland Museum of Art (CC0)

Un lirón pasa siete meses del año dormido. Baja su temperatura, apaga casi todo, y no gasta nada. Cuando llega el calor, tarda unos minutos en volver a estar entero. No se muere y vuelve a nacer: sigue siendo el mismo lirón.

Ese es exactamente el truco que le aplicamos a la máquina que deploya Ghosty Studio. Y de paso resultó ser el mismo truco que ya usábamos con los agentes de nuestros clientes, sólo que aplicado a nuestra propia infraestructura.

Esto es lo que cambió en las últimas dos semanas.

De dónde veníamos: un script y una laptop

Al principio, deployar era correr un script desde mi laptop por SSH. Funcionaba, con dos problemas serios.

El primero: sólo empujaba el build. Si un cambio traía una dependencia nueva, el servidor la recibía en el código pero no en node_modules, el servicio no arrancaba, y el sitio devolvía 502 hasta que alguien entrara a mano. Nos pasó.

El segundo: dependía de mi laptop. De mi llave SSH, de mi red, de que yo estuviera despierto. Eso no es un pipeline, es una superstición.

Primer intento: una caja nueva por cada deploy

La versión obvia era la efímera: cada push a main levanta una micro-VM limpia, ésta se registra como runner de GitHub Actions, corre el deploy y se destruye. Cero estado entre corridas, cero residuos.

Es la respuesta correcta en el papel y la equivocada en la práctica. Registrar un runner desde cero cuesta tiempo: crear la caja, arrancar el sistema, descargar y configurar el agente, darse de alta contra GitHub. Doce segundos antes de que empezara a correr la primera línea útil del deploy. Doce segundos de nada, en cada push.

La idea: que no muera, que duerma

En vez de destruir la caja al terminar, la suspendemos. El flujo quedó así:

  1. GitHub encola el job y dispara un webhook workflow_job.
  2. Nuestro waker recibe el webhook y despierta la caja suspendida.
  3. El runner —que nunca se dio de baja— reconecta y toma el job.
  4. Al terminar, el webhook de cierre la vuelve a suspender.

Lo que hace que esto sea barato y no un servidor prendido con pasos extra: una micro-VM suspendida no consume RAM. Al suspenderla, el proceso que la ejecuta termina y su memoria vuelve entera al host. Mientras nadie deploya, esos 4 GB están disponibles para lo que de verdad importa, que son los agentes de los clientes.

El resultado medido: el resume de la máquina toma unos 245 milisegundos. El resto del arranque en frío —unos 4 segundos— es el runner volviendo a saludar a GitHub. Contra los 12 de antes.

La contrapartida es honesta y vale nombrarla: el workspace sobrevive entre deploys. Es menos hermético que una caja limpia. A cambio, el checkout es incremental y no pagamos el registro cada vez. Para un pipeline de deploy —donde el artefacto se construye en otro lado y se verifica al final— nos pareció un cambio que sale a favor.

Bajar de un minuto: no instalar lo que ya está

Con el arranque resuelto, el que dominaba el reloj era npm ci. La solución fue hornear las dependencias dentro de la imagen de la caja: el workflow compara el hash del package-lock.json contra el de la imagen y, si coinciden, copia el árbol de node_modules por hardlinks. Un segundo.

Cuando agregas una dependencia el hash deja de coincidir y el pipeline cae solo al npm ci completo: sigue deployando verde, sólo tarda unos veinte segundos más. Recuperar el atajo es rehornear la imagen, y olvidarlo no rompe nada. Un camino rápido que se degrada en vez de fallar vale mucho más que uno rápido que se rompe.

En paralelo movimos el typecheck a su propio job, fuera del camino crítico.

Números finales, medidos: sitio actualizado a los 22 segundos desde el push. Arranque en frío 4s, job de deploy 18s.

Las tres trampas

Ningún cambio de infraestructura es gratis. Éstas fueron las nuestras.

Cajas huérfanas. Si el webhook de cierre se perdía, la caja quedaba despierta comiendo 4 GB indefinidamente. Lo cerramos por cuatro lados: el waker barre cualquier caja de CI sobrante que encuentre además de la suya, los despertares concurrentes se serializan, hay un auto-suspend por inactividad como red, y un proceso de limpieza recicla lo que lleve mucho tiempo dormido. Cuatro redes porque cada una falla de forma distinta.

El deploy y el reinicio del demonio. El script hablaba con la API del host y un reinicio de ese servicio a media corrida tumbaba el deploy con un error de transporte opaco. Ahora los fallos de transporte se reintentan: el deploy sobrevive a que el piso se mueva debajo.

La base de datos equivocada. Había un .env viejo horneado en la imagen con una ruta de base de datos de desarrollo. Las migraciones se aplicaban contra esa base fantasma y reportaban éxito. El diagnóstico se llevó una tarde entera. Ahora la variable va explícita en el comando de migración, y el archivo ya no existe. Una migración que reporta éxito sobre la base equivocada es peor que una que falla.

Sin llaves privadas en ningún lado

Un detalle del que estamos contentos: no hay SSH en el pipeline. La caja llega a la API del host por la red interna, así que no existe una llave privada guardada en GitHub que alguien pueda extraer. El único secreto del lado de la caja es un bearer que vive en su disco con permisos restringidos.

Y una regla que dejamos escrita en el repo con signo de admiración: este pipeline nunca se dispara desde un pull request. El runner ejecuta código con acceso a la API del host; un PR desde un fork ajeno sería ejecución de código arbitrario con nuestras credenciales. Es la clase de puerta que se cierra antes de que alguien la encuentre.

El mismo primitivo, dos usos

Lo que más nos gusta de todo esto es que no inventamos nada nuevo.

Suspender y reanudar micro-VMs en cientos de milisegundos es la pieza sobre la que ya funcionaban los agentes de Ghosty: un agente que nadie usa se duerme, deja de pesar sobre el servidor, y despierta cuando llega el siguiente mensaje sin perder su contexto. Cuando fuimos a resolver el costo de nuestro propio pipeline, la respuesta ya estaba construida.

Ésa suele ser la señal de que un primitivo es el correcto: que la segunda vez que lo necesitas, ya está ahí.

Pruébalo gratis hoy

Sin código. Sin complicaciones. Crea tu agente y ponlo a trabajar en minutos.

Ghosty ® 2026. Todos los derechos reservados.