Installation Guide
Detailed setup instructions for running PerfLoad locally with Docker, and deploying it to a Kubernetes cluster.
Docker
The fastest way to run PerfLoad. A single pre-built image, one port, one volume.
Prerequisites
- Docker installed and running
1. Run the container
docker run -d --name perfload \
-p 3000:3000 \
-v perfload-runs:/app/runs \
perfload/perfload-runner:latest
-d— run in the background-p 3000:3000— dashboard, API & k6 live dashboard (all on one port)-v perfload-runs:/app/runs— named volume so run history survives restarts and image updates
To pin a specific release instead, replace latest with a version tag, e.g. 1.6.0.
2. Verify it's up
curl http://localhost:3000/health
Expected response: {"status":"ok"}
| Interface | URL |
|---|---|
| Dashboard | http://localhost:3000/ |
| Workbench | http://localhost:3000/load-tester.html |
| k6 live dashboard | http://localhost:3000/runs/<id>/dashboard/live/ |
3. Update to a new version
docker pull perfload/perfload-runner:latest
docker stop perfload && docker rm perfload
docker run -d --name perfload \
-p 3000:3000 \
-v perfload-runs:/app/runs \
perfload/perfload-runner:latest
Run history in the perfload-runs volume survives this — it's independent of the container.
4. Managing the container
# Stop, keep the volume
docker stop perfload
# Start again
docker start perfload
# View logs
docker logs perfload
# Remove the container (volume persists)
docker rm perfload
# Only if you want run history gone too
docker volume rm perfload-runs
Kubernetes
Deployed via kustomize from cd-deploy-configs/apps/perfload-runner/. A docker run
gives the container the whole host's CPU, memory, and a writable layer that survives until removed — none
of that is true by default in a k8s pod, which shares the node and starts from a clean, ephemeral filesystem
on every reschedule. The manifests and checklist below cover what has to be made explicit to get equivalent
behavior in the cluster.
Prerequisites
kubectlconfigured against the target cluster- Write access to
cd-deploy-configs— deploys are managed from there, not applied ad hoc
Manifests
cd-deploy-configs/apps/perfload-runner/ holds the following five files. If the directory doesn't exist yet for a first-time deploy, create them.
apiVersion: apps/v1
kind: Deployment
metadata:
name: perfload-runner
spec:
replicas: 1
selector:
matchLabels:
app: perfload-runner
template:
metadata:
labels:
app: perfload-runner
spec:
containers:
- name: perfload-runner
image: perfload/perfload-runner:latest
ports:
- containerPort: 3000
resources:
requests:
cpu: 500m
memory: 768Mi
limits:
cpu: "1"
memory: 1Gi
livenessProbe:
httpGet:
path: /health
port: 3000
readinessProbe:
httpGet:
path: /health
port: 3000
envFrom:
- configMapRef:
name: app-configmap
volumeMounts:
- name: runs
mountPath: /app/runs
- name: dshm
mountPath: /dev/shm
volumes:
- name: runs
persistentVolumeClaim:
claimName: perfload-runner-runs
- name: dshm
emptyDir:
medium: Memory
sizeLimit: 512Mi
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: perfload-runner-runs
spec:
accessModes:
- ReadWriteOnce
resources:
requests:
storage: 5Gi
Exposes the pod inside the cluster. How it's reached from outside (Ingress, LoadBalancer, etc.) is cluster-specific and not covered here.
apiVersion: v1
kind: Service
metadata:
name: perfload-runner
spec:
selector:
app: perfload-runner
ports:
- port: 3000
targetPort: 3000
The deployment's envFrom requires this to exist even if empty, since a missing ConfigMap reference blocks the pod from starting. Every key below has a safe in-code default (see "Config differences" below) — only add one if you're deliberately overriding it.
apiVersion: v1
kind: ConfigMap
metadata:
name: app-configmap
data: {}
# To override a default, replace `data: {}` above with e.g.:
# data:
# PORT: "3000"
# K6_DASHBOARD_PORT_START: "5665"
# K6_DASHBOARD_PORT_COUNT: "20"
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
resources:
- deployment.yaml
- pvc.yaml
- service.yaml
- configmap.yaml
Deploy
kubectl apply -k cd-deploy-configs/apps/perfload-runner/
Verify
kubectl rollout status deployment/perfload-runner
kubectl get pods -l app=perfload-runner
# Confirm it actually serves traffic, not just that the pod is Ready
kubectl port-forward svc/perfload-runner 3000:3000
curl http://localhost:3000/health
With the port-forward still open, also run one real test end-to-end (via the Workbench at
http://localhost:3000/load-tester.html, or POST /runs directly) — see
"Health checks" below for why a passing probe alone isn't enough to call the rollout good.
After deploying: differences from Docker to check
A k8s pod shares the node with other pods and starts from a clean, ephemeral filesystem every time it's rescheduled, unlike docker run above. Work through this checklist once the manifests are applied.
1. Resource requests/limits
-
Set
resources.requests/resources.limitson the container — don't deploy without them. Why: without a limit, a spawnedk6process (uncapped virtual users) or a Puppeteer/Chromium PDF render can consume all CPU/memory on a shared node and starve neighboring pods; without a request, the scheduler has no basis to place the pod on a node with enough headroom. Verify:kubectl top pod -l app=perfload-runnerduring idle vs. a running load test — idle should sit near the request value; a 50-VU test shouldn't approach the limit. -
Treat k6 itself as the primary CPU cost, not just Puppeteer. Every
POST /runsspawns an unbounded, un-queuedk6child process — no concurrency cap, and CPU cost scales with theusers(VUs) requested. Why: two or three concurrent high-VU runs on one pod can spike CPU well past a single-run baseline; the500m/1CPU request/limit above is sized for one moderate run (~10 VUs) at a time. Verify: run two overlapping load tests against the same runner and watchkubectl top pod— CPU pinned at the limit plus client-side timeouts is the signature of under-provisioning here, not an app bug. -
Size for PDF generation separately. Each
report.pdf/compare/report.pdfrequest launches a full headless Chromium — memory-spiky (typically 150–300MB+ RSS) but short-lived, unlike a sustained k6 run. Why: a memory limit sized only from idle + one k6 run can still get OOMKilled if a PDF export happens during an active test, since both costs land on the same pod at once. Verify: hit/runs/:id/report.pdfwhile a test is running and watch memory; checkkubectl get pod -o jsonpath='{.status.containerStatuses[0].lastState}'forOOMKilledafter any unexpected restart.
2. Ephemeral vs. persistent storage
-
Mount a PersistentVolumeClaim at
/app/runs— this is not optional. Run state lives in an in-memory map that's only ever populated once, at boot, by scanning/app/runs/*/run.json. Why: on anemptyDir(or no volume at all), a pod restart wipes/app/runsand the in-memory state together — every run in history disappears, not just the most recent one. Verify: create a run,kubectl delete podto force a reschedule, thenGET /runs— with the PVC mounted, past runs are still listed (in-progress ones correctly flip tofailed); without persistent storage, the list comes back empty. -
Keep
replicas: 1— do not scale this deployment horizontally as-is. A second replica has no way to see runs created on the first one, even if both mounted the same volume, because run state is only loaded from disk at startup, never re-read per request. Why: behind a single Service, a request can land on a pod that never created that run and get a 404, or a list endpoint can show a different result depending on which replica answers — this looks like flaky data loss but is really a scaling mismatch. Verify: this is a design constraint to check for, not a live test — scaling past 1 replica requires moving run state to something shared (a database or Redis) first, not just changing storage access modes. -
RWO (not RWX) is correct given the point above — don't "fix" it by switching to RWX.
Why: RWX only matters once multiple pods need to mount the same volume concurrently, which this app doesn't support today regardless of storage mode.
Verify:
kubectl get pvc perfload-runner-runs -o jsonpath='{.status.accessModes}'should showReadWriteOnce.
3. /dev/shm / shared memory
-
Confirm Chromium isn't relying on
/dev/shmin the first place. The PDF generator already launches Puppeteer with--disable-dev-shm-usage, which routes Chromium's shared memory files to/tmpinstead — the standard fix for Chromium crashing in containers. Why: if this flag were ever removed (e.g. during a Puppeteer version bump), the default 64MB/dev/shmin a container is undersized for Chromium and causes hard crashes rather than a clean error. Verify: grep the PDF generator source fordisable-dev-shm-usagebefore any deploy that touches it; if missing, restore the flag or rely on the memory-backeddshmemptyDir above (512Mi) as a backstop, not the primary fix.
4. Health checks
-
/healthis a liveness signal, not a readiness one — it always returns{"status":"ok"}unconditionally, regardless of whether thek6binary or Chromium actually resolve, or whether/app/runsis writable. Why: a pod can pass both probes and start receiving traffic even ifk6is missing from$PATH— the failure only surfaces later, per-run, as afailedstatus and a spawn-error log line, not as a probe failure at startup. Verify: after any base-image change, don't just check podReady— run one real test end-to-end (POST /runs→ poll status →finished) before calling the rollout verified.
5. Config differences (local docker run vs. cluster)
-
PUPPETEER_SKIP_DOWNLOADandPUPPETEER_EXECUTABLE_PATHneed no action — they're baked into the image via the Dockerfile'sENV, not passed atdocker runtime, so they carry over into the pod automatically. Why: it's easy to assume every env var needs re-declaring in the ConfigMap — these two don't, and duplicating them risks drift if the Dockerfile's path ever changes. Verify:kubectl execinto the pod andecho $PUPPETEER_EXECUTABLE_PATH— already set, no ConfigMap entry needed. -
PORT,K6_DASHBOARD_PORT_START, andK6_DASHBOARD_PORT_COUNTdo need to go in the ConfigMap if you override their defaults (3000,5665,20). Why: a value only ever passed via a developer's localdocker run -e(or a dev.env) doesn't exist inside the image and silently falls back to the in-code default in k8s unless explicitly added to the ConfigMap. Verify:kubectl execinto the pod and checkenv | grep K6_DASHBOARD— blank means it's running on code defaults. -
No Secret is required today — don't add one speculatively. Nothing in the runner currently reads an API key, token, or credential from the environment.
Why: if a future feature adds one, it belongs in a k8s Secret via
envFrom.secretRef, not bundled intoapp-configmapalongside the plain tunables above. Verify: grep the runner source forprocess.env.and confirm every match is one of the tunables above — any new one is the trigger to revisit this list.