Add deployment phase and Traefik compose override

Insert a new Phase 1 (deployment: Compose + Traefik on a server, marked
Done) between the base project and the OTel concepts phase, and shift
the old phases 1-10 to 2-11. Update summary, intro, README and CLAUDE.md
references (spoiler rules now target Phase 9, the bottleneck hunt).

Add docker-compose.traefik.yml: an override that fronts the gateway with
Traefik and Let's Encrypt and removes all other published host ports.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
Movses Aslanyan
2026-10-09 15:38:29 +03:30
co-authored by Claude Sonnet 5.5
parent b2112e55e1
commit 5e514ecf25
17 changed files with 1080 additions and 937 deletions
+60 -78
View File
@@ -1,94 +1,76 @@
# Phase 6 — Load testing with k6, and percentiles
# Phase 6 — Dashboards and alerts as code
*Part of `docs/roadmap/` — see [`intro.md`](intro.md) for platform/background context and [`summary.md`](summary.md) for how every phase fits together. Prev: [`phase-5.md`](phase-5.md) · Next: [`phase-7.md`](phase-7.md)*
**Status:** Not started
**Message:** Depends on Phase 4 (metrics to plot k6's output against) and
benefits from Phase 5's dashboards existing.
**Message:** Depends on Phases 3–5 producing traces, logs and metrics worth
dashboarding.
**Time-box:** 1–2 evenings.
**Time-box:** 2 evenings. This is an explicit requirement from Babak: *destroy and
recreate in seconds.*
### Concepts — P95 and P99 properly
### Concepts
You asked about these specifically, and there's more to them than "the 95th
percentile."
- **A percentile is meaningless without a population and a window.** "p95 = 400 ms"
is not a fact; "p95 of `POST /api/transactions` over the last 5 minutes = 400 ms"
is.
- **You cannot average percentiles.** Not across instances, not across time buckets.
`avg(p99)` is wrong and produces a number that looks plausible. The correct move is
to aggregate the *histogram buckets* and compute the quantile from the sum:
`histogram_quantile(0.95, sum(rate(..._bucket[5m])) by (le))`.
- **Prometheus classic histograms give you an interpolated estimate**, bounded by
your bucket edges. If your buckets jump 0.5 s → 1 s and everything lands in that
bucket, your "p99" is a linear guess inside a 500 ms range. Choose buckets that
bracket your actual SLO threshold. (Native histograms largely solve this and are
worth knowing about.)
- **Which population?** An overall p99 across all endpoints is dominated by whichever
endpoint has the most traffic. Slice by route, or you'll miss that
`/api/accounts/{id}/insights` is catastrophic while the aggregate looks fine.
- **Tail latency amplification** — the most important idea here for a microservice
system. If one user request fans out to 10 backend calls, each with an independent
p99 of 1 s, then the chance the *user request* touches at least one slow call is
`1 − 0.99¹⁰ ≈ 9.6%`. Your user-facing p99 is worse than any individual service's
p99. This is Dean & Barroso's *The Tail at Scale*, and it's why "each service is
fine" and "the product is slow" coexist. Ledgerline's fan-out makes this
measurable — go measure it.
- **p99 vs p99.9:** the users in the deep tail are often your heaviest, most valuable,
most data-rich accounts. Ledgerline's five hot accounts are a stand-in for exactly
that.
- **Coordinated omission** — a load-generator trap. If your generator waits for a
response before sending the next request, then when the system stalls, the
generator stalls too and simply *doesn't record* the requests that would have been
slow. Your measured latency is systematically optimistic. Fix: use an **open
model** — a fixed *arrival rate* independent of response times. In k6 that means
the `constant-arrival-rate` / `ramping-arrival-rate` executors rather than
`constant-vus`. Knowing this is a genuine senior-level differentiator.
### Concepts — k6
- **Test types:** smoke (does it work at all), average-load (normal traffic),
stress (past normal, find the knee), spike (sudden surge, test recovery),
soak (hours, find leaks and lag drift), breakpoint (ramp until failure).
Your `unbounded_cache` and `slow_consumer` pathologies are only visible in a soak.
- **Executors:** closed model (`constant-vus`, `ramping-vus`) vs open model
(`constant-arrival-rate`, `ramping-arrival-rate`). See coordinated omission above.
- **`checks` vs `thresholds`:** checks are assertions that record pass/fail but don't
fail the run; **thresholds are SLOs expressed in code and they set the process exit
code**, which is what makes k6 a CI gate. `http_req_duration: ['p(95)<500']`.
- **Output:** k6 can remote-write its own metrics to Prometheus, so the load test's
view and the application's view sit on the same Grafana time axis. That side-by-side
is the single most convincing screenshot you can show Babak.
- k6 tests are JavaScript/TypeScript, which is fine — you're not learning JS, you're
writing 60 lines of it.
- Three levels of "as code", and you should know when each applies:
1. **File provisioning** — YAML in `provisioning/datasources/` and
`provisioning/dashboards/`, plus dashboard JSON files mounted into the
container. Grafana loads them at startup. Simplest, no state, no API tokens,
perfect for a Compose demo. **This alone satisfies the requirement.**
2. **Grafana Foundation SDK** — typed libraries (Go, **Python**, TypeScript, Java,
PHP) that *generate* the dashboard JSON programmatically. Now a dashboard is a
function: `red_dashboard("ledger")`, called seven times in a loop. Reviewable
diffs, reusable panels, no 2000-line JSON blobs. Grafana now points here instead
of Grafonnet (which is legacy and unofficial).
3. **Terraform provider / Grafana Operator / Git Sync** — for state-managed,
multi-environment, or GitOps setups. Overkill here; the right answer *later*,
when this lands on your Kubernetes cluster.
- **Dashboards should answer questions, not display data.** A good service dashboard
answers, in order: is it up, is it serving, is it erroring, is it slow, and where
is the pressure. Panels that nobody looks at during an incident are cost, not value.
- **Alerts as code too.** Grafana alert rules, contact points and notification
policies all provision from YAML. And keep the *alert definition* next to the
dashboard that explains it.
### Resources
- Grafana k6 docs → *Get started*, *Test types*, *Scenarios & executors*,
*Thresholds*. Free, genuinely well written; ~2 h to work through.
- Grafana workshop — *Introduction to Performance Testing with Grafana k6* (free,
hands-on).
- Udemy backbone (Aref Karimi's course) has a k6 section if you took that one.
- Grafana docs → *k6 output to Prometheus remote write*.
- Grafana docs → *Observability as code* (the index page — it lays out Foundation
SDK, file provisioning, Git Sync and Terraform, and tells you which to use when).
- Grafana Foundation SDK repo → the Python examples, especially the `red-method`
example.
- Udemy backbone: dashboard design sections (panel types, transformations,
variables, dashboard best practice).
### Build
1. `loadtest/` with four scripts: `smoke.js`, `average.js`, `stress.js`, `soak.js`,
sharing a `common.js` for setup and payload generation against random accounts.
2. Use **arrival-rate executors** for `average` and `stress`. Write down why.
3. Thresholds encoding your intended SLOs: `p(95)<500`, `p(99)<2000`,
`http_req_failed rate<0.01`.
4. Pipe k6 metrics into Prometheus; add a k6 row to your overview dashboard.
5. Run the same profile under `chaos-clean` and `chaos-nightmare`. Save both result
sets. This before/after pair is your baseline for Phase 8.
6. Optional but impressive: have k6 send W3C trace context headers so load-test
requests appear as traces, letting you click from a k6 spike into a specific
trace. Check the current k6 docs for the tracing module's status — it has moved
between experimental and stable.
Your platform provisions **datasources only** — there is no dashboards provider at
all. So this phase is entirely yours, and the right home for it is inside the
Ansible role, using the same mechanism as everything else.
**Done when:** `make load-stress` produces a Grafana view where you can see the
arrival rate climb, p95 diverge from p50, error rate turn up, and you can name the
service that broke first.
1. **Extend the role.** Add
`roles/observability/templates/grafana-stack/config/grafana/provisioning/dashboards/dashboards.yml.j2`
(a file-based dashboard provider pointing at a mounted directory), a
`config/grafana/dashboards/` directory for the JSON, and the tasks to template
and copy them. The Compose template already mounts
`./config/grafana/provisioning` read-only, so the provider is picked up on
restart.
2. **Generate the JSON from code**, don't hand-write it. Use the **Grafana
Foundation SDK (Python)**: one function producing a RED dashboard for a given
service name, looped over the seven services; a second producing a system
overview (service graph, stream lag, pool saturation, error-budget burn). Commit
the generator *and* its output; the Ansible role ships the output.
3. **The demo is now `ansible-playbook`.** Delete every dashboard through the
Grafana UI, re-run `playbooks/grafana-stack.yml`, and watch them all come back.
That is a strictly stronger answer to Babak's "destroy and recreate in seconds"
than a local Makefile target, because it's the same command that builds the
platform.
4. While you're in there, review the provisioned datasources and add what's missing:
`tracesToMetrics` on Tempo (pointing at the Tempo-generated span metrics) is a
worthwhile addition, and the existing `tracesToLogsV2` has no `tags` mapping —
understand why `filterByTraceID: true` alone is sufficient here, and when it
wouldn't be.
5. Add alert rules — see Phase 8 for the Alertmanager-vs-Grafana-alerting choice.
**Done when:** re-running the playbook restores every dashboard from scratch, and no
dashboard has ever been saved through the Grafana UI.