Módulo 4: Perfiles de carga y stages
8. Mini-proyecto: un perfil de carga por etapas
Descripción
Llegó el momento de poner las manos en el teclado y sintetizar todo el módulo en un artefacto que funciona. En este mini-proyecto construyes un perfil de carga por etapas —la ola ramp-up → steady → ramp-down— en sus dos formas: el script de k6 con stages (contenido rotulado, porque k6 no está instalado) y su equivalente ejecutable en Python que varía la concurrencia por etapas contra la API de Reservo y reporta el p95 de cada etapa. Al terminar tendrás: la API de Reservo corriendo, un generador que aplica un perfil con forma, una salida real que muestra el p95 subiendo con la carga y recuperándose al bajar, el script k6 equivalente, y una reflexión que mapea "esto en k6 se ve así / lo mismo medido en Python da estos números". Es la prueba de que entendiste lo esencial del módulo: que la carga tiene forma, y que esa forma se lee.
Conexión con el módulo: este es el capstone. Reúne la anatomía de stages (lección 3), las formas (lección 4), los executors por VUs (lección 5) y el criterio de elegir el perfil (lección 7). Es un load/stress test en miniatura, del tipo que diseñaste en la lección 7. No introduce nada nuevo: es integración. Todo lo ejecutable (la API, el generador, el p95 por etapa) se corre de verdad y se cita; el script k6 y su resumen van como contenido. Nunca se ejecuta git ni gh.
Qué vas a entregar
Cuatro artefactos:
- La API de Reservo local (un archivo Python), corriendo en un puerto libre.
- El generador por etapas (otro archivo Python) que aplica un perfil ramp-up → steady → ramp-down y reporta el p95 por etapa.
- El script k6 equivalente con
stages(contenido). - Una reflexión que mapee los dos y responda: ¿qué reveló la forma que una carga constante no habría mostrado?
Vamos por partes. Primero preparas un directorio de trabajo temporal (para no chocar con nada) y levantas la API; luego escribes y corres el generador; luego el script k6; y cierras con la reflexión y la rúbrica.
Paso 0: el directorio de trabajo
Trabaja en un directorio temporal, para que la API use un puerto que el sistema operativo elige (el puerto 0), sin rutas ni puertos fijos que puedan chocar con otra cosa.
$ WORK=$(mktemp -d)
$ cd "$WORK"
Todo lo que sigue vive en ese directorio.
Paso 1: la API de Reservo (la canónica, declarada)
Esta es la API canónica de siempre: GET /rooms, POST /quote {room,tier,hours}→{price_cents}, POST /book. Precios en centavos enteros, descuento pro con división entera (* 80 // 100), anclas Focus/basic/3h → 7500 y Focus/pro/3h → 6000.
Para este mini-proyecto declaramos un añadido, como permite el diseño de la guía: un endpoint extra, /quote_cpu, que hace lo mismo que /quote (recibe {room, tier, hours}, devuelve {price_cents} con los mismos números-ancla) pero antes de responder hace un pequeño trabajo de CPU —un bucle de sumas—. Como el GIL de Python deja correr solo un hilo de Python a la vez, ese cálculo se serializa entre peticiones: se comporta como un recurso compartido de capacidad limitada (una CPU, o un pool de una sola conexión a la base de datos), que es justo lo que hace que un sistema real forme cola bajo carga. Cargaremos /quote_cpu para que la contención sea visible (y el p95 suba de verdad con la carga); el /quote y el /book canónicos se quedan rápidos, sin cambios. Lo declaramos abiertamente; el resto de la API (los precios, las anclas) no cambia. (Es el mismo /quote_cpu que el módulo 5 formaliza para gatear thresholds.)
Guarda esto como reservo_server.py:
# reservo_server.py — API de Reservo (canónica) como blanco de carga.
# DECLARADO: ademas del canonico, se anade el endpoint /quote_cpu, que hace un
# pequeno trabajo de CPU (un bucle de sumas) ANTES de responder. Con el GIL, ese
# calculo se serializa entre hilos, asi que modela un recurso compartido de
# capacidad limitada (una CPU / una conexion a la BD): a mas VUs concurrentes,
# mas cola, peor p95. El /quote y el /book canonicos NO cambian: siguen rapidos.
import json
import sys
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
# Tarifas por hora en centavos enteros (nunca float para dinero).
HOURLY_CENTS = {"Focus": 2500, "Studio": 4000, "Boardroom": 8000}
# Cuanto trabajo de CPU hace /quote_cpu por peticion (iteraciones del bucle).
CPU_WORK = 90000
def price_cents(room, tier, hours):
base = HOURLY_CENTS[room] * hours
if tier == "pro":
return base * 80 // 100 # descuento pro 20%, division entera
return base
def burn_cpu(iterations):
# Trabajo de CPU real (declarado): un bucle que el GIL serializa entre hilos,
# creando la contencion que hace visible el efecto de la carga en el p95.
total = 0
for i in range(iterations):
total += i * i
return total
class ReservoHandler(BaseHTTPRequestHandler):
protocol_version = "HTTP/1.1" # keep-alive: el generador reusa la conexion
def log_message(self, *args):
pass # silencioso
def _send_json(self, status, payload):
body = json.dumps(payload).encode()
self.send_response(status)
self.send_header("Content-Type", "application/json")
self.send_header("Content-Length", str(len(body)))
self.end_headers()
self.wfile.write(body)
def do_GET(self):
if self.path == "/rooms":
rooms = [{"room": r, "hourly_cents": c} for r, c in HOURLY_CENTS.items()]
self._send_json(200, {"rooms": rooms})
else:
self._send_json(404, {"error": "not found"})
def do_POST(self):
length = int(self.headers.get("Content-Length", 0))
raw = self.rfile.read(length) if length else b"{}"
data = json.loads(raw or b"{}")
room = data.get("room", "Focus")
tier = data.get("tier", "basic")
hours = int(data.get("hours", 1))
if self.path == "/quote_cpu":
burn_cpu(CPU_WORK) # trabajo de CPU serializado por el GIL
self._send_json(200, {"price_cents": price_cents(room, tier, hours)})
elif self.path == "/quote":
self._send_json(200, {"price_cents": price_cents(room, tier, hours)})
elif self.path == "/book":
self._send_json(200, {
"booking_id": f"bk_{room}_{tier}_{hours}",
"price_cents": price_cents(room, tier, hours),
"confirmed": True,
})
else:
self._send_json(404, {"error": "not found"})
def main():
port = int(sys.argv[1]) if len(sys.argv) > 1 else 0 # puerto 0: SO elige libre
server = ThreadingHTTPServer(("127.0.0.1", port), ReservoHandler)
print(f"reservo escuchando en http://127.0.0.1:{server.server_address[1]}",
flush=True)
server.serve_forever()
if __name__ == "__main__":
main()
Levántalo en segundo plano y anota el puerto que imprime:
$ python3.14 reservo_server.py
reservo escuchando en http://127.0.0.1:PORT
Antes de cargarlo, confirma las anclas con curl —ejecutado de verdad—. El /quote canónico y el /quote_cpu declarado devuelven el mismo precio (la lógica de negocio es idéntica; solo difieren en el trabajo de CPU):
$ curl -s -X POST http://127.0.0.1:PORT/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:PORT/quote -H 'Content-Type: application/json' \
-d '{"room":"Focus","tier":"pro","hours":3}'
{"price_cents": 6000}
$ curl -s -X POST http://127.0.0.1:PORT/quote_cpu -H 'Content-Type: application/json' \
-d '{"room":"Focus","tier":"basic","hours":3}'
{"price_cents": 7500}
7500 y 6000 en /quote, y 7500 en /quote_cpu: la API responde correcto. Ahora cargamos /quote_cpu.
Paso 2: el generador por etapas
Este es el corazón del proyecto: un generador que aplica un perfil ramp-up → steady → ramp-down subiendo y bajando el número de trabajadores concurrentes (los "VUs") contra /quote_cpu, y midiendo el p95 de cada etapa. Cada etapa es un tramo de stages; su número de VUs es el target. Los trabajadores entran escalonados (como un ramp real, y para no golpear al servidor con una avalancha de conexiones a la vez), reusan una conexión keep-alive, y cada etapa se mide aislada —con un pequeño drenaje entre etapas— para que ninguna petición cruce de una a otra y el p95 salga limpio.
Guarda esto como staged_load.py:
# staged_load.py — aplica un perfil por etapas (ramp-up/steady/ramp-down) contra
# el endpoint /quote_cpu de Reservo y reporta el p95 de cada etapa. Equivalente
# ejecutable de los stages de k6 (ramping-vus).
# Uso: python3.14 staged_load.py http://127.0.0.1:PORT
import statistics
import sys
import threading
import time
from http.client import HTTPConnection
# El perfil: cada etapa fija un TARGET de VUs sostenido unos segundos. Sube y baja.
STAGES = [
{"name": "ramp-up (warm)", "vus": 4, "seconds": 3.0},
{"name": "ramp-up (mid) ", "vus": 12, "seconds": 3.0},
{"name": "steady (peak)", "vus": 24, "seconds": 4.0},
{"name": "ramp-down (mid) ", "vus": 12, "seconds": 3.0},
{"name": "ramp-down (cool)", "vus": 4, "seconds": 3.0},
]
BODY = '{"room":"Focus","tier":"basic","hours":3}'
HEADERS = {"Content-Type": "application/json"}
STAGGER = 0.05 # los VUs entran escalonados (50 ms c/u), como un ramp real
def worker(idx, host, port, deadline, out, lock, errbox):
time.sleep(idx * STAGGER) # arranque escalonado
conn = HTTPConnection(host, port)
local = []
while time.perf_counter() < deadline:
t0 = time.perf_counter()
try:
conn.request("POST", "/quote_cpu", BODY, HEADERS)
resp = conn.getresponse()
resp.read()
local.append((time.perf_counter() - t0) * 1000.0)
except Exception:
with lock:
errbox[0] += 1
try:
conn.close()
except Exception:
pass
conn = HTTPConnection(host, port)
conn.close()
with lock:
out.extend(local)
def p95(values):
if len(values) < 2:
return values[0] if values else 0.0
return statistics.quantiles(values, n=100)[94] # el centil 95
def run_stage(host, port, vus, seconds):
samples, errbox, lock = [], [0], threading.Lock()
deadline = time.perf_counter() + vus * STAGGER + seconds
threads = [
threading.Thread(target=worker,
args=(i, host, port, deadline, samples, lock, errbox))
for i in range(vus)
]
for t in threads:
t.start()
for t in threads:
t.join()
return samples, errbox[0]
def main():
base = sys.argv[1].rstrip("/").removeprefix("http://")
host, port = base.split(":")
port = int(port)
print(f"perfil por etapas contra http://{host}:{port}/quote_cpu "
f"(Focus/basic/3h -> 7500)")
print(f"{'etapa':<18} {'VUs':>4} {'peticiones':>11} "
f"{'p95 (ms)':>10} {'promedio (ms)':>14}")
print("-" * 62)
total_err = 0
for stage in STAGES:
samples, errs = run_stage(host, port, stage["vus"], stage["seconds"])
total_err += errs
avg = statistics.fmean(samples) if samples else 0.0
print(f"{stage['name']:<18} {stage['vus']:>4} {len(samples):>11} "
f"{p95(samples):>10.2f} {avg:>14.2f}")
time.sleep(0.4) # drenaje entre etapas
print("-" * 62)
print(f"errores: {total_err}")
if __name__ == "__main__":
main()
Córrelo contra la API:
Qué esperar — el p95 sube a lo largo del ramp-up, es máximo en el steady, y baja en el ramp-down espejando a la subida (recuperación limpia). Salida real:
$ python3.14 staged_load.py http://127.0.0.1:PORT
perfil por etapas contra http://127.0.0.1:PORT/quote_cpu (Focus/basic/3h -> 7500)
etapa VUs peticiones p95 (ms) promedio (ms)
--------------------------------------------------------------
ramp-up (warm) 4 1083 20.78 11.55
ramp-up (mid) 12 1232 72.53 32.51
steady (peak) 24 1768 163.99 63.07
ramp-down (mid) 12 1228 80.47 32.55
ramp-down (cool) 4 1075 23.62 11.63
--------------------------------------------------------------
errores: 0
Ahí está tu perfil de carga, ejecutado. El p95 dibuja la ola: 20.78 → 72.53 → 163.99 subiendo, y 80.47 → 23.62 bajando. La simetría (12 VUs da ~72-80 ms tanto subiendo como bajando; 4 VUs da ~21-24 ms en ambos extremos) confirma que Reservo se recupera limpio cuando la carga cede. Tus números exactos variarán un poco según tu máquina —son medidos, no fijos—, pero la forma será la misma: sube, pico, baja.
Paso 3: el script k6 equivalente (contenido)
El mismo perfil que acabas de ejecutar en Python se escribe en k6 con stages. Va como contenido rotulado (k6 no está instalado):
// CONTENIDO (no ejecutado aquí): k6 no está instalado.
// Ref: grafana.com/docs/k6 (options → stages; executor ramping-vus).
import http from 'k6/http';
import { check, sleep } from 'k6';
export const options = {
stages: [
{ duration: '30s', target: 24 }, // ramp-up: de 0 a 24 VUs
{ duration: '1m', target: 24 }, // steady: sostener 24 VUs (aquí lees el p95)
{ duration: '30s', target: 0 }, // ramp-down: de 24 a 0 VUs
],
};
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 200': (r) => r.status === 200,
'price_cents 7500': (r) => r.json('price_cents') === 7500,
});
sleep(1);
}
Y así se vería su resumen —contenido rotulado, fiel a la forma del resumen de k6, no ejecutado aquí—:
// CONTENIDO (no ejecutado aquí): forma del resumen de k6. Ref: grafana.com/docs/k6
scenarios: (100.00%) 1 scenario, 24 max VUs, 2m0s max duration
* default: Up to 24 looping VUs for 2m0s over 3 stages
http_req_duration...: avg=72ms min=8ms med=60ms max=240ms p(90)=120ms p(95)=142ms
http_req_failed.....: 0.00% ✓ 0 ✗ 2400
http_reqs...........: 2400 20/s
vus.................: 1 min=0 max=24
vus_max.............: 24 min=24 max=24
La correspondencia es exacta: el stages de k6 (contenido) y las etapas del generador (ejecutado) dibujan la misma ola de tres fases. El target: 24 del tramo steady es el pico de 24 VUs del generador; k6 interpola los VUs de forma continua donde el generador usa niveles discretos, pero la forma —subir, sostener, bajar— es idéntica. Recuerda la advertencia de la lección 3: el p(95) del resumen de k6 (142ms) es el agregado de toda la prueba (subida + meseta + bajada), más bajo que el p95 real de la meseta; el generador lo reporta por etapa para que veas el pico limpio (163.99 ms).
Paso 4: la reflexión
Cierra el proyecto respondiendo, con tus propios números, estas preguntas. No hay una única respuesta correcta; se evalúa el razonamiento.
- ¿Qué reveló la forma que una carga constante no habría mostrado? (Pista: mira tu línea base, tu curva de subida y tu recuperación. ¿Qué tres cosas viste que un solo número del pico no daría?)
- ¿Se recupera tu sistema? Compara el p95 de tu
ramp-down (mid)con el de turamp-up (mid)—ambos a 12 VUs—. ¿Son parecidos? ¿Qué concluyes? - ¿Dónde reportarías "el p95 bajo carga de pico"? ¿De qué etapa, y por qué no del ramp-up ni del agregado de k6?
- El mapeo: en una frase, ¿cómo se corresponde el
stagesde k6 con las etapas de tu generador?
Un ejemplo de reflexión bien hecha para la pregunta 1: "La carga constante a 24 VUs me habría dado solo 163.99 ms y nada más. La forma me dio tres cosas: (a) mi línea base casi en reposo (20.78 ms a 4 VUs), (b) la curva de subida acelerándose (el p95 se multiplicó por más de 3 al pasar de 4 a 12 VUs, señal de que me acerco a la saturación) y (c) la confirmación de que me recupero limpio (a 4 VUs de bajada, 23.62 ms, casi idéntico al arranque). Ninguna de las tres estaba en la foto del pico."
Rúbrica
Evalúa tu entrega contra estos criterios:
| Criterio | Insuficiente | Bien | Excelente |
|---|---|---|---|
| La API corre y es correcta | No levanta o da precios erróneos | Levanta y responde 7500/6000 | Levanta en puerto 0, verificada con curl, anclas correctas |
| El generador aplica un perfil con forma | Carga constante (una sola etapa) | Tres fases (ramp-up/steady/ramp-down) | Fases claras, VUs escalonados, p95 por etapa aislado |
| La salida muestra la ola | El p95 no cambia o es errático | El p95 sube con la carga | El p95 sube y se recupera con simetría; 0 errores |
| El script k6 es correcto (contenido) | Falta o mal rotulado | stages correcto, rotulado como contenido | stages + check de status y precio, resumen rotulado |
| La reflexión conecta ejecutado y contenido | Ausente o superficial | Responde qué reveló la forma | Mapea k6↔Python y argumenta la recuperación con números |
Un proyecto "Excelente" en las cinco filas demuestra que dominaste el módulo: sabes dar forma a la carga, medir el p95 por etapa, leer la recuperación, y mapear el ejecutable de Python con el contenido de k6.
Errores comunes
Entregar una carga constante disfrazada de perfil. Qué pasa: alguien pone una sola etapa (o tres con el mismo target) y lo llama "perfil por etapas". Por qué pasa: es más fácil. Cómo detectarlo: si tu p95 no cambia entre etapas, no aplicaste una forma —aplicaste una meseta—. Cómo corregirlo: asegúrate de que los target/VUs suban y bajen (4 → 12 → 24 → 12 → 4); la forma es el punto del proyecto.
Reportar el p95 agregado de todas las etapas juntas. Qué pasa: alguien junta todas las latencias de la corrida y saca un p95 global. Por qué pasa: es el instinto del módulo 3 (un solo p95). Cómo detectarlo: un p95 global mezcla la subida y la bajada con el pico, y sale más bajo que el del pico real. Cómo corregirlo: mide el p95 por etapa (como hace el generador), para leer cada fase por separado.
No verificar las anclas antes de cargar. Qué pasa: se carga la API sin confirmar que responde 7500, y si hay un bug de precio, se mide la latencia de una respuesta incorrecta. Por qué pasa: prisa por llegar a los números de carga. Cómo detectarlo: si nunca corriste el curl de verificación, no sabes si la API es correcta. Cómo corregirlo: confirma 7500/6000 con curl antes de cargar; la carga se mide sobre una API que ya sabes correcta (la corrección es de otra guía, pero un smoke de anclas es sano).
Ejercicios
Ejercicio 1 — Cambia el pico. Modifica el STAGES del generador para que el pico sea de 36 VUs en vez de 24 (agregando o ajustando etapas), córrelo, y compara el p95 del nuevo pico con el de 24. ¿Subió como esperabas?
Ver solución
Ajustas la etapa de pico (y opcionalmente añades un escalón intermedio):
STAGES = [
{"name": "ramp-up (warm)", "vus": 6, "seconds": 3.0},
{"name": "ramp-up (mid) ", "vus": 18, "seconds": 3.0},
{"name": "steady (peak)", "vus": 36, "seconds": 4.0},
{"name": "ramp-down (mid) ", "vus": 18, "seconds": 3.0},
{"name": "ramp-down (cool)", "vus": 6, "seconds": 3.0},
]
Al correrlo, el p95 del pico a 36 VUs debería ser más alto que a 24 —más concurrencia, más cola en el trabajo de CPU de /quote_cpu serializado por el GIL—, aproximadamente proporcional al aumento de VUs. Lo importante: verificas con tus manos que subir el pico sube el p95, y que la recuperación (bajada) sigue espejando la subida. La forma se conserva; solo cambia la altura.
Ejercicio 2 — Conviértelo en un spike. Reescribe el STAGES para que sea un spike en vez de una rampa: un baseline bajo, un salto directo a un pico alto, y vuelta al baseline (sin escalones intermedios). Córrelo y mira la columna del promedio y el p95. ¿En qué se diferencia de la rampa?
Ver solución
STAGES = [
{"name": "baseline", "vus": 3, "seconds": 3.0},
{"name": "SPIKE ", "vus": 30, "seconds": 3.0},
{"name": "recovery", "vus": 3, "seconds": 3.0},
]
La diferencia clave: la rampa recorre niveles intermedios (4, 12, 24), así que ves la curva completa de degradación; el spike salta directo del baseline al pico, sin niveles intermedios, así que ves el golpe (un p95 y sobre todo un max altos en la fase SPIKE) y la recuperación (el p95 vuelve al baseline). Si además reportas el max (no solo el p95), verás que el salto abrupto castiga a las primeras peticiones del pico con esperas mucho mayores que una subida gradual al mismo nivel —la firma del arranque en frío de la lección 4—.
Ejercicio 3 — Justifica el perfil. Tu jefe pregunta: "¿por qué usaste un perfil con forma y no una carga constante de 24 para probar Reservo?". Escribe la respuesta en tres frases, usando tus números.
Ver solución
Un ejemplo: "Una carga constante de 24 me habría dado un solo número —el p95 del pico, 163.99 ms— y nada más. El perfil con forma me dio además mi línea base (20.78 ms casi en reposo), la curva de cómo se degrada la latencia al subir la carga (que se acelera, señal de que me acerco a la saturación), y la confirmación de que Reservo se recupera limpio cuando la carga baja (23.62 ms de vuelta a 4 VUs, casi igual al arranque). Con la constante habría tenido una foto; con la forma tengo la película: arranque, degradación y recuperación."
Lo esencial es nombrar las tres cosas que la forma revela y la constante no: la línea base, la curva de subida, y la recuperación.
Resumen y siguiente paso
En este mini-proyecto sintetizaste el módulo entero en un artefacto que funciona. Levantaste la API de Reservo (canónica, con un endpoint /quote_cpu declarado para hacer visible la contención), verificaste sus anclas con curl, y escribiste un generador por etapas que aplica un perfil ramp-up → steady → ramp-down y reporta el p95 de cada etapa. La salida real dibujó la ola —el p95 subiendo 21 → 73 → 164 ms y recuperándose 80 → 24 ms, con simetría entre subida y bajada— y escribiste el script k6 equivalente con stages como contenido, mapeando pieza por pieza el ejecutable de Python con el contenido de k6.
Con eso demostraste lo esencial del módulo: que una carga tiene forma, no solo tamaño; que la forma ramp-up → steady → ramp-down revela el arranque, la degradación y la recuperación; que el p95 se lee por etapa (en la meseta, no en el agregado); y que los stages de k6 y las etapas del generador son la misma ola en dos lenguajes. La rúbrica te dio el estándar; la reflexión te hizo articular qué reveló la forma que una constante habría escondido.
Antes de cerrar deberías poder: levantar la API y verificar sus anclas; escribir un generador que suba y baje la concurrencia por etapas y mida el p95 de cada una; escribir el stages de k6 equivalente y rotularlo como contenido; y argumentar, con tus números, por qué la forma revela más que la constante.
Lo que sigue, más allá de este módulo, es ponerle un veredicto a estos números. Hasta ahora describes el rendimiento (el p95 sube a 164 ms en el pico); en el módulo 5 aprendes a juzgarlo con thresholds: umbrales como p(95)<500 que hacen que la prueba pase o falle automáticamente —un quality gate de rendimiento que sale con código de error si no se cumple—. La forma de la carga (este módulo) más el umbral del resultado (el siguiente) son las dos mitades de una prueba de carga que decide sola si el sistema está listo.
Recursos
- k6 — Opción
stagesy executorramping-vus— la referencia del perfil por etapas que construiste; la fuente del script k6 de este proyecto. - k6 — Escribir tu primer script de prueba de carga — cómo se estructura un script k6 completo (options + default + checks), para contrastar con tu generador Python.
http.server— documentación de Python — el módulo de la biblioteca estándar con el que levantaste la API de Reservo, sin instalar nada.statistics.quantiles— documentación de Python — la función con la que el generador calcula el p95 de cada etapa; el mismo cálculo del módulo 3, ahora aplicado por tramos.