Stack: k6 + Django/DRF · Proyecto: TicketFlow Estado: Publicada — cierre del módulo de pruebas Prerrequisito: Lección 35 — Pruebas E2E y de contrato
Objetivos
- Escribir un escenario k6 del pre-venta de TicketFlow: rampa de usuarios, thresholds de p95/p99 y comprobaciones de corrección bajo presión.
- Encontrar el cuello de botella con la disciplina del perfilado (37 lo profundiza): medir capa por capa, no adivinar.
- Decidir la capacidad honesta: qué N de concurrentes soporta el sistema y con qué hardware — el número que el negocio necesita.
1. Qué pregunta (y qué no) una prueba de carga
La pregunta correcta: ¿cuántos usuarios concurrentes soporta el checkout sin romper los SLOs (46)? — no "¿cuánto aguanta?" (sin objetivo, sin criterio). Los criterios se definen ANTES: p95 de POST /reservations < 400 ms, p99 < 900 ms, tasa de error < 0.5%, cero dobles-vendedores (la corrección no se negocia bajo presión). Y el escenario REALISTA no es "GET /events 1000 veces" (el listado cacheado aguanta milagros, 12): es la mezcla del negocio — 70% browse (listados), 20% detalle, 9% reservas, 1% pagos — con el pico de las 10:00 del viernes de pre-venta.
// load/checkout.js — el escenario de pre-venta
import http from "k6/http";
import { check, sleep } from "k6";
import { Trend } from "k6/metrics";
const reservaDur = new Trend("reserva_duration");
export const options = {
stages: [
{ duration: "2m", target: 200 }, // rampa: el tráfico crece, no salta
{ duration: "5m", target: 200 }, // meseta: el estado estacionario que mides
{ duration: "1m", target: 400 }, // pico de stress: ¿dónde rompe?
{ duration: "1m", target: 0 }, // descarga: ¿se recupera?
],
thresholds: {
"http_req_duration{scenario:default}": ["p(95)<400"],
"reserva_duration": ["p(99)<900"],
"http_req_failed": ["rate<0.005"],
"checks": ["rate>0.999"],
},
};
export default function () {
const evento = http.get(`${BASE}/api/v1/events/01H...`).json();
check(evento, { "detalle ok": (r) => r.status === 200 });
sleep(Math.random() * 2 + 0.5); // think time: humanos, no máquinas
const res = http.post(`${BASE}/api/v1/reservations`,
JSON.stringify({ event: evento.id, seats: ["A1"] }),
{ headers: { "Content-Type": "application/json", Authorization: `Bearer ${TOKEN}` } });
reservaDur.add(res.timings.duration);
check(res, { "reserva 201 o 409": (r) => [201, 409].includes(r.status) }); // 409 es ÉXITO del sistema
}Dos detalles que separan un buen script de un script ingenuo: el think time (los humanos piensan entre acciones; sin sleep, mides tu servidor contra una horda de robots sin piedad) y el check que acepta 409 como éxito (bajo contención, el conflicto es el sistema funcionando: la trampa es contarlo como error y "arreglar" lo que no está roto).
2. El entorno de prueba: honesto y aislado
La carga se prueba contra un entorno con la MISMA topología de prod (app + PG + Redis + worker, 40) pero aislado (staging pequeño o una réplica en tu máquina para los primeros números). Dos mentiras clásicas: probar contra manage.py runserver (un solo proceso síncrono: mides el servidor de desarrollo, no a gunicorn) y probar SIN datos (1M de asientos hace que el índice (09) y el planificador se comporten distinto que con 100 filas). El setup honesto: gunicorn con los workers de prod (la fórmula 2×CPU+1 como punto de partida), seed de datos realista (50k eventos, 2M de asientos, 100k usuarios), y el EXPLAIN de las queries del pico ya validado (09).
El aislamiento: nunca contra prod (la 22/47: un test de carga mal lanzado ES un incidente DoS), y los tokens de k6 son usuarios de test (el rate limiter de la 23 te va a throttlear si reutilizas credenciales reales — y ese es otro finding).
3. Encontrar el cuello: capa por capa
El número malo llega ("p95 2 s"), y el adivino dice "más servidores" (la 55). La disciplina: medir cada capa en la MISMA corrida: (1) el p95 por endpoint (k6 lo separa con tags); (2) el lado del servidor: gunicorn logs de latencia, pg_stat_statements para las queries top, el pool de conexiones (39: ¿el p95 coincide con "esperando conexión"?), el uso de CPU por contenedor (¿un core al 100% es Python o Postgres?). El resultado típico de TicketFlow:
p95 global 2.1s → desglose:
browse (GET /events) 180 ms ✓ (cacheado, 12)
detalle (GET /events/:id) 210 ms ✓
reserva (POST /reservations) 2.1 s ✗ el cuello
└─ pg_stat_statements: UPDATE seats SET status WHERE id IN (...) 1.8 s
└─ el UPDATE masivo del checkout contiende por el mismo eventoEl cuello casi nunca es "el framework es lento": es una query (09), un lock (10), el pool (39) o la pasarela remota (32: el p95 del checkout incluye 1.2 s de la pasarela sandbox — la carga local no puede arreglar la latencia ajena; se separa la latencia propia de la remota en la medición).
4. El pico y la descarga: lo que la curva revela
Las fases de la curva cuentan la historia: en la rampa (2m→200), el p95 sube suave y se estabiliza (capacidad suficiente); en la meseta, el número honesto (el SLO se cumple o no); en el pico de stress (400), la pregunta es ¿degradación elegante o colapso? — los errores 429/409 crecen (bien: el sistema rechaza con gracia) o los timeouts en cadena y el OOM (mal: el sistema se cae entero). Y la descarga (target 0): ¿la latencia vuelve a la línea base en 30 s? Si no: hay acumulación (cola de tareas sin drenar (29), conexiones zombis, caché entera invalidada (38) — el "después del pico quedó cojo" es un finding tan valioso como el pico).
k6 run load/checkout.js --out json=results.json # o influxdb/grafana (46)
k6 run load/checkout.js --vus 100 --duration 5m # el humo rápido pre-mesetaLa corrida fija (meseta 200 por 5 m) es la que produce el número de capacidad; las rampas exploratorias son diagnóstico. Cada corrida se registra con su commit (44: el IaC del entorno de carga) y su resultado en el repo (load/results/2027-03-01.md) — la capacidad es un dato con fecha, no una leyenda.
5. Del número al plan
El output del ejercicio: "TicketFlow soporta 200 concurrentes de mezcla realista (≈ 12k reservas/min) con p95 380 ms en 2 vCPU/4GB; a 400 degrada con 409s elegante (sin corrupción); recupera línea base en 40 s". Ese número alimenta: el autoscaling (43/55), la cola de preventa (el "entrar por turnos" de los festivales reales: limitar la admisión al checkout ANTES de que el lock contienda, 23), y el plan de capacidad del negocio. Y la lista de mejoras priorizada por impacto medido: (1) el UPDATE de asientos por lotes con CASE único en vez de N updates (09), (2) partición de reservas por evento (55), (3) read replicas para el browse (55). Cada mejora se re-mide con la MISMA corrida k6: antes/después con números, no con opinión.
Autoevaluación
- ¿Qué SLOs defines ANTES de lanzar k6 para
POST /reservationsy por qué el 409 cuenta como éxito del sistema? - ¿Por qué el think time y el seed de datos realista son obligatorios y qué mide cada mentira (runserver, sin datos)?
- El cuello del ejemplo es el UPDATE de asientos: ¿cómo lo localizaste con las herramientas del servidor y qué NO es el cuello?
- ¿Qué revela cada fase de la curva (rampa/meseta/pico/descarga) y qué finding escondido tiene la fase de descarga?
- ¿Cómo se convierte "200 concurrentes" en decisiones de producto e infraestructura?
Continúa con los ejercicios. Las solutions.md solo tras intentarlo.