Benchmarking & target onboarding
The operator's advertised flow is two steps — instrument a
Deployment (BasquinTarget), then run a campaign
(BasquinCampaign). Benchmarking a new app against other tools needs more
than that: get the unmodified app deployed in the cluster, give it a request grammar so
exploration has a reachable surface, and stand up the load-tool baselines. This is the full,
reproducible process, grounded in the two working targets (jpetstore/,
jspwiki/).
The operator instruments an app you already run. A benchmark target is one you must first package, deploy, and teach (grammar) — plus the comparison harness (k6/Locust, the A/A′/B corpora). Steps 1–4 are the deploy; 5 is the operator's own flow; 6–9 are the benchmark battery.
Prerequisites (one-time)
- A Kubernetes cluster with the Basquin operator installed — from the published
images. Follow the README quickstart:
helm repo add basquin https://ianp94.github.io/basquin/chartsthenhelm install basquin basquin/basquin-operator …. The chart pulls the released multi-archghcr.io/ianp94/basquin-{operator,agents,runner,dashboard}images at itsappVersion(0.3.0) — nothing to build or repackage. (For a throwaway local cluster,kind create clusterfirst.) - The app-under-test is either an app you already run (the operator instruments
it in place) or, for a reproducible bench target, packaged as a
rawimage in step 2 — that's your app, not Basquin. - (Contributors only.) If you're iterating on Basquin's own source in a
from-source local cluster (
deploy/e2e/e2e.sh), rebuild the changed image after edits andkind loadit, e.g.deploy/runner-image/build.sh 0.3.0 basquin— otherwise the cluster keeps running stale code. Users on the published images never need this. KUBECONFIG=…,K="kubectl …",NS=basquin-system.
-
Prepare the app artifact
Get the unmodified WAR and, if it uses a filesystem/content store, seed it. See each app's
setup.sh(e.g.jspwiki/setup.shexplodes the WAR intowebapp/and seeds 70 content-rich pages). The app is deployed unmodified — Basquin instruments it at deploy time. -
Build a
rawimageA
rawimage is the app on Tomcat with no agents (the operator adds them). Mirror the jpetstore-raw pattern (deploy/e2e/e2e.sh:130-144):Dockerfile.rawFROM tomcat:9.0-jdk17-temurin COPY webapp/ /usr/local/tomcat/webapps/ROOT/ # exploded, NOT a war (see below) # ...bake any content store the app reads (jspwiki: COPY pages/ /var/jspwiki/pages/)Two requirements that bite:
- Explode the WAR into
ROOT/, don't shipROOT.war. Tomcat serving the exploded dir is incidental; the real reason is the campaign's coverage initContainer copiesWEB-INF/classesout of this image — those.classfiles must exist as files, which a war-only image doesn't provide until runtime. - If the app ships its classes in
WEB-INF/lib/*.jar(notWEB-INF/classes) — JSPWiki does, most Spring/library-heavy apps do — the coverage initContainer finds nothing and the campaign'sverify-classesfails with "no .class files extracted." Extract them in the image into a dedicated (non-classpath) dir and pointclassesPaththere:RUN mkdir -p /basquin-app-classes && cd /basquin-app-classes \ && for j in /usr/local/tomcat/webapps/ROOT/WEB-INF/lib/<app>-*.jar; do jar xf "$j" 2>/dev/null || true; done
Build and load:
docker build -t basquin/<app>-raw:0.3.0 . && kind load docker-image basquin/<app>-raw:0.3.0 --name basquin. - Explode the WAR into
-
Deploy the app + a Service
app.yamlapiVersion: apps/v1 kind: Deployment metadata: { name: <app>, namespace: basquin-system, labels: { app: <app>-raw } } spec: replicas: 1 selector: { matchLabels: { app: <app>-raw } } template: metadata: { labels: { app: <app>-raw } } spec: containers: - name: <app> image: basquin/<app>-raw:0.3.0 imagePullPolicy: IfNotPresent ports: [{ containerPort: 8080 }] env: [{ name: CATALINA_OPTS, value: "-Xmx2g" }] # right-size! (see gotcha) readinessProbe: # NO query string in path httpGet: { path: /Wiki.jsp, port: 8080 } initialDelaySeconds: 20 failureThreshold: 40 --- apiVersion: v1 kind: Service metadata: { name: <app>-app, namespace: basquin-system } spec: selector: { app: <app>-raw } ports: [{ port: 8080, targetPort: 8080 }]importantHeap sizing is not optional. At
-Xmx512man instrumented app GC-thrashes under c=50 and the drift poll times out — the run reports a silentheapDriftKb:0. Use-Xmx2g. -
Instrument with a
BasquinTargettarget.yamlapiVersion: basquin.dev/v1alpha1 kind: BasquinTarget metadata: { name: <app>, namespace: basquin-system } spec: deploymentRef: { name: <app> } container: <app> jvmOptsVar: CATALINA_OPTS coverageService: true agents: threadTracker: true valve: true coverage: { enabled: true, includes: "org.<app>.*", port: 6300 } invariants: { mode: soft, latencyMaxMs: 25, heapDeltaMaxKb: 512 } # tune to the app's steady-stateWait for
status.phase == Injectedand the pod to re-roll. Verify the boundary took:kubectl logs <pod> | grep "agent boundary installed"andkubectl exec <pod> -- curl -s localhost:8080/__basquin/drift(returnsheapKb,threads,ts). -
Author a grammar + seed corpus (the real per-app cost, ~half-day)
The grammar is the reachable surface as data (DD-016/018). Model on
examples/grammar/jpetstore.grammar:$name = @valuesFile | ~pattern | literal | <generator>for value spaces,/path?x=${name}for routes,METHOD /path bodyfor writes,@sequencefor multi-step transactions. Aim the grammar at the app's expensive/stateful paths — for JSPWiki that'sSearch.jsp(~173ms),Diff.jsp(~164ms),Edit.jsprender (~157ms) vs a warm page view (~9ms); probe a few routes withcurl -w %{time_total}first to find them. Put value files underexamples/corpus/<app>/values/.Create the configmaps (exactly as
e2e.sh:325-333):$K -n $NS create configmap <app>-grammar --from-file=<app>.grammar=examples/grammar/<app>.grammar $K -n $NS create configmap <app>-corpus --from-file=examples/corpus/<app>/ --from-file=examples/corpus/<app>/values/ -
Explore → the fuzz corpus (arm B)
explore.yamlapiVersion: basquin.dev/v1alpha1 kind: BasquinCampaign metadata: { name: <app>-armb-explore, namespace: basquin-system } spec: mode: explore targetRef: { name: <app> } baseURL: http://<app>-app.basquin-system.svc.cluster.local:8080 driver: grammarConfigMap: <app>-grammar corpusConfigMap: <app>-corpus classesPath: /basquin-app-classes # or WEB-INF/classes if the app has real ones there duration: 30m # duration, NOT iterations (durations compare; counts don't)The emitted replay corpus is
status.corpusConfigMap(<app>-armb-explore-corpus-out) — cost-ranked, carrying the method/sequence-aware entries the fuzzer found expensive. -
Corpus arms A and A′
- A (happy path) — the routes a k6/Gatling user would script by hand (e.g. the catalog GETs).
- A′ (steelman) — A plus a hand-written valid multi-step journey (login → … → checkout) as TAB-separated method-aware sequences. This is the fair comparand.
Create
<app>-corpus-a/<app>-corpus-aprimeconfigmaps (--from-file=corpus.txt=<file>). k8s names must be lowercase —corpus-Ais silently rejected and the run becomes a no-op. -
Run the load battery (A / A′ / B)
For each arm: restart the target (fresh state), confirm it's in load mode (
curl -X POST '.../__basquin/mode?to=load&ttlMs=900000'), launch a load campaign (mode: load, corpusConfigMap: <arm>, concurrency: 50, warmup: 30s, duration: 10m), and collect.tipCollect from the driver's termination summary, not the live dashboard — it's authoritative and survives teardown:
$K -n $NS get pod <driver-pod> \ -o jsonpath='{.status.containerStatuses[0].state.terminated.message}' # the load-block JSONThe live dashboard
/api/campaign/<id>/statusalso works but you must re-resolve the dashboard pod+token each sample. The load block'sitersis the explore counter — always 0 in load mode; the real metrics arethroughputRps,latencyMs,serverErrors,heapDriftKb,threadDrift. -
Load-tool baselines (the other axis)
Run k6 (
deploy/bench/k6/<app>.js) and Locust (deploy/bench/locust/locustfile.py) against the same target, same routes, same concurrency, target in load mode — so the comparison measures the load generator, not boundary state. See BENCHMARKS.md for the method.
Collecting and publishing the results
Three scripts turn a finished campaign into a published figure. They exist because the benchmark page used to carry hand-plotted SVG coordinates — every re-run was a transcription exercise, and nothing prevented a chart from drifting away from the run it claimed to show.
| Script | Does |
|---|---|
deploy/bench/collect.py |
Snapshots a campaign out of the cluster into
bench-results/<app>/<campaign>/ — the campaign object, the
driver's terminal summary, and its log. Read-only. The terminal summary is the
authoritative source: it carries counters the operator's status subresource does not map,
including the redirect classification. |
deploy/bench/battery.sh |
Runs the load half of the battery for one app across a set of concurrency levels and collects each one. Campaigns run strictly one at a time — see the warning below. |
deploy/bench/render_page.py |
Regenerates docs/benchmarks.html. Prose lives in the script; every number
and every chart coordinate is read from bench-results/. A section whose
campaign has not been collected is omitted rather than filled with a stale figure. |
Never run two load campaigns at once on a single-node cluster. Two drivers on
one node contend for the same CPU, so each one's latency percentiles measure the other driver as
much as they measure the app. The numbers look entirely plausible and are unusable.
battery.sh blocks on each campaign reaching a terminal phase before starting the
next; if you drive campaigns by hand, do the same.
Gotchas checklist
- Runner image current (rebuild if the cluster is stale) — else driver crashes on new features.
- Classes in jars → extract into
/basquin-app-classes, setclassesPath. - Target heap
-Xmx2g— 512m GC-thrashes and starves the drift poll. - All k8s object/configmap names lowercase.
- Readiness probe path has no query string.
- Collect from the driver termination summary;
itersis not the load metric. - Fresh target restart between arms; reset any filesystem store the app writes.
Once a target is onboarded, the results land on the benchmarks page — see the JSPWiki / JPetStore run for what the output looks like end to end.