Módulo 5: Thresholds — pasa/falla y SLOs
1. Presentación del módulo: de medir a juzgar
Descripción
Los cuatro módulos anteriores te enseñaron a medir. Sabes escribir un script de k6 y lanzar usuarios virtuales (módulo 2); sabes qué significan la latencia p95, el throughput y la tasa de error, y sabes calcularlos de verdad (módulo 3); sabes moldear la carga con perfiles y etapas para que se parezca a la realidad (módulo 4). Al final de todo eso tienes números. Pero un número no decide nada por sí solo. "El p95 fue 246 ms" no es una conclusión: es un dato esperando a que alguien lo juzgue. Y mientras ese juicio dependa de que un humano mire una gráfica y diga "mmm, me parece bien", el rendimiento no está protegido —porque los humanos se distraen, tienen prisa antes de un lanzamiento y aprueban cosas que no deberían—.
Este módulo cierra esa brecha. Aquí la prueba de carga deja de informar y empieza a juzgar. La herramienta que lo hace posible se llama threshold (umbral): una regla sobre una métrica —"el p95 debe estar por debajo de 200 ms"— que convierte una medición en un veredicto binario: pasa o falla. Y ese veredicto tiene dientes: cuando un threshold falla, la prueba sale con un código de error que un pipeline de integración continua (CI) entiende como "fallé", y que detiene el deploy sin que nadie tenga que aprobar nada a mano. Eso es un quality gate de rendimiento: una compuerta automática que solo se abre si la app aguanta la carga con la latencia y la fiabilidad que prometiste.
Conexión con el módulo: esta lección es el mapa. Explica el salto conceptual —de medir a juzgar—, presenta la pieza que lo hace posible (el threshold y el código de salida), declara el endpoint /quote_cpu que este módulo añade a la API canónica para poder ver un p95 cruzar un umbral de verdad, y fija las fronteras con el resto de la guía. Reúsa todo lo anterior: el script y los VUs (M2), las métricas que vas a poner bajo umbral (M3), los perfiles que generan la carga (M4). Lo que viene después —los check() de corrección y los escenarios (M6), y el análisis y el pipeline de CI a fondo (M7)— usa este gate como cimiento. Aquí construimos el veredicto; M7 lo instala en el pipeline entero.
El semáforo de la fábrica
Imagina una línea de embotellado. Al final de la cinta, cada botella pasa por una estación con una báscula y una cámara. La estación no te entrega un informe con el peso exacto de cada botella para que tú lo leas botella por botella —serían miles por hora, nadie los miraría—. Hace algo mucho más útil: tiene una regla ("la botella debe pesar entre 498 y 502 gramos y estar bien tapada") y, con esa regla, enciende una luz. Verde: la botella sigue a empaque. Roja: un brazo mecánico la saca de la cinta. La estación convirtió una medición continua (el peso, en gramos) en una decisión binaria (pasa / no pasa), y esa decisión actúa sola: aparta la botella mala sin que un supervisor tenga que estar mirando.
Un threshold es exactamente esa regla, y el código de salida es exactamente ese brazo mecánico. La báscula es tu medición de p95; la regla es p(95) < 200; la luz verde o roja es el veredicto pasa/falla; y el brazo que aparta la botella es el pipeline de CI que, al ver el código de error, bloquea el deploy. Sin la estación, alguien tendría que pesar botellas a mano y confiar en su ojo —lento, caro y falible—. Con la estación, la calidad se protege sola, a la velocidad de la cinta. Este módulo te enseña a construir esa estación para el rendimiento de tu API.
Qué es un quality gate (y por qué el rendimiento necesita uno)
Un quality gate —compuerta de calidad— es un chequeo automático que un cambio de código debe pasar antes de avanzar hacia producción. Ya conoces varios de otras familias de pruebas, aunque quizás no los llamabas así:
- Los tests unitarios que corren en CI son un gate: si uno falla, el build se pone rojo y el merge se bloquea.
- Un coverage gate es otro: si la cobertura de pruebas baja del 80%, el build falla (lo verás a fondo en la lección 6, porque es el pariente más cercano de lo que hacemos aquí).
- El linter es un gate de estilo: si el código no cumple las reglas de formato, no pasa.
Todos comparten la misma anatomía: miden algo, lo comparan contra un umbral, y emiten un veredicto binario que el pipeline respeta. El rendimiento se quedaba fuera de esa lista por una razón tonta: medirlo requería lanzar carga, y lanzar carga parecía "algo que se hace de vez en cuando, a mano, antes de un lanzamiento grande". Los thresholds de k6 corrigen eso. Convierten la prueba de carga en un gate más —uno que se puede correr en cada cambio— y así el rendimiento pasa de ser una preocupación intermitente y subjetiva a una condición objetiva y continua, igual que la corrección o la cobertura.
La consecuencia es profunda: una regresión de rendimiento se vuelve tan difícil de mergear como un test roto. Si tu cambio hace que el p95 suba de 180 ms a 260 ms y tu gate exige p(95) < 200, el build se pone rojo y el equipo se entera antes de desplegar, no después de que los usuarios se quejen. Eso es lo que este módulo te enseña a montar.
El endpoint que este módulo declara: /quote_cpu
Hay un problema práctico para enseñar thresholds contra Reservo: la API canónica es rapidísima. Corriendo en localhost, POST /quote responde en pocos milisegundos aun con decenas de clientes concurrentes. Eso es estupendo para la app real, pero pésimo para ver fallar un umbral: si el p95 siempre sale en 8 ms, cualquier umbral razonable (p(95) < 200) siempre pasa, y nunca verías la luz roja. Necesitamos un blanco cuyo p95 suba de verdad bajo carga, para poder verlo cruzar el umbral.
Como estableció el diseño de la guía, un módulo que necesita un comportamiento extra lo añade y lo declara. El módulo 3 declaró un endpoint lento (/quote_slow) con latencia fija. Este módulo declara uno distinto y más interesante para nuestro propósito: /quote_cpu, que hace lo mismo que /quote (recibe {room, tier, hours}, devuelve {price_cents}, con los mismos números-ancla 7500 y 6000) pero antes de responder hace trabajo de CPU real: un bucle que suma cuadrados. La clave es por qué eso degrada bajo carga.
Python tiene un GIL (Global Interpreter Lock, "candado global del intérprete"): un candado que permite que solo un hilo ejecute bytecode de Python a la vez. El trabajo de espera (dormir, esperar red) libera el GIL, pero el trabajo de CPU puro —como nuestro bucle de sumas— lo retiene. Consecuencia: cuando llegan 4 peticiones concurrentes a /quote_cpu, casi no compiten (hay CPU de sobra y poco solapamiento). Pero cuando llegan 120 a la vez, todas quieren el GIL para su bucle, y el intérprete las serializa: cada petición espera su turno detrás de las demás. Esa espera es real, se acumula, y el p95 se dispara de decenas de milisegundos a cientos. Es un modelo fiel de un backend CPU-bound (que hace cómputo pesado por petición) saturándose bajo tráfico —justo el tipo de degradación que una prueba de carga existe para atrapar—.
Este es el añadido al servidor canónico. El resto del servidor (el de la lección 6 del módulo 1) no cambia; solo se agrega la función burn_cpu, la rama de /quote_cpu en do_POST, y la constante CPU_WORK:
# --- AÑADIDO por el modulo 5 al servidor canonico de Reservo ---
# Cuanto trabajo de CPU hace /quote_cpu por peticion (iteraciones del bucle).
# Serializado por el GIL: bajo concurrencia alta, cada peticion espera su turno.
CPU_WORK = 60000
def burn_cpu(iterations):
"""Trabajo de CPU real (no sleep): un bucle que el GIL serializa entre hilos."""
total = 0
for i in range(iterations):
total += i * i
return total
# ...dentro de do_POST, despues de validar y ANTES de calcular el precio:
# if self.path == "/quote_cpu":
# burn_cpu(CPU_WORK) # trabajo de CPU serializado por el GIL
# cents = price_cents(room, tier, hours)
#
# if self.path in ("/quote", "/quote_cpu"):
# self._send_json(200, {"price_cents": cents})
Fíjate en la honestidad del diseño: /quote_cpu no finge estar lento con un sleep fijo. Hace trabajo real cuya latencia depende de la carga —barata cuando hay poca concurrencia, cara cuando hay mucha—. Por eso el mismo endpoint, con el mismo umbral, pasa con carga liviana y falla con carga pesada. Esa dependencia de la carga es precisamente lo que hace de él un buen blanco para enseñar el pasa/falla de un threshold: la degradación viene de la carga, que es la variable que una prueba de carga manipula.
La regla del entorno (otra vez, porque importa)
Esta guía tiene una regla de honestidad que gobierna cada lección, y en este módulo es especialmente relevante porque el veredicto —el pasa/falla— es el corazón del asunto:
- Lo que se ejecuta de verdad y se cita es Python. La API de Reservo (con
/quote_cpu), el generador de carga, el cálculo de p95/error/checks y —la estrella de este módulo— la funciónevaluate_thresholdscon su código de salida real (sys.exit), corren de verdad contralocalhosty su salida se pega tal cual. Cuando veas un bloque con el comandopython3.14 ...y unGATE: PASSoGATE: FAIL, eso pasó. - k6 va como contenido rotulado. k6 no está instalado en este entorno (es un binario de Go con su propio runtime JavaScript; node no lo corre). Sus bloques
options.thresholdsy su resumen son contenido, fieles a la documentación oficial de k6, nunca una salida fabricada y presentada como ejecutada. Cuando veas un bloque de JavaScript de k6 o su resumen, estará rotulado como contenido.
Esta separación no es una limitación molesta: es la que hace que el aprendizaje sea sólido. Ves el mecanismo real del pasa/falla con tus propias métricas en Python (un umbral roto → sys.exit(1) → el shell recibe $? = 1), y ves la forma industrial de ese mismo mecanismo en k6 (un umbral roto → k6 run sale con 99 → el CI falla). Son la misma idea a dos escalas, y entender la de Python es entender la de k6 por dentro.
Las fronteras de este módulo
Para que sepas qué es de este módulo y qué no:
- El script de k6 y los VUs (cómo se escribe la función
default, cómo se lanzan usuarios virtuales) son del módulo 2. Aquí los damos por sabidos: un threshold se declara sobre un script que ya existe. - Las métricas (qué es el p95, cómo se calcula, qué es la tasa de error) son del módulo 3. Aquí no las re-explicamos; las ponemos bajo umbral. Un threshold no inventa una métrica nueva: le pone una regla a una que ya sabes medir.
- Los perfiles de carga (stages, ramp-up, executors) son del módulo 4. Aquí generamos la carga con el generador de Python de siempre; el threshold juzga el resultado, sea cual sea el perfil que lo produjo.
- Los
check()de corrección y los escenarios son del módulo 6. Ojo con un matiz: en este módulo aparece el thresholdchecks: ['rate>0.99'], que usa la tasa de checks —pero elcheck()como herramienta de verificar la corrección bajo carga se desarrolla a fondo en M6—. Aquí lo tratamos como una métrica más a la que ponerle un umbral. - El análisis a fondo y el pipeline de CI completo (el
.github/workflows/load.yml, exportar resultados, detectar regresiones) son del módulo 7. Aquí construimos el veredicto (el exit code); M7 lo instala en el pipeline entero y muestra el YAML.
En una frase: este módulo es sobre el threshold y el pasa/falla. Cómo una métrica se vuelve una regla, cómo esa regla se vuelve un código de salida, y cómo ese código de salida se vuelve un gate que aprueba o rechaza.
Cómo se ve el destino (un adelanto ejecutado)
Para que el mapa no sea solo palabras, aquí está el final del camino, ejecutado de verdad. Es el gate en Python evaluando las métricas reales de /quote_cpu bajo dos cargas. Primero, carga liviana (4 clientes concurrentes): las tres reglas pasan, el gate abre, el código de salida es 0.
Qué esperar — con poca concurrencia el p95 se queda en milisegundos, bien por debajo del umbral de 200 ms; todo verde:
$ python3.14 threshold_gate.py http://127.0.0.1:PORT /quote_cpu 400 4 200
# /quote_cpu | 400 peticiones, concurrencia 4
THRESHOLD MEDIDO RESULTADO
----------------------------------------------------------------------
http_req_duration: p(95) < 200ms p(95) = 9.74ms PASS
http_req_failed: rate < 1.00% rate = 0.00% PASS
checks: rate > 99.00% rate = 100.00% PASS
----------------------------------------------------------------------
GATE: PASS (exit code 0)
Y ahora la misma regla, el mismo endpoint, pero con carga pesada (120 clientes concurrentes): el GIL serializa el trabajo de CPU, el p95 se dispara sobre el umbral, y el gate se cierra con código de salida 1.
Qué esperar — con 120 concurrentes el p95 cruza los 200 ms; solo esa regla falla, y con eso basta para que el gate entero falle:
$ python3.14 threshold_gate.py http://127.0.0.1:PORT /quote_cpu 2000 120 200
# /quote_cpu | 2000 peticiones, concurrencia 120
THRESHOLD MEDIDO RESULTADO
----------------------------------------------------------------------
http_req_duration: p(95) < 200ms p(95) = 246.96ms FAIL
http_req_failed: rate < 1.00% rate = 0.00% PASS
checks: rate > 99.00% rate = 100.00% PASS
----------------------------------------------------------------------
GATE: FAIL (exit code 1)
Ese es el módulo entero en dos corridas: la misma app, la misma regla, y un veredicto que cambia de verde a rojo según la carga —automático, binario, con un código de salida que un pipeline respeta—. (Los números exactos varían un poco en cada corrida, porque dependen de cómo el sistema operativo reparte la CPU; lo que no varía es la historia: liviana pasa, pesada falla.) El resto de las lecciones desarma esto pieza por pieza.
Errores comunes
Creer que medir es lo mismo que gatear. Qué pasa: un equipo corre pruebas de carga, guarda las gráficas de p95 en un panel bonito, y cree que "tiene cubierto el rendimiento". Pero nadie mira el panel con disciplina, y una regresión se cuela igual. Por qué pasa: se confunde tener el número con actuar sobre el número. Cómo detectarlo: si tu prueba de carga no puede fallar un build, no es un gate, es un reporte. Cómo corregirlo: ponle thresholds y conéctalos al exit code, para que el veredicto actúe solo (todo este módulo).
Pensar que k6 corrió aquí. Qué pasa: alguien ve un bloque options.thresholds o un resumen de k6 y lo cita como "lo que midió esta guía". Por qué pasa: el contenido de k6 se ve muy real. Cómo detectarlo: k6 no está instalado; los números ejecutados siempre vienen con un comando python3.14 ... y un GATE: PASS/FAIL. Cómo corregirlo: recuerda la regla —Python se ejecuta y se cita; k6 es contenido rotulado, fiel a la doc—.
Suponer que /quote_cpu es un truco irreal. Qué pasa: alguien objeta que degradar a propósito con un bucle de CPU es "hacer trampa". Por qué pasa: no se ve que es un modelo fiel de un backend real. Cómo detectarlo: si crees que en producción los endpoints nunca se vuelven lentos bajo carga, no has visto suficientes incidentes. Cómo corregirlo: entiende que /quote_cpu reproduce, en pequeño y de forma controlada, exactamente lo que le pasa a un servicio CPU-bound cuando le llega más tráfico del que su CPU aguanta —la degradación que el gate debe atrapar—.
Ejercicios
Ejercicio 1 — Reconoce el patrón del gate. Los cuatro chequeos siguientes son quality gates de distintas familias. Para cada uno, identifica: (i) la métrica que mide, (ii) el umbral, (iii) qué pasa si falla. (a) pytest --cov-fail-under=80. (b) Un linter que rechaza líneas de más de 100 caracteres. (c) http_req_duration: ['p(95)<200'] en k6. (d) La estación de la fábrica que pesa botellas.
Ver solución
- (a) Métrica: cobertura de pruebas (%). Umbral: 80%. Si falla (cobertura < 80%): el comando sale con código ≠ 0 y el build se pone rojo.
- (b) Métrica: longitud de cada línea (caracteres). Umbral: 100. Si falla (hay líneas más largas): el linter sale con código ≠ 0 y el paso de CI falla.
- (c) Métrica: latencia p95 (
http_req_duration). Umbral: 200 ms. Si falla (p95 ≥ 200):k6 runsale con código 99 y el paso de CI falla. - (d) Métrica: peso de la botella (gramos). Umbral: 498–502 g. Si falla: la luz se pone roja y el brazo aparta la botella.
Los cuatro tienen la misma anatomía: medir → comparar con umbral → veredicto binario que actúa solo. Esa es la idea unificadora del módulo.
Ejercicio 2 — Predice el veredicto. El umbral es p(95) < 200. Para cada corrida medida, di si el gate pasa o falla, y por qué. (a) p95 = 9.74 ms. (b) p95 = 246.96 ms. (c) p95 = 199.9 ms. (d) p95 = 200.0 ms.
Ver solución
- (a) PASA. 9.74 < 200. Carga liviana; sobra margen.
- (b) FALLA. 246.96 ≥ 200. Carga pesada; el p95 cruzó el umbral.
- (c) PASA. 199.9 < 200. Por un pelo, pero pasa: la regla es estricta menor-que.
- (d) FALLA. 200.0 no es menor que 200. El umbral
p(95)<200exige estrictamente menor; 200.0 exacto no cumple. (Es un buen recordatorio de que el operador importa:<no es<=.)
Ejercicio 3 — Explica el GIL en /quote_cpu. En dos o tres frases, explica por qué /quote_cpu responde rápido con 4 clientes concurrentes pero lento con 120, mencionando el GIL. ¿Por qué esa dependencia de la carga es justo lo que necesitamos para enseñar thresholds?
Ver solución
/quote_cpu hace trabajo de CPU puro (un bucle de sumas) por petición, y el GIL de Python permite que solo un hilo ejecute bytecode a la vez. Con 4 clientes concurrentes hay poco solapamiento y casi no compiten por el GIL, así que cada petición termina rápido (p95 de milisegundos). Con 120 clientes, todos quieren el GIL para su bucle a la vez y el intérprete los serializa: cada petición espera su turno detrás de muchas otras, esa espera se acumula, y el p95 se dispara a cientos de milisegundos. Esa dependencia de la carga es ideal para enseñar thresholds porque hace que el mismo endpoint con el mismo umbral pase con carga liviana y falle con carga pesada —el pasa/falla en función de la carga, que es exactamente lo que un threshold juzga—.
Resumen y siguiente paso
Este módulo da el salto de medir a juzgar. Hasta ahora una prueba de carga producía números para que un humano los interpretara; a partir de aquí, produce un veredicto binario —pasa o falla— que actúa solo. La pieza que lo hace posible es el threshold: una regla sobre una métrica (p(95) < 200) que, cuando se rompe, hace que la prueba salga con un código de error que un pipeline de CI entiende como "fallé" y usa para bloquear el deploy. Eso es un quality gate de rendimiento, y tiene la misma anatomía que un coverage gate o un test roto: medir, comparar con umbral, emitir un veredicto que el pipeline respeta.
Viste la analogía de la estación de la fábrica (báscula = medición, regla = threshold, luz roja = veredicto, brazo mecánico = exit code), la razón por la que el rendimiento merecía su propio gate, y el endpoint que este módulo declara —/quote_cpu, con trabajo de CPU serializado por el GIL— para poder ver un p95 cruzar el umbral de verdad. Y viste el destino ejecutado: el mismo gate, verde con carga liviana (p95 = 9.74 ms) y rojo con carga pesada (p95 = 246.96 ms), con su código de salida real.
Antes de avanzar deberías poder: explicar en qué se diferencia medir de gatear; nombrar la anatomía común de todos los quality gates; y explicar por qué /quote_cpu degrada bajo carga y por qué eso lo hace un buen blanco. Lo que sigue, en la lección 2, es la definición precisa de un threshold y del pasa/falla, con la primera versión ejecutable del espejo en Python: una función que toma tus latencias reales y devuelve un veredicto. Empezamos a construir la estación.
Recursos
- k6 — Thresholds — la referencia oficial que gobierna todo el módulo: qué son los thresholds, cómo se declaran y por qué hacen fallar la prueba. La fuente de todo el contenido de k6 aquí.
- Google SRE Book — Service Level Objectives — el fundamento de por qué se le pone un umbral a una métrica y cómo se elige (lo desarrollamos en la lección 5). SLI, SLO, SLA y error budget.
sys.exit— documentación de Python — el mecanismo con el que el gate en Python devuelve su código de salida (0 = pasa, ≠ 0 = falla), el espejo ejecutable del exit code de k6. Lo usamos a fondo en la lección 4.- k6 — El GIL de Python no aplica a k6 (contexto) — nota de contraste: k6 corre los VUs en paralelo real (es Go); el GIL que degrada
/quote_cpues una característica de nuestro servidor Python, el blanco, no del generador de carga. Útil para no confundir dónde vive la serialización.