Módulo 1: Por qué load y performance testing
7. Primer contacto: un script k6 y un generador en Python
Descripción
Llegó el momento de dejar la teoría y lanzar tráfico. En esta lección haces tu primer contacto con la carga, y lo haces por partida doble, para que veas los dos lados de la misma moneda. Primero, el lado industrial: un script de k6 mínimo que golpea POST /quote de Reservo y su resumen de salida —presentado como contenido rotulado, porque k6 no está instalado en este entorno—, para que reconozcas cómo se ve una prueba de carga "de verdad". Segundo, el lado ejecutable y con las manos: un mini-generador de carga escrito en Python con concurrent.futures y urllib, que le pega al mismo /quote con N peticiones concurrentes y mide la latencia real —mínimo, promedio, máximo, p95—, con salida ejecutada de verdad que citamos aquí. Ver los dos lado a lado deja la idea clara como el agua: k6 hace conceptualmente lo mismo que el generador Python, solo que industrializado y a escala. Si entiendes el generador de treinta líneas, entiendes qué hace k6 por dentro.
Conexión con el módulo: esta lección junta todo lo anterior. Usa el blanco de la lección 6 (la API de Reservo), responde las preguntas de la lección 3 (latencia p95) con números reales, y contrasta la herramienta de la lección 5 (k6, como contenido) con su equivalente ejecutable en Python. Es el ensayo general del mini-proyecto (lección 8), donde harás este recorrido completo con tus manos. La anatomía a fondo del script de k6 —qué es exactamente un VU, cómo se configuran options, cómo funciona check— es el módulo 2; aquí lo vemos entero pero sin diseccionarlo pieza por pieza.
Dos maneras de medir la fila del puesto de tacos
Tienes el puesto de tacos montado (la API de Reservo). Quieres medir cuánto tarda en despachar cuando le llega gente. Hay dos maneras de traer a los clientes de prueba.
La primera: contratas una empresa de estudios de mercado con cien actores profesionales, walkie-talkies y cronómetros de precisión. Llegan, se coordinan, hacen colas realistas, miden todo al milisegundo y te entregan un informe pulido con percentiles y gráficas. Es potente, escala a mil actores si hace falta, y es reutilizable. Pero es una empresa externa con su propia manera de trabajar, que tú configuras y contratas. Eso es k6.
La segunda: agarras a diez amigos, les dices "cuando yo diga ya, todos piden un taco a la vez", y tú mismo, con el cronómetro del teléfono, anotas cuánto tardó cada uno. Es artesanal, no escala a mil, pero lo montas tú en cinco minutos, entiendes exactamente qué mide porque lo escribiste, y los números son igual de reales. Eso es el mini-generador en Python.
Las dos miden la misma fila del mismo puesto. La empresa profesional (k6) es lo que usarás en producción; los diez amigos (el generador Python) son para que entiendas con las manos qué significa "lanzar carga y medir latencia", sin caja negra. En esta guía, como no tenemos contratada a la empresa (k6 no está instalado), la vemos por su folleto (contenido) y hacemos la medición real con los amigos (Python).
El lado ejecutable: el mini-generador en Python
Empecemos por el que sí corre, porque tocar los números reales es lo que hace que todo lo demás tenga sentido. El generador es un script corto que lanza N peticiones a /quote, usando un pool de hilos para que muchas estén en vuelo a la vez (esa concurrencia es la que crea la carga, como vimos en la lección 2), y mide cuánto tarda cada una.
"""Mini-generador de carga en Python — golpea /quote de Reservo en concurrencia.
Lanza N peticiones concurrentes con un pool de hilos (concurrent.futures) y
mide la latencia REAL de cada una con time.perf_counter. Reporta minimo,
maximo, promedio y p95. Es el hermano ejecutable del script k6 (que va como
contenido): aqui vemos numeros de verdad.
"""
import json
import statistics
import sys
import time
import urllib.request
from concurrent.futures import ThreadPoolExecutor
BASE_URL = sys.argv[1] # p.ej. http://127.0.0.1:51568
TOTAL_REQUESTS = int(sys.argv[2]) if len(sys.argv) > 2 else 200
CONCURRENCY = int(sys.argv[3]) if len(sys.argv) > 3 else 20
def one_quote():
"""Hace UNA peticion POST /quote y devuelve (latency_ms, price_cents)."""
payload = json.dumps({"room": "Focus", "tier": "basic", "hours": 3}).encode()
req = urllib.request.Request(
f"{BASE_URL}/quote", data=payload,
headers={"Content-Type": "application/json"}, method="POST",
)
start = time.perf_counter()
with urllib.request.urlopen(req) as resp:
body = json.loads(resp.read())
latency_ms = (time.perf_counter() - start) * 1000 # segundos -> milisegundos
return latency_ms, body["price_cents"]
def main():
latencies = []
prices = []
wall_start = time.perf_counter()
# ThreadPoolExecutor mantiene CONCURRENCY peticiones en vuelo a la vez:
# ESA concurrencia es la que crea la carga (no el total de peticiones).
with ThreadPoolExecutor(max_workers=CONCURRENCY) as pool:
futures = [pool.submit(one_quote) for _ in range(TOTAL_REQUESTS)]
for fut in futures:
latency_ms, price = fut.result()
latencies.append(latency_ms)
prices.append(price)
wall_seconds = time.perf_counter() - wall_start
latencies.sort()
# p95 = el valor por debajo del cual cae el 95% de las latencias.
p95_index = int(len(latencies) * 0.95)
p95 = latencies[min(p95_index, len(latencies) - 1)]
print(f"peticiones .......... {TOTAL_REQUESTS} (concurrencia {CONCURRENCY})")
print(f"todas devolvieron ... price_cents={prices[0]} "
f"(correcto: {all(p == 7500 for p in prices)})")
print(f"duracion total ...... {wall_seconds:.3f} s")
print(f"throughput .......... {TOTAL_REQUESTS / wall_seconds:.1f} req/s")
print(f"latencia min ........ {min(latencies):.2f} ms")
print(f"latencia promedio ... {statistics.mean(latencies):.2f} ms")
print(f"latencia max ........ {max(latencies):.2f} ms")
print(f"latencia p95 ........ {p95:.2f} ms")
if __name__ == "__main__":
main()
Las tres ideas que hacen de esto un generador de carga (y no un simple bucle de peticiones):
- La concurrencia la crea
ThreadPoolExecutor(max_workers=CONCURRENCY). El pool mantieneCONCURRENCYpeticiones en vuelo al mismo tiempo. Ese "al mismo tiempo" es la carga —recuerda la lección 2: la contención nace de las peticiones simultáneas, no del total acumulado—. CambiarCONCURRENCYde 20 a 50 es, conceptualmente, subir de 20 a 50 usuarios virtuales. - La latencia se mide con
time.perf_counter()alrededor de cada petición.perf_counteres el reloj de alta resolución de Python, pensado para medir intervalos cortos. Marcamos justo antes de enviar y justo después de recibir; la diferencia (en milisegundos) es la latencia real de esa petición, con su espera en la fila incluida. - El p95 se calcula ordenando y cortando. Se ordenan todas las latencias y se toma la que está en la posición del 95%. Sin promediar: el percentil ordena y corta, como vimos en la lección 3. (En el módulo 3 usaremos
statistics.quantilespara hacerlo con más rigor; aquí, la versión directa deja ver el mecanismo.)
Corriéndolo (salida real)
Arrancamos la API de Reservo de la lección 6, leemos su puerto, y lanzamos el generador. Todo esto es salida real, ejecutada contra el servidor en localhost. Primero con 200 peticiones y concurrencia 20:
Qué esperar — 200 cotizaciones de Focus/basic/3h con 20 clientes a la vez; todas correctas (7500), y una latencia p95 de unos milisegundos:
$ python3.14 load_generator.py http://127.0.0.1:51568 200 20
peticiones .......... 200 (concurrencia 20)
todas devolvieron ... price_cents=7500 (correcto: True)
duracion total ...... 0.048 s
throughput .......... 4157.5 req/s
latencia min ........ 2.67 ms
latencia promedio ... 4.50 ms
latencia max ........ 20.20 ms
latencia p95 ........ 17.21 ms
Ahí lo tienes: tu primera medición de carga real. Con 20 clientes concurrentes, Reservo sostuvo ~4157 peticiones por segundo, con un p95 de 17 ms —y todas las respuestas fueron correctas (7500)—. Subamos la concurrencia a 50 para ver el efecto:
Qué esperar — al pasar de 20 a 50 concurrentes, hay más contención: el p95 sube:
$ python3.14 load_generator.py http://127.0.0.1:51568 500 50
peticiones .......... 500 (concurrencia 50)
todas devolvieron ... price_cents=7500 (correcto: True)
duracion total ...... 0.103 s
throughput .......... 4856.2 req/s
latencia min ........ 2.74 ms
latencia promedio ... 9.60 ms
latencia max ........ 46.19 ms
latencia p95 ........ 29.13 ms
El p95 pasó de 17 a 29 ms al duplicar y pico la concurrencia: más usuarios a la vez, más fila, más latencia en la cola. Esto es performance testing de verdad, con treinta líneas de Python: lanzaste carga, mediste latencia percentil, y observaste cómo la latencia responde a la concurrencia. Todo lo que viene en la guía es refinar esto.
El lado industrial: el mismo test en k6 (contenido)
Ahora el otro lado. Así se escribiría la misma prueba —golpear POST /quote de Reservo— en k6. Rótulo importante: este script y su resumen son CONTENIDO, no una ejecución de este entorno (k6 no está instalado aquí). Son correctos y fieles a la documentación oficial de k6; su anatomía a fondo es el módulo 2.
// CONTENIDO (no ejecutado aqui): la MISMA prueba de /quote, en k6.
// Ver grafana.com/docs/k6. Se correria con: k6 run quote_test.js
import http from "k6/http";
import { check } from "k6";
// options: la "forma" de la carga. 50 usuarios virtuales durante 10 segundos
// (es el analogo del max_workers=50 del generador Python).
export const options = {
vus: 50,
duration: "10s",
};
// La funcion default es lo que cada VU ejecuta en bucle (una "peticion").
export default function () {
const url = "http://127.0.0.1:8000/quote";
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(): verifica corrección BAJO carga, como el "correcto: True" del generador.
check(res, {
"status es 200": (r) => r.status === 200,
"price_cents es 7500": (r) => r.json("price_cents") === 7500,
});
}
Fíjate en el paralelismo con el generador Python, línea por concepto:
| Concepto | Generador Python | Script k6 |
|---|---|---|
| Concurrencia (usuarios a la vez) | max_workers=50 | vus: 50 |
| Qué hace cada "usuario" | one_quote() | la función default |
| La petición | urllib.request a /quote | http.post(url, payload, params) |
| Verificar corrección bajo carga | all(p == 7500 ...) | check(res, {...}) |
| Cuánto dura | número total de peticiones | duration: "10s" |
| Medir latencia/percentiles | time.perf_counter + p95 a mano | k6 lo mide solo (http_req_duration) |
La diferencia principal: en el generador Python tú cronometras y calculas el p95; en k6, el runtime lo hace por ti y lo entrega en el resumen. Así se vería ese resumen —también contenido, no ejecutado aquí; los números son coherentes con lo que el generador Python midió de verdad (~4856 req/s, p95 ~29 ms), como debe ser, ya que hacen lo mismo—:
// CONTENIDO (no ejecutado aqui): forma del resumen de k6 run. Ver grafana.com/docs/k6
✓ status es 200
✓ price_cents es 7500
checks.........................: 100.00% ✓ 96000 ✗ 0
data_received..................: 6.1 MB 610 kB/s
data_sent......................: 5.3 MB 530 kB/s
http_req_duration..............: avg=9.6ms min=2.7ms med=8ms max=46ms p(90)=22ms p(95)=29ms
http_req_failed................: 0.00% ✓ 0 ✗ 48000
http_reqs......................: 48000 4800/s
iteration_duration.............: avg=10.4ms min=2.9ms max=48ms
iterations.....................: 48000 4800/s
vus............................: 50 min=50 max=50
vus_max........................: 50 min=50 max=50
Léelo con lo que ya sabes: checks al 100% (corrección bajo carga, como el correcto: True del generador), http_req_duration con su p(95)=29ms (la latencia percentil de la lección 3), http_reqs a 4800/s (el throughput), http_req_failed en 0.00% (ninguna petición falló), y vus: 50 (la concurrencia). Es el mismo retrato que pintó tu generador Python, hecho por la herramienta profesional. Esa correspondencia es el punto entero de la lección: k6 no es magia; es tu generador de treinta líneas, industrializado.
Errores comunes
Poner max_workers=1 (o pocos) y creer que se está generando carga. Qué pasa: se corre el generador con concurrencia 1, se ve un p95 bajísimo, y se concluye "aguanta". Por qué pasa: se confunde total con simultaneidad. Cómo detectarlo: si CONCURRENCY es 1, no hay contención (lo viste en la lección 2: p95 ≈ promedio). Cómo corregirlo: la carga la fija la concurrencia; súbela (20, 50, 100) y observa cómo cambia el p95.
Medir la latencia incluyendo cosas que no son la petición. Qué pasa: alguien pone el perf_counter antes de construir el payload o de crear el pool, y mide de más. Por qué pasa: no se acota bien qué es "la petición". Cómo detectarlo: latencias sospechosamente altas o que incluyen tiempo de preparación. Cómo corregirlo: cronometra solo el ida y vuelta HTTP —justo antes de enviar, justo después de recibir—, como hace one_quote(). Todo lo demás (armar el JSON, crear el pool) va fuera del cronómetro.
Confundir el script de k6 (contenido) con algo que corrió aquí. Qué pasa: alguien ve el resumen de k6 y lo cita como "resultado medido". Por qué pasa: el resumen se ve muy real. Cómo detectarlo: si no tienes k6 instalado, no ejecutaste k6; su salida es contenido. Cómo corregirlo: mantén la honestidad de la guía —los números ejecutados son los del generador Python (con su comando python3.14 load_generator.py ...); los de k6 son la forma esperada, rotulada como contenido—. Si instalas k6, el script corre tal cual y produce su propio resumen real.
Ejercicios
Ejercicio 1 — Mapea Python↔k6. Para cada elemento del generador Python, di cuál es su equivalente en el script de k6. (a) max_workers=50. (b) one_quote(). (c) all(p == 7500 for p in prices). (d) el cálculo manual del p95 con perf_counter.
Ver solución
- (a)
max_workers=50↔vus: 50(la concurrencia, los usuarios virtuales a la vez). - (b)
one_quote()↔ la funcióndefault(lo que cada usuario/VU ejecuta). - (c)
all(p == 7500 ...)↔ elcheck(res, {"price_cents es 7500": ...})(verificar corrección bajo carga). - (d) el p95 calculado a mano ↔ el
http_req_durationcon sup(95)que k6 mide y reporta solo (en k6 no lo calculas tú; lo hace el runtime).
Ejercicio 2 — Interpreta dos corridas. Con 20 concurrentes el p95 fue 17.21 ms; con 50, fue 29.13 ms. (a) ¿Por qué subió el p95 al subir la concurrencia? (b) El throughput apenas cambió (4157 → 4856 req/s) mientras el p95 casi se duplicó: ¿qué sugiere eso sobre el estado del sistema? (c) Si quisieras encontrar el punto de quiebre, ¿qué harías a partir de aquí?
Ver solución
- (a) Más clientes concurrentes → más peticiones compiten por los recursos (hilos, CPU) → más peticiones esperan su turno → más latencia en la cola, que el p95 captura.
- (b) Que el throughput casi no suba mientras el p95 se dispara sugiere que el sistema se está acercando a la saturación: ya no sacas mucho más trabajo por segundo, y el costo de meter más carga es solo más espera (más latencia). Es la antesala del punto de quiebre.
- (c) Seguir subiendo la concurrencia por escalones (100, 200, 400...) y observar dónde el p95 se descontrola de forma no lineal o la tasa de error deja de ser 0%. Eso es un stress test (lección 4), construido con perfiles de carga (módulo 4).
Ejercicio 3 — Añade el p50 (mediana). El generador reporta mín, promedio, máx y p95. Describe en dos o tres frases cómo añadirías la mediana (p50) al reporte usando la lista latencies ya ordenada, y por qué comparar p50 con p95 es informativo.
Ver solución
Como latencies ya está ordenada, el p50 es el valor en la posición del 50%: p50 = latencies[int(len(latencies) * 0.50)] (o statistics.median(latencies), que hace justo eso). Se imprimiría con una línea más, igual que el p95. Comparar p50 con p95 es informativo porque el p50 describe la experiencia típica (la mitad de los usuarios vio esto o menos) y el p95 describe la cola (el peor 5%): si el p95 es mucho mayor que el p50, hay una cola larga —contención, picos puntuales— que el usuario típico no nota pero el desafortunado sí. Esa brecha p50↔p95 es una de las señales más útiles de un sistema bajo presión.
Resumen y siguiente paso
En esta lección hiciste tu primer contacto con la carga, por los dos lados. Del lado ejecutable, escribiste (y corriste de verdad) un mini-generador en Python que usa ThreadPoolExecutor para lanzar N peticiones concurrentes a /quote, mide cada latencia con time.perf_counter y reporta mín/promedio/máx/p95. Viste tus primeras mediciones reales: p95 de 17 ms con 20 concurrentes, 29 ms con 50 —la latencia respondiendo a la concurrencia ante tus ojos, con todas las respuestas correctas (7500)—. Del lado industrial, viste el mismo test escrito en k6 (como contenido rotulado) y su resumen, y comprobaste el paralelismo exacto: vus ↔ max_workers, default ↔ one_quote, check ↔ la verificación de corrección, http_req_duration/p(95) ↔ tu p95 calculado a mano.
La idea que se lleva la lección es liberadora: k6 no es una caja negra mágica; hace conceptualmente lo mismo que tu generador de treinta líneas, solo que a escala, con más precisión y con las métricas calculadas por ti. Entender el generador es entender k6 por dentro.
Antes de avanzar deberías poder: explicar qué línea del generador crea la carga (la concurrencia del pool) y qué línea mide la latencia (el perf_counter alrededor de la petición); mapear cada parte del generador a su equivalente en k6; e interpretar cómo el p95 sube con la concurrencia.
Lo que sigue es hacer este recorrido completo con tus manos, de principio a fin. En la lección 8, el mini-proyecto: levantas la API de Reservo, escribes tu propio generador que la golpea con N peticiones concurrentes y reporta la latencia real, y escribes el script de k6 equivalente como contenido. Es la síntesis de todo el módulo.
Recursos
concurrent.futures.ThreadPoolExecutor— documentación de Python — el mecanismo con el que el generador crea la concurrencia (los "usuarios virtuales" simultáneos).time.perf_counter— documentación de Python — el reloj de alta resolución con el que se mide la latencia de cada petición.- k6 —
http.posty hacer peticiones — cómo el script de k6 hace la peticiónPOST /quote; la referencia del módulok6/http. - k6 —
check()— la verificación de corrección bajo carga, equivalente alcorrecto: Truedel generador; la desarrollamos a fondo en el módulo 6.