AWS Builder Center

De $32 a $0.90 al mes: un pool de CI que solo existe cuando hace falta

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

Plain text
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:
Plain text
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:
  1.  desiredCapacity  se 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, un  cdk deploy  corriendo 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.
  1. 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:
Plain text
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:
Plain text
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:
Plain text
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

Plain text
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 :
Plain text
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:
Plain text
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:
  1. Solo el  new Date()  sin argumentos y  Date.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.
  1. 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  .coverage  y exige 60% sobre la unión (las compuertas por shard dejan pasar módulos que un shard nunca importa). Luego  cdk deploy  del 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/  o  infra/ .
  • 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.
Plain text
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:
  •  wake  nunca ve a un runner ponerse en línea, y hace poll por los 300 segundos completos cada vez. El pool de todos modos despierta, porque  set-desired-capacity  pasa antes del poll. El costo son de 5 a 7 minutos facturados de runner hospedado por evento en un repo privado.
  • El chequeo de  sleep  de "¿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, que  actions: read  sí cubre:
Plain text
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:
Plain text
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".
Any opinions in this article are those of the individual author and may not reflect the opinions of AWS.
Enjoyed reading this content? Let the author know!

Your likes, comments, shares, and saves help creators reach more builders.

Loading recommendations

Loading article