Github Actions: Re-intentar automáticamente los jobs de CI que un reclamo de spot instance mató

작성자

카테고리:

← 피드로
DEV Community · Franchesco Romero · 2026-09-17 개발(SW)

Mi CI corre en un runner spot ARM64 autoalojado. Cuando AWS se lleva la instancia de vuelta a media corrida, GitHub muestra una X roja en cualquier paso que estuviera corriendo, y se ve idéntico a una prueba que falla.
Esto es un workflow que lee el log del job, reconoce esa falla específica, y re-corre los jobs fallidos por su cuenta. Las fallas de prueba reales se quedan en rojo.

Stack: GitHub Actions, una instancia spot EC2 en un Auto Scaling Group, CDK.

Qué pasó

Un deploy de backend se puso en rojo en Tests (shard 1/2). El log crudo:

2026-09-16T19:31:20Z ........................................................................ [ 75%]
2026-09-16T19:31:31Z ##[error]The runner has received a shutdown signal. This can happen when the runner service is stopped, or a manually started runner is canceled.
2026-09-16T19:31:44Z ##[error]The operation was canceled.
2026-09-16T19:31:44Z Terminate orphan process: pid (18652) (pytest)

Enter fullscreen mode Exit fullscreen mode

Puntos ininterrumpidos hasta 75%, luego la máquina se fue. Cero pruebas fallaron. El otro shard se murió 13 segundos después, mismo host. El deploy de frontend entonces abortó a propósito, porque se niega a publicar cuando el deploy de backend para ese commit no tuvo éxito.

Un detalle que vale la pena saber: gh run view <id> --log-failed imprime nada para esta clase de falla. La salida vacía se ve como una herramienta rota, así que el log real casi nunca se abre. Hay que jalarlo por job:

gh api repos/myorg/myapp/actions/jobs/<job-id>/logs | tail -20

Enter fullscreen mode Exit fullscreen mode

Qué hace

Un listener de workflow_run dispara después de que cualquier corrida de CI o de deploy termina. Para una corrida fallida jala los logs de los jobs fallidos y busca líneas que solo el host del runner puede producir:

RUNNER_LOSS_MARKERS = (
    "The runner has received a shutdown signal",
    "lost communication with the server",
    "The runner has received a cancellation signal from the server",
    "The operation was canceled by the runner service",
)

Enter fullscreen mode Exit fullscreen mode

Si una hace match, llama a rerun-failed-jobs. Si ninguna hace match, no hace nada y la corrida se queda en rojo.

También se niega a actuar en tres casos:

  • Existe una corrida más nueva del mismo workflow y branch. Re-correr un intento más viejo nada más pelea con el concurrency group.
  • Intento 3 o posterior. Si un tercer intento todavía muestra una señal de shutdown, el pool está caído, no inestable, y un humano debería mirar el ASG.
  • El log no se pudo leer. Un log expirado o que todavía se está subiendo no es evidencia de nada.

Cómo usarlo

Dos archivos, sin dependencias más allá del CLI de gh que los runners hospedados por GitHub ya tienen.

  1. Copia rerun_on_runner_loss.py y rerun-on-runner-loss.yml a .github/scripts/ y .github/workflows/.
  2. Lista tus workflows por nombre en el trigger:
on:
  workflow_run:
    workflows: [CI, Deploy Backend, Deploy Frontend]
    types: [completed]

permissions:
  actions: write        # rerun-failed-jobs, nada más
  contents: read

jobs:
  maybe-rerun:
    if: >-
      github.event.workflow_run.conclusion == 'failure' ||
      github.event.workflow_run.conclusion == 'cancelled'
    runs-on: ubuntu-latest      # no self-hosted, ve abajo
    steps:
      - uses: actions/checkout@v5
      - run: python3 .github/scripts/rerun_on_runner_loss.py
        env:
          GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
          REPO: ${{ github.repository }}
          RUN_ID: ${{ github.event.workflow_run.id }}
          RUN_ATTEMPT: ${{ github.event.workflow_run.run_attempt }}

Enter fullscreen mode Exit fullscreen mode

runs-on: ubuntu-latest no es un detalle. En self-hosted este job se formaría detrás del mismísimo outage que existe para reparar.

workflow_run solo dispara para la copia del workflow que está en la rama por default, así que no hace nada hasta que se mergea. Pruébalo re-corriendo el script a mano contra un run id conocido.

La mitad de infraestructura

Re-correr solo ayuda si un runner regresa. Capacity Rebalance suscribe el ASG al aviso de interrupción de spot de dos minutos para que un reemplazo arranque antes de que se coseche la instancia, y llevaba meses habilitado aquí sin hacer nada:

capacityRebalance: true,
minCapacity: 1,
maxCapacity: 1,     // sin holgura, así que tiene que terminar primero
desiredCapacity: 1,

Enter fullscreen mode Exit fullscreen mode

Un ASG no puede exceder MaxSize. Con MaxSize: 1 no hay dónde poner el reemplazo, así que la funcionalidad se degrada en silencio a terminar primero, que es el apagón que se suponía prevenía. maxCapacity: 2 con desiredCapacity: 1 lo arregla, y el costo en estado estable no cambia: la segunda caja solo existe durante el traslape.

Ese cambio tiene una precondición. Cada instancia registraba sus runners bajo nombres fijos con --replace:

./config.sh --name myapp-runner-$i --unattended --replace

Enter fullscreen mode Exit fullscreen mode

Con dos cajas vivas, el reemplazo le roba el registro a la caja vieja
mientras todavía está corriendo un job, convirtiendo un rebalanceo elegante en una versión más rápida del kill que estaba evitando. Los nombres tienen que cargar el id de la instancia:

INSTANCE_ID=$(curl -fsS --retry 5 -H "X-aws-ec2-metadata-token: $IMDS_TOKEN" \
  http://169.254.169.254/latest/meta-data/instance-id || true)
RUNNER_SUFFIX=$(printf "%s" "${INSTANCE_ID##*-}" | tail -c 9)
[ -n "$RUNNER_SUFFIX" ] || RUNNER_SUFFIX=$(date +%s)

./config.sh --name myapp-runner-$RUNNER_SUFFIX-$i --unattended --replace

Enter fullscreen mode Exit fullscreen mode

Cada || true ahí importa: ese user-data corre bajo set -euo pipefail, y un solo curl sin guardar fallando al arrancar una vez produjo una instancia sana con cero runners registrados, lo que forma cada job para siempre.

Los archivos completos están en blog-ci-spot-reclaim-code/runner-asg.ts y runner-user-data.sh.
omatización lo deja en paz.

El lado de infraestructura afirma contra la plantilla de CloudFormation
sintetizada, no contra el TypeScript, porque todo este post es evidencia de que
una propiedad puede leerse bien y no hacer nada:

print("ok" if int(asg["MaxSize"]) > int(asg["DesiredCapacity"]) else "no")

Enter fullscreen mode Exit fullscreen mode

Qué no arregla

Si el mercado de spot de ARM está apretado y el ASG se queda en cero instancias
por horas, lo que ha pasado, el retry tampoco encuentra runner. La única cura
para eso es onDemandBaseCapacity: 1, un cambio deliberado de precio de como $9
al mes a como $25 al mes.

Auto-rerun CI jobs that a spot reclaim killed

CI on a self-hosted spot runner goes red for two very different reasons: your code broke, or AWS took the instance back mid-job. GitHub presents both the same way, as a red X on whatever step was running.

This repo is a small workflow_run listener that tells them apart. It reads the logs of the failed jobs, and only when they carry the runner-loss fingerprint does it re-run them. Real test failures stay red.

Full write-up: blog-post.md.

Layout

Files sit where they are meant to be installed, so the .github/ directory can be copied wholesale.

.github/
  scripts/rerun_on_runner_loss.py        the decision, and the re-run call
  scripts/test/rerun_on_runner_loss.test.sh   contract test, stubs the gh CLI
  workflows/rerun-on-runner-loss.yml     the listener (must stay ubuntu-latest)
  workflows/test.yml                     runs the contract test in this repo
infra/
  runner-asg.ts                          ASG capacity block (CDK)
  runner-user-data.sh                    runner registration + offline sweep
  scripts/test/github-runner.test.sh     contract test on the

원문에서 계속 ↗