Internals: benchmark reports
Kubernetes ingress benchmark harness
Compares the ScalWS ingress controller (chart deploy/helm/scalws in ingress mode) with
ingress-nginx and Traefik on one k3s node. Report (round 2 on top, round 1 below):
docs/benchmarks/2026-10-14-xeon-k8s-ingress.md.
Requirements
- A Linux host with two NUMA nodes (or two CPU sets that share nothing): node 0 runs k3s
and every pod, node 1 the load generator. The scripts assume CPUs
0-7,16-23for node 0 (NODE0_CPUSchanges it) and8-15,24-31foroha. Run as root from the repository root, on a host that serves nothing else; k3s is restarted by the setup. - k3s with Traefik and ServiceLB disabled (
--disable traefik --disable servicelb), so only the controller under test handles Ingresses;kubectlandhelm3. oha1.16 or later,jq,curl,openssl,python3,perf(only for theperfscenario) and Docker with buildx to build the ScalWS image from the checked-out tree (packaging/container/Dockerfile). The image is imported into k3s’s containerd; no registry is involved.- About 3 GB free for the oha result files of the fixed-rate scenarios (reduced to
per-second summaries by
aggregate.py).
Files
| File | Purpose |
|---|---|
setup-host.sh | One-time host preparation: k3s and all pods on NUMA node 0, ScalWS image import, client sysctls, helm repositories. |
run.sh CONTROLLER | Installs one controller (scalws, ingress-nginx, traefik, or a variant such as scalws-fountain), runs the scenarios, uninstalls it. |
run-all.sh | All controllers, throughput then fixed rate, then the aggregate. |
aggregate.py DIR | Medians and spreads per controller and scenario: DIR/aggregate.csv, DIR/aggregate.md. |
manifests/backend.yaml | Namespace bench, the nginx backend (4 replicas), its Service. |
manifests/ingress.yaml | The measured Ingresses (bench.local plain, tls.bench.local TLS); only ingressClassName differs per controller. |
values/*.yaml | Helm values per controller; each file says what differs from the chart defaults and why. |
values/<controller>-<variant>.yaml | A variant: applied on top of the controller’s values, results under $OUT/<controller>-<variant>/. scalws-fountain (accept policy fountain), ingress-nginx-tuned (64k proxy buffer, keep-alive limits raised), ingress-nginx-keepalive (only the keep-alive limits). |
Method
- One k3s node. k3s, containerd and every pod (controller and backend) run on NUMA node 0
(CPUs 0-7,16-23): a
CPUAffinitydrop-in fork3s.serviceplusAllowedCPUson the kubelet’skubepods.slice.oharuns on node 1 (taskset -c 8-15,24-31). One controller is installed at a time; the namespace is deleted before the next. - Every controller gets 16 workers (ingress-nginx
worker-processes: 16, TraefikGOMAXPROCS=16, ScalWSserver.event_loops: 16), no CPU limit, one replica, access logs off.environment.txtrecords the processes, thread counts and CPU affinity seen. - Load goes to the controller pod IP from the host (
oha --connect-to), so no Service, kube-proxy or NodePort path is involved. Same Host headers and the same Ingress objects. - Backend:
nginx:1.27.5-alpine, 4 replicas x 2 workers,/small(13 bytes),/100k(102 400 bytes), upstream keep-alive for 100 000 requests per connection. Itsstub_statuscounts the upstream connections each controller opens. - Throughput scenarios: warm-up (
WARMUP, 10 s), thenRUNS(3) runs ofDURATION(30 s) at 256 in flight. Fixed-rate scenarios:RUNSruns ofFIXED_DURATION(60 s) atRATEreq/s with--latency-correction; every successful request goes to an SQLite file for the worst one-second p99. - Per run: controller pod CPU (cgroup
cpu.stat), peak RSS of the pod’s processes (sampled every 2 s), oha CPU, backend connections opened.
| Scenario | Request |
|---|---|
h1-small | HTTP/1.1 keep-alive, GET /small, 256 connections |
h1-100k | same, GET /100k |
tls-h1 | HTTPS (TLS from the Ingress Secret, RSA 2048), HTTP/1.1, 256 connections |
tls-h2 | HTTPS, HTTP/2, 64 connections x 4 streams |
tls-newconn | HTTPS, a new connection per request, 256 in flight |
latency | fixed rate, HTTP/1.1 /small |
churn | as latency, the backend is scaled 4 -> 8 -> 2 -> 4 at 10 s, 25 s and 40 s |
config | as latency, every 2 s an unrelated Ingress is added or removed; at the 15th step a path is added to the measured host |
perf | profile, not a measurement: the load of each of PERF_SCENARIOS (throughput scenarios or latency at RATE), perf record -g on the data-plane process for PERF_SECONDS (20) after the warm-up, CPU per thread from /proc; perf-<scenario>.{txt,data} |
Running
bench/kubernetes/setup-host.sh # once (restarts k3s)
nohup bench/kubernetes/run-all.sh > run-all.log 2>&1 &run-all.sh runs the throughput scenarios for every controller (ORDER, default
scalws ingress-nginx traefik), sets RATE to half of the slowest controller’s
h1-small median (rounded down to 1000 req/s) unless given, runs the fixed-rate scenarios
and writes the aggregate. Single pieces:
SCENARIOS="h1-small tls-h2" RUNS=5 bench/kubernetes/run.sh ingress-nginx
RATE=19000 SCENARIOS="churn" bench/kubernetes/run.sh traefik
SCENARIOS=probe bench/kubernetes/run.sh scalws # diagnostics, not a measurement
RATE=19000 SCENARIOS=perf PERF_SCENARIOS="latency h1-small" bench/kubernetes/run.sh scalws-fountain
ORDER="scalws ingress-nginx-tuned scalws-fountain ingress-nginx traefik" bench/kubernetes/run-all.sh
bench/kubernetes/aggregate.py "$OUT"Results go to $OUT (default: bench-results/k8s-ingress-<date> under root’s home
directory), one directory per controller. SCALWS_TAG (default the benchmarked commit’s
tag) names the ScalWS image setup-host.sh builds and imports.
probe records the negotiated TLS 1.3 group, the server bytes per connection of a
connection-per-request run (to tell resumed from full handshakes), and for ingress-nginx
the HTTP/2 errors and the 100 KiB throughput with a few configuration changes.
Afterwards stop k3s (systemctl stop k3s; /usr/local/bin/k3s-killall.sh). The k3s
CPUAffinity drop-in (/etc/systemd/system/k3s.service.d/bench-cpuaffinity.conf) only
affects k3s; the kubepods.slice setting is runtime only and gone after a reboot.
Reading the results
aggregate.md in $OUT has one table per scenario group: requests/s as the median of the
runs with min and max, p50 and p99, errors (non-2xx plus client errors), controller CPU in
cores and microseconds per request, peak RSS of the controller pod, the load generator’s
CPU and the backend connections opened per 1000 requests. For the fixed-rate scenarios the
“clean” percentiles leave out the first second and the seconds in which oha itself stalls
(its result vector grows at about 7, 14, 28 and 55 s at 19 000 req/s). Check before
comparing:
environment.txtof each controller: all controller and backend processes on node 0, the worker or event loop counts as configured, no restarts.- The spread of the runs (min to max): within about 2 % on a quiet host. A run order
effect shows when the throughput scenarios are repeated in reverse order (
ORDER). - oha cores well below 16: if the client is near its limit (the connection-per-request scenario can get there), the figure is a lower bound for the faster controller.