Phase 8 of the certctl architecture diligence remediation closes
SCALE-H2 by adding three new k6 scenarios that exercise the scale-
relevant load surfaces the API tier + connector tier left uncovered:
fleet-scale bulk renewal, ACME enrollment burst, and agent heartbeat
storm.
Audit miscount + path correction (live-grep at Phase 8 audit time)
==================================================================
- The Phase 8 prompt referenced both `deploy/test/load/` and
`deploy/test/loadtest/`. Repo truth: the existing harness lives at
`deploy/test/loadtest/`. New scenarios land there.
- The audit's prior framing "k6 covers the API tier at 50 req/s
only" omitted Bundle 10 (2026-05-02) which added four connector-
tier handshake scenarios (nginx/apache/haproxy/f5) at 100 conns/min
each, plus the Phase 5 ACME directory/nonce/ARI scenario at 100 VUs
in `k6/acme_flow.js`. Phase 8 appends to what's there rather than
rewriting.
What ships
==========
Three new k6 scenario files under deploy/test/loadtest/k6/:
bulk_renewal.js — 10K-cert seed + 5 req/s POST /bulk-renew × 5min
p99 < 5s, p95 < 2s, errors < 1%
acme_burst.js — 200 VU sustained × directory/nonce/ARI × 5min
directory p95 < 500ms, nonce p95 < 300ms,
renewal-info p95 < 800ms, 5xx-only < 0.1%
Pins RFC 7807 rate-limit response shape via
acme_rate_limit_shape_ok Counter.
agent_storm.js — 5K-agent seed + 167 req/s POST /heartbeat × 5min
p99 < 1s, p95 < 500ms, errors < 0.1%
Two seed SQL fixtures under deploy/test/loadtest/seed/:
01_bulk_renewal_certs.sql — 10,000 managed_certificates rows
linked to seed_demo.sql FKs (iss-local, o-alice, t-platform,
rp-standard). status='active', expires_at distributed across
next 30 days, name prefix `loadtest-bulk-` so the scenario
can scope its criteria. Idempotent via
ON CONFLICT (name) DO NOTHING.
02_agent_fleet.sql — 5,000 agents rows with name prefix
`loadtest-agent-`. status='Online', last_heartbeat_at
staggered across prior 60s, OS distribution 80%/10%/10%
linux/windows/darwin. Idempotent via
ON CONFLICT (id) DO NOTHING.
Plus seed/README.md documenting the opt-in profile + when these
run vs the default `make loadtest` fast path.
Compose + Makefile + CI wiring
==============================
deploy/test/loadtest/docker-compose.yml gains four new services,
all gated behind the `scale` compose profile so the default
`make loadtest` is unchanged:
scale-seed — one-shot postgres:16-alpine container that runs
every ./seed/*.sql in lexical order against the
same postgres the server uses. Depends on
postgres healthy + certctl-server healthy (so
migrations + seed_demo.sql have already run).
k6-scale-bulk — grafana/k6:0.54.0 driver running bulk_renewal.js
k6-scale-acme — grafana/k6:0.54.0 driver running acme_burst.js
k6-scale-agent — grafana/k6:0.54.0 driver running agent_storm.js
Each driver depends_on scale-seed completed_successfully so the
scenarios never run against an unseeded DB (the acme scenario
doesn't need the seed itself but uses the same dependency chain for
ordering predictability).
Makefile gains four new phony targets:
loadtest-scale-bulk - runs bulk_renewal.js via compose --profile scale
loadtest-scale-acme - runs acme_burst.js
loadtest-scale-agent - runs agent_storm.js
loadtest-scale - all three serially
.github/workflows/loadtest.yml gains a new k6-scale matrix job that
runs after the existing k6 job (needs: k6) with a matrix on the
three scenarios — fail-fast: false so a regression in one scenario
doesn't cancel the others. Same workflow_dispatch + weekly cron
cadence as the existing API + connector tier job.
Documentation
=============
docs/operator/scale.md gains a new "Scale-tier scenarios (SCALE-H2,
Phase 8)" section between the cursor-pagination subsection and the
profiling-production subsection. Documents:
- Scenario + seed + sustained load table
- Threshold contract (regression guards, NOT measured baselines)
- Measured-baseline table with TBD placeholders + the canonical-
hardware capture procedure
- How to run the scale tier locally
- Four documented limitations (JWS-signed ACME, scheduler renewal
scan throughput, production-sized Postgres, pull-only deployment
model)
deploy/test/loadtest/README.md gains a short "Scale tier (Phase 8
SCALE-H2, 2026-05-14)" section pointing at scale.md as the canonical
operator-facing baseline source. Avoids duplication; the README
remains the harness-mechanics doc.
Deliberate deviations from the prompt
======================================
The Phase 8 prompt's "concrete deliverables" section referenced
`deploy/test/load/` (no -test) for the new k6 files. The actual
harness lives at `deploy/test/loadtest/` — the new files land there
to match existing convention. The prompt's audit-questions section
also referenced `deploy/test/loadtest/` so the prompt was internally
inconsistent on this; repo truth wins.
The prompt described the ACME burst as "200 concurrent ACME orders
against /acme/profile/<id>/new-order ... pin the rate-limit response
shape." new-order is JWS-signed (RFC 8555 §7.4 requires JWS for
every POST except newAccount-pre-account-key flows). k6 doesn't
ship JWS and bundling a signer (e.g. lego) into the k6 container
would obscure the server-side latency the scenario is trying to
measure. Same trade-off the existing Phase 5 acme_flow.js made.
Phase 8's acme_burst.js measures the unauthenticated
directory + nonce + ARI surface at burst rate AND pins the 429
rate-limit response shape via a custom Counter that increments only
when the response is `application/problem+json` with the
`urn:ietf:params:acme:error:rateLimited` type. End-to-end JWS
conformance under load remains a follow-up; the canonical JWS
correctness gate is `make acme-rfc-conformance-test` (lego-based,
non-load).
Deferred (operator-side, not engineering)
==========================================
Canonical-hardware baseline capture. The TBD placeholders in
docs/operator/scale.md's measured-baseline table are intentional —
sandbox-captured numbers from a developer laptop are misleading
(same anti-pattern the original loadtest README guards against).
Operator triggers loadtest.yml from the Actions tab, waits for the
k6-scale matrix jobs to complete, downloads the per-scenario
summary artifacts, copies p50/p95/p99 into the table, commits the
captured numbers alongside the date + commit SHA.
Files changed (10):
.github/workflows/loadtest.yml (+72 -1)
Makefile (+47 -1)
deploy/test/loadtest/README.md (+28 -1)
deploy/test/loadtest/docker-compose.yml (+108 -1)
deploy/test/loadtest/k6/bulk_renewal.js (new, 106 lines)
deploy/test/loadtest/k6/acme_burst.js (new, 192 lines)
deploy/test/loadtest/k6/agent_storm.js (new, 124 lines)
deploy/test/loadtest/seed/01_bulk_renewal_certs.sql (new, 95 lines)
deploy/test/loadtest/seed/02_agent_fleet.sql (new, 92 lines)
deploy/test/loadtest/seed/README.md (new, 86 lines)
docs/operator/scale.md (+109 -0)
Verification (sandbox-runnable):
python3 -c 'import yaml; yaml.safe_load(open("deploy/test/loadtest/docker-compose.yml"))'
→ compose YAML OK
python3 -c 'import yaml; yaml.safe_load(open(".github/workflows/loadtest.yml"))'
→ workflow YAML OK
grep -E 'bulk_renewal|acme_burst|agent_storm' deploy/test/loadtest/k6/*.js
→ all three scenarios + tags present
grep loadtest-scale Makefile
→ 4 new targets registered in .PHONY + 3 recipes + 1 aggregate
Runtime verification (deferred — requires docker on canonical hardware):
make loadtest-scale-bulk # 10K cert fixture + 5 req/s × 5min
make loadtest-scale-acme # 200 VU × 5min
make loadtest-scale-agent # 5K agent fixture + 167 req/s × 5min
make loadtest-scale # all three serially
Closes: cowork/certctl-architecture-diligence-audit.html#fix-SCALE-H2
certctl Load-Test Harness
Closes the #8 acquisition-readiness blocker from the 2026-05-01 issuer coverage audit (the 2026-05-01 issuer coverage audit). Pre-fix, certctl had zero benchmarks or load tests for any API path; an acquirer evaluating "can certctl handle our 50k-cert fleet at 47-day rotation" had nothing to point at. This harness is the substantiation.
What it measures
A k6 driver hits two scenarios in parallel for 5 minutes at a fixed 50 req/s:
POST /api/v1/certificates— the issuance-acceptance hot path. Exercises auth, JSON decode, validation,service.CreateCertificate, and themanaged_certificatesinsert. This is the operator-facing request-acceptance throughput an automation client (Terraform, Crossplane, GitOps controller) would generate.GET /api/v1/certificates?per_page=50— the most-trafficked read endpoint. Exercises pagination + filtering on the cert list query.
Latency is reported as avg / min / med / p95 / p99 / max. The error
floor is < 1% (any 4xx/5xx counts as failed).
What it explicitly does NOT measure
- Issuer connector latency. Connector calls (DigiCert, ACME, Vault,
AWS ACM PCA, etc.) happen asynchronously via the renewal scheduler.
Their latency is pinned by the
certctl_issuance_duration_seconds{issuer_type=...}Prometheus histogram (audit fix #4). Driving them through k6 would load-test someone else's API, which is wrong. - Full ACME enrollment flow. The audit prompt mentioned ACME-via- pebble; sustained 100/s through a multi-RTT order/challenge/finalize flow requires pebble tuning + crypto helpers k6 doesn't ship out of the box. Deferred to a follow-up.
- Bulk-revoke / bulk-renew. Those are admin endpoints with their own throughput characteristics and warrant a separate scenario.
- Scheduler concurrency under bulk renewal. That's audit fix #9's scope; the harness here measures the API tier, not the scheduler.
Threshold contract
Any future change that breaches one of these fails the test:
| Scenario | p95 | p99 | Error rate |
|---|---|---|---|
issuance_acceptance |
< 2 s | < 5 s | n/a |
list_certificates |
< 800 ms | < 2 s | n/a |
| All requests | n/a | n/a | < 1% |
These are the regression guards, not the SLO. The SLO is whatever the operator chooses based on the baseline below.
How to run
From the repo root:
make loadtest
This:
- Builds the certctl image from the repo root
Dockerfile. - Spins up postgres, the tls-init bootstrap, certctl-server (with
CERTCTL_DEMO_SEED=trueso the FK rows the script needs exist), and the k6 driver. - Runs the k6 script for ~5 minutes 5 seconds (5s stagger between scenarios + 5m duration).
- Prints the summary text to stdout.
- Exits non-zero if any threshold was breached.
The full machine-readable summary lands at
deploy/test/loadtest/results/summary.json (gitignored). The
human-readable summary lands at results/summary.txt.
To run against a server already booted on the host (skip the compose spin-up):
docker run --rm \
-e CERTCTL_BASE=https://localhost:8443 \
-e CERTCTL_TOKEN=load-test-token \
-e K6_INSECURE_SKIP_TLS_VERIFY=true \
-v "$(pwd)/deploy/test/loadtest/k6.js:/scripts/k6.js:ro" \
-v "$(pwd)/deploy/test/loadtest/results:/results" \
--network host \
grafana/k6:0.54.0 run /scripts/k6.js
Current baseline
The first operator run captures real numbers and commits them into
this section. Pre-baseline this section reads "TBD — operator captures
on first make loadtest run." The numbers below are the agreed
minimum-acceptable thresholds, not the captured baseline; once captured,
the baseline goes here as a separate row so future regressions have a
diff target.
| Scenario | p50 | p95 | p99 | Error rate |
|---|---|---|---|---|
| issuance_acceptance (threshold) | — | < 2 s | < 5 s | < 1% |
| issuance_acceptance (baseline)1 | 2.12 ms | 6.19 ms | 8.58 ms | 0.00% |
| list_certificates (threshold) | — | < 800 ms | < 2 s | < 1% |
| list_certificates (baseline)1 | 2.12 ms | 6.19 ms | 8.58 ms | 0.00% |
Methodology of the sandbox-placeholder capture above:
- Hardware: Linux/aarch64 unprivileged sandbox (uid 1019, no root, ~1.2 GiB free disk). NOT canonical hardware.
- Postgres: 14.22 (Ubuntu, native binaries, unix-socket dir
/tmp/pg-sock), unix sockets only, port 55432. - certctl: built from HEAD via
go build -o bin/certctl-server ./cmd/server. - Concurrency: 50 req/s sustained per scenario, both scenarios in parallel (= 100 req/s combined).
- Duration: 10 seconds per scenario (NOT 5 minutes — sandbox bash-call budget is bounded; canonical-hardware run uses 5 minutes).
- TLS: ECDSA-P256 self-signed
localhostcert at/tmp/certctl-tls/. - Auth: api-key, single Bearer token (
CERTCTL_AUTH_SECRET=load-test-token). - Rate limiting: disabled (
CERTCTL_RATE_LIMIT_ENABLED=false) — without this, the 100 req/s combined load trips the default token-bucket and drives error rate to ~40%, masking real latency. - Encryption:
CERTCTL_CONFIG_ENCRYPTION_KEYset (32+ bytes). - Captured: 2026-05-02. Total: 1002 requests, 100.15 req/s sustained,
0 failures, 100% checks passed. Raw
summary.jsonis not committed (gitignored per the existingresults/convention).
Methodology pinned at canonical baseline capture (replace placeholder):
- Hardware: GitHub-hosted
ubuntu-latestrunner (4 vCPU / 16 GiB / SSD). Run viagh workflow run loadtest.yml; rawsummary.jsonis available for 90 days as a workflow artifact. - Postgres: 16-alpine in compose, default config.
- certctl: image built from this repo at the commit referenced below.
- Concurrency: 50 req/s sustained per scenario (100 req/s total).
- Duration: 5 minutes per scenario, 5s stagger.
- Auth: api-key (Bearer token, single key).
- Encryption:
CERTCTL_CONFIG_ENCRYPTION_KEYset (32+ bytes).
To recapture the baseline after a tuning commit:
make loadtest
# Inspect deploy/test/loadtest/results/summary.txt for the new numbers.
# Update the table above + the methodology line, commit alongside the
# tuning commit.
Interpreting a regression
If a future PR's make loadtest run pushes p99 above the threshold,
the make target exits non-zero and CI fails. The summary.txt prints
which threshold breached. Triage:
- Look at the per-scenario
http_req_durationp95 + p99 insummary.json. If only one scenario regressed, the change is localized to that endpoint's hot path. - Look at the
iteration_durationper scenario — if total iteration time grew buthttp_req_durationis flat, the latency is in k6 client setup (rare; suggests something changed in the script). - Compare against the committed baseline. If p99 was 800 ms at baseline and is now 1.5 s but still under the 5 s threshold, the change is below the regression guard but still meaningful — flag in the PR description.
The harness deliberately does NOT auto-tune. Tuning is informed by the data; tuning commits land separately, each with their own captured baseline update.
CI cadence
Defined in .github/workflows/loadtest.yml:
workflow_dispatch— manual trigger from the Actions tab. Used before tagging a release or after a meaningful tuning commit.- Weekly cron — Mondays at 06:00 UTC. Catches gradual regressions from cumulative changes that no single PR triggered.
The workflow does not run per-push. Load tests are minutes long
and would not provide useful per-PR signal; per-push pressure goes
through make verify (which is fast) and the deploy-vendor-e2e job.
Connector-tier baseline (Bundle 10 of the 2026-05-02 deployment-target audit)
Bundle 10 extended the harness to cover per-target-type handshake throughput
in addition to the API-tier issuance/list throughput documented above. The
docker-compose stack now boots four target sidecars (nginx, apache, haproxy,
f5-mock) each serving a starter cert from a shared target-tls-init
container, and k6 runs four additional scenarios — nginx_handshake,
apache_handshake, haproxy_handshake, f5_handshake — at sustained
100 conns/min for 5 minutes against each.
What the connector tier measures
End-to-end TCP connect + TLS handshake + tiny HTTP request/response latency
per target type, tagged via the k6 target_type label so summary.json's
connector_tier section breaks the numbers out per sidecar:
{
"connector_tier": {
"nginx": { "p50": ..., "p95": ..., "p99": ..., "error_rate": ..., "iterations": ... },
"apache": { ... },
"haproxy": { ... },
"f5": { ... }
}
}
This validates the target sidecar daemons are operational under sustained connection load. Procurement asks "can certctl's nginx target handle 5,000 endpoints at 47-day rotation?" — the connector code's correctness is pinned by per-connector unit tests; the underlying daemon's connection-rate ceiling is what these scenarios pin.
What the connector tier explicitly does NOT measure (v1)
- The full agent-driven deploy hot path. v1 measures handshake throughput against the sidecars directly. v2 of the harness is a follow-up that POSTs cert requests bound to per-target-type targets, polls the deployments endpoint until the agent reports complete, and measures the full POST → poll → cert-served loop. v2 needs the agent registration + target-binding API surface plumbed end-to-end in the loadtest stack — meaningful work, but not a blocker for the connection- rate procurement question.
- Kubernetes connector. kind-in-docker requires
privileged: trueand is operationally fragile in CI. Deferred until Bundle 2 (realk8s.io/client-go) lands and a CI-friendly envtest harness is wired. - Real F5 BIG-IP. The harness uses the in-tree
f5-mock-icontrolGo server (already used by the deploy-vendor-e2e CI job). Real F5 appliance benchmarking is out of scope; operators with a real F5 vagrant box perdocs/connector-f5.mdcan substitute it manually.
Threshold contract
Defined in k6.js's thresholds block. Any change pushing past these
fails the test:
| Target type | p95 | p99 | Error rate |
|---|---|---|---|
nginx |
< 1 s | < 3 s | < 1% (global) |
apache |
< 1 s | < 3 s | < 1% (global) |
haproxy |
< 1 s | < 3 s | < 1% (global) |
f5 |
< 1.5 s | < 5 s | < 1% (global) |
f5-mock's threshold is looser because the iControl REST handler does slightly more work per request (login+upload+install dance the F5 connector itself drives — not exercised here, but the daemon's request handler is heavier).
Connector-tier captured baseline
| Target type | p50 | p95 | p99 | Error rate | Iterations |
|---|---|---|---|---|---|
| nginx (threshold) | — | < 1 s | < 3 s | < 1% | n/a |
| nginx (baseline) | TBD | TBD | TBD | TBD | TBD |
| apache (threshold) | — | < 1 s | < 3 s | < 1% | n/a |
| apache (baseline) | TBD | TBD | TBD | TBD | TBD |
| haproxy (threshold) | — | < 1 s | < 3 s | < 1% | n/a |
| haproxy (baseline) | TBD | TBD | TBD | TBD | TBD |
| f5 (threshold) | — | < 1.5 s | < 5 s | < 1% | n/a |
| f5 (baseline) | TBD | TBD | TBD | TBD | TBD |
The em-dash placeholders are deliberate: do not commit numeric values
without running the loadtest on canonical hardware first. Numbers from a
developer laptop are misleading. The first gh workflow run loadtest.yml
on a clean GitHub runner captures the baseline; commit the captured numbers
into the table above as a follow-up commit alongside the methodology line.
Methodology pinned at baseline capture (canonical hardware):
- Hardware: GitHub-hosted
ubuntu-latestrunners (currently 4 vCPU / 16 GiB / SSD-backed). Operator captures fromgh workflow run loadtest.ymlto keep the hardware constant across runs. - Sidecar images: nginx:1.27-alpine, httpd:2.4-alpine, haproxy:2.9-alpine,
in-tree f5-mock-icontrol (built from
deploy/test/f5-mock-icontrol/). - Concurrency: 100 conns/min sustained per target type (400 conns/min total across the four target scenarios + 100 req/s on the API tier).
- Duration: 5 minutes per scenario, 10s stagger between API tier and connector tier so warmup overlap doesn't skew the first 30 seconds.
- TLS: starter cert from
target-tls-init(ECDSA P-256, multi-SAN). The loadtest scenarios connect withK6_INSECURE_SKIP_TLS_VERIFY=true.
To recapture the connector-tier baseline after a tuning commit affecting target sidecars or the connector code:
make loadtest
# Inspect deploy/test/loadtest/results/summary.json for the
# connector_tier object and update the table above.
Files in this directory
deploy/test/loadtest/
├── README.md (this file)
├── docker-compose.yml
├── k6.js (the load script)
├── certs/ (gitignored — tls-init writes here)
├── fixtures/ (Bundle 10: target sidecar configs + shared starter cert)
│ ├── nginx.conf
│ ├── httpd.conf
│ ├── haproxy.cfg
│ └── target-certs/ (gitignored — target-tls-init writes here)
└── results/ (gitignored — k6 writes summary.{json,txt} here)
ACME flows (Phase 5)
The deploy/test/loadtest/k6/acme_flow.js scenario hammers the
unauthenticated ACME surface (directory + new-nonce + ARI synthetic
lookups) at constant 100 VUs for 5 minutes. JWS-signed paths
(new-account / new-order / finalize) are intentionally out of scope:
k6 doesn't ship JWS, and bundling lego inside k6 would obscure the
underlying-server p95 we're trying to measure. Instead, the
make acme-rfc-conformance-test target drives lego against the same
stack for the full happy-path conformance gate.
Run it:
cd deploy/test/loadtest
docker compose up -d certctl postgres
k6 run --env CERTCTL_ACME_DIRECTORY=https://localhost:8443/acme/profile/prof-test/directory \
k6/acme_flow.js
Baseline (ACME flows, 100 VUs × 5m)
The baseline is operator-captured on a workstation-class machine with a single certctl-server container + a single postgres container. Re-capture after schema migrations or transport changes; commit the new numbers so regressions are visible in code review.
| Metric | Threshold | Last captured | Notes |
|---|---|---|---|
directory_duration p95 |
< 500 ms | operator | Unauth GET; cache-friendly. |
new_nonce_duration p95 |
< 300 ms | operator | Single Postgres INSERT under the hood. |
renewal_info_duration p95 (synthetic id) |
< 800 ms | operator | Synthetic cert-id → 4xx fast path. |
http_req_failed rate |
< 1% | operator | Should be ~0 — failures here mean transport issues. |
Capture command: make loadtest after pointing the compose stack at
the ACME flow scenario. Operators with kind / cert-manager available
should pair this with make acme-cert-manager-test for end-to-end
verification.
Scale tier (Phase 8 SCALE-H2, 2026-05-14)
Phase 8 closure added three new k6 scenarios that exercise the scale-relevant load surfaces the API tier and connector tier left uncovered:
| Scenario | k6 file | Seed | Make target |
|---|---|---|---|
| Bulk-renewal under load | k6/bulk_renewal.js |
seed/01_bulk_renewal_certs.sql (10K certs) |
make loadtest-scale-bulk |
| ACME enrollment burst | k6/acme_burst.js |
(none — unauth surface) | make loadtest-scale-acme |
| Agent heartbeat storm | k6/agent_storm.js |
seed/02_agent_fleet.sql (5K agents) |
make loadtest-scale-agent |
The scale-tier scenarios live behind the scale compose profile so
the default make loadtest (API tier + connector tier, ~7 min)
stays fast. Run all three serially with make loadtest-scale, or
trigger the loadtest.yml workflow's k6-scale matrix jobs from
the Actions tab for canonical-hardware capture.
Operator-facing baseline table + threshold contracts + documented
limitations live in docs/operator/scale.md
under the "Scale-tier scenarios (SCALE-H2, Phase 8)" section. Treat
that as the canonical source — this README only links.
The seed fixtures + their idempotency contract are documented in
seed/README.md.
Audit references
- API tier: 2026-05-01 issuer coverage audit fix #8.
- Connector tier: 2026-05-02 deployment-target audit Bundle 10.
- ACME flows: Phase 5 master prompt (project notes).
- Scale tier: 2026-05-14 architecture diligence Phase 8 (SCALE-H2).
-
Sandbox-aggregate placeholder — captured at HEAD on a Linux/aarch64 unprivileged sandbox (no Docker, no GitHub-hosted runner). Both rows show the same aggregate combined-load numbers because the sandbox run did not break out per-scenario tags in
summary.json. Treat these as a sanity floor (proof the API tier handles 100 req/s combined with zero errors and sub-10ms p99), not as the per-scenario baselines the threshold contract is written against. Replace viagh workflow run loadtest.ymlon the canonicalubuntu-latestrunner — that produces per-scenario tagged metrics insummary.json. ↩︎