Internals: benchmark reports
Kubernetes ingress controllers on one k3s node: ScalWS, ingress-nginx, Traefik, 2026-10-14
ScalWS in ingress mode (chart deploy/helm/scalws) against ingress-nginx and Traefik on
one k3s node: the same backend, the same Ingress objects, one controller installed at a
time. Harness: bench/kubernetes/ (README there). Round 2 (below, the current figures)
measures ScalWS at commit 4823999 with both accept policies and ingress-nginx with its
defaults and tuned for this workload. The first section measures the fountain accept
policy again after its connection cap (00ae108, 2026-10-15). Round 1 (ScalWS 115f5f2)
and “After fixes” (d14639f) follow as history.
Fountain with the connection cap: ScalWS 00ae108, 2026-10-15
ADR-0044 now also counts a loop as full while it holds twice its fair share of the live
connections (at least 8): with 16 loops and 256 connections a cap of 32 per loop. This
section measures fountain with that change in the same harness, and ScalWS shared
again as a same-day control. The round 2 figures below are unchanged; ingress-nginx and
Traefik were not run again.
Image scalws:bench-00ae108 (sha256:561d3a0ae6c98ea90b6bad7560776198d983d3632422b8e8ec698cbe6ddedafb),
values as in round 2 (values/scalws.yaml, values/scalws-fountain.yaml), all scenarios,
3 runs each, fixed rate 19000 req/s. Order: throughput fountain, throughput shared, fixed
rate fountain, fixed rate shared, then a perf pass of fountain for the CPU per loop.
No pod restarts. Raw results on the bench host, in its results directory: results/k8s-ingress-2026-10-15-fcap
and ...-fcap-perf.
The same-day shared control repeats round 2 within 1 % in requests/s (h1-small 64482
against 64273, tls-h2 62209 against 62389) and within 0.1 ms in fixed-rate p99, so the
two days compare.
Throughput, requests/s (median of 3 runs) and p99:
| scenario | fountain with cap | fountain round 2 | ScalWS shared (today) | ScalWS shared round 2 | ingress-nginx tuned round 2 | fountain with cap / shared today |
|---|---|---|---|---|---|---|
| h1-small req/s | 62325 | 59839 | 64482 | 64273 | 49938 | 0.97x |
| h1-100k req/s | 28196 | 27277 | 28722 | 29043 | 24311 | 0.98x |
| tls-h1 req/s | 58755 | 57605 | 60814 | 61155 | 45075 | 0.97x |
| tls-h2 req/s | 57093 | 57373 | 62209 | 62389 | 40241 | 0.92x |
| tls-newconn req/s | 13237 | 13042 | 13535 | 13521 | 5486 | 0.98x |
| h1-small p99 ms | 17.02 | 22.00 | 17.27 | 16.74 | 19.96 | 0.99x |
| h1-100k p99 ms | 35.87 | 57.83 | 33.41 | 32.64 | 36.12 | 1.07x |
| tls-h1 p99 ms | 16.04 | 22.28 | 17.55 | 17.80 | 21.93 | 0.91x |
| tls-h2 p99 ms | 22.96 | 23.00 | 14.86 | 15.18 | 21.26 | 1.55x |
| tls-newconn p99 ms | 51.91 | 53.45 | 46.64 | 46.70 | 200.28 | 1.11x |
Fixed rate, 19000 req/s, 3 runs of 60 s, medians (clean percentiles as defined in round 2):
| scenario | metric | fountain with cap | fountain round 2 | ScalWS shared (today) | ScalWS shared round 2 | ingress-nginx tuned round 2 |
|---|---|---|---|---|---|---|
| latency | p50 ms | 0.77 | 0.82 | 0.58 | 0.57 | 0.69 |
| latency | p99 clean ms | 2.46 | 2.80 | 1.57 | 1.56 | 2.05 |
| latency | p99.9 clean ms | 3.17 | 3.66 | 2.16 | 2.12 | 2.80 |
| churn | p50 ms | 0.78 | 1.10 | 0.58 | 0.57 | 0.86 |
| churn | p99 clean ms | 2.54 | 6.08 | 1.73 | 1.70 | 4668.45 |
| churn | failed requests (3 runs) | 0 | 1 | 0 | 0 | 8512 |
| config | p50 ms | 0.79 | 1.00 | 0.58 | 0.58 | 0.71 |
| config | p99 clean ms | 2.35 | 11.15 | 1.59 | 1.60 | 7.85 |
| config | failed requests (3 runs) | 0 | 0 | 0 | 0 | 981 |
| latency | controller CPU cores | 2.2 | 2.1 | 2.5 | 2.4 | 3.8 |
| latency | peak RSS MiB | 108 | 106 | 111 | 119 | 376 |
Raw p99 per run (ms): latency 2.88, 2.55, 2.86; churn 3.18, 3.08, 3.59; config 3.25,
2.78, 2.73 (round 2: churn 8.50, 16.88, 5.98; config 12.46, 11.47, 7.86). The worst
one-second p99 per run is still higher than with shared (churn 7.1 to 10.3 ms against
3.7 to 4.0 ms).
CPU per event loop during the perf pass (% of one core, busiest first; perf itself
costs CPU, so the figures are relative):
| scenario | fountain with cap | fountain round 2 |
|---|---|---|
| latency (19000 req/s) | 84/78/44/35, twelve loops at 4 to 5 | 90/89/46, thirteen loops at about 4 |
| h1-small | 108/108/108/69/69/69/66/65/65/65/52/49/49/49/48/48 | 108/108/108/73/72/69/68/66/54/54/53/52/52/51/51/49 |
| tls-h2 | 109/109/75/73/73/72/72/71/71/71/70/70/69/69/69, one loop idle | 106/106/69/69/68/68/67/67/67/66/66/66/65/65/64/64 |
Findings:
- The p99 penalty of fountain shrank. At the fixed rate fountain’s p99 is now 1.5x to
1.6x that of
shared(round 2: 1.8x to 7x); the Ingress churn and backend churn scenarios lost their outliers (11.15 to 2.35 ms, 6.08 to 2.54 ms) and the runs no longer differ from each other by a factor of two to three. Requests/s went up to 0.92x to 0.98x ofshared(round 2: 0.92x to 0.96x). The controller CPU saving at the fixed rate is the same as before (2.2 against 2.5 cores, 0.88x). - Against tuned ingress-nginx fountain now loses in five figures instead of ten: tls-h2 p99 (22.96 against 21.26 ms), latency p50 (0.77 against 0.69 ms), latency p99 clean (2.46 against 2.05 ms), latency p99.9 clean (3.17 against 2.80 ms) and config p50 (0.79 against 0.71 ms). It is ahead in the p99 of h1-small, h1-100k (35.87 against 36.12 ms, within noise), tls-h1 and tls-newconn, in every churn and config p99, and in requests/s, CPU and memory everywhere. The target (fountain’s p99 no longer behind tuned ingress-nginx) is met for the HTTP/1.1 throughput scenarios and the churn scenarios, not for tls-h2 and the quiet fixed-rate scenario.
- Why the rest remains: the cap does not bind for the opening burst. At 19000 req/s the 256 connections still sit on four loops (84/78/44/35 %), on average 64 each against a cap of 32. Only connections accepted before the current 20 ms window count against the cap (ADR-0044: counting the current window throttled short-connection workloads), so connections that arrive together are placed by the surge estimate, and the loop that is awake keeps accepting while it is full. tls-h2 (64 connections, cap 8) shows the same: loops 0 and 1 at the ceiling. The core benchmark with all connections opened at once (ADR-0044) shows this in pure form. A loop that also steps back while full from a surge fixes the closed-loop placement but collapsed the accept rate under many new connections in a first experiment (ADR-0044, “Not done”).
Recommendation unchanged: shared for ingress mode. fountain remains the policy for
hosting servers with many short connections, where it saves CPU.
Reproduce:
# on the bench host, as root, a checkout of 00ae108
SCALWS_TAG=bench-00ae108 bench/kubernetes/setup-host.sh
export SCALWS_TAG=bench-00ae108 OUT=results/k8s-ingress-2026-10-15-fcap
for c in scalws-fountain scalws; do SCENARIOS="h1-small h1-100k tls-h1 tls-h2 tls-newconn" bench/kubernetes/run.sh $c; done
for c in scalws-fountain scalws; do RATE=19000 SCENARIOS="latency churn config" bench/kubernetes/run.sh $c; done
OUT=$OUT-perf RATE=19000 SCENARIOS=perf PERF_SCENARIOS="latency h1-small h1-100k tls-h1 tls-h2" bench/kubernetes/run.sh scalws-fountain
bench/kubernetes/aggregate.py $OUT
systemctl stop k3s; /usr/local/bin/k3s-killall.shRound 2: ScalWS 4823999, both accept policies, ingress-nginx default and tuned
Raw results on the bench host, in its results directory: results/k8s-ingress-2026-10-14-r2 (pass 1: all
scenarios, all five configurations), ...-r2-pass2 (throughput again in reverse order,
ScalWS both policies and tuned ingress-nginx), ...-r2-diag (ingress-nginx with the
keep-alive settings only), ...-r2-perf and ...-r2-perf2 (profiles). Run on the night of
2026-10-14 to 2026-10-15.
Summary
Throughput: median of 3 runs of 30 s, 256 requests in flight. “ScalWS shared / ingress-nginx tuned” is the ratio of requests/s.
| scenario | ScalWS shared | ScalWS fountain | ingress-nginx default | ingress-nginx tuned | Traefik | ScalWS shared / ingress-nginx tuned | ScalWS fountain / ingress-nginx tuned |
|---|---|---|---|---|---|---|---|
| h1-small | 64273 | 59839 | 58156 | 49938 | 39560 | 1.29x | 1.20x |
| h1-100k | 29043 | 27277 | 12335 | 24311 | 19499 | 1.19x | 1.12x |
| tls-h1 | 61155 | 57605 | 51416 | 45075 | 36873 | 1.36x | 1.28x |
| tls-h2 | 62389 | 57373 | 44359 | 40241 | 28118 | 1.55x | 1.43x |
| tls-newconn | 13521 | 13042 | 5532 | 5486 | 6013 | 2.46x | 2.38x |
Fixed rate, 19000 req/s, 3 runs of 60 s. Latency is the median over the runs of the clean p99 (see “Fixed rate”); failed requests are non-2xx plus client errors, summed over the 3 runs. “ingress-nginx tuned / ScalWS” is the ratio of p99 (above 1.00x: ScalWS lower).
| scenario | metric | ScalWS shared | ScalWS fountain | ingress-nginx default | ingress-nginx tuned | Traefik | ingress-nginx tuned / ScalWS shared | ingress-nginx tuned / ScalWS fountain |
|---|---|---|---|---|---|---|---|---|
| latency | p99 clean ms | 1.56 | 2.80 | 2.24 | 2.05 | 3.20 | 1.32x | 0.73x |
| config | p99 clean ms | 1.60 | 11.15 | 5.02 | 7.85 | 3.48 | 4.90x | 0.70x |
| churn | p99 clean ms | 1.70 | 6.08 | 4647.93 | 4668.45 | 9708.00 | n/a | n/a |
| latency | failed requests | 0 | 0 | 0 | 0 | 0 | n/a | n/a |
| churn | failed requests | 0 | 1 | 5979 | 8512 | 13690 | n/a | n/a |
| config | failed requests | 0 | 0 | 826 | 981 | 0 | n/a | n/a |
Efficiency: controller pod CPU per request in the throughput scenarios (us), controller CPU at the fixed rate (cores) and peak RSS (MiB). “ingress-nginx tuned / ScalWS shared” above 1.00x means ScalWS uses less.
| scenario | metric | ScalWS shared | ScalWS fountain | ingress-nginx default | ingress-nginx tuned | Traefik | ingress-nginx tuned / ScalWS shared |
|---|---|---|---|---|---|---|---|
| h1-small | CPU us per request | 168.5 | 168.4 | 210.0 | 254.4 | 330.7 | 1.51x |
| h1-100k | CPU us per request | 341.8 | 349.9 | 1127.4 | 484.1 | 640.8 | 1.42x |
| tls-h1 | CPU us per request | 183.1 | 180.8 | 241.5 | 285.7 | 357.8 | 1.56x |
| tls-h2 | CPU us per request | 188.8 | 196.4 | 274.2 | 317.3 | 482.6 | 1.68x |
| tls-newconn | CPU us per request | 1107.3 | 1120.4 | 2622.5 | 2635.1 | 2521.2 | 2.38x |
| latency | CPU cores at 19000 req/s | 2.4 | 2.1 | 3.3 | 3.8 | 5.8 | 1.58x |
| h1-small | peak RSS MiB | 130 | 113 | 382 | 381 | 350 | 2.93x |
| h1-100k | peak RSS MiB | 327 | 261 | 380 | 380 | 362 | 1.16x |
In this setup ScalWS with the default accept policy (shared) is ahead of both
ingress-nginx configurations and Traefik in every scenario and every metric measured:
requests/s, p50 and p99, failed requests, CPU per request and memory. Against tuned
ingress-nginx it is 1.29x in the tested HTTP/1.1 small-response workload, 1.19x with
100 KiB responses, 1.36x (TLS, HTTP/1.1), 1.55x (TLS, HTTP/2) and 2.46x with a new TLS
connection per request. The tuning helps ingress-nginx only in the 100 KiB scenario and
costs it elsewhere (next section); against the best ingress-nginx configuration per
scenario the margins are 1.10x (h1-small) to 2.44x (tls-newconn), see “ingress-nginx:
which settings help”. With the fountain accept policy ScalWS still has more requests/s
than every other controller, but loses to tuned ingress-nginx in ten latency figures
(p99 of the four keep-alive throughput scenarios, p50 and p99 at the fixed rate); see
“Where ScalWS loses and why”.
What changed since round 1
- ScalWS image
scalws:bench-4823999(ScalWS 1.0.0-rc.1 at 4823999,sha256:43e9582c6044): the round 1 findings fixed (pool retry of idempotent requests on a broken reused connection, listener backlog 4096, no default memory limit in the chart), the controller writesretries: 2into its pools, only the namespace that owns a host adds paths to it, and idle-connection replay only for idempotent requests. - ScalWS runs twice:
server.accept_policy: shared(the default,values/scalws.yaml) andfountain(ADR-0044,values/scalws-fountain.yaml). - ingress-nginx runs twice: chart defaults as in round 1 (
values/ingress-nginx.yaml) and tuned (values/ingress-nginx-tuned.yaml, next section). - Run order, throughput: ScalWS shared, ingress-nginx tuned, ScalWS fountain, ingress-nginx default, Traefik; the fixed-rate scenarios in the same order afterwards. Traefik was measured again in this order (all scenarios).
- The fixed rate stays at 19000 req/s, as in round 1, for comparability.
Everything else (host, k3s, pinning, backend, Ingresses, oha, scenarios, method) is as in round 1; see “Setup” and “Fairness choices” below.
| version | |
|---|---|
| ScalWS | chart deploy/helm/scalws 1.0.0-rc.1 at 4823999, image scalws:bench-4823999 built with packaging/container/Dockerfile (sha256:43e9582c60446a9b52a4c881e879fcf8c4f8b3d36699d4cad118c0143165e3aa) |
| ingress-nginx | chart 4.15.1, controller v1.15.1 (registry.k8s.io/ingress-nginx/controller@sha256:594ceea76b01c592858f803f9ff4d2cb40542cae2060410b2c95f75907d659e1) |
| Traefik | chart 41.7.1, Traefik v3.7.14 (docker.io/library/traefik@sha256:575fa15b135078fe5e50aa847987d96dbddd7b093c172429618404df73f3fa7c) |
| backend | nginx:1.27.5-alpine (sha256:65645c7bb6a0661892a8b03b89d0743208a18dd2f3f17a54ef4b76fb8e2f2a10) |
| k3s, kernel, load | k3s v1.36.5+k3s1, kernel 7.0.0-38-generic, oha 1.16.0, perf from the distribution |
ingress-nginx tuned: the settings
values/ingress-nginx-tuned.yaml, on top of the round 1 values (16 workers, access log
off). The keys an operator would reach for after round 1 and the ingress-nginx ConfigMap
reference:
| key | default | tuned | why |
|---|---|---|---|
proxy-buffer-size | 4k | 64k | with proxy-buffering: off (the default, kept: turning it on was slower in round 1) nginx relays a response in reads and writes of this size |
keep-alive-requests | 1000 | 10000 | client side: HTTP/1.1 connections are closed and HTTP/2 connections get GOAWAY after this many requests |
upstream-keepalive-requests | 1000 | 10000 | fewer upstream reconnects |
upstream-keepalive-connections | 320 | 320 | idle upstream connections per worker; the default, stated explicitly |
worker-processes | auto | 16 | as round 1 |
Not changed: proxy-connect-timeout (5 s) and retries (proxy-next-upstream, 3 tries),
TLS settings (session cache on, tickets off, the default curves), worker-cpu-affinity
(ScalWS does not pin its loops either), max-worker-connections (16384) and
reuse-port (on). The backend has no preStop hook, as in round 1; an operator would add
one to the backend, which would help every controller in the churn scenario alike.
ingress-nginx: which settings help
The tuned values are slower than the defaults except at 100 KiB. A diagnostic run
(...-r2-diag, values/ingress-nginx-keepalive.yaml: the tuned values without
proxy-buffer-size) separates the two changes. Requests/s, median of 3 runs:
| scenario | ingress-nginx default | ingress-nginx keep-alive only | ingress-nginx tuned | ScalWS shared | ScalWS shared / best ingress-nginx |
|---|---|---|---|---|---|
| h1-small | 58156 | 58637 | 49938 | 64273 | 1.10x |
| h1-100k | 12335 | 12460 | 24311 | 29043 | 1.19x |
| tls-h1 | 51416 | 52398 | 45075 | 61155 | 1.17x |
| tls-h2 | 44359 | 45922 | 40241 | 62389 | 1.36x |
| tls-newconn | 5532 | n/a | 5486 | 13521 | 2.44x |
- With the 64 KiB
proxy-buffer-sizeingress-nginx reaches 0.85x of the keep-alive-only requests/s with 13-byte responses (CPU per request 205 to 254 us), 0.86x with TLS and HTTP/1.1 and 0.88x with HTTP/2; it doubles the 100 KiB throughput. nginx sets up the proxy buffer for every request; that the larger buffer is the cost per request is the likely reading, ingress-nginx was not profiled. An operator would set it per Ingress (annotationnginx.ingress.kubernetes.io/proxy-buffer-size) for the paths that return large bodies; here one Ingress carries both paths, so the global setting is what applies. - The keep-alive settings help a little (1.01x to 1.04x). HTTP/2 still had about 1100
to 1300 failed streams per 3 runs with
keep-alive-requests: 10000(15560 with the default 1000): the GOAWAY comes ten times less often but still comes. - tls-newconn was not part of the diagnostic run; both measured configurations are within 1 % there, so the best of the two is used.
Throughput (scenarios 1 to 4)
Median of 3 runs; min and max of the 3 runs; CPU and RSS of the controller pod (all its containers); “oha cores” is the load generator’s CPU (16 available); errors are non-2xx plus client errors.
| scenario | controller | req/s | min | max | p50 ms | p99 ms | errors | ctrl CPU cores | ctrl CPU us/req | peak RSS MiB | oha cores | backend conns per 1k req |
|---|---|---|---|---|---|---|---|---|---|---|---|---|
| h1-small | ScalWS shared | 64273 | 64066 | 64397 | 2.74 | 16.74 | 0 | 10.4 | 168.5 | 130 | 3.9 | 0.16 |
| h1-small | ScalWS fountain | 59839 | 57495 | 61256 | 1.25 | 22.00 | 0 | 9.7 | 168.4 | 113 | 3.6 | 0.34 |
| h1-small | ingress-nginx default | 58156 | 58099 | 58222 | 3.46 | 16.99 | 0 | 11.7 | 210.0 | 382 | 3.6 | 0.18 |
| h1-small | ingress-nginx tuned | 49938 | 49362 | 50295 | 4.00 | 19.96 | 0 | 12.3 | 254.4 | 381 | 3.0 | 0.21 |
| h1-small | Traefik | 39560 | 39523 | 39621 | 5.24 | 22.93 | 0 | 12.7 | 330.7 | 350 | 2.4 | 0.32 |
| h1-100k | ScalWS shared | 29043 | 29002 | 29046 | 6.54 | 32.64 | 0 | 9.7 | 341.8 | 327 | 4.2 | 0.40 |
| h1-100k | ScalWS fountain | 27277 | 26942 | 27591 | 2.48 | 57.83 | 0 | 9.4 | 349.9 | 261 | 4.0 | 0.48 |
| h1-100k | ingress-nginx default | 12335 | 12300 | 12352 | 17.78 | 70.49 | 0 | 13.8 | 1127.4 | 380 | 7.5 | 0.76 |
| h1-100k | ingress-nginx tuned | 24311 | 24281 | 24383 | 8.56 | 36.12 | 0 | 11.6 | 484.1 | 380 | 3.7 | 0.40 |
| h1-100k | Traefik | 19499 | 19490 | 19522 | 10.11 | 50.56 | 0 | 12.4 | 640.8 | 362 | 4.2 | 0.58 |
| tls-h1 | ScalWS shared | 61155 | 60999 | 61250 | 2.91 | 17.80 | 0 | 10.8 | 183.1 | 292 | 4.3 | 0.17 |
| tls-h1 | ScalWS fountain | 57605 | 57596 | 57963 | 1.55 | 22.28 | 0 | 10.0 | 180.8 | 221 | 4.1 | 0.22 |
| tls-h1 | ingress-nginx default | 51416 | 51126 | 51543 | 3.81 | 20.20 | 0 | 12.0 | 241.5 | 445 | 3.7 | 0.22 |
| tls-h1 | ingress-nginx tuned | 45075 | 45074 | 45090 | 4.34 | 21.93 | 0 | 12.5 | 285.7 | 414 | 3.2 | 0.25 |
| tls-h1 | Traefik | 36873 | 36730 | 36926 | 5.54 | 24.78 | 0 | 12.9 | 357.8 | 374 | 2.6 | 0.27 |
| tls-h2 | ScalWS shared | 62389 | 59935 | 63612 | 3.15 | 15.18 | 0 | 11.3 | 188.8 | 141 | 5.4 | 0.14 |
| tls-h2 | ScalWS fountain | 57373 | 57118 | 57484 | 2.33 | 23.00 | 0 | 10.8 | 196.4 | 154 | 5.0 | 0.21 |
| tls-h2 | ingress-nginx default | 44359 | 44328 | 44617 | 4.27 | 20.75 | 15560 | 11.8 | 274.2 | 469 | 5.8 | 0.29 |
| tls-h2 | ingress-nginx tuned | 40241 | 40144 | 40674 | 5.16 | 21.26 | 1092 | 12.4 | 317.3 | 422 | 5.5 | 0.27 |
| tls-h2 | Traefik | 28118 | 27913 | 28136 | 7.72 | 28.86 | 0 | 13.3 | 482.6 | 354 | 3.8 | 0.15 |
| tls-newconn | ScalWS shared | 13521 | 13489 | 13574 | 17.22 | 46.70 | 0 | 14.8 | 1107.3 | 146 | 14.2 | 0.48 |
| tls-newconn | ScalWS fountain | 13042 | 13013 | 13064 | 17.30 | 53.45 | 0 | 14.5 | 1120.4 | 153 | 13.6 | 0.94 |
| tls-newconn | ingress-nginx default | 5532 | 5525 | 5549 | 35.22 | 183.99 | 0 | 14.4 | 2622.5 | 565 | 4.9 | 1.14 |
| tls-newconn | ingress-nginx tuned | 5486 | 5455 | 5491 | 34.74 | 200.28 | 0 | 14.4 | 2635.1 | 564 | 4.8 | 1.34 |
| tls-newconn | Traefik | 6013 | 6003 | 6021 | 26.74 | 166.79 | 0 | 15.1 | 2521.2 | 282 | 5.0 | 0.69 |
The ingress-nginx default and Traefik rows repeat round 1 within 1 % (round 1: 58479, 12374, 51518, 44867, 5532 and 39623, 19482, 36691, 28064, 6013). ScalWS shared also repeats its round 1 and “After fixes” figures within 2 %. All controller pods ran without a restart.
Fixed rate (scenarios 5 to 7)
19000 req/s, HTTP/1.1 keep-alive, /small, 256 connections, 3 runs of 60 s, medians
over the runs (failed requests and slow requests: sums). Definitions as in round 1:
“clean” leaves out the first second and the seconds in which oha itself stalls; “worst
1 s p99” is the highest per-second p99 of all runs; “slow over 1 s” counts successful
requests whose own round trip took more than 1 s.
| scenario | controller | p50 ms | p99 ms | p99.9 ms | p99 clean ms | p99.9 clean ms | worst 1 s p99 ms | non-2xx | errors | slow over 1 s | ctrl CPU cores | peak RSS MiB |
|---|---|---|---|---|---|---|---|---|---|---|---|---|
| latency | ScalWS shared | 0.57 | 1.85 | 33.34 | 1.56 | 2.12 | 2.3 | 0 | 0 | 0 | 2.4 | 119 |
| latency | ScalWS fountain | 0.82 | 3.25 | 38.80 | 2.80 | 3.66 | 5.1 | 0 | 0 | 0 | 2.1 | 106 |
| latency | ingress-nginx default | 0.71 | 2.63 | 33.31 | 2.24 | 3.09 | 8.6 | 0 | 0 | 0 | 3.3 | 377 |
| latency | ingress-nginx tuned | 0.69 | 2.61 | 38.75 | 2.05 | 2.80 | 3.0 | 0 | 0 | 0 | 3.8 | 376 |
| latency | Traefik | 0.76 | 6.77 | 57.82 | 3.20 | 6.06 | 6.5 | 0 | 0 | 0 | 5.8 | 208 |
| churn | ScalWS shared | 0.57 | 2.34 | 32.72 | 1.70 | 3.48 | 3.5 | 0 | 0 | 0 | 2.5 | 128 |
| churn | ScalWS fountain | 1.10 | 8.50 | 37.63 | 6.08 | 12.26 | 32.0 | 1 | 0 | 0 | 2.1 | 118 |
| churn | ingress-nginx default | 0.88 | 4590.30 | 4945.24 | 4647.93 | 4948.88 | 5001.6 | 5979 | 0 | 768 | 3.4 | 381 |
| churn | ingress-nginx tuned | 0.86 | 4613.72 | 4941.56 | 4668.45 | 4946.76 | 5001.5 | 8512 | 0 | 768 | 3.9 | 382 |
| churn | Traefik | 1.06 | 9665.90 | 9937.97 | 9708.00 | 9941.45 | 9965.3 | 12922 | 768 | 0 | 5.6 | 333 |
| config | ScalWS shared | 0.58 | 1.98 | 27.69 | 1.60 | 2.59 | 2.0 | 0 | 0 | 0 | 2.5 | 133 |
| config | ScalWS fountain | 1.00 | 11.47 | 37.58 | 11.15 | 13.17 | 17.7 | 0 | 0 | 0 | 2.2 | 126 |
| config | ingress-nginx default | 0.72 | 13.11 | 38.94 | 5.02 | 29.77 | 34.9 | 0 | 826 | 0 | 3.4 | 379 |
| config | ingress-nginx tuned | 0.71 | 16.27 | 43.57 | 7.85 | 33.15 | 34.2 | 0 | 981 | 0 | 4.0 | 380 |
| config | Traefik | 0.76 | 6.85 | 53.90 | 3.48 | 6.32 | 7.4 | 0 | 0 | 0 | 5.9 | 298 |
Per run:
| scenario | controller | run 1 | run 2 | run 3 |
|---|---|---|---|---|
| churn | ScalWS shared | 0 failed | 0 failed | 0 failed |
| churn | ScalWS fountain | 0 failed | 0 failed | 1 failed (502) |
| churn | ingress-nginx default | 2246 x 502, 30 x 504 | 1404 x 502, 27 x 504 | 2222 x 502, 50 x 504 |
| churn | ingress-nginx tuned | 2618 x 502, 6 x 504 | 2846 x 502, 66 x 504 | 2921 x 502, 55 x 504 |
| churn | Traefik | 4473 x 502, 256 timeouts | 3760 x 502, 256 timeouts | 4689 x 502, 256 timeouts |
| config | ingress-nginx default | 308 errors | 257 errors | 261 errors |
| config | ingress-nginx tuned | 319 errors | 290 errors | 372 errors |
| churn | ScalWS fountain, raw p99 ms | 8.50 | 16.88 | 5.98 |
| config | ScalWS fountain, raw p99 ms | 12.46 | 11.47 | 7.86 |
- Backend churn (4 -> 8 -> 2 -> 4). ScalWS shared had no failed request in 3 runs
(round 1: 126, “After fixes”: 15). The pools now retry twice (
retries: 2), and the 15 remaining 502s of “After fixes”, all at the 8 -> 2 step where the one retry went to another pod that was leaving, are gone. The 1 failure withfountainis the same kind (502 at the scale-down). ingress-nginx (both configurations) and Traefik behave as in round 1: thousands of 502s per run and 256 requests per run that wait for the 5 s connect timeout (ingress-nginx) or hit oha’s 10 s timeout (Traefik), which is what their p99 shows. The ingress-nginx tuning does not change this. - Ingress churn. ScalWS shared: no failed request, p99 clean 1.60 ms against 1.56
quiet. ingress-nginx: 826 and 981 failed requests in 3 runs (
connection error,connection closed before message completed,operation was canceled) from the reloads closing client keep-alive connections, as in round 1. Traefik: no failed request, p99 clean 3.48 ms. - CPU at the same rate. ScalWS shared 2.4 cores, fountain 2.1, ingress-nginx 3.3 (default) and 3.8 (tuned: the 64 KiB buffer again), Traefik 5.8.
Accept policy for ingress mode: shared
Recommendation: keep shared (the chart default) for ingress mode. In this setup:
| figure | ScalWS shared | ScalWS fountain | fountain / shared |
|---|---|---|---|
| h1-small req/s | 64273 | 59839 | 0.93x |
| h1-100k req/s | 29043 | 27277 | 0.94x |
| tls-h1 req/s | 61155 | 57605 | 0.94x |
| tls-h2 req/s | 62389 | 57373 | 0.92x |
| tls-newconn req/s | 13521 | 13042 | 0.96x |
| latency p99 clean ms | 1.56 | 2.80 | 1.79x |
| churn p99 clean ms | 1.70 | 6.08 | 3.58x |
| config p99 clean ms | 1.60 | 11.15 | 6.97x |
| latency CPU cores at 19000 req/s | 2.4 | 2.1 | 0.88x |
fountain does what ADR-0044 says it does, lower controller CPU at a moderate fixed rate
(0.88x at 19000 req/s, with lower peak RSS too), but an ingress controller is judged on
latency and throughput, and fountain is behind shared in every throughput scenario
(0.92x to 0.96x) and has 1.8x to 7x the p99 at the fixed rate. Pass 2 repeated the
throughput gap (0.91x to 0.97x). The CPU saving is worth having on a hosting server where
PHP or Node.js share the cores (the case ADR-0044 targets), not in front of a Service. The
cause is in the next section.
Where ScalWS loses and why
ScalWS shared is not behind tuned ingress-nginx (nor default ingress-nginx, the keep-alive-only variant or Traefik) in any scenario or metric of this run: requests/s, p50, p99 (throughput and fixed rate), failed requests, CPU per request and peak RSS. The narrowest margins are against the best ingress-nginx configuration per scenario: 1.10x in h1-small (keep-alive only) and 1.19x in h1-100k (tuned).
ScalWS fountain loses to tuned ingress-nginx in these figures:
| scenario | metric | ScalWS fountain | ingress-nginx tuned | ScalWS shared |
|---|---|---|---|---|
| h1-small | p99 ms | 22.00 | 19.96 | 16.74 |
| h1-100k | p99 ms | 57.83 | 36.12 | 32.64 |
| tls-h1 | p99 ms | 22.28 | 21.93 | 17.80 |
| tls-h2 | p99 ms | 23.00 | 21.26 | 15.18 |
| latency | p50 ms | 0.82 | 0.69 | 0.57 |
| latency | p99 clean ms | 2.80 | 2.05 | 1.56 |
| latency | p99.9 clean ms | 3.66 | 2.80 | 2.12 |
| churn | p50 ms | 1.10 | 0.86 | 0.57 |
| config | p50 ms | 1.00 | 0.71 | 0.58 |
| config | p99 clean ms | 11.15 | 7.85 | 1.60 |
(In churn its p99 is far below ingress-nginx’s, which is dominated by the 5 s connect timeouts.) Requests/s, CPU per request and RSS of fountain are still better than tuned ingress-nginx everywhere.
Profiles. run.sh scenario perf: the load of a scenario runs as in the measurement,
and after the 10 s warm-up perf record -F 499 -g samples the scalwsd process for 20 s
(perf pinned to the load CPUs), while the CPU time of every scalwsd thread is read from
/proc before and after. Files: ...-r2-perf2/{scalws,scalws-fountain}/perf-<scenario>.{txt,data}.
perf costs CPU itself (scalwsd used 12.4 cores in h1-small under the profiler against 10.4
without), so the thread figures are relative; values slightly above 100 % come from tick
accounting over the 20 s window.
CPU per event loop thread (% of one core, busiest first, the 16 scalws-loop-N
threads, separated by /):
| scenario | ScalWS shared | ScalWS fountain |
|---|---|---|
| latency (19000 req/s) | 27/27/26/26/25/25/24/23/23/22/21/21/19/17/17/16 | 90/89/46/5/4/4/4/4/4/4/4/4/4/4/4/4 |
| h1-small | 105/100/93/92/85/83/83/80/79/78/78/77/75/69/64 (one loop idle) | 108/108/108/73/72/69/68/66/54/54/53/52/52/51/51/49 |
| h1-100k | 106/100/85/83/83/79/72/70/70/69/69/55/52/52/52/6 | 108/108/108/68/68/68/67/53/52/51/50/50/49/49/49/46 |
| tls-h1 | 101/100/97/95/93/90/86/84/83/77/65/64/63/62/59/39 | 108/108/102/71/71/70/70/70/69/57/53/52/52/51/50/49 |
| tls-h2 | 108/108/108/108/108/108/108/108/106/93/92, five loops idle | 106/106/69/69/68/68/67/67/67/66/66/66/65/65/64/64 |
The symbol profile of fountain is the same as shared’s (top self entries in the latency
scenario: scalws_http::conn::drive 1.7 %, nft_do_chain 1.6 %, route_and_handle 1.4 %,
PoolProxyHandler::forward 1.4 %, none above 2 %): fountain does not do more work per
request, it does it on fewer loops. Findings:
- fountain packs the connections onto the first loops and keeps them there. At 19000 req/s the 256 keep-alive connections end up on three loops: loops 0 and 1 at about 90 % of a core, loop 2 at 46 %, the other 13 at about 4 % each. By ADR-0044 a loop counts as full from 85 % busy and a later loop only takes connections while the earlier ones are full; established connections never move. A loop at 85 to 90 % busy is a queue: requests wait for the loop, which is the 1.8x p99 against shared (where 16 loops share the load at 16 to 27 %). Under the throughput load the same happens one level up: the first three loops at the ceiling, the rest at about 50 to 70 %; the requests of the connections on the saturated loops are the slow ones (p99) and, with a closed-loop client, they also cap requests/s (0.92x to 0.96x).
- The placement is decided by the opening burst and then fixed for the run. Per-second
p99 of the config scenario, run 1 (
config-run1.persec.csv): fountain 11.4 to 13.2 ms in every second from the first one on, apart from the oha stall seconds and one second at 17.7 ms; shared 1.5 to 1.9 ms apart from the stall seconds. The Ingress events (every 2 s) do not show in either series; the level is set when oha opens its 256 connections at once at the start. That is why the fountain runs differ so much from each other (raw p99 of the three config runs 12.46, 11.47, 7.86 ms; churn 8.50, 16.88, 5.98 ms; latency about 2.5 ms per second in its run 1): the burst estimate (10 % per connection above the previous window’s count) decides how many loops get connections, and nothing evens it out later. The config and churn scenarios are not harder for fountain than the latency scenario; their runs drew a worse placement. - Fix candidates (not made here): for proxy workloads a much lower “full” threshold
(busy share around 50 %) or a cap on connections per loop while later loops are idle;
or moving idle keep-alive connections off a loop that stays above the threshold.
Until then
sharedis the right policy for ingress mode (previous section). The cap on connections per loop was added on 2026-10-15; its measurement is the first section of this report.
Headroom in ScalWS shared (not a loss against ingress-nginx; where the profiles say the next gains are):
- h1-small: 49.8 % of scalwsd’s samples are in the kernel, 45.3 % in scalwsd, 4.1 % in
libc.
writev(sending responses and upstream requests) is 37.5 % inclusive, of which about 24 points are the softirq that delivers the packet through the veth, thecni0bridge and bridge netfilter in the sender’s context (br_nf_pre_routing16.9 %,nf_hook_slow14.1 %,nft_do_chain4.0 %, all inclusive). That is the pod network path of flannel and kube-proxy rules, paid by every controller per packet. In user space no symbol is above 1.9 % (conn::drive1.90,route_and_handle1.63,PoolProxyHandler::forward1.25,Target::send1.13, mimallocmi_freeplus_mi_page_malloc_zero1.2): there is no single hot spot left, and fewer system calls or packets per request are the larger lever. - h1-100k: 68.9 % kernel.
rep_movs_alternative10.8 % is copying the body: out of the upstream socket (recvfrom,skb_copy_datagram_iter) and into the client socket.kernel_init_pages8.0 % is the kernel zeroing fresh pages for socket send buffers (tcp_sendmsg_locked->sk_page_frag_refill->alloc_pages->post_alloc_hook): Ubuntu’s kernel is built withCONFIG_INIT_ON_ALLOC_DEFAULT_ON=y, so every page TCP allocates for sent data is cleared first. Both costs are per byte relayed through user space and apply to nginx and Traefik as well; only a zero-copy relay (splice) would avoid them. - tls-h2: the 64 HTTP/2 connections landed on 11 of the 16 loops, 9 of them at the ceiling, 5 loops idle. The HTTP/2 figure (62389 req/s, 1.55x tuned ingress-nginx) is reached with 11 loops; with 64 long-lived connections the shared accept queue leaves part of the CPU unused (the first-come concentration ADR-0044 describes). The throughput scenarios with 256 connections show the same in milder form (one loop idle or at 6 % in h1-small and h1-100k).
User-space call chains in these profiles are not usable (scalwsd and libc are built without frame pointers; frames above the system call show up as unrelated libc symbols); the kernel chains and the flat symbol profile are.
Run spread and order
Within each pass the 3 runs of a throughput scenario are within 1 % of each other for every configuration except ScalWS fountain (h1-small 57495 to 61256, 6 %) and ScalWS shared tls-h2 (59935 to 63612, 6 %). Pass 2 ran the throughput scenarios again in reverse order (ingress-nginx tuned, ScalWS fountain, ScalWS shared); this also serves as the A/A check for the same image and values:
| scenario | controller | pass 1 req/s | pass 2 req/s | pass 2 / pass 1 |
|---|---|---|---|---|
| h1-small | ScalWS shared | 64273 | 64435 | 1.003 |
| h1-small | ScalWS fountain | 59839 | 59396 | 0.993 |
| h1-small | ingress-nginx tuned | 49938 | 49603 | 0.993 |
| h1-100k | ScalWS shared | 29043 | 28757 | 0.990 |
| h1-100k | ScalWS fountain | 27277 | 27826 | 1.020 |
| h1-100k | ingress-nginx tuned | 24311 | 24309 | 1.000 |
| tls-h1 | ScalWS shared | 61155 | 61498 | 1.006 |
| tls-h1 | ScalWS fountain | 57605 | 55707 | 0.967 |
| tls-h1 | ingress-nginx tuned | 45075 | 44586 | 0.989 |
| tls-h2 | ScalWS shared | 62389 | 61212 | 0.981 |
| tls-h2 | ScalWS fountain | 57373 | 56895 | 0.992 |
| tls-h2 | ingress-nginx tuned | 40241 | 40283 | 1.001 |
| tls-newconn | ScalWS shared | 13521 | 13524 | 1.000 |
| tls-newconn | ScalWS fountain | 13042 | 13037 | 1.000 |
| tls-newconn | ingress-nginx tuned | 5486 | 5469 | 0.997 |
So differences below about 2 % (4 % for fountain) are within the noise of this setup. The fixed-rate scenarios ran once per configuration (3 runs each); their run-to-run spread is in the per-run table above.
Round 2 limitations
Those of round 1 apply (one node, controller and backend on the same 16 CPUs, oha’s vector-growth stalls, self-signed RSA 2048, ingress-nginx retired upstream). In addition:
- “Tuned” is one operator’s reasonable global configuration, not a search for the best ingress-nginx configuration per scenario; the diagnostic run shows the global 64 KiB buffer costs it at small bodies, and the comparison against the best configuration per scenario is given next to it.
- ScalWS was not tuned beyond the accept policy.
- Traefik ran with its round 1 values in the new order; it repeats round 1 within 1 %.
- The oha SQLite files (2.9 GB) were reduced to per-second summaries
(
*.persec.csv: requests, p50, p99, p99.9 and max per second) after aggregation;aggregate.csvkeeps the clean figures computed from them.
Reproduce round 2
# on the bench host, as root, a checkout of 4823999 with these bench files
SCALWS_TAG=bench-4823999 bench/kubernetes/setup-host.sh # builds and imports the image
OUT=results/k8s-ingress-2026-10-14-r2 RATE=19000 \
ORDER="scalws ingress-nginx-tuned scalws-fountain ingress-nginx traefik" bench/kubernetes/run-all.sh
for c in ingress-nginx-tuned scalws-fountain scalws; do
OUT=results/k8s-ingress-2026-10-14-r2-pass2 SCENARIOS="h1-small h1-100k tls-h1 tls-h2 tls-newconn" bench/kubernetes/run.sh $c
done
OUT=results/k8s-ingress-2026-10-14-r2-diag SCENARIOS="h1-small h1-100k tls-h1 tls-h2" bench/kubernetes/run.sh ingress-nginx-keepalive
for c in scalws scalws-fountain; do
OUT=results/k8s-ingress-2026-10-14-r2-perf2 RATE=19000 SCENARIOS=perf \
PERF_SCENARIOS="latency h1-small h1-100k tls-h1 tls-h2" bench/kubernetes/run.sh $c
done
systemctl stop k3s; /usr/local/bin/k3s-killall.shRound 1: ScalWS 115f5f2 (history)
The first run, ScalWS at 115f5f2, all controllers with chart defaults. Raw results:
results/k8s-ingress-2026-10-14 (main run), ...-pass2 (throughput
repeated in reverse controller order), ...-probe (diagnostics). Findings 1 to 3 were
fixed afterwards (see “After fixes” and round 2). Numbers in the tables are written
without thousands separators like the round 2 tables; the text is the original.
Summary
| scenario | ScalWS | ingress-nginx | Traefik |
|---|---|---|---|
| 1. HTTP/1.1 keep-alive, 13 B (req/s) | 64338 | 58479 | 39623 |
| 2. HTTP/1.1 keep-alive, 100 KiB (req/s) | 28526 | 12374 | 19482 |
| 3a. HTTPS, HTTP/1.1, 13 B (req/s) | 61098 | 51518 | 36691 |
| 3b. HTTPS, HTTP/2, 13 B (req/s) | 61685 | 44867 (15695 errors) | 28064 |
| 4. HTTPS, new connection per request (req/s) | 13486 | 5532 | 6013 |
| 5. 19000 req/s: p50 / p99 clean / p99.9 clean (ms) | 0.56 / 1.55 / 2.15 | 0.69 / 2.17 / 2.82 | 0.76 / 3.21 / 5.92 |
| 6. backend 4 -> 8 -> 2 -> 4 at 19000 req/s: failed requests (3 runs) | 126 | 8357 | 14659 |
| 7. Ingress churn at 19000 req/s: failed requests (3 runs), p99 (ms) | 0, 1.58 | 889, 4.66 | 0, 3.37 |
| controller CPU per request, scenario 1 (us) | 169 | 207 | 330 |
| controller peak RSS, scenarios 1 / 2 (MiB) | 132 / 323 | 380 / 378 | 332 / 361 |
ScalWS was ahead in every scenario of this setup: 1.10x ingress-nginx in the tested plain HTTP/1.1 small-body workload up to 2.44x in the connection-per-request workload. The margins and the conditions behind them are discussed below; the most important caveats: one node with the controller and the backend sharing 16 CPUs, chart defaults everywhere (ingress-nginx’s defaults cost it in the 100 KiB and HTTP/2 scenarios: with a 64 KiB proxy buffer it reaches 84 % of ScalWS at 100 KiB, see “Diagnostics”), and one finding against ScalWS: with the chart’s default memory limit scalwsd was OOM-killed in the 100 KiB scenario (fixed since, see “After fixes”).
Setup
Machine: xeon, 2x Xeon E5-2650 v2 (8 cores, 16 threads each, no AVX2), 121 GB RAM,
performance governor, turbo off, Ubuntu 26.04, kernel 7.0.0-38-generic.
| version | |
|---|---|
| k3s | v1.36.5+k3s1 (containerd 2.3.4-k3s1.36, flannel CNI, Traefik and ServiceLB disabled), one node |
| ScalWS | chart deploy/helm/scalws 1.0.0-rc.1 at 115f5f2, image built from that tree with packaging/container/Dockerfile (scalws:bench-115f5f2, image id sha256:264b359b1242...) |
| ingress-nginx | chart 4.15.1, controller v1.15.1 (registry.k8s.io/ingress-nginx/controller@sha256:594ceea76b01c592858f803f9ff4d2cb40542cae2060410b2c95f75907d659e1) |
| Traefik | chart 41.7.1, Traefik v3.7.14 (docker.io/library/traefik@sha256:575fa15b135078fe5e50aa847987d96dbddd7b093c172429618404df73f3fa7c) |
| backend | nginx:1.27.5-alpine (sha256:65645c7bb6a0661892a8b03b89d0743208a18dd2f3f17a54ef4b76fb8e2f2a10) |
| load | oha 1.16.0, helm v3.16.4, OpenSSL 3.5.5 (diagnostics) |
Fairness choices
- One node, one controller at a time. Each controller is installed with helm into the
namespace
ingress, measured, and removed with its namespace and IngressClass before the next one. The backend Deployment is restarted for every controller. - CPU split. k3s, containerd and every pod (controller and backend) run on NUMA node 0
(CPUs 0-7,16-23): a
CPUAffinity=0-7 16-23drop-in fork3s.serviceandAllowedCPUs=0-7 16-23on the kubelet’skubepods.slice. The k3s drop-in alone did not reach the containers: attaching a task to a cgroup v2 cpuset resets its affinity to the cgroup’s CPUs (all 32), so the slice property is needed too; the kubelet creates the slice as a transient unit, so it is set at runtime (setup-host.sh, and again byrun.sh).environment.txtof every controller shows all controller and backend processes on 0-7,16-23. oha runs on node 1 (taskset -c 8-15,24-31). - Workers. No CPU limits. Every controller gets 16 workers: ingress-nginx
worker-processes: "16"(16 nginx workers seen), TraefikGOMAXPROCS=16, ScalWSserver.event_loops: 16(scalwsd showed 34 threads: 16 event loops plus helpers). The defaults would pick the same from the CPU affinity; they are set so it is explicit. - Controller and backend share node 0. The backend (4 replicas x 2 nginx workers) runs
on the same 16 CPUs as the controller. Requests/s is therefore the throughput of
“controller plus backend on 8 cores”; a controller that needs less CPU leaves more to the
backend. Controller CPU per request (pod cgroup
cpu.stat) is reported next to it and is the cleaner efficiency figure. - Traffic path. oha connects to the controller pod IP from the host (
--connect-to), so no Service, kube-proxy or NodePort path is involved and the path (host -> cni0 bridge -> veth) is the same for all three. hostNetwork was not used: the ScalWS chart has no option for it. - Same Ingresses.
manifests/ingress.yaml:bench.local(plain) andtls.bench.local(TLS from the Secretbench-tls, self-signed RSA 2048), path/Prefix to Servicebackend:80. OnlyingressClassNamediffers. No annotations. - Same backend. nginx,
/small13 bytes (return 200),/100ka 102 400-byte file, access log off, upstream keep-alive for 100 000 requests per connection. Itsstub_statusgives the upstream connections each controller opened (last column of the throughput table: every controller keeps upstream connections alive, 0.1 to 1 new connection per 1000 requests). - Settings changed from the chart defaults (all in
values/): one replica, ClusterIP Service, the worker counts above, access logs off for all three (ingress-nginxdisable-access-log: "true", ScalWSserver.logging.access_log: false; Traefik logs no access by default), and for ScalWS no memory limit (next point). Everything else is the chart default, including upstream keep-alive (ingress-nginx 320 idle connections per worker, Traefik 200 per host, ScalWS 32 per member, fixed by the controller), retries (ingress-nginx up to 3 tries on error or timeout with a 5 s connect timeout, ScalWS 1 retry, Traefik none) and ingress-nginx’skeep-alive-requests: 1000andproxy-buffering: off(both matter below). - ScalWS memory limit (finding 1, fixed in 5015c8b). The chart set
resources.limits.memory: 256Mifor scalwsd. In the 100 KiB scenario at 256 connections scalwsd was OOM-killed twice (oom-kill ... task=scalwsd, anon RSS 254 MB at the limit) and the run answered mostly “connection refused”. ingress-nginx and Traefik have no memory limit by default, so the bench values remove it for ScalWS (limits: null); peak RSS then was 323 MiB in that scenario. This is a chart default that breaks under ordinary load (100 KiB responses), not a bench artefact; see “Findings”. - Load. Throughput: 10 s warm-up, then 3 runs of 30 s, 256 requests in flight (HTTP/2:
64 connections x 4 streams), median and min to max. Fixed rate: 3 runs of 60 s at 19 000
req/s (half of the slowest controller’s scenario 1 median, rounded down to 1000), 256
connections,
--latency-correction(no coordinated omission), request timeout 10 s. oha’saborted due to deadline(requests in flight when a timed run ends, at most 256 per run) is not counted as an error.
Results
Throughput (scenarios 1 to 4)
Median of 3 runs. CPU and RSS are the controller pod (all its containers: for ScalWS scalwsd plus the scalws-ingress sidecar, for ingress-nginx the Go controller plus nginx). “oha cores” is the load generator’s CPU (16 available).
| scenario | controller | req/s | min to max | p50 ms | p99 ms | errors | ctrl CPU cores | ctrl CPU us/req | peak RSS MiB | oha cores | backend conns / 1k req |
|---|---|---|---|---|---|---|---|---|---|---|---|
| h1-small | ScalWS | 64338 | 63749 to 64946 | 2.74 | 17.25 | 0 | 10.4 | 168.6 | 132 | 3.9 | 0.17 |
| h1-small | ingress-nginx | 58479 | 58093 to 58534 | 3.36 | 17.36 | 0 | 11.7 | 206.5 | 380 | 3.6 | 0.17 |
| h1-small | Traefik | 39623 | 39583 to 39643 | 5.25 | 22.84 | 0 | 12.7 | 330.4 | 332 | 2.4 | 0.20 |
| h1-100k | ScalWS | 28526 | 28522 to 28772 | 6.55 | 33.83 | 0 | 9.7 | 344.9 | 323 | 4.2 | 0.41 |
| h1-100k | ingress-nginx | 12374 | 12349 to 12381 | 17.31 | 67.99 | 0 | 13.8 | 1126.1 | 378 | 7.5 | 0.71 |
| h1-100k | Traefik | 19482 | 19472 to 19502 | 10.09 | 50.67 | 0 | 12.3 | 641.4 | 361 | 4.2 | 0.58 |
| tls-h1 | ScalWS | 61098 | 60835 to 61313 | 3.02 | 16.96 | 0 | 10.8 | 183.7 | 291 | 4.3 | 0.14 |
| tls-h1 | ingress-nginx | 51518 | 51251 to 51700 | 3.82 | 19.96 | 0 | 12.0 | 240.3 | 443 | 3.7 | 0.23 |
| tls-h1 | Traefik | 36691 | 36670 to 36903 | 5.57 | 24.75 | 0 | 12.9 | 358.4 | 368 | 2.6 | 0.27 |
| tls-h2 | ScalWS | 61685 | 61505 to 62315 | 3.26 | 15.64 | 0 | 11.3 | 190.1 | 161 | 5.4 | 0.15 |
| tls-h2 | ingress-nginx | 44867 | 44790 to 44937 | 4.45 | 20.78 | 15695 | 11.8 | 273.5 | 467 | 5.9 | 0.29 |
| tls-h2 | Traefik | 28064 | 27936 to 28234 | 7.69 | 29.15 | 0 | 13.4 | 484.0 | 353 | 3.8 | 0.15 |
| tls-newconn | ScalWS | 13486 | 13465 to 13505 | 17.27 | 47.02 | 0 | 14.8 | 1109.1 | 155 | 14.1 | 0.55 |
| tls-newconn | ingress-nginx | 5532 | 5507 to 5541 | 35.01 | 197.23 | 0 | 14.4 | 2615.9 | 564 | 4.9 | 1.04 |
| tls-newconn | Traefik | 6013 | 5914 to 6067 | 26.61 | 167.24 | 0 | 15.1 | 2527.5 | 282 | 5.0 | 0.69 |
The spread between runs is below 2 % everywhere. No run answered anything but 200 apart from the ingress-nginx HTTP/2 errors (below). Peak RSS is the highest sample of the pod during the scenario; the pod is not restarted between throughput scenarios, so it carries the high-water mark of earlier scenarios (ScalWS 323 MiB in h1-100k, then 291 in tls-h1).
Pass 2 (...-pass2): the five throughput scenarios again, controller order reversed
(Traefik, ingress-nginx, ScalWS), same method. Medians in req/s, change against the table
above in brackets:
| scenario | ScalWS | ingress-nginx | Traefik |
|---|---|---|---|
| h1-small | 64572 (+0.4 %) | 57597 (-1.5 %) | 39679 (+0.1 %) |
| h1-100k | 28733 (+0.7 %) | 12434 (+0.5 %) | 19513 (+0.2 %) |
| tls-h1 | 60432 (-1.1 %) | 51126 (-0.8 %) | 36752 (+0.2 %) |
| tls-h2 | 63192 (+2.4 %) | 44755 (-0.2 %, 15715 errors) | 28062 (0.0 %) |
| tls-newconn | 13491 (0.0 %) | 5544 (+0.2 %) | 6088 (+1.2 %) |
Order has no visible effect; CPU per request repeats within 2 %.
Fixed rate (scenarios 5 to 7)
19 000 req/s, HTTP/1.1 keep-alive, /small, 3 runs of 60 s each, medians. “clean” leaves
out the first second (oha opens its 256 connections) and the seconds in which oha itself
stalls (see Limitations); the raw percentiles are given as well. “failed” is non-2xx plus
oha errors (timeouts, resets). “slow > 1 s” counts successful requests whose own round
trip took over 1 s.
| scenario | controller | p50 ms | p99 ms (raw) | p99.9 ms (raw) | p99 clean ms | p99.9 clean ms | worst 1 s p99 ms (per run) | non-2xx | errors | slow > 1 s | ctrl CPU cores | peak RSS MiB |
|---|---|---|---|---|---|---|---|---|---|---|---|---|
| latency | ScalWS | 0.56 | 1.89 | 29.47 | 1.55 | 2.15 | 1.8 / 1.8 / 1.9 | 0 | 0 | 19 | 2.5 | 114 |
| latency | ingress-nginx | 0.69 | 2.52 | 30.98 | 2.17 | 2.82 | 4.1 / 2.9 / 9.6 | 0 | 0 | 0 | 3.2 | 377 |
| latency | Traefik | 0.76 | 6.93 | 60.05 | 3.21 | 5.92 | 4.4 / 4.1 / 4.5 | 0 | 0 | 0 | 5.8 | 201 |
| churn | ScalWS | 0.59 | 2.42 | 38.56 | 1.73 | 3.58 | 4.3 / 3.7 / 3.3 | 126 | 0 | 0 | 2.5 | 123 |
| churn | ingress-nginx | 0.86 | 4587 | 4949 | 4647 | 4954 | 5001 / 5001 / 5001 | 8357 | 0 | 768 | 3.3 | 383 |
| churn | Traefik | 1.02 | 9656 | 9916 | 9706 | 9919 | 9928 / 9964 / 9935 | 13891 | 768 | 0 | 5.6 | 306 |
| config | ScalWS | 0.58 | 1.93 | 35.06 | 1.58 | 2.65 | 1.9 / 1.9 / 1.8 | 0 | 0 | 0 | 2.5 | 130 |
| config | ingress-nginx | 0.73 | 13.39 | 37.43 | 4.66 | 30.16 | 32.0 / 29.3 / 28.1 | 0 | 889 | 0 | 3.4 | 380 |
| config | Traefik | 0.75 | 5.94 | 51.13 | 3.37 | 6.64 | 12.1 / 12.3 / 4.3 | 0 | 0 | 0 | 5.8 | 305 |
Per run: churn non-2xx ScalWS 49 / 31 / 46 (502), ingress-nginx 3 018 / 2 718 / 2 621
(502 and 27 to 41 504 each), Traefik 4 901 / 4 531 / 4 459 (502) plus 256 timeouts each.
Config errors, ingress-nginx: 700 connection error, 97 connection closed before message completed, 92 operation was canceled, about 300 per run.
Controller CPU at the same 19 000 req/s: ScalWS 2.5 cores, ingress-nginx 3.2, Traefik 5.8.
Discussion
Plain HTTP/1.1, small body. ScalWS +10 % over ingress-nginx and +62 % over Traefik in requests/s, with 18 % less controller CPU per request than ingress-nginx and half of Traefik’s. The controller and backend together are what saturates node 0; oha used 4 of its 16 CPUs, so the client is not the limit.
100 KiB body. ScalWS 2.3x ingress-nginx and 1.5x Traefik. ingress-nginx used 13.8 cores
for 12 374 req/s, 1.1 ms of CPU per request against 0.34 ms for ScalWS. ingress-nginx ships
proxy-buffering: off with a 4 KiB proxy-buffer-size, so nginx relays a 100 KiB response
in 4 KiB reads and writes; the diagnostics confirm it: with proxy-buffer-size: 64k ingress-nginx reaches 24 092 req/s, 84 % of ScalWS. The table shows a default install;
against a tuned ingress-nginx the ScalWS lead at 100 KiB is about 1.2x, not 2.3x.
TLS, HTTP/1.1 and HTTP/2. ScalWS +19 % (HTTP/1.1) and +37 % (HTTP/2) over
ingress-nginx, +67 % and +120 % over Traefik. ScalWS loses almost nothing for TLS (64.3k
plain, 61.1k TLS) and its HTTP/2 is as fast as its HTTP/1.1. ingress-nginx answered about
0.4 % of the HTTP/2 requests with errors (http2 error, operation was canceled): its
default keep-alive-requests: 1000 closes an HTTP/2 connection with GOAWAY after 1000
requests, and oha fails the streams it had queued on it. With keep-alive-requests: 100000
the errors disappear and requests/s rise by about 3 % (diagnostics). Browsers retry such
streams, so the errors are partly a load-generator effect; the cost of the reconnects is
in the requests/s figure.
New connection per request. ScalWS 13 486 req/s, 2.4x ingress-nginx and 2.2x Traefik, with less than half of their CPU per request. All three resume TLS 1.3 sessions and negotiate X25519MLKEM768 with OpenSSL 3.5’s default client; the server-to-client bytes per oha connection (1 710 to 1 998) are close to a resumed handshake plus the response and well below a full one (about 2 400), so oha very likely resumes on all three and every connection pays the hybrid post-quantum key exchange. Treat the ScalWS figure as a lower bound: oha used 14.1 of its 16 CPUs there, and the controller 14.8 cores, so both sides were close to saturation.
Latency at a fixed rate. At 19 000 req/s ScalWS has the lowest p50, p99 and p99.9 (clean p99 1.55 ms, ingress-nginx 2.17, Traefik 3.21) and uses the least CPU. ScalWS had 19 responses slower than 1 s, all in the first 35 ms of one run and each 1.015 s: 19 of the 256 connections oha opens at the start took one SYN retransmission (1 s), so some connection attempts of that burst were dropped by scalwsd’s listener. The other two controllers had none. Small, but a real difference in the connection-burst path; not investigated further here.
Backend churn. This scenario separates the controllers most. The backend is scaled 4 -> 8 -> 2 -> 4; the backend pods have no preStop hook, so a removed pod stops right after SIGTERM (nginx graceful quit) while controllers may still send to it.
- ScalWS: 126 non-2xx in 3 runs (0.004 %), p99 unchanged (1.73 ms clean), worst second
4.3 ms. The 502s are at the scale-down: the scalwsd log shows
upstream request failed ... Connection reset by peerfrom pods being removed, on reused keep-alive connections. The single retry did not save those requests (it was never made: see “After fixes”). - ingress-nginx: about 2 800 502s and 30 504s per run, and 256 requests per run that took 5 s (the default 5 s proxy connect timeout to a pod that is gone, then a retry). p99 is 4.6 s because oha’s 256 connections all end up waiting on such requests at the scale-down, and requests scheduled behind them queue (latency correction counts that).
- Traefik: about 4 600 502s per run and 256 requests that hit oha’s 10 s timeout: Traefik has no retry and its dial timeout (30 s) is longer than oha’s request timeout. p99 9.7 s for the same reason as above.
The p99 figures of ingress-nginx and Traefik are shaped by oha’s fixed connection count (a blocked connection blocks its queue); the counts of failed and slow requests are the robust figures. With more clients, more requests would wait.
Ingress churn. An unrelated Ingress is added or removed every 2 s and once a path is added to the measured host. ScalWS and Traefik: no failed request, p99 unchanged (ScalWS 1.58 ms against 1.55 quiet; Traefik 3.37 against 3.21). ingress-nginx: about 300 failed requests per run and clean p99 4.66 ms against 2.17 quiet: each new or removed host is an nginx reload, the old workers close their keep-alive client connections, and requests sent on them at that moment fail. ScalWS also reloads for a new host (the structure channel); its reload evidently keeps the established client connections serving.
Memory. ScalWS has the smallest footprint at small bodies (114 to 132 MiB against about 380 for ingress-nginx and 200 to 330 for Traefik) but reached 323 MiB in the 100 KiB scenario, about the level of the others. That figure includes the scalws-ingress sidecar.
Findings
- Fixed in 5015c8b: chart default memory limit too low (ScalWS, chart).
resources.limits.memory: 256Miindeploy/helm/scalws/values.yamlgets scalwsd OOM-killed when proxying 100 KiB responses at 256 concurrent connections (peak RSS without the limit: 323 MiB). A controller that restarts under ordinary load is a production outage. Options: raise or drop the default limit in ingress mode, and/or find out why scalwsd needs several hundred KiB per in-flight 100 KiB response (allocator retention per event loop is a candidate). Not changed here; the bench values work around it. Fix: no memory limit by default, request 128Mi (see “After fixes”). - Fixed in 0358812: 502 on reset keep-alive upstream connections at pod removal (ScalWS). About 40 per scale-down. A retry of idempotent requests that failed on a reused connection before any response byte would likely remove them. Fix: such requests are now retried on another member (idempotent methods only); 15 instead of 126 in three runs (see “After fixes”).
- Fixed in 2df2cba: listener drops in a connection burst (ScalWS). 19 of 256
simultaneous connection attempts needed a SYN retransmission in one of three runs.
Cause: the shared listener socket was created by std’s
TcpListener::bind, which listens with a backlog of 128; it now uses 4096 like the reuseport sockets. - Upstream keep-alive is not tunable in ingress mode (ScalWS). The controller writes
keepalive: 32into every pool; ingress-nginx exposesupstream-keepalive-connections. It did not matter here (0.17 to 0.55 new upstream connections per 1000 requests). - Traefik chart value names.
service.typeis ignored by Traefik chart 41.x (service.spec.typeis the key) and its pods are labelledapp.kubernetes.io/instance=<release>-<namespace>; only relevant to the harness.
Diagnostics (not part of the measurement)
run.sh scenario probe, after the measured runs, results/k8s-ingress-2026-10-14-probe:
| check | ScalWS | ingress-nginx | Traefik |
|---|---|---|---|
| TLS 1.3 group with OpenSSL 3.5 defaults | X25519MLKEM768 | X25519MLKEM768 | X25519MLKEM768 |
| cipher | AES-256-GCM | AES-256-GCM | AES-128-GCM |
openssl s_client -sess_in resumes | yes | yes | yes |
| server bytes per oha connection (connection per request) | 1998 | 1711 | 1776 |
s_client server bytes, full / resumed handshake | 2403 / 1307 | 2469 / 1333 | 2453 / 1313 |
ingress-nginx, 10 s runs on a fresh install, changes applied one after the other with
kubectl patch of the controller ConfigMap:
| run | req/s | errors |
|---|---|---|
tls-h2, defaults (keep-alive-requests: 1000) | 50494 | 453 http2 error, 1431 operation was canceled |
tls-h2, keep-alive-requests: 100000 | 52035 | none |
h1-100k, defaults (proxy-buffering: off, 4k buffer) | 12478 | none |
h1-100k, proxy-buffer-size: 64k | 24092 | none |
h1-100k, then proxy-buffering: on | 22383 | none |
So a 64 KiB proxy buffer nearly doubles ingress-nginx in the 100 KiB scenario, to 84 % of ScalWS’s 28 526 (and CPU per request drops by about half). The ScalWS lead in that scenario is mostly an ingress-nginx default, not a data-plane difference of 2.3x. The tls-h2 figure of these short runs (50 to 52k) is higher than in the measured sequence (44.9k); the probe runs are 10 s without warm-up on a fresh pod, and the difference is not explained here, so the measured 44.9k may understate ingress-nginx’s HTTP/2 by up to about 15 %.
Limitations
- One node, one machine. Controller, backend and load generator share one host (two NUMA nodes, the load on the other node). No network card, no kube-proxy path, one controller replica. Multi-node behaviour, endpoint propagation across nodes and real NIC interrupts are not covered.
- Controller and backend share 16 CPUs, so requests/s mixes controller and backend cost; the CPU-per-request column isolates the controller.
- Defaults. Each controller runs its chart defaults except the settings listed above.
A tuned ingress-nginx (proxy buffers,
keep-alive-requests) or Traefik (retries, timeouts) would do better in the scenarios that hurt them; ScalWS was not tuned either. - oha artefacts. oha keeps every result in a growing vector; when it doubles (at 2^17, 2^18, … results, at 19 000 req/s around 7, 14, 28 and 55 s) the client stalls for 30 to 270 ms and every controller shows a latency spike in that second. The raw p99.9 values carry it for all three controllers alike; the “clean” columns leave those seconds out. In the connection-per-request scenario oha itself was close to its CPU limit with ScalWS.
- Self-signed RSA 2048 certificate, TLS 1.3 only in practice, cipher chosen by each server (ScalWS and ingress-nginx AES-256-GCM, Traefik AES-128-GCM).
- ingress-nginx is retired upstream (the Kubernetes project ended its maintenance in 2026), but it is still the most widely deployed controller and the one ScalWS’s annotation support targets, which is why it is the reference here.
- Single day, single host; the throughput scenarios were repeated in reverse controller order (pass 2) to check for order effects, the fixed-rate scenarios once.
Reproduce
# on the bench host, as root, in the repository checkout
bench/kubernetes/setup-host.sh
bench/kubernetes/run-all.sh # throughput, then fixed rate at half the slowest
SCENARIOS=probe bench/kubernetes/run.sh scalws # diagnostics, per controller
systemctl stop k3s; /usr/local/bin/k3s-killall.shAfter fixes: ScalWS d14639f (history)
Commits on top of 115f5f2: 0358812 (pool retry), 2df2cba (listen backlog), 5015c8b
(chart default), d14639f (test formatting only). Image scalws:bench-d14639f, ScalWS
only, same host, harness and method as above; the ScalWS values now use the chart’s
resources (no memory limit). Raw results, in the bench host’s results directory: results/k8s-ingress-fixes.
Finding 2, root cause. In a pool the outcome of an attempt whose connection broke
after the request was written (Attempt::Broken, here Connection reset by peer on a
reused keep-alive connection to a pod that is shutting down) was never retried, for any
method; only connect failures, timeouts and 502 to 504 answers were. The “single retry”
never happened for these requests. The connection-level replay in the HTTP/1 client only
covers requests hyper had not written yet and bodyless requests on connections idle for at
least 1 s, which is never the case at 19 000 req/s. Now a broken attempt is retried on
another member for idempotent methods (as nginx’s proxy_next_upstream error does); POST
and PATCH are still not repeated once written. Removing a member through the admin API
already closed its idle connections (the member, its connection pool and the idle
connections are dropped with the old member set); a test now pins that. Tests:
reset_on_a_reused_connection_is_retried_for_get_on_another_member (failed before the
fix with a 502, passes after), reset_on_a_reused_connection_is_not_retried_for_post_once_written,
removed_members_close_their_idle_connections, balance::tests::retry_rules.
Finding 3, root cause. The default listener mode shares one socket across the event
loops; it was created with std’s TcpListener::bind, which listens with a backlog of 128
(the reuseport sockets already used 4096). 256 clients connecting at once overflowed the
accept queue and some retransmitted their SYN after 1 s. The socket now listens with
4096 (capped by net.core.somaxconn, 4096 by default in a pod’s network namespace;
ingress-nginx uses net.core.somaxconn, plain nginx 511). Test:
cores::tests::shared_listener_queues_a_connection_burst (129 of 300 connections before,
300 after).
Finding 1. The chart sets no memory limit by default (as the ingress-nginx and Traefik charts) and requests 128Mi. Memory under the 100 KiB load is per connection, not a leak or whole-response buffering by design: the response body is streamed frame by frame, but the client-side write queue holds up to 256 KiB per connection (more than a 100 KiB body) and each chunk read by the upstream HTTP/1 client keeps its read buffer allocation alive (hyper’s adaptive read buffer grows to a few hundred KiB on fast reads) until it is written to the client. That gives about 0.8 MiB per in-flight 100 KiB response at 256 connections over the baseline of about 110 MiB, in line with ingress-nginx and Traefik (361 to 378 MiB). Lowering it (a smaller write high-water mark or upstream read buffer) trades throughput and was not changed here.
| scenario | before (115f5f2) | after (d14639f) |
|---|---|---|
| h1-small req/s (median, min to max) | 64338 | 65206 (64751 to 65777) |
| h1-small peak RSS MiB | 132 | 130 |
| h1-100k req/s | 28526 (no memory limit set by the bench values) | 28980 (28852 to 29068), chart default, no restart |
| h1-100k peak RSS MiB | 323 | 332 |
| latency: p99 clean / p99.9 clean ms | 1.55 / 2.15 | 1.59 / 2.12 |
| latency: requests slower than 1 s (3 runs) | 19 (one run) | 0 |
| churn: non-2xx per run | 49 / 31 / 46 (126) | 8 / 3 / 4 (15) |
| churn: p99 clean / p99.9 clean ms, worst 1 s p99 | 1.73 / 3.58, 4.3 / 3.7 / 3.3 | 1.71 / 3.57, 3.9 / 3.6 / 3.6 |
The 15 remaining 502s all come from the 8 -> 2 step (25 s into each run), where 6 of 8
pods stop at once. The scalwsd log line of each is the last attempt, the retry, and it
again ended in Connection reset by peer: the retry went to another pod of the same
scale-down (6 of the 7 other members were leaving). More retries (the pool’s retries, up to 5; the controller writes 1) or a preStop
delay on the backend pods remove these; neither was changed here. The 1 s SYN
retransmissions of finding 3 occurred in one run of three originally, so “0 in 3 runs” is
consistent with the fix but does not prove it alone; the unit test does.
Core regression gate (bench/multi.sh, MODE=release, 5 runs, base = 115f5f2, new =
d14639f, both cargo build --release --locked -p scalwsd --features http3, k3s stopped;
results/xeon-ingress-fixes-ab). The absolute figures are lower than in
2026-10-14-xeon-ingress-core-regression.md for both binaries alike; only the ratio
counts:
| scenario | base req/s | new req/s | new / base | cpu_us base | cpu_us new | gate (>= 0.98) |
|---|---|---|---|---|---|---|
| static-small | 68479 | 68163 | 0.995 | 58.40 | 58.67 | pass |
| respond | 108901 | 106949 | 0.982 | 36.72 | 37.40 | pass |
| proxy | 45664 | 45481 | 0.996 | 87.59 | 87.94 | pass |
| cache-hit | 84544 | 83385 | 0.986 | 47.30 | 47.96 | pass |
CV of the runs 0.4 to 1.1 %. None of the changes is on the path of these scenarios (the pool retry rule is evaluated after an upstream attempt, the backlog is set once at bind), so the differences are within the 1 to 2 % that A/A runs of the same binary show.