Módulo 8: Proyecto — Prueba de carga de la API de Reservo
3. El perfil smoke→load→stress con stages
Descripción
El escenario de la lección 2 dice qué hace cada usuario virtual. Este perfil dice cuántos usuarios hay y cuándo. En esta lección envolvemos el escenario cotizar→reservar en una forma de carga de tres fases con la opción stages de k6: smoke (una pizca de carga para comprobar que la prueba funciona), load (la carga normal esperada, sostenida) y stress (empujar por encima de lo normal hasta ver dónde se dobla el sistema). Escribimos el export const options = { stages: [...] } en k6 (contenido) y corremos el equivalente ejecutable en Python: el generador variando la concurrencia por etapas contra Reservo, midiendo cómo el p95 sube con la carga. Con /quote_cpu como blanco, verás el p95 trepar de ~14 ms en smoke a ~57 ms en load y a ~245 ms en stress —la degradación que solo el pico revela—.
Conexión con el módulo: esta es la segunda pieza del capstone —la forma que envuelve el escenario de la lección 2—. Reúsa por entero el módulo 4: la opción stages, las tres fases ramp-up/steady/ramp-down, y los tipos de prueba smoke/load/stress. Aquí no re-explicamos qué es un stage ni cómo k6 interpola los VUs; los usamos para dibujar el perfil canónico del capstone. La lección 4 le pondrá a este perfil los thresholds que lo convierten en un veredicto; la lección 5 leerá a fondo las métricas que produce. Aquí nos concentramos en la forma y en lo que cada fase revela.
Las tres marchas de una prueba de esfuerzo
Piensa en una prueba de esfuerzo cardíaco en el médico. No te suben a la caminadora y arrancan a toda velocidad —eso no mediría nada útil y sería peligroso—. Van por fases. Primero, un calentamiento: caminas despacio unos minutos, para confirmar que los sensores funcionan y que estás bien para empezar. Luego, un ritmo sostenido: caminas a un paso normal, el que harías a diario, y el médico mide cómo responde tu corazón a esa carga habitual. Y por último, el esfuerzo creciente: suben la velocidad y la inclinación por encima de lo normal, empujando hasta encontrar tu límite —el punto donde el ritmo se dispara o te quedas sin aliento—. Cada fase responde una pregunta distinta: ¿funciona la prueba? ¿aguantas el día a día? ¿dónde está tu techo?
El perfil smoke→load→stress es esa prueba de esfuerzo para tu API. Smoke es el calentamiento: poca carga, solo para confirmar que el escenario corre y la API responde —si algo está roto, lo ves aquí, barato, antes de invertir en la carga pesada—. Load es el ritmo sostenido: la carga normal esperada, mantenida un rato, para medir el rendimiento del día a día —el p95 que le prometes al negocio bajo tráfico habitual—. Stress es el esfuerzo creciente: empujar por encima de lo normal hasta ver dónde el p95 se dispara —el techo, el punto de quiebre, la degradación que en producción sería un pico de tráfico real—. Un solo nivel de carga responde una sola pregunta; el perfil de tres fases las responde las tres.
El perfil en k6 (contenido)
Aquí está el options del capstone con su perfil de tres fases. Recuerda: contenido rotulado, fiel a la documentación de k6, no ejecutado aquí.
// CONTENIDO (no ejecutado aquí): k6 no está instalado.
// Referencia: grafana.com/docs/k6 (options → stages).
export const options = {
stages: [
// SMOKE — pizca de carga: ¿corre la prueba? ¿responde la API?
{ duration: '30s', target: 5 }, // ramp-up a 5 VUs
{ duration: '1m', target: 5 }, // sostener 5 VUs
// LOAD — carga normal esperada: el rendimiento del día a día.
{ duration: '1m', target: 20 }, // ramp-up a 20 VUs
{ duration: '3m', target: 20 }, // sostener 20 VUs (aquí lees el p95 "normal")
// STRESS — por encima de lo normal: ¿dónde se dobla el sistema?
{ duration: '1m', target: 80 }, // ramp-up a 80 VUs
{ duration: '3m', target: 80 }, // sostener 80 VUs (aquí ves la degradación)
// RAMP-DOWN — bajada ordenada para observar la recuperación.
{ duration: '1m', target: 0 },
],
};
Léelo con lo que ya sabes de M4, fijándote en cómo las tres fases emergen de la lista de tramos:
- Smoke (5 VUs). Un ramp-up suave a 5 VUs y un minuto sosteniéndolos. Con tan poca carga, el objetivo no es medir rendimiento sino confirmar que todo funciona: el escenario corre, la API responde, los checks pasan. Si el smoke ya falla, no tiene sentido gastar en la carga pesada —arreglas primero—.
- Load (20 VUs). Sube a 20 VUs y los sostiene tres minutos. Esta es la carga normal esperada —el tráfico que la API vive un día cualquiera—, y la meseta larga deja que las métricas se estabilicen. El p95 que reportas como "el rendimiento normal de Reservo" sale de aquí (M4: se lee del steady, no del ramp-up).
- Stress (80 VUs). Empuja a 80 VUs, cuatro veces la carga normal, y los sostiene. Aquí ves qué pasa cuando el sistema se estresa: si el p95 se mantiene, el sistema tiene margen; si se dispara, encontraste el punto de degradación. Con
/quote_cpu, el pico es justo donde el p95 cruza el SLO. - Ramp-down (0 VUs). Una bajada ordenada para que las iteraciones en curso terminen y para observar si el sistema se recupera limpio. Cierra la prueba.
La suma de las duraciones (30s + 1m + 1m + 3m + 1m + 3m + 1m = 10m 30s) es cuánto dura la prueba. En una prueba real las mesetas duran minutos, para que el estado estable sea de verdad estable; en la corrida ejecutada de abajo usamos etapas cortas (segundos) para verlo rápido, pero la forma —subir la carga por fases y medir el p95 de cada una— es idéntica.
El perfil ejecutado en Python
El generador dibuja esta misma forma con etapas de concurrencia: corre el escenario cotizar→reservar a 5, luego a 20, luego a 80 VUs, midiendo el p95 de cada etapa. No usa mesetas de minutos (para verlo rápido), pero el mapeo es directo: cada etapa es una fase del perfil, y su número de VUs es el target del stage. Lo corremos contra /quote_cpu, el motor de precios cuya latencia depende de la carga:
Qué esperar — el p95 debe subir con la carga: bajo en smoke, moderado en load, disparado en stress. Salida real contra Reservo:
$ python3.14 loadtest.py http://127.0.0.1:PORT /quote_cpu l3.json
PRUEBA DE CARGA — escenario cotizar->reservar contra /quote_cpu
perfil: smoke(5) -> load(20) -> stress(80) VUs
--------------------------------------------------------------------------
etapa VUs reqs RPS p50 p95 p99 error checks
--------------------------------------------------------------------------
smoke 5 2338 583.3 8.65 14.03 17.09 0.00% 100.00%
load 20 2918 578.6 34.93 56.96 63.91 0.00% 100.00%
stress 80 3522 583.6 136.75 245.17 261.04 0.00% 100.00%
--------------------------------------------------------------------------
Mapea cada fila a su fase y lee la historia que cuenta el p95:
- Smoke (5 VUs): p95 = 14.03 ms. Poca carga, latencia baja. La prueba corre y la API responde correcto (checks 100%). Todo en orden para seguir.
- Load (20 VUs): p95 = 56.96 ms. La carga normal cuadruplica los VUs y el p95 sube a ~57 ms —todavía cómodo bajo un SLO de 200 ms—. Este sería el "p95 del día a día" de Reservo con este motor de precios.
- Stress (80 VUs): p95 = 245.17 ms. El pico cuadruplica otra vez los VUs, y aquí el p95 se dispara por encima de 200 ms. El trabajo de CPU serializado por el GIL no escala con la concurrencia: cuando 80 clientes piden precio a la vez, la cola de cómputo crece y la latencia se rompe. Esto es lo que el stress existe para revelar —una degradación que smoke y load, con su carga menor, no muestran—.
Fíjate en dos cosas más. El RPS se mantiene casi constante (~580 req/s) en las tres etapas: el servidor procesa aproximadamente la misma cantidad de trabajo por segundo (está saturado de CPU), así que subir VUs no aumenta el throughput —solo alarga la cola, y esa cola es lo que dispara el p95—. Y la tasa de error sigue en 0%: el sistema no falla, solo se pone lento. Latencia y disponibilidad son cosas distintas; el stress rompió una (la latencia) sin tocar la otra (los errores). Por eso se leen los tres instrumentos juntos (M3), y por eso el capstone pone un threshold sobre cada uno (lección 4).
Errores comunes
Saltarse el smoke y arrancar en la carga pesada. Qué pasa: se lanza directo la etapa de stress y la prueba revienta —pero no se sabe si por un bug del escenario o porque el sistema de verdad no aguanta—. Por qué pasa: el smoke parece un paso trivial que se puede omitir. Cómo detectarlo: si tu perfil empieza en decenas de VUs sin una fase mínima previa, no tienes forma de distinguir un error de prueba de un límite del sistema. Cómo corregirlo: empieza siempre por smoke (pocos VUs). Si el smoke pasa, sabes que la prueba y la API funcionan, y cualquier fallo posterior es de carga, no de setup. El smoke es barato y te ahorra depurar a ciegas bajo carga pesada.
Leer el p95 del ramp-up o del agregado como el del pico. Qué pasa: se toma un p95 medido durante la subida (cuando la carga aún no llegó al target) o el promedio de toda la prueba, y se reporta como "el p95 bajo stress". Por qué pasa: son los números más a mano. Cómo detectarlo: si tu p95 "de pico" mezcla fases de menor carga, sale artificialmente bajo. Cómo corregirlo: reporta el p95 de la meseta de cada fase por separado —como hace el generador, con una fila por etapa—. El p95 del stress es el de los 80 VUs sostenidos, no el de la subida ni el de la prueba entera (M4).
Interpretar el RPS plano como que "no pasa nada". Qué pasa: se ve que el RPS casi no cambia entre load y stress y se concluye que el sistema aguanta igual. Por qué pasa: se espera que más VUs = más RPS. Cómo detectarlo: si el RPS se estanca mientras el p95 sube, el sistema está saturado: no procesa más trabajo, solo acumula cola. Cómo corregirlo: lee el RPS y el p95 juntos. Un RPS plano con p95 creciente es la firma de la saturación —el sistema llegó a su techo de throughput y los VUs extra solo esperan en fila—. Eso es exactamente lo que el stress busca encontrar.
Ejercicios
Ejercicio 1 — Nombra la fase. Para cada tramo, di a qué fase (smoke / load / stress / ramp-down) pertenece y qué pregunta responde: (a) { duration: '3m', target: 20 } con la carga normal esperada en 20 VUs. (b) { duration: '1m', target: 5 } al inicio de la prueba. (c) { duration: '3m', target: 80 } a cuatro veces la carga normal. (d) { duration: '1m', target: 0 } al final.
Ver solución
- (a) Load (carga normal sostenida). Responde: ¿cuál es el p95 del día a día bajo el tráfico esperado?
- (b) Smoke (pizca de carga al inicio). Responde: ¿corre la prueba? ¿responde la API? ¿vale la pena seguir a la carga pesada?
- (c) Stress (por encima de lo normal). Responde: ¿dónde se dobla el sistema? ¿el p95 se mantiene o se dispara bajo el pico?
- (d) Ramp-down (bajada a 0). Responde: ¿se recupera el sistema limpio cuando la carga cae?
Ejercicio 2 — Predice la forma del p95. Con /quote (el endpoint rápido, sin trabajo de CPU) en vez de /quote_cpu, ¿cómo esperarías que se vea la columna p95 en las tres etapas: subiría igual de fuerte, subiría poco, o no cambiaría? Justifica.
Ver solución
Subiría poco. /quote solo calcula un precio (una multiplicación y una división entera) y responde: no tiene trabajo de CPU que el GIL serialice, así que atiende las peticiones concurrentes casi en paralelo. Al subir de 5 a 20 a 80 VUs, el p95 crecería —hay más contención por el servidor y la red local— pero de milisegundos a decenas de milisegundos, muy lejos del SLO de 200 ms. La forma sería la misma (p95 creciente con la carga) pero la magnitud mucho menor: el sistema tiene margen de sobra. Es exactamente el caso "verde" del capstone, y contrasta con /quote_cpu, cuyo p95 sí cruza el umbral bajo stress. (En la corrida real, /quote dio p95 ~1 → ~6 → ~26 ms en las tres etapas.)
Ejercicio 3 — Diseña un perfil de spike. El perfil de esta lección sube la carga en escalones sostenidos. Quieres, además, probar un spike: la carga normal (20 VUs) interrumpida por un salto brusco a 100 VUs durante 10 segundos, y de vuelta a 20. Escribe los stages de ese spike y di qué pregunta responde que el perfil escalonado no responde.
Ver solución
stages: [
{ duration: '30s', target: 20 }, // carga normal
{ duration: '1m', target: 20 }, // sostener lo normal
{ duration: '5s', target: 100 }, // SPIKE: salto brusco a 100 VUs
{ duration: '10s', target: 100 }, // sostener el pico breve
{ duration: '5s', target: 20 }, // volver de golpe a lo normal
{ duration: '1m', target: 20 }, // observar la recuperación
]
Responde una pregunta que el perfil escalonado no: ¿cómo reacciona el sistema a un aumento súbito de carga (un pico de tráfico repentino: una campaña, una mención viral, un lunes por la mañana)? El escalonado sube gradual y deja calentar; el spike golpea de golpe, sin aviso, y mide si el sistema absorbe la ráfaga o colapsa —y, en la vuelta a 20 VUs, si se recupera rápido o queda degradado—. Son degradaciones distintas: una prueba de stress encuentra el techo con carga creciente; un spike encuentra la fragilidad ante la sorpresa (M4).
Resumen y siguiente paso
En esta lección envolviste el escenario en su forma de carga: el perfil smoke→load→stress con la opción stages de k6. Cada fase responde una pregunta —smoke: ¿funciona la prueba?; load: ¿cuál es el p95 del día a día?; stress: ¿dónde se dobla el sistema?—, como las tres marchas de una prueba de esfuerzo. Lo escribiste en k6 (contenido) y lo corriste de verdad en Python contra /quote_cpu, viendo el p95 trepar con la carga: 14.03 → 56.96 → 245.17 ms. El pico reveló la degradación que smoke y load no muestran, con dos señales clave: el RPS plano (~580 req/s, el sistema saturado de CPU) y la tasa de error en 0% (se pone lento, no falla) —latencia y disponibilidad son cosas distintas—.
Reusaste por entero el módulo 4 (stages, las tres fases, los tipos de prueba). Antes de avanzar deberías poder: leer un stages y nombrar sus fases; explicar qué revela el stress que load no; y leer el RPS y el p95 juntos para reconocer la saturación. Lo que sigue, en la lección 4, es ponerle a este perfil un veredicto: los thresholds ligados al SLO (M5) que convierten ese p95 de 245 ms en un FAIL que bloquea el deploy —y de dónde sale el número del umbral—.
Recursos
- k6 — Opción
stages— la referencia exacta de la lista de tramos{duration, target}con la que se dibuja el perfil del capstone; la fuente del contenido de k6 de esta lección. - k6 — Test types (smoke, load, stress, spike, soak) — la guía oficial de qué pregunta responde cada tipo de prueba y cómo se dibuja su perfil; el fundamento de las tres fases.
- k6 — Stress testing — cómo se diseña específicamente la fase de stress (por encima de la carga normal) para encontrar el punto de quiebre; el detalle de la etapa que rompe el p95.
concurrent.futures.ThreadPoolExecutor— documentación de Python — el pool de hilos con el que el generador sube y baja la concurrencia por etapas para dibujar la forma; el motor de la corrida ejecutada.