Module 4: Load Profiles And Stages
8. Mini-project: a staged load profile
Overview
The moment has come to put hands on the keyboard and synthesize the whole module into an artifact that works. In this mini-project you build a staged load profile —the ramp-up → steady → ramp-down wave— in its two forms: the k6 script with stages (labeled content, because k6 isn't installed) and its executable equivalent in Python that varies the concurrency in stages against the Reservo API and reports the p95 of each stage. By the end you'll have: the Reservo API running, a generator that applies a shaped profile, real output showing the p95 rising with the load and recovering on the way down, the equivalent k6 script, and a reflection that maps "this in k6 looks like this / the same measured in Python gives these numbers." It's the proof that you understood the essence of the module: that the load has a shape, and that that shape is read.
Connection to the module: this is the capstone. It brings together the anatomy of stages (lesson 3), the shapes (lesson 4), the by-VU executors (lesson 5), and the criterion of choosing the profile (lesson 7). It's a load/stress test in miniature, of the kind you designed in lesson 7. It introduces nothing new: it's integration. Everything executable (the API, the generator, the per-stage p95) is actually run and cited; the k6 script and its summary go as content. It never runs git or gh.
What you'll deliver
Four artifacts:
- The local Reservo API (a Python file), running on a free port.
- The staged generator (another Python file) that applies a ramp-up → steady → ramp-down profile and reports the p95 per stage.
- The equivalent k6 script with
stages(content). - A reflection that maps the two and answers: what did the shape reveal that a constant load wouldn't have shown?
Let's go step by step. First you prepare a temporary working directory (to avoid colliding with anything) and bring up the API; then you write and run the generator; then the k6 script; and you close with the reflection and the rubric.
Step 0: the working directory
Work in a temporary directory, so the API uses a port the operating system chooses (port 0), with no fixed paths or ports that could collide with something else.
$ WORK=$(mktemp -d)
$ cd "$WORK"
Everything that follows lives in that directory.
Step 1: the Reservo API (the canonical one, declared)
This is the usual canonical API: GET /rooms, POST /quote {room,tier,hours}→{price_cents}, POST /book. Prices in integer cents, pro discount with integer division (* 80 // 100), anchors Focus/basic/3h → 7500 and Focus/pro/3h → 6000.
For this mini-project we declare one addition, as the guide's design allows: an extra endpoint, /quote_cpu, that does the same as /quote (receives {room, tier, hours}, returns {price_cents} with the same anchor numbers) but before responding does a small bit of CPU work —a loop of additions—. Since Python's GIL lets only one Python thread run at a time, that computation is serialized between requests: it behaves like a shared resource of limited capacity (a CPU, or a single-connection database pool), which is exactly what makes a real system queue under load. We'll load /quote_cpu so the contention is visible (and the p95 really rises with the load); the canonical /quote and /book stay fast, unchanged. We declare it openly; the rest of the API (the prices, the anchors) doesn't change. (It's the same /quote_cpu that module 5 formalizes to gate thresholds.)
Save this as reservo_server.py:
# reservo_server.py — Reservo API (canonical) as a load target.
# DECLARED: besides the canonical, the /quote_cpu endpoint is added, which does a
# small bit of CPU work (a loop of additions) BEFORE responding. With the GIL, that
# computation is serialized between threads, so it models a shared resource of
# limited capacity (a CPU / a DB connection): more concurrent VUs mean
# more queue, worse p95. The canonical /quote and /book do NOT change: they stay fast.
import json
import sys
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
# Hourly rates in integer cents (never float for money).
HOURLY_CENTS = {"Focus": 2500, "Studio": 4000, "Boardroom": 8000}
# How much CPU work /quote_cpu does per request (loop iterations).
CPU_WORK = 90000
def price_cents(room, tier, hours):
base = HOURLY_CENTS[room] * hours
if tier == "pro":
return base * 80 // 100 # 20% pro discount, integer division
return base
def burn_cpu(iterations):
# Real CPU work (declared): a loop the GIL serializes between threads,
# creating the contention that makes the load's effect on the p95 visible.
total = 0
for i in range(iterations):
total += i * i
return total
class ReservoHandler(BaseHTTPRequestHandler):
protocol_version = "HTTP/1.1" # keep-alive: the generator reuses the connection
def log_message(self, *args):
pass # silent
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) # CPU work serialized by the 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 # port 0: OS picks a free one
server = ThreadingHTTPServer(("127.0.0.1", port), ReservoHandler)
print(f"reservo listening on http://127.0.0.1:{server.server_address[1]}",
flush=True)
server.serve_forever()
if __name__ == "__main__":
main()
Bring it up in the background and note the port it prints:
$ python3.14 reservo_server.py
reservo listening on http://127.0.0.1:PORT
Before loading it, confirm the anchors with curl —actually executed—. The canonical /quote and the declared /quote_cpu return the same price (the business logic is identical; they differ only in the CPU work):
$ 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 and 6000 on /quote, and 7500 on /quote_cpu: the API responds correctly. Now we load /quote_cpu.
Step 2: the staged generator
This is the heart of the project: a generator that applies a ramp-up → steady → ramp-down profile by raising and lowering the number of concurrent workers (the "VUs") against /quote_cpu, and measuring the p95 of each stage. Each stage is a stages segment; its number of VUs is the target. The workers enter staggered (like a real ramp, and to avoid hitting the server with an avalanche of connections at once), reuse a keep-alive connection, and each stage is measured in isolation —with a small drain between stages— so no request crosses from one to another and the p95 comes out clean.
Save this as staged_load.py:
# staged_load.py — applies a staged profile (ramp-up/steady/ramp-down) against
# Reservo's /quote_cpu endpoint and reports the p95 of each stage. Executable
# equivalent of k6's stages (ramping-vus).
# Usage: python3.14 staged_load.py http://127.0.0.1:PORT
import statistics
import sys
import threading
import time
from http.client import HTTPConnection
# The profile: each stage fixes a TARGET of VUs held for a few seconds. Rises and falls.
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 # the VUs enter staggered (50 ms each), like a real ramp
def worker(idx, host, port, deadline, out, lock, errbox):
time.sleep(idx * STAGGER) # staggered startup
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] # the 95th percentile
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"staged profile against http://{host}:{port}/quote_cpu "
f"(Focus/basic/3h -> 7500)")
print(f"{'stage':<18} {'VUs':>4} {'requests':>11} "
f"{'p95 (ms)':>10} {'average (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) # drain between stages
print("-" * 62)
print(f"errors: {total_err}")
if __name__ == "__main__":
main()
Run it against the API:
What to expect — the p95 rises throughout the ramp-up, is maximal at the steady, and falls on the ramp-down mirroring the rise (clean recovery). Real output:
$ python3.14 staged_load.py http://127.0.0.1:PORT
staged profile against http://127.0.0.1:PORT/quote_cpu (Focus/basic/3h -> 7500)
stage VUs requests p95 (ms) average (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
--------------------------------------------------------------
errors: 0
There's your load profile, executed. The p95 draws the wave: 20.78 → 72.53 → 163.99 rising, and 80.47 → 23.62 falling. The symmetry (12 VUs gives ~72-80 ms both rising and falling; 4 VUs gives ~21-24 ms at both ends) confirms Reservo recovers cleanly when the load eases. Your exact numbers will vary a bit depending on your machine —they're measured, not fixed—, but the shape will be the same: rise, peak, fall.
Step 3: the equivalent k6 script (content)
The same profile you just executed in Python is written in k6 with stages. It goes as labeled content (k6 isn't installed):
// CONTENT (not run here): k6 is not installed.
// 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: from 0 to 24 VUs
{ duration: '1m', target: 24 }, // steady: hold 24 VUs (you read the p95 here)
{ duration: '30s', target: 0 }, // ramp-down: from 24 to 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);
}
And this is what its summary would look like —labeled content, faithful to the shape of the k6 summary, not run here—:
// CONTENT (not run here): shape of the k6 summary. 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
The correspondence is exact: k6's stages (content) and the generator's stages (executed) draw the same three-phase wave. The target: 24 of the steady segment is the generator's peak of 24 VUs; k6 interpolates the VUs continuously where the generator uses discrete levels, but the shape —rise, hold, fall— is identical. Remember lesson 3's warning: the k6 summary's p(95) (142ms) is the aggregate of the whole test (rise + plateau + fall), lower than the plateau's real p95; the generator reports it per stage so you see the clean peak (163.99 ms).
Step 4: the reflection
Close the project by answering, with your own numbers, these questions. There's no single correct answer; the reasoning is what's evaluated.
- What did the shape reveal that a constant load wouldn't have shown? (Hint: look at your baseline, your rise curve, and your recovery. What three things did you see that a single peak number wouldn't give?)
- Does your system recover? Compare the p95 of your
ramp-down (mid)with that of yourramp-up (mid)—both at 12 VUs—. Are they similar? What do you conclude? - Where would you report "the p95 at peak load"? From which stage, and why not the ramp-up's or k6's aggregate?
- The mapping: in one sentence, how does k6's
stagescorrespond to your generator's stages?
An example of a well-done reflection for question 1: "The constant load at 24 VUs would have given me only 163.99 ms and nothing else. The shape gave me three things: (a) my almost-at-rest baseline (20.78 ms at 4 VUs), (b) the accelerating rise curve (the p95 multiplied by more than 3 going from 4 to 12 VUs, a signal that I'm approaching saturation), and (c) the confirmation that I recover cleanly (at 4 VUs on the way down, 23.62 ms, almost identical to the start). None of the three were in the peak's photo."
Rubric
Evaluate your deliverable against these criteria:
| Criterion | Insufficient | Good | Excellent |
|---|---|---|---|
| The API runs and is correct | Doesn't start or gives wrong prices | Starts and responds 7500/6000 | Starts on port 0, verified with curl, correct anchors |
| The generator applies a shaped profile | Constant load (a single stage) | Three phases (ramp-up/steady/ramp-down) | Clear phases, staggered VUs, isolated per-stage p95 |
| The output shows the wave | The p95 doesn't change or is erratic | The p95 rises with the load | The p95 rises and recovers with symmetry; 0 errors |
| The k6 script is correct (content) | Missing or badly labeled | Correct stages, labeled as content | stages + status and price check, labeled summary |
| The reflection connects executed and content | Absent or superficial | Answers what the shape revealed | Maps k6↔Python and argues the recovery with numbers |
An "Excellent" project on all five rows proves you mastered the module: you know how to shape the load, measure the p95 per stage, read the recovery, and map the Python executable with the k6 content.
Common mistakes
Delivering a constant load disguised as a profile. What happens: someone puts a single stage (or three with the same target) and calls it a "staged profile." Why it happens: it's easier. How to detect it: if your p95 doesn't change between stages, you didn't apply a shape —you applied a plateau—. How to fix it: make sure the target/VUs rise and fall (4 → 12 → 24 → 12 → 4); the shape is the point of the project.
Reporting the aggregate p95 of all the stages together. What happens: someone pools all the run's latencies and computes a global p95. Why it happens: it's the module-3 instinct (a single p95). How to detect it: a global p95 mixes the rise and the fall with the peak, and comes out lower than the real peak's. How to fix it: measure the p95 per stage (as the generator does), to read each phase separately.
Not verifying the anchors before loading. What happens: the API is loaded without confirming it responds 7500, and if there's a price bug, you measure the latency of an incorrect response. Why it happens: haste to get to the load numbers. How to detect it: if you never ran the verification curl, you don't know whether the API is correct. How to fix it: confirm 7500/6000 with curl before loading; the load is measured over an API you already know is correct (correctness belongs to another guide, but an anchor smoke is healthy).
Exercises
Exercise 1 — Change the peak. Modify the generator's STAGES so the peak is 36 VUs instead of 24 (by adding or adjusting stages), run it, and compare the new peak's p95 with the 24 one. Did it rise as you expected?
See solution
You adjust the peak stage (and optionally add an intermediate step):
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},
]
On running it, the peak's p95 at 36 VUs should be higher than at 24 —more concurrency, more queue in the CPU work of /quote_cpu serialized by the GIL—, roughly proportional to the VU increase. The key: you verify with your own hands that raising the peak raises the p95, and that the recovery (fall) keeps mirroring the rise. The shape is preserved; only the height changes.
Exercise 2 — Turn it into a spike. Rewrite the STAGES so it's a spike instead of a ramp: a low baseline, a direct jump to a high peak, and a return to the baseline (no intermediate steps). Run it and look at the average and p95 columns. How does it differ from the ramp?
See solution
STAGES = [
{"name": "baseline", "vus": 3, "seconds": 3.0},
{"name": "SPIKE ", "vus": 30, "seconds": 3.0},
{"name": "recovery", "vus": 3, "seconds": 3.0},
]
The key difference: the ramp traverses intermediate levels (4, 12, 24), so you see the complete degradation curve; the spike jumps straight from the baseline to the peak, with no intermediate levels, so you see the blow (a high p95 and above all a high max in the SPIKE phase) and the recovery (the p95 returns to the baseline). If you also report the max (not just the p95), you'll see the abrupt jump punishes the first requests of the peak with much greater waits than a gradual rise to the same level —the cold-start signature from lesson 4—.
Exercise 3 — Justify the profile. Your boss asks: "why did you use a shaped profile and not a constant load of 24 to test Reservo?". Write the answer in three sentences, using your numbers.
See solution
An example: "A constant load of 24 would have given me a single number —the peak's p95, 163.99 ms— and nothing else. The shaped profile also gave me my baseline (20.78 ms almost at rest), the curve of how the latency degrades as the load rises (which accelerates, a signal that I'm approaching saturation), and the confirmation that Reservo recovers cleanly when the load drops (23.62 ms back at 4 VUs, almost equal to the start). With the constant I'd have had a photo; with the shape I have the film: startup, degradation, and recovery."
The essential thing is to name the three things the shape reveals and the constant doesn't: the baseline, the rise curve, and the recovery.
Summary and next step
In this mini-project you synthesized the whole module into an artifact that works. You brought up the Reservo API (canonical, with a declared /quote_cpu endpoint to make the contention visible), verified its anchors with curl, and wrote a staged generator that applies a ramp-up → steady → ramp-down profile and reports the p95 of each stage. The real output drew the wave —the p95 rising 21 → 73 → 164 ms and recovering 80 → 24 ms, with symmetry between rise and fall— and you wrote the equivalent k6 script with stages as content, mapping piece by piece the Python executable with the k6 content.
With that you proved the essence of the module: that a load has a shape, not just a size; that the ramp-up → steady → ramp-down shape reveals the startup, the degradation, and the recovery; that the p95 is read per stage (on the plateau, not on the aggregate); and that k6's stages and the generator's stages are the same wave in two languages. The rubric gave you the standard; the reflection made you articulate what the shape revealed that a constant would have hidden.
Before closing you should be able to: bring up the API and verify its anchors; write a generator that raises and lowers the concurrency in stages and measures each one's p95; write the equivalent k6 stages and label it as content; and argue, with your numbers, why the shape reveals more than the constant.
What comes next, beyond this module, is putting a verdict on these numbers. So far you describe the performance (the p95 rises to 164 ms at the peak); in module 5 you learn to judge it with thresholds: limits like p(95)<500 that make the test pass or fail automatically —a performance quality gate that exits with an error code if it's not met—. The shape of the load (this module) plus the limit on the result (the next) are the two halves of a load test that decides on its own whether the system is ready.
Resources
- k6 —
stagesoption andramping-vusexecutor — the reference for the staged profile you built; the source of this project's k6 script. - k6 — Write your first load test script — how a complete k6 script is structured (options + default + checks), to contrast with your Python generator.
http.server— Python documentation — the standard-library module you brought up the Reservo API with, without installing anything.statistics.quantiles— Python documentation — the function the generator computes each stage's p95 with; the same computation from module 3, now applied per segment.