Un recorrido por el pipeline de CI/CD detrás de un proyecto chico de SPA + API + workers de IA: siete workflows de GitHub Actions, una caja spot ARM64
autoalojada que ahora duerme en cero y se despierta por corrida, un listener que
re-corre los jobs que un reclamo de spot mató, y una segunda pasada de la suite
de frontend con el reloj movido 400 días hacia adelante. Con los números de los
primeros tres días de scale-to-zero, y dos cosas que los logs mostraron y las
pruebas no.
Stack: GitHub Actions, un Auto Scaling Group de EC2 de instancias spot Graviton,
AWS CDK, ECS Fargate (arm64), S3 + CloudFront, pytest, Vitest.
TL;DR
| Pool siempre encendido | Pool scale-to-zero | |
|---|---|---|
| Horas que la caja está arriba | 24/7 | 2.7% (2h12m de 81h) |
| Costo del pool (spot + EBS + IPv4) | $32.34/mes | ~$0.90/mes (mismas tarifas, escaladas por uptime) |
| Push al primer job autoalojado | ~2 s | 1m44s a 3m56s (5 cold starts) |
| Jobs matados por un reclamo de spot | rojos hasta que alguien re-corre | se re-corren automáticamente, máximo 3 intentos |
| Bombas de tiempo de pruebas con compuerta de fecha | encontradas el día que explotan | encontradas en el PR que las escribe |
La forma
pull_request / push a main / cron / dispatch
|
+------------------------------+-------------------------------+
| |
runner-pool.yml (ubuntu-latest) CI / Deploy * / Security Scan
despierta: DesiredCapacity=1 runs-on: self-hosted
| (los jobs se quedan "queued" hasta
v que un runner se registra, ~2-4 min)
ASG lanza 1 caja spot Graviton --------------------------> los jobs corren
el user-data registra 2 runners |
v
workflow_run: completed
|
+---------------------------------------+------------------+
| |
runner-pool.yml duerme (ubuntu-latest) rerun-on-runner-loss.yml (ubuntu-latest)
DesiredCapacity=0 salvo que otro ¿falló + el log dice "shutdown signal"?
workflow de pool esté queued/en progreso -> rerun-failed-jobs (máx 3)
Siete workflows. Todo lo que buildea o prueba corre en el pool autoalojado; todo
lo que administra el pool corre en runners hospedados por GitHub:
| Workflow | Trigger | Corre en | Qué hace |
|---|---|---|---|
ci.yml |
PR hacia main
|
self-hosted | Filtro de rutas, luego los jobs de frontend, frontend-time-travel, backend, intelligence, infra, ci-scripts en paralelo |
deploy-backend.yml |
push a main (backend/**, infra/**) |
self-hosted | lint, 2 shards de pruebas, compuerta de cobertura mergeada, cdk deploy, imagen arm64, migraciones, ECS |
deploy-frontend.yml |
push a main (frontend/**) |
self-hosted | Vitest, esperar el deploy de backend del mismo commit, audit, build, S3, invalidar |
deploy-intelligence.yml |
push a main (intelligence/**) |
self-hosted | pruebas, imagen arm64, migraciones, ECS |
security-scan.yml |
domingo 03:00 UTC | self-hosted | Trivy sobre las imágenes más recientes |
runner-pool.yml |
todo lo de arriba, más cron | ubuntu-latest | despertar antes, dormir después |
rerun-on-runner-loss.yml |
workflow_run: completed |
ubuntu-latest | re-correr los jobs que un reclamo de spot mató |
El lado del pool tiene que estar en runners hospedados por GitHub. Un job
self-hosted no puede arrancar el pool que necesita para correr, y no puede
detener el pool en el que está corriendo.
El pool
Un Auto Scaling Group, solo spot, diez tipos de instancia Graviton de 2 vCPU a lo
largo de dos AZs, PRICE_CAPACITY_OPTIMIZED. Cada caja registra dos procesos de
runner, así que una caja corre dos jobs a la vez. MaxSize es 2 para que Capacity
Rebalance pueda lanzar el reemplazo antes de que AWS se lleve la caja vieja de
vuelta.
Hasta la semana pasada era MinSize: 1, DesiredCapacity: 1, sin política de
escalado. Medido a lo largo de las 120 horas previas: 3 horas con CPU por
encima de 10%, media de 1.08%. Una caja inactiva el 97.5% del tiempo, costando
$23.94 de spot, $4.80 de EBS y $3.60 de IPv4 público al mes.
El cambio de CDK son dos líneas y una eliminación:
const asg = new autoscaling.AutoScalingGroup(this, "RunnerAsg", {
vpc,
mixedInstancesPolicy: { /* diez tipos Graviton, solo spot */ },
minCapacity: 0,
maxCapacity: 2,
// desiredCapacity deliberadamente NO declarado.
capacityRebalance: true,
groupMetrics: [autoscaling.GroupMetrics.all()],
});
Dos detalles no obvios:
-
desiredCapacityse tiene que ir. CDK advierte que declararlo reinicia el tamaño del grupo en cada deploy (aws/aws-cdk#5215). Con el tamaño ahora manejado desde fuera de CDK, uncdk deploycorriendo dentro del deploy de backend le arrancaría la caja de debajo al job que está corriendo ese mismísimo deploy. Omitido, CloudFormation lo pone en la creación y lo deja en paz después. -
El volumen de EBS es
deleteOnTermination. Dormir el pool termina la instancia, lo que se lleva las horas de spot, el volumen y el IPv4 público con ella. Detener en lugar de terminar seguiría pagando por el volumen.
Despertar y dormir sin tocar los otros workflows
El diseño obvio es un job wake arriba de cada workflow y una arista needs: wake
en cada job autoalojado. Eso son ocho archivos y docenas de aristas needs:, y una
arista equivocada rompe un deploy. En su lugar, un workflow separado dispara con
los mismos eventos:
on:
pull_request:
branches: [main]
push:
branches: [main]
workflow_dispatch:
schedule:
- cron: "50 2 * * 0" # 10 min antes del security scan del domingo
- cron: "*/30 * * * *" # barredor
workflow_run:
workflows: [CI, Deploy Backend, Deploy Frontend, Deploy Intelligence, Deploy Marketing, Security Scan]
types: [completed]
concurrency:
group: runner-pool
cancel-in-progress: false
Corre en paralelo con el workflow real. Los jobs autoalojados empiezan queued, y
GitHub retiene un job en cola hasta que un runner con labels que empatan se
registra. La cola es la sincronización. Ningún archivo de workflow fuera de este
sabe que el pool puede dormir.
El trigger de push a propósito no tiene filtros de ruta. Un push de solo-docs
despierta la caja para nada; el lado de dormir limpia eso.
wake pone DesiredCapacity=1. sleep lo pone en 0, pero solo si nada más
necesita la caja:
def cmd_sleep(asg, repo, run_id, *, dry_run):
if any_runner_busy(repo):
print("A runner is still busy; leaving the pool up.")
return 0
others = other_active_runs(repo, run_id)
if others:
print("Other runs still need the pool, leaving it up:")
for name in others:
print(f" - {name}")
return 0
set_desired(asg, 0, dry_run=dry_run)
return 0
La asimetría es deliberada. Un scale-down perdido cuesta centavos y el dormir de
la siguiente corrida lo recoge. Un scale-down equivocado mata un deploy a media
migración, que es la falla exacta que el pool ya sufre por los reclamos de spot.
El mismo razonamiento para los errores: cada camino en runner_pool.py regresa 0.
Un despertar que no puede alcanzar AWS deja los jobs en cola, lo cual es visible y
recuperable. Un workflow de pool en rojo en cada PR no es ninguna de las dos.
Esa regla se aprendió en el PR que la introdujo. El paso que resuelve el nombre del
ASG corría bajo bash -e, y el grant de IAM que necesitaba estaba en el mismo PR,
así que DescribeAutoScalingGroups regresó AccessDenied y el PR se puso en rojo. El
arreglo es un if ! alrededor de la llamada:
if ! name=$(aws autoscaling describe-auto-scaling-groups \
--query "AutoScalingGroups[?Tags[?Key=='Name' && Value=='${RUNNER_ASG_TAG}']].AutoScalingGroupName | [0]" \
--output text 2>&1); then
echo "::warning::could not resolve the runner ASG, leaving the pool alone: ${name}"
name=""
fi
El nombre del ASG se resuelve por tag, no hardcodeado. CloudFormation lo genera y
cambia si el construct alguna vez se reemplaza.
Cómo se ve en el log de escalado
2026-10-10T21:04:51 Successful Launching a new EC2 instance: i-058255690229cf59c
2026-10-10T00:47:25 Successful Terminating EC2 instance: i-0327f991f9094b5b1
2026-10-10T00:16:22 Successful Launching a new EC2 instance: i-0327f991f9094b5b1
2026-10-07T20:32:53 Successful Terminating EC2 instance: i-0d799f051fa663669
2026-10-07T20:12:34 Successful Launching a new EC2 instance: i-0d799f051fa663669
2026-10-07T14:19:47 Successful Terminating EC2 instance: i-0d92076be33dcc045
2026-10-07T14:01:02 Successful Launching a new EC2 instance: i-0d92076be33dcc045
Siete sesiones en 81 horas, de 3 a 31 minutos cada una, 2h12m en total. El script
que las suma está en la carpeta de código (04-measure/pool_uptime.py). Lee
describe-scaling-activities, empareja cada lanzamiento con su terminación, y
cuenta una sesión todavía abierta hasta ahora.
El precio es el cold start. Antes, el primer job autoalojado arrancaba como 2
segundos después de que la corrida se creaba. Ahora:
| Corrida | Creada | Primer job autoalojado | Espera |
|---|---|---|---|
| Deploy Backend | 14:00:40 | 14:04:16 | 3m36s |
| Deploy Backend | 20:12:13 | 20:15:51 | 3m38s |
| Deploy Frontend | 00:16:53 | 00:18:37 | 1m44s |
| Deploy Backend | 00:16:53 | 00:19:43 | 2m50s |
| CI | 21:04:21 | 21:08:17 | 3m56s |
Tres minutos por corrida en frío, contra un deploy de backend que toma mucho más
que eso. Las corridas pegadas una tras otra aterrizan en la caja caliente y no
pagan nada.
CI en un pull request
ci.yml es solo chequeos: sin credenciales de AWS, sin deploy. Un job de
dorny/paths-filter decide qué áreas cambiaron, y cada job de área tiene compuerta
en su propio output, así que un PR de solo-frontend nunca arranca el servicio de
PostgreSQL del backend. Las corridas de PR se cancelan en vuelo cuando el autor
empuja de nuevo; las corridas de deploy nunca lo hacen, porque un deploy a medias
es peor que uno lento.
El job ci-scripts es el que la mayoría de los pipelines no tienen. Los scripts
que reparan el propio CI (rerun_on_runner_loss.py, runner_pool.py) tienen
pruebas de contrato que stubbean gh y aws en el PATH, y corren cuando esos
scripts o sus workflows cambian. El filtro de rutas incluye el propio
runner-pool.yml, porque la lista de workflow_run ahí tiene que empatar con
POOL_WORKFLOWS en el script, y editar solo el YAML es justo el drift que la
prueba existe para atrapar.
El reloj 400 días adelante
Cuatro veces en unas semanas, main se puso en rojo en un commit que no tocaba
nada relacionado. Cada vez era una prueba con un fixture como
closes_at: "2026-10-01" afirmando la rama de "todavía abierto". Pasaba en el PR
que lo escribía, luego fallaba en cada corrida desde la mañana en que esa fecha
llegaba en la vida real.
El arreglo a nivel de prueba es fijar el reloj (vi.spyOn(Date, "now").mockReturnValue(...))
a un instante entre las dos ramas. El arreglo a nivel de pipeline es hacer que la
siguiente falle ahora, no en un año. src/test/setup.ts:
const timeTravelMs = Number(process.env.TIME_TRAVEL_DAYS || 0) * 86_400_000
if (timeTravelMs) {
const RealDate = Date
const realNow = RealDate.now.bind(RealDate)
class ShiftedDate extends RealDate {
constructor(...args: ConstructorParameters<typeof Date> | []) {
if (args.length === 0) super(realNow() + timeTravelMs)
else super(...(args as ConstructorParameters<typeof Date>))
}
static now() {
return realNow() + timeTravelMs
}
}
globalThis.Date = ShiftedDate as DateConstructor
}
Y un segundo job de CI, corriendo en paralelo con la suite normal:
frontend-time-travel:
needs: changes
if: needs.changes.outputs.frontend == 'true'
runs-on: self-hosted
steps:
# checkout, pnpm, node, install
- name: Vitest (clock 400 days ahead)
env:
TIME_TRAVEL_DAYS: "400"
run: cd frontend && pnpm exec vitest run
Dos detalles no obvios:
-
Solo el
new Date()sin argumentos yDate.now()se mueven.new Date("2026-10-01")sigue parseando a ese instante. Así que las fechas de fixture se quedan fijas y "ahora" se mueve más allá de ellas, que es la situación exacta que rompe una prueba con compuerta de fecha. - Una prueba que fija su propio reloj anula el movimiento y es inmune. Esa es la presión buscada: la única manera de poner en verde el job de time-travel en una prueba con compuerta de fecha es escribirla de la manera permanente.
Jobs separados en lugar de un paso secuencial, para que una palomita en rojo diga
de qué tipo de falla se trata: una regresión, o una bomba de fecha. En local:
TIME_TRAVEL_DAYS=400 pnpm test:run, y 1095 para un horizonte más largo.
Deploys en push a main
Cada workflow de deploy tiene un filtro de rutas, un grupo de concurrency con
cancel-in-progress: false, y un input runner que cae por default en
self-hosted.
-
Backend: ruff, basedpyright y pip-audit primero, luego dos shards de pytest
divididos por duraciones registradas, luego un job que mergea los dos archivos
.coveragey exige 60% sobre la unión (las compuertas por shard dejan pasar módulos que un shard nunca importa). Luegocdk deploydel stack de backend, un build de imagen arm64 nativo, las migraciones corridas como una tarea de ECS de una sola vez con la imagen nueva, y solo entonces la actualización del servicio. -
Frontend: Vitest, luego una compuerta que espera el deploy de backend del
mismo commit cuando el commit tocó
backend/oinfra/. - Intelligence: pruebas con un chequeo de drift de modelo-a-schema, imagen arm64, migraciones, ECS.
El input runner: ubuntu-latest es la salida de emergencia cuando spot no tiene
capacidad en absoluto. Para el frontend es un build estático y funciona tal cual.
Para los servicios arm64 tiene que buildear a través de QEMU, ya que un build
amd64 nativo produce una imagen que la tarea de Fargate no puede jalar.
Cuando spot se lleva la caja a media corrida
Un reclamo aparece como una X roja en cualquier paso que estuviera corriendo,
indistinguible de una prueba que falla hasta que se abre el log crudo.
rerun-on-runner-loss.yml lee los logs de los jobs fallidos, busca líneas que solo
el host del runner produce (The runner has received a shutdown signal,
lost communication with the server), y re-corre los jobs fallidos. Se niega
cuando existe una corrida más nueva, cuando el intento ya es el tercero, o cuando
el log no se pudo leer.
El scale-to-zero y el listener de re-corrida se componen sin saber el uno del otro.
La re-corrida crea un intento nuevo, el intento se forma en cola, la corrida en
cola evita que sleep escale hacia abajo, y si el pool ya se durmió, el siguiente
despertar lo dispara... nada. Una re-corrida no es un evento push ni
pull_request. El barredor de cada media hora solo duerme. En la práctica la caja
reclamada la reemplaza Capacity Rebalance antes de que sleep vea siquiera un pool
inactivo, pero es la única costura en el diseño que vale la pena conocer.
Qué mostraron los logs
Cada prueba unitaria de runner_pool.py pasa. Leer los logs reales de despertar y
dormir de los primeros tres días sacó dos cosas que las pruebas no pudieron.
1. El listado de runners es un 403 en producción.
Wake the pool 2026-10-10T21:14:30Z gh api /repos/acme/myapp/actions/runners failed (1): gh: Resource not accessible by integration (HTTP 403)
Wake the pool 2026-10-10T21:14:41Z gh api /repos/acme/myapp/actions/runners failed (1): gh: Resource not accessible by integration (HTTP 403)
Wake the pool 2026-10-10T21:15:11Z No runner came online within 300s. The self-hosted jobs will queue. If spot has no capacity at all, re-run with the `runner: ubuntu-latest` input.
Listar runners autoalojados necesita el permiso de Administration del repositorio,
y GITHUB_TOKEN no se le puede otorgar. permissions: actions: read no es
suficiente. El script trata cualquier error de API como "sin datos", así que:
-
wakenunca ve a un runner ponerse en línea, y hace poll por los 300 segundos completos cada vez. El pool de todos modos despierta, porqueset-desired-capacitypasa antes del poll. El costo son de 5 a 7 minutos facturados de runner hospedado por evento en un repo privado. - El chequeo de
sleepde "¿algún runner está ocupado?" siempre responde no. Ha sido seguro solo porque el segundo chequeo, "¿algún workflow de pool está en cola o en progreso?", usa la API de runs, queactions: readsí cubre:
Sleep the pool if nothing else needs it gh api /repos/acme/myapp/actions/runners failed (1): gh: Resource not accessible by integration (HTTP 403)
Sleep the pool if nothing else needs it Other runs still need the pool, leaving it up:
Sleep the pool if nothing else needs it - Deploy Backend#38086462352 (queued)
Sleep the pool if nothing else needs it - Deploy Frontend#38086462341 (in_progress)
La prueba de contrato stubbea gh y sirve un runners.json enlatado para esa
ruta. Verificaba la tabla de decisión contra un permiso que el token real no tiene.
2. */30 no significa cada 30 minutos. El barredor disparó 12 veces entre el
2026-10-08 y el 2026-10-10:
03:29 10:50 17:38 22:38 02:39 09:47 16:42 21:23 01:02 07:21 13:53 18:06
Huecos de 4 a 7 horas. GitHub documenta que los workflows agendados se pueden
demorar o tirar bajo carga; en este repo, una agenda de 30 minutos se porta como
una de 5 horas. El comentario en el workflow dice que el peor caso de un despertar
perdido es "~30 min de spot inactivo, como 3 centavos". El peor caso real son
varias horas: siguen siendo centavos, pero no el número del comentario.
Top comments (1)
tr.ee/dev-to