Módulo 5: Thresholds — pasa/falla y SLOs
8. Mini-proyecto: un gate de rendimiento
Descripción
Es hora de reunir todo el módulo con tus propias manos. En este mini-proyecto construyes un gate de rendimiento completo para la API de Reservo: levantas el servidor (con el endpoint /quote_cpu que el módulo declaró), mides sus métricas reales bajo dos cargas —una liviana y una pesada—, aplicas una función evaluate_thresholds que emite un veredicto pasa/falla y sale con el código de salida correspondiente, y escribes el bloque thresholds de k6 equivalente como contenido. El resultado es el módulo entero encarnado en una entrega: verás el gate en verde con la carga liviana (todo bajo umbral, exit code 0) y en rojo con la pesada (el p95 cruza el umbral, exit code 1), con los números y los códigos de salida reales que produjo tu máquina. No es un ejercicio de rellenar huecos: es el flujo real de poner un rendimiento bajo gate, de principio a fin.
Conexión con el módulo: este capstone sintetiza las siete lecciones anteriores. Usa el threshold y el pasa/falla (L2), las tres métricas de k6 (L3), el exit code que gatea el CI (L4), el criterio para elegir el umbral (L5), la visión del gate como uno más de la familia (L6) y —opcionalmente— los umbrales por parte del sistema (L7). Es la pieza ejecutable que demuestra que entendiste el módulo: no "sé qué es un threshold", sino "puedo montar un gate de rendimiento que aprueba o rechaza un deploy con datos reales". Lo que sigue (módulo 6) añade los check() de corrección bajo carga y los escenarios; el módulo 7 instala este gate en el pipeline de CI completo.
Qué vas a entregar
Tu entrega tiene cuatro piezas:
- La API de Reservo corriendo, con
/quote_cpudeclarado (el servidor canónico + el añadido de la lección 1). - El gate ejecutable (
threshold_gate.py): una funciónevaluate_thresholdsque evalúa p95, tasa de error y tasa de checks reales contra sus umbrales, imprime PASS/FAIL por cada uno, y sale consys.exit(0)osys.exit(1). - Dos corridas reales: el gate en verde con carga liviana y en rojo con carga pesada, cada una con su código de salida real (
echo $?). - El bloque
thresholdsde k6 equivalente, como contenido rotulado, más una breve justificación del umbral elegido (su SLO).
Paso 1 — Levanta la API con /quote_cpu
Parte del servidor canónico de Reservo (módulo 1, lección 6) y añádele el endpoint /quote_cpu que declaró este módulo (lección 1): la constante CPU_WORK, la función burn_cpu, y la rama en do_POST que hace el trabajo de CPU antes de responder. Recuerda las tres decisiones de diseño: dinero en centavos enteros, request_queue_size amplia y ThreadingHTTPServer para aguantar concurrencia, y puerto 0 para no chocar con otros procesos. Arráncalo y lee su puerto.
Qué esperar — el servidor imprime el puerto que le asignó el sistema operativo (varía en cada arranque), y responde con los números-ancla en los dos endpoints:
$ python3.14 reservo_server.py &
Reservo escuchando en http://127.0.0.1:57565
$ curl -s -X POST http://127.0.0.1:57565/quote \
-H 'Content-Type: application/json' -d '{"room":"Focus","tier":"basic","hours":3}'
{"price_cents": 7500}
$ curl -s -X POST http://127.0.0.1:57565/quote_cpu \
-H 'Content-Type: application/json' -d '{"room":"Focus","tier":"basic","hours":3}'
{"price_cents": 7500}
Los dos endpoints devuelven 7500 (Focus/basic/3h). La diferencia no está en qué responden —la lógica de negocio es idéntica—, sino en cuánto tardan bajo carga: /quote_cpu hace trabajo de CPU que el GIL serializa, así que su latencia se degrada con la concurrencia. Ese es el blanco que hará cruzar el umbral.
Paso 2 — El gate ejecutable
El corazón de la entrega es la función evaluate_thresholds y el main que la envuelve. La función aplica las tres reglas y devuelve el veredicto; el main mide, evalúa, y sale con el código correspondiente. Este es el gate completo (reúne lo de las lecciones 2 y 4):
"""Gate de rendimiento — mide Reservo y evalua thresholds con exit code real."""
import json
import statistics
import sys
import time
import urllib.error
import urllib.request
from concurrent.futures import ThreadPoolExecutor
BASE_URL = sys.argv[1]
PATH = sys.argv[2]
TOTAL_REQUESTS = int(sys.argv[3])
CONCURRENCY = int(sys.argv[4])
P95_LIMIT_MS = float(sys.argv[5])
def q(data, p):
return statistics.quantiles(data, n=100, method="inclusive")[p - 1]
def one_request():
"""Una peticion. Devuelve (latency_ms, ok_error, ok_check)."""
payload = json.dumps({"room": "Focus", "tier": "basic", "hours": 3}).encode()
req = urllib.request.Request(
f"{BASE_URL}{PATH}", data=payload,
headers={"Content-Type": "application/json"}, method="POST",
)
start = time.perf_counter()
try:
with urllib.request.urlopen(req, timeout=30) as resp:
body = json.loads(resp.read())
latency_ms = (time.perf_counter() - start) * 1000
return latency_ms, True, body.get("price_cents") == 7500
except (urllib.error.URLError, OSError):
return (time.perf_counter() - start) * 1000, False, False
def measure():
latencies, errors, checks_ok = [], 0, 0
with ThreadPoolExecutor(max_workers=CONCURRENCY) as pool:
futures = [pool.submit(one_request) for _ in range(TOTAL_REQUESTS)]
for fut in futures:
latency_ms, ok_error, ok_check = fut.result()
latencies.append(latency_ms)
errors += 0 if ok_error else 1
checks_ok += 1 if ok_check else 0
return latencies, errors / TOTAL_REQUESTS, checks_ok / TOTAL_REQUESTS
def evaluate_thresholds(latencies, error_rate, checks_rate, p95_limit_ms):
"""Evalua las metricas contra los umbrales. True solo si TODOS pasan."""
p95 = q(latencies, 95)
checks = [
(f"http_req_duration: p(95) < {p95_limit_ms:.0f}ms",
p95 < p95_limit_ms, f"p(95) = {p95:.2f}ms"),
("http_req_failed: rate < 1.00%",
error_rate < 0.01, f"rate = {error_rate:.2%}"),
("checks: rate > 99.00%",
checks_rate > 0.99, f"rate = {checks_rate:.2%}"),
]
all_pass = True
print(f"{'THRESHOLD':<38} {'MEDIDO':<18} RESULTADO")
print("-" * 70)
for label, passed, measured in checks:
if not passed:
all_pass = False
print(f"{label:<38} {measured:<18} {'PASS' if passed else 'FAIL'}")
print("-" * 70)
return all_pass
def main():
latencies, error_rate, checks_rate = measure()
print(f"# {PATH} | {TOTAL_REQUESTS} peticiones, concurrencia {CONCURRENCY}")
if evaluate_thresholds(latencies, error_rate, checks_rate, P95_LIMIT_MS):
print("GATE: PASS (exit code 0)")
sys.exit(0)
print("GATE: FAIL (exit code 1)")
sys.exit(1)
if __name__ == "__main__":
main()
Los tres umbrales son tus tres SLOs hechos ejecutables: p95 bajo el límite que pasas por argumento, error bajo 1%, checks sobre 99%. El all_pass es el Y lógico (una regla rota reprueba todo). Y el main traduce el veredicto a código de salida: True → 0, False → 1.
Paso 3 — Corre el gate bajo dos cargas
Ahora la parte que demuestra que el gate funciona: córrelo dos veces contra /quote_cpu, con la misma regla (p(95) < 200), cambiando solo la carga.
Primero, carga liviana (400 peticiones, 4 concurrentes). Poca contención por el GIL, p95 bajo, todo verde.
Qué esperar — las tres reglas pasan, el gate imprime PASS, y echo $? confirma el código de salida 0:
$ python3.14 threshold_gate.py http://127.0.0.1:57565 /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)
$ echo $?
0
Ahora, la misma regla, el mismo endpoint, pero carga pesada (2000 peticiones, 120 concurrentes). El GIL serializa el trabajo, el p95 se dispara sobre el umbral, y el gate se pone rojo.
Qué esperar — el p95 cruza los 200 ms; esa sola regla falla, el gate imprime FAIL, y echo $? confirma el código de salida 1:
$ python3.14 threshold_gate.py http://127.0.0.1:57565 /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)
$ echo $?
1
Esas dos corridas son el mini-proyecto: la misma app, la misma regla, un veredicto que pasa de verde a rojo según la carga, con códigos de salida reales (0 y 1) que un pipeline respetaría. (Los números exactos varían en cada corrida —dependen de cómo el sistema operativo reparte la CPU—; lo que no varía es la historia: liviana pasa, pesada falla.)
Paso 4 — El bloque thresholds de k6 equivalente
Cierra la entrega escribiendo cómo se declararía este mismo gate en k6, como contenido rotulado (k6 no está instalado; esto no se ejecuta aquí). Es la forma industrial de tu evaluate_thresholds:
// CONTENIDO (no ejecutado aqui): el gate de rendimiento de Reservo en k6.
// Ver grafana.com/docs/k6/latest/using-k6/thresholds/. Se correria: k6 run gate.js
import http from "k6/http";
import { check } from "k6";
export const options = {
vus: 50,
duration: "30s",
thresholds: {
http_req_duration: ["p(95)<200"], // el p95 bajo 200 ms (SLO interactivo)
http_req_failed: ["rate<0.01"], // menos del 1% de error
checks: ["rate>0.99"], // mas del 99% de checks correctos
},
};
export default function () {
const url = "http://127.0.0.1:8000/quote_cpu";
const payload = JSON.stringify({ room: "Focus", tier: "basic", hours: 3 });
const params = { headers: { "Content-Type": "application/json" } };
const res = http.post(url, payload, params);
check(res, {
"status es 200": (r) => r.status === 200,
"price_cents es 7500": (r) => r.json("price_cents") === 7500,
});
}
Y la justificación del umbral (su SLO), que es lo que separa un gate con criterio de uno arbitrario:
Gateamos el p95 de la cotización en 200 ms porque es una interacción síncrona: el usuario espera la respuesta mirando la pantalla, y los puntos de referencia de usabilidad sitúan en ~100 ms la sensación de "instantáneo" y en ~1 s el límite antes de perder la atención. Un p95 de 200 ms significa que 19 de cada 20 usuarios perciben la app ágil. El umbral de error (1%) y el de checks (99%) aseguran que la rapidez no venga a costa de respuestas fallidas o incorrectas —una cotización veloz pero equivocada no sirve—.
Con eso, si mañana un cambio hace que el p95 suba a 260 ms, el gate se pone rojo y el equipo se entera antes de desplegar. Eso es un rendimiento bajo gate.
Rúbrica de autoevaluación
Tu entrega está completa si:
- La API corre con
/quote_cpudeclarado y devuelve7500en/quotey/quote_cpu(Focus/basic/3h). Dinero en centavos enteros, puerto 0. -
evaluate_thresholdsaplica las tres reglas (p95, error, checks) y devuelveTruesolo si todas pasan (Y lógico: una rota reprueba todo). - El gate sale con el código correcto:
sys.exit(0)en PASS,sys.exit(1)en FAIL. Lo confirmas conecho $?. (No basta imprimir "FAIL": debe salir con ≠ 0.) - Dos corridas reales: verde con carga liviana (exit 0), roja con carga pesada (exit 1), con la misma regla —lo único que cambia es la carga—.
- El p95 va sobre un percentil, no el promedio (
q(latencies, 95), nomean). - El bloque
thresholdsde k6 está escrito como contenido rotulado y mapea uno a uno con las tres reglas de Python. - El umbral está justificado desde el usuario/negocio (su SLO), no como número redondo.
- Honestidad de ejecución: lo de Python se corrió y se cita; lo de k6 va rotulado como contenido. Nada de git/gh.
Extensiones (opcionales)
Si quieres ir más allá:
- Añade el p99. Suma una cuarta regla
p(99) < 500aevaluate_thresholds(ejercicio de la L2) y gatea también la cola extrema. - Umbrales por parte del sistema. Usa el
gate_multi.pyde la L7 para gatear/quote(estricto, 200 ms) y/quote_cpu(holgado, 400 ms) con umbrales distintos, cada uno según su SLO, y añadeabortOnFailal crítico. - Barrido de concurrencia. Corre el gate con concurrencias crecientes (4, 20, 60, 120, 200) y encuentra el punto donde el p95 cruza el umbral —el "punto de quiebre" del gate—.
- La cadena CI. Encadena
gate && echo "deploy" || echo "bloqueado"(L4) para ver el veredicto autorizar o bloquear un deploy simulado.
Resumen y a dónde sigue el módulo
Construiste un gate de rendimiento completo para Reservo, de punta a punta: levantaste la API con /quote_cpu declarado, escribiste evaluate_thresholds (tres reglas, Y lógico, veredicto), la envolviste en un main que sale con el código de salida real (0 en PASS, 1 en FAIL), y la corriste bajo dos cargas —verde con la liviana (p95 = 9.74 ms, exit 0), roja con la pesada (p95 = 246.96 ms, exit 1)— con la misma regla, cambiando solo la carga. Y escribiste el bloque thresholds de k6 equivalente como contenido, con su umbral justificado desde el SLO. Eso es un rendimiento bajo gate: una regresión de latencia ahora reprueba un build igual que un test roto.
Con esto cierras el módulo de thresholds. Aprendiste el salto de medir a juzgar: qué es un threshold y el pasa/falla, cómo k6 los declara, cómo el exit code hace fallar el CI, cómo elegir el umbral con criterio (SLO/SLA), por qué es hermano de los coverage gates, y cómo abortar temprano y poner un umbral por parte del sistema. Tienes el veredicto.
Lo que sigue, en el módulo 6, es fortalecer la otra mitad de una prueba de carga realista: los check() que verifican la corrección bajo carga (no solo que la app sea rápida, sino que responda bien —el price_cents correcto— mientras la martillas), los group() para organizar, la parametrización de datos y la correlación (extraer un booking_id de una respuesta y usarlo en la siguiente: el flujo cotizar→reservar). El threshold checks: ['rate>0.99'] que usaste aquí vigila justo esa tasa de checks; el módulo 6 la produce a fondo. Y el módulo 7 toma este gate y lo instala en el pipeline de CI completo, con el YAML de GitHub Actions y el análisis de regresiones. El gate que construiste es el cimiento de todo eso.
Recursos
- k6 — Thresholds — la referencia oficial del bloque
options.thresholdsque escribiste como contenido en el paso 4. La forma industrial de tu gate. sys.exit— documentación de Python — el mecanismo con el que el gate devuelve su código de salida (0 = pasa, 1 = falla), lo que confirmas conecho $?. El corazón del paso 3.concurrent.futures.ThreadPoolExecutor— documentación de Python — cómo el gate genera la carga concurrente (los "usuarios virtuales") que hace variar el p95 entre las dos corridas.- Google SRE Book — Service Level Objectives — el marco para justificar el umbral (su SLO) que pide la rúbrica. Por qué el 200 ms no es arbitrario.