Módulo 7: Analizar resultados y CI
6. Correr k6 en CI: el `load.yml` y el gate
Descripción
Todo lo anterior —medir, exportar, detectar una regresión— vale mucho más cuando corre solo, sin que nadie tenga que acordarse de lanzarlo. Eso es integración continua (CI): un pipeline que, ante un disparador (un push, una hora programada), ejecuta pasos automáticamente. En esta lección pones la prueba de carga en CI. Del lado de k6, escribes el .github/workflows/load.yml —presentado como contenido rotulado, porque aquí nunca se ejecuta git ni gh— que instala k6, corre k6 run con los thresholds del módulo 5 como gate, y sube el JSON como artifact. El mecanismo central es el que ya conoces: si un threshold falla, k6 run sale con código distinto de cero, el step del pipeline falla, y eso bloquea el merge o el deploy. Del lado ejecutable, montas ese mismo mecanismo en un paso de shell real: el chequeo de regresión de la lección 4, cuyo exit code —con && y ||— autoriza o bloquea el deploy de verdad.
Conexión con el módulo: esta lección abre la mitad de automatizar. Toma el veredicto del módulo 5 (el threshold → exit code) y el chequeo de la lección 4, y los instala en un pipeline. Reúsa los thresholds (M5) como el gate, el script de k6 (M2) como lo que se corre, y la exportación (lección 3) como el artifact. Lo que sigue (lección 7) responde la pregunta que este YAML deja abierta: cuándo disparar este pipeline (el bloque on:).
El portero automático de la fábrica
En una fábrica bien montada, ningún producto sale al camión de reparto sin pasar por un portero automático al final de la línea. El portero no opina ni se distrae: tiene una regla ("la caja debe pesar lo correcto y estar sellada") y una compuerta física. Si la caja cumple, la compuerta se abre y la caja sube al camión. Si no cumple, la compuerta se cierra y la caja se desvía a revisión. Nadie tiene que estar mirando; el portero actúa con cada caja, a la velocidad de la línea, sin cansarse.
Un pipeline de CI es esa línea, y el gate de rendimiento es ese portero. El load.yml describe la línea (los pasos); el threshold es la regla del portero; y el exit code es la compuerta física. Cuando el threshold falla, k6 run sale con código ≠ 0, la compuerta se cierra, y el cambio de código se desvía —no se mergea, no se despliega—. Lo que hace tan poderoso a este montaje es que el portero es incansable y objetivo: revisa cada cambio con el mismo rigor, a las 3 de la mañana o antes de un lanzamiento importante, sin ceder a la prisa. Este es el portero que vas a construir.
El load.yml (contenido)
Aquí está el workflow de GitHub Actions que corre la prueba de carga. Es contenido rotulado, fiel a la documentación de GitHub Actions y de k6; en este entorno no se ejecuta git ni gh, así que este YAML es para leer y entender, no para correr aquí:
# CONTENIDO (no ejecutado aqui): .github/workflows/load.yml
# Corre la prueba de carga de k6 en CI con los thresholds como gate.
# Ver grafana.com/docs/k6 y docs.github.com/actions.
name: load-test
on:
schedule:
- cron: "0 3 * * *" # cada noche a las 03:00 UTC (nightly)
workflow_dispatch: # y a mano, cuando alguien lo dispare
# (por que NO 'on: pull_request' -> es la leccion 7)
jobs:
load:
runs-on: ubuntu-latest
steps:
- name: Traer el codigo
uses: actions/checkout@v4
- name: Arrancar la API de Reservo (el blanco)
run: |
python3 reservo_server.py & # en segundo plano
sleep 2 # darle un momento para levantar
- name: Instalar k6
uses: grafana/setup-k6-action@v1
- name: Correr la prueba de carga (los thresholds son el GATE)
run: k6 run --out json=results.json load_test.js
# Si un threshold falla, `k6 run` sale con codigo 99 -> este step
# falla -> el job entero falla -> el merge/deploy queda bloqueado.
- name: Guardar el JSON como artifact
if: always() # subirlo aunque el step anterior fallara
uses: actions/upload-artifact@v4
with:
name: k6-results
path: results.json
Desarmémoslo por las partes que importan:
on:— los disparadores. Aquí,schedule(nightly, con un cron) yworkflow_dispatch(a mano). Deliberadamente no estápull_request: la prueba de carga no va en cada PR, y el porqué es la lección 7.- Arrancar el blanco. Antes de lanzar carga hay que levantar la API que se va a probar. En CI, el runner arranca el servidor de Reservo en segundo plano (o levanta el servicio real contra el que se prueba).
- Instalar k6. El paso
grafana/setup-k6-action@v1instala el binario de k6 en el runner. (k6 es un binario de Go; el runner de CI sí lo instala, a diferencia de este entorno de la guía.) k6 runcon el gate. Este es el corazón.k6 runcorre el script con susoptions.thresholds(M5). El comportamiento decisivo: si un threshold falla,k6 runtermina con exit code99. Como cualquier paso de CI, un exit code ≠ 0 marca el paso —y con él el job— como fallido. Un job fallido es lo que bloquea el merge (si es un check requerido) o detiene el deploy. El--out json=results.jsonexporta el resultado (lección 3) para el siguiente paso.upload-artifactconif: always(). Sube elresults.jsoncomo artifact descargable. Elif: always()es importante: lo sube aunque el step anterior haya fallado —justo cuando el gate falla es cuando más quieres el JSON para investigar por qué—.
La pieza que convierte esto en un gate no es ninguna configuración especial: es que k6 run propaga su veredicto como exit code, y CI respeta los exit codes por diseño. El threshold del módulo 5 no era un adorno; era la condición que, a través del exit code, cierra la compuerta.
El lado ejecutable: el exit code que decide el deploy
k6 y el YAML son contenido, pero el mecanismo —un exit code que autoriza o bloquea— lo podemos correr de verdad, y es idéntico. Un paso de CI, en el fondo, es un comando de shell cuyo éxito o fracaso se decide por su exit code. Podemos simular el "paso del gate" con el chequeo de regresión de la lección 4 y los operadores && (ejecuta lo siguiente solo si el anterior tuvo éxito) y || (ejecuta lo siguiente solo si el anterior falló):
Qué esperar — cuando el gate falla (hay regresión), el || se dispara y el deploy queda bloqueado; el exit code del gate fue 1:
$ python3.14 check_regression.py results_baseline.json results_actual.json 20 > /dev/null \
&& echo "DEPLOY: autorizado" \
|| echo "DEPLOY: BLOQUEADO (el step de CI fallo, exit code $?)"
DEPLOY: BLOQUEADO (el step de CI fallo, exit code 1)
Qué esperar — cuando el gate pasa (sin regresión), el && se dispara y el deploy queda autorizado:
$ python3.14 check_regression.py results_baseline.json results_baseline2.json 20 > /dev/null \
&& echo "DEPLOY: autorizado" \
|| echo "DEPLOY: BLOQUEADO"
DEPLOY: autorizado
Eso es, en miniatura y ejecutado de verdad, exactamente lo que hace CI. El check_regression.py es el análogo de k6 run: devuelve exit code 1 cuando el veredicto es "fallé", y el shell —como el pipeline— reacciona a ese código. El && echo "autorizado" || echo "BLOQUEADO" es el análogo de "el job pasó → despliega / el job falló → detente". En un pipeline real, ese "BLOQUEADO" es GitHub marcando el check en rojo y no dejando mergear. La lógica es la misma; solo cambia la escala.
k6 y el chequeo de regresión, juntos en el pipeline
Vale la pena aclarar cómo conviven las dos herramientas del módulo dentro de un mismo load.yml, porque atrapan cosas distintas (lo viste en la lección 4):
k6 runcon thresholds es el gate del SLO absoluto: falla si el p95 cruza el límite que prometiste (200 ms). Es el paso principal.- El chequeo de regresión es el gate relativo: falla si el p95 empeoró respecto al baseline guardado, aunque siga bajo el SLO. Se añade como un paso extra que descarga el
results.jsondel baseline (un artifact anterior) y lo compara con el de esta corrida.
Un pipeline maduro tiene los dos pasos. Cada uno propaga su propio exit code, y cualquiera de los dos que falle pone el job en rojo. Así el deploy se bloquea tanto si rompiste el SLO como si degradaste el rendimiento sin llegar a romperlo todavía —las dos redes de seguridad, en el mismo portero—.
Errores comunes
Correr la prueba de carga pero ignorar su exit code. Qué pasa: el pipeline corre k6 run, pero el resultado se guarda en un panel que nadie mira, o el paso está configurado para "no fallar nunca" (continue-on-error: true). Por qué pasa: se trata la prueba como un reporte, no como un gate. Cómo detectarlo: si una regresión no puede poner el build en rojo, no tienes un gate, tienes un adorno. Cómo corregirlo: deja que el exit code de k6 run (o del chequeo) falle el step; el gate solo protege si tiene dientes.
Subir el artifact solo cuando el gate pasa. Qué pasa: el upload-artifact no tiene if: always(), así que cuando el gate falla —el caso más interesante— el JSON no se sube y no puedes investigar. Por qué pasa: por defecto, un step no corre si el anterior falló. Cómo detectarlo: builds rojos sin artefacto para diagnosticar. Cómo corregirlo: pon if: always() en el paso de subida, para tener el results.json especialmente cuando algo falló.
Olvidar arrancar el blanco antes de correr k6. Qué pasa: el k6 run arranca pero todas las peticiones fallan con "connection refused", porque la API no estaba levantada. Por qué pasa: se asume que el servicio ya está corriendo en el runner. Cómo detectarlo: tasa de error del 100% y un http_req_failed que dispara el threshold por la razón equivocada. Cómo corregirlo: añade el paso que arranca la API (y espera a que esté lista) antes del k6 run, como en el YAML de arriba.
Ejercicios
Ejercicio 1 — ¿Qué pone el build en rojo? En el load.yml, di si cada situación hace fallar el job (build rojo) o no. (a) Un threshold p(95)<200 se rompe y k6 run sale con 99. (b) El k6 run termina con todos los thresholds en verde (exit 0). (c) El paso de upload-artifact con if: always() corre después de que k6 run falló.
Ver solución
- (a) Falla (build rojo). Exit code 99 ≠ 0 → el step falla → el job falla → merge/deploy bloqueado.
- (b) No falla (build verde). Exit code 0 → el step pasa → el job continúa.
- (c) El paso de subida corre (por el
if: always()) y, si sube bien, tiene exit 0 —pero el job ya estaba marcado como fallido por elk6 runanterior—. Subir el artifact no "rescata" el build: el job sigue rojo por el fallo del gate. Elif: always()solo garantiza que el JSON quede disponible para investigar.
Ejercicio 2 — Lee el shell. El comando fue python3.14 check_regression.py A B 20 && echo "autorizado" || echo "BLOQUEADO". (a) ¿Qué imprime si el chequeo sale con exit 0? (b) ¿Y con exit 1? (c) ¿Cómo se relaciona esto con "el job de CI pasó/falló"?
Ver solución
- (a)
autorizado. Exit 0 = éxito → el&&ejecuta el primerecho. - (b)
BLOQUEADO. Exit 1 = fallo → el&&se salta y el||ejecuta el segundoecho. - (c) Es el mismo mecanismo: en CI, "el job pasó" es exit 0 y dispara el siguiente paso (desplegar); "el job falló" es exit ≠ 0 y detiene el pipeline (bloquear). El
&&/||del shell es la versión de una línea de lo que el pipeline hace entre pasos.
Ejercicio 3 — Añade el paso de regresión. El load.yml de arriba solo corre k6 run (el gate absoluto). Describe, en prosa, cómo añadirías el gate relativo (el chequeo de regresión) como un paso más. (a) ¿Qué necesita ese paso para poder comparar? (b) ¿Qué pasa si ese paso falla?
Ver solución
- (a) Necesita el baseline: el
results.jsonde una corrida anterior de referencia. El paso lo descargaría (por ejemplo, el artifact de la última corrida enmain, o un baseline versionado en el repo), y luego correríacheck_regression.py baseline.json results.json 20comparándolo con elresults.jsonquek6 runacaba de exportar en esta corrida. - (b) Si el chequeo de regresión falla (exit 1 porque el p95 empeoró más del umbral), ese step falla, y —como cualquier step fallido— pone el job en rojo y bloquea el deploy. Así el pipeline tiene dos gates: el absoluto (
k6 runvs SLO) y el relativo (regresión vs baseline). Cualquiera de los dos que falle detiene el deploy.
Resumen y siguiente paso
En esta lección pusiste la prueba de carga en CI. Escribiste el .github/workflows/load.yml (contenido): traer el código, arrancar el blanco, instalar k6, correr k6 run con los thresholds como gate, y subir el JSON como artifact con if: always(). El mecanismo que lo hace un gate es el mismo del módulo 5: k6 run sale con código 99 si un threshold falla, ese exit code ≠ 0 marca el job como fallido, y un job fallido bloquea el merge o el deploy —el portero automático de la fábrica—. Del lado ejecutable, montaste ese mecanismo de verdad con &&/||: el chequeo de regresión que, según su exit code, autoriza (DEPLOY: autorizado) o bloquea (DEPLOY: BLOQUEADO) el deploy. Y viste cómo conviven en un mismo pipeline el gate absoluto (k6 run vs SLO) y el relativo (regresión vs baseline), cada uno con su exit code.
Antes de avanzar deberías poder: leer un load.yml y señalar qué paso es el gate y por qué; explicar cómo un exit code ≠ 0 bloquea el deploy; y describir cómo &&/|| reproducen esa lógica. Lo que sigue, en la lección 7, es la pregunta que el on: de este YAML deja abierta: cuándo correr la prueba de carga —por qué nightly y pre-release sí, y en cada PR no—.
Recursos
- k6 — Running k6 in CI / GitHub Actions — la referencia oficial de integrar k6 en un pipeline y cómo su exit code gatea el build. La fuente del contenido de esta lección.
- GitHub Actions — Workflow syntax — la sintaxis del
load.yml:on,jobs,steps,uses,run. Cómo un step que sale con código ≠ 0 falla el job. - GitHub Actions —
upload-artifact— cómo se sube elresults.jsoncomo artifact descargable, conif: always()para tenerlo incluso cuando el gate falla. - k6 — Thresholds — el recordatorio de que un threshold roto hace salir a
k6 runcon código 99; es la pieza que, vía exit code, convierte el paso de CI en un gate.