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
The resources block is the part most worth adjusting per environment — see two alternative sizing profiles below.
resources:
requests:
cpu: 250m
memory: 512Mi
limits:
cpu: 500m
memory: 768Mi
resources:
requests:
cpu: "1"
memory: 1.5Gi
limits:
cpu: "2"
memory: 2.5Gi
See "Resource requests/limits" below for why k6 (CPU) and Puppeteer/Chromium (memory) are the two costs to size for.
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: perfload-runner-runs
spec:
accessModes:
- ReadWriteOnce
resources:
requests:
storage: 5Gi
accessModes should stay ReadWriteOnce in every profile below — see "Ephemeral vs. persistent storage" further down for why. Only storage changes with run volume and retention.
resources:
requests:
storage: 2Gi
resources:
requests:
storage: 20Gi
Each run directory holds its generated k6 script, summary, raw metrics stream, and (if kept) the exported dashboard HTML — dashboard exports are the largest of these, so retention needs scale mostly with how many dashboards you keep, not run count alone.
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
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: watch CPU and memory usage during an idle period 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 watch CPU/memory usage — 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; check the pod's last terminated state 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, force the pod to reschedule, then checkGET /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: the PVC's access mode should show
ReadWriteOnce.
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.