Módulo 8: Proyecto — Prueba de carga de la API de Reservo

7. El `load.yml` de CI como contenido

Descripción

Un gate que alguien tiene que acordarse de correr a mano no protege nada de forma confiable. La última pieza del capstone es automatizar la prueba: ponerla en un pipeline de integración continua que la corra solo y use el threshold como gate que bloquea el deploy. En esta lección escribimos el .github/workflows/load.yml como contenido —fiel a GitHub Actions y a la integración oficial de k6—: levantar la API, correr k6 con los thresholds como gate (si un threshold falla, k6 run sale con 99 y el step falla), subir el results.json como artifact, y —clave— decidir cuándo dispararlo (nightly, pre-release, on-demand, no en cada PR). Y comprobamos el mecanismo de verdad con un mirror local en shell: un step que corre el gate y usa su exit code para autorizar o bloquear, sin tocar git ni gh.

Conexión con el módulo: esta lección instala en un pipeline el gate que cerraste en la lección 6. Reúsa por entero el módulo 7 (correr k6 en CI, el load.yml, cuándo dispararlo) y el módulo 5 (el threshold que se vuelve el gate). Aquí el YAML va como contenido —k6 no está instalado y aquí nunca se ejecuta git/gh—, pero el mecanismo (el exit code que hace fallar el step) lo ejecutamos localmente en Python para que veas que es real. La lección 8 juntará este load.yml con todo lo demás en la entrega. Es la pieza que convierte "una prueba que corres" en "una prueba que corre sola".

El detector de humo cableado a la alarma

Un extintor en la pared es útil, pero depende de que alguien vea el fuego, lo agarre y lo use a tiempo. Un detector de humo cableado a la alarma es otra cosa: vigila solo, sin que nadie se acuerde, y cuando detecta humo actúa —dispara la alarma, corta la ventilación, llama a los bomberos— sin esperar a que un humano decida. La diferencia no es el sensor (ambos "detectan"); es que uno está cableado a una consecuencia automática y el otro espera a una persona.

El gate de la lección 6 es el extintor: funciona, pero alguien tiene que correrlo. El load.yml lo cablea a la alarma: el pipeline corre la prueba solo (en un horario, antes de un release), y si el threshold falla, actúa —marca el build en rojo, bloquea el merge, detiene el deploy— sin que nadie mire un p95. El sensor es el mismo (los thresholds); lo nuevo es el cableado a la consecuencia. Y como todo detector, hay que decidir cuándo lo activas: uno que salta con cada tostada (cada PR) es tan inútil como uno apagado, porque la gente aprende a ignorarlo.

El load.yml (contenido)

Aquí está el workflow del capstone. Recuerda: contenido rotulado, fiel a la sintaxis de GitHub Actions y a la integración oficial de k6, no ejecutado aquí (k6 no está instalado y aquí nunca se corre git/gh).

# .github/workflows/load.yml
# CONTENIDO (no ejecutado aquí): fiel a docs.github.com/actions y grafana.com/docs/k6.
name: Prueba de carga (Reservo)

# CUÁNDO se dispara: NO en cada push/PR (es lenta y cara). Sí de forma programada
# y bajo demanda.
on:
  schedule:
    - cron: '0 3 * * *'        # cada noche a las 03:00 UTC (nightly)
  workflow_dispatch: {}         # botón manual (on-demand)
  push:
    tags:
      - 'v*'                    # antes de un release (pre-release), al taggear vX.Y.Z

jobs:
  load-test:
    runs-on: ubuntu-latest
    timeout-minutes: 20         # una prueba de carga no debe colgar el runner
    steps:
      - name: Checkout
        uses: actions/checkout@v4

      - name: Levantar la API de Reservo
        # Arranca el blanco en segundo plano y espera a que escuche.
        run: |
          python3 reservo_server.py &
          for i in $(seq 1 30); do
            [ -f PORT ] && break
            sleep 0.5
          done
          echo "BASE_URL=http://127.0.0.1:$(cat PORT)" >> "$GITHUB_ENV"

      - name: Instalar k6
        uses: grafana/setup-k6-action@v1

      - name: Correr la prueba de carga (el gate)
        # k6 aplica los thresholds del script. Si UNO falla, k6 sale con 99,
        # este step falla, y el job entero se marca en rojo -> bloquea el deploy.
        run: k6 run quote_book_test.js --out json=results.json
        env:
          BASE_URL: ${{ env.BASE_URL }}

      - name: Subir el resultado como artifact
        # `if: always()` -> se sube el JSON aunque la prueba haya fallado
        # (justo el que quieres revisar). El registro de la corrida.
        if: always()
        uses: actions/upload-artifact@v4
        with:
          name: k6-results
          path: results.json

Léelo por bloques, fijándote en cómo cada pieza del capstone aparece:

  • on: — cuándo corre. Tres disparadores, ninguno es "en cada push/PR". schedule con un cron lo corre cada noche (nightly); workflow_dispatch da un botón para correrlo a mano (on-demand); push sobre tags v* lo corre antes de un release (pre-release). Esto es deliberado (lo desarrollamos abajo).
  • Levantar la API. El paso arranca reservo_server.py en segundo plano, espera a que escriba su PORT, y guarda la BASE_URL para el paso de k6. El blanco tiene que existir antes de golpearlo (M1).
  • Instalar k6. La acción oficial grafana/setup-k6-action instala el binario en el runner (recuerda: k6 es un binario de Go; el runner sí lo puede instalar, aunque este entorno de la guía no).
  • Correr la prueba (el gate). k6 run quote_book_test.js --out json=results.json corre el script del capstone (escenario + stages + thresholds) y exporta el JSON. Aquí está el gate: si un threshold falla, k6 run sale con 99, el step falla, y el job se pone rojo. El threshold de M5 se volvió el gate del pipeline.
  • Subir el artifact con if: always(). Sube results.json aunque la prueba haya fallado —el if: always() es clave: el registro de una corrida roja es el que más quieres revisar—. El equivalente del "exportar antes de salir" de la lección 6.

Cuándo dispararlo (y por qué no en cada PR)

La decisión de on: es tan importante como el resto del YAML. Una prueba de carga no va en cada pull request, por cuatro razones (M7):

  • Es lenta. Un perfil realista dura minutos (el del capstone, 10m30s). Meterla en cada PR haría que cada cambio esperara diez minutos por una prueba que casi siempre pasa —la gente empezaría a saltársela—.
  • Es cara. Corre carga sostenida sobre un entorno; consume runners y, si el blanco es un entorno compartido, lo satura para todos. Multiplicado por cada PR del día, es un costo enorme.
  • Necesita un entorno estable. Los números de una prueba de carga dependen de la máquina y su estado. En un runner de PR compartido y ruidoso, el p95 varía tanto que el gate daría falsos rojos —y un gate que falla al azar se ignora—.
  • Su señal es de otro ritmo. Un bug funcional lo atrapas en segundos con un test unitario, en cada PR. Una regresión de rendimiento es más lenta de gestar y más cara de medir: basta atraparla cada noche o antes de cada release, no en cada commit.

Por eso el capstone la dispara nightly (para atrapar regresiones acumuladas), pre-release (para no desplegar una degradación) y on-demand (cuando alguien quiere probar un cambio grande). Los tests unitarios y E2E cuidan cada PR; la prueba de carga cuida el rendimiento en un ritmo más pausado. Cada prueba en su lugar de la pirámide.

El mecanismo, ejecutado localmente

El load.yml es contenido, pero el mecanismo que lo hace funcionar —el exit code que hace fallar el step— es real, y lo ejecutamos en Python. Este ci_step.sh imita el paso "correr la prueba (el gate)": corre el gate y usa su exit code para autorizar o bloquear, con la anotación ::error:: que GitHub Actions usa para marcar un step en rojo. No usa git ni gh; es solo el shell mostrando la lógica del pipeline:

#!/bin/bash
# Mirror LOCAL del step de CI. NO usa git/gh; solo el shell y el gate en Python.
BASE="$1"; TARGET="$2"
python3.14 loadtest.py "$BASE" "$TARGET" results.json > run.log 2>&1
CODE=$?
tail -n 3 run.log
if [ "$CODE" -ne 0 ]; then
    echo "::error::La prueba de carga no cumplio el SLO (exit $CODE). Deploy BLOQUEADO."
    exit "$CODE"
fi
echo "Prueba de carga dentro del SLO. Deploy autorizado."

Primero contra el build sano (/quote):

Qué esperar — el gate pasa, el step no marca error, el job sale con 0 (verde):

$ ./ci_step.sh "http://127.0.0.1:$(cat PORT)" /quote
== step: prueba de carga contra /quote ==
metricas exportadas -> results.json
GATE: PASS  (exit code 0)
Prueba de carga dentro del SLO. Deploy autorizado.
$ echo $?
0

Y contra el motor de precios pesado (/quote_cpu):

Qué esperar — el gate falla, el step emite ::error:: y sale con 1: el job se pone rojo y el deploy se bloquea:

$ ./ci_step.sh "http://127.0.0.1:$(cat PORT)" /quote_cpu
== step: prueba de carga contra /quote_cpu ==
metricas exportadas -> results.json
GATE: FAIL  (exit code 1)
::error::La prueba de carga no cumplio el SLO (exit 1). Deploy BLOQUEADO.
$ echo $?
1

Ese es el corazón del load.yml, ejecutado localmente: el step corre el gate, y su exit code decide si el job es verde o rojo. En GitHub Actions, k6 run haría de gate saliendo con 99 en vez del 1 de Python, y ::error:: marcaría el step —pero la lógica es idéntica: un código ≠ 0 pone el job en rojo y detiene el deploy. El YAML es la forma industrial; el shell es el mismo mecanismo, ejecutado.

Errores comunes

Poner la prueba de carga en cada PR. Qué pasa: se añade on: [push, pull_request] y cada commit dispara una prueba de diez minutos. Por qué pasa: se copia el patrón de los tests unitarios. Cómo detectarlo: si tu load.yml corre en cada push, los PRs se vuelven lentísimos y el equipo empieza a ignorar (o desactivar) la prueba. Cómo corregirlo: dispárala con schedule (nightly), workflow_dispatch (on-demand) y push sobre tags (pre-release) —no en cada PR—. La prueba de carga cuida el rendimiento en un ritmo pausado; los PRs los cuidan pruebas rápidas.

Olvidar if: always() en el artifact. Qué pasa: el paso que sube el results.json corre solo si los anteriores tuvieron éxito, así que las corridas fallidas no dejan artifact. Por qué pasa: por defecto, un step no corre si un step previo falló. Cómo detectarlo: si el build rojo no tiene JSON adjunto, olvidaste el if: always(). Cómo corregirlo: añade if: always() al step de upload-artifact —el registro de la corrida roja es justo el que quieres bajar para investigar—. Es el mismo principio que "exportar antes de salir" de la lección 6, en la sintaxis de CI.

Creer que el load.yml corrió aquí. Qué pasa: alguien ve el YAML y lo cita como "lo que hizo la guía". Por qué pasa: el contenido de GitHub Actions se ve muy real. Cómo detectarlo: k6 no está instalado y aquí nunca se ejecuta git/gh; lo ejecutado es el mirror en shell con python3.14 .... Cómo corregirlo: recuerda la regla —el load.yml es contenido rotulado, fiel a la doc; lo que se ejecuta y demuestra el mecanismo es el gate en Python—.

Ejercicios

Ejercicio 1 — Elige el disparador. Para cada situación, di qué disparador de on: la cubre: (a) atrapar regresiones que se acumulan a lo largo de la semana. (b) no desplegar la versión v2.3.0 si su rendimiento se degradó. (c) un dev quiere probar el rendimiento de su rama grande antes de mergear.

Ver solución
  • (a) schedule con un cron (nightly): corre cada noche y atrapa las regresiones acumuladas sin que nadie se acuerde.
  • (b) push sobre tags v* (pre-release): al taggear v2.3.0, la prueba corre y bloquea el release si el threshold falla.
  • (c) workflow_dispatch (on-demand): el botón manual que el dev aprieta para correr la prueba contra su rama cuando quiere.

Los tres disparadores del capstone cubren los tres ritmos; ninguno es "en cada PR".

Ejercicio 2 — ¿Por qué 99 y no 1? El load.yml usa k6 run, que sale con 99 cuando un threshold falla; el gate de Python sale con 1. (a) ¿Le importa la diferencia al pipeline? (b) ¿Por qué k6 reserva un código específico (99) en vez de un 1 genérico?

Ver solución
  • (a) No. El pipeline solo distingue "0" (éxito) de "≠ 0" (fallo). Tanto el 99 de k6 como el 1 de Python son ≠ 0, así que ambos hacen fallar el step y bloquean el deploy exactamente igual.
  • (b) Para distinguir la causa. k6 usa 99 (ThresholdsHaveFailed) solo cuando "la prueba corrió completa pero no cumplió los umbrales", y otros códigos para "error de script", "fallo de setup", etc. Así, quien lea el resultado sabe si el build se puso rojo por un threshold roto (un problema de rendimiento real) o por un error de la prueba misma (un problema de infraestructura). El pipeline no necesita la distinción, pero el humano que investiga, sí (M5).

Ejercicio 3 — El gate que no bloquea. Un equipo pone la prueba de carga en CI pero le añade continue-on-error: true al step de k6 run, "para que el build no se ponga rojo". (a) ¿Qué le pasa al gate? (b) ¿Qué tienen ahora, y para qué sirve?

Ver solución
  • (a) El gate deja de ser un gate. continue-on-error: true hace que el job siga (verde) aunque el step de k6 run falle con 99. El threshold sigue evaluándose, pero su fallo ya no detiene nada: el deploy ocurre igual, con la regresión adentro.
  • (b) Tienen la prueba de carga como monitoreo, no como gate: mide y reporta (el JSON, el resumen), pero no bloquea. Sirve para observar el rendimiento sin arriesgar bloquear deploys —útil al principio, cuando aún no confías en la estabilidad de la prueba, para juntar datos sin frenar al equipo—. Pero mientras tenga continue-on-error, no protege el deploy: una regresión pasa igual. Convertirla en gate de verdad es quitar ese continue-on-error y dejar que el exit code haga su trabajo. (Es el equivalente en CI del "gate decorativo" de la lección 6: mide pero no bloquea.)

Resumen y siguiente paso

En esta lección automatizaste la prueba: la cableaste a la alarma con el .github/workflows/load.yml (contenido). El workflow levanta la API, corre k6 con los thresholds como gate (si un threshold falla, k6 run sale con 99 y el job se pone rojo), y sube el results.json como artifact con if: always() para conservar el registro de las corridas rojas. Y decidiste cuándo dispararlo —nightly, pre-release, on-demand, no en cada PR—, porque una prueba de carga es lenta, cara, sensible al entorno y de otro ritmo que un test unitario. Comprobaste el mecanismo de verdad con un mirror local en shell: un step que corre el gate y usa su exit code (con la anotación ::error::) para autorizar el deploy con el build sano y bloquearlo con el motor pesado —sin tocar git ni gh—.

Reusaste el módulo 7 (correr k6 en CI, cuándo dispararlo) y el módulo 5 (el threshold como gate). Antes de avanzar deberías poder: leer un load.yml y explicar cada bloque; justificar por qué no va en cada PR; y explicar por qué if: always() importa. Lo que sigue, en la lección 8, es la entrega: juntar las cuatro piezas —el script k6, la API, la corrida Python con su gate verde y rojo, y este load.yml— en la prueba de carga completa, con una rúbrica. Cierra el capstone y cierra la guía.

Recursos