I’ve been building an ingress controller in Rust and would like feedback from people who operate ingress at scale. It implements the same Ingress API surface as ingress-nginx (same path semantics, same canary annotations), but the design starts from a different premise.
The premise: in ingress-nginx, a change to an Ingress spec regenerates nginx.conf and reloads workers. That reload costs you connections, and the cost scales with how much traffic you’re carrying — which is backwards. Here, the controller compiles an immutable route table and publishes it by storing one pointer (ArcSwap). Readers do a single atomic load per request. There is no reload, no drain, no config file. Load-balancer counters deliberately live outside the table, so a config change doesn’t make backends forget what they’re currently serving.
What that buys, measured
Single-node k0s on an EC2 t3.xlarge, both controllers installed in the same cluster, load generated on-box. Read the caveats below before quoting any of this.
Configuration churn — one Ingress spec change every 2 seconds for 120 seconds, under constant load, with 100 idle keep-alive connections held open:
| keep-alives survived | HTTP errors | reloads | |
|---|---|---|---|
| ramjet-ingress | 100 / 100 | 0 | n/a (90 table publishes) |
| ingress-nginx | 0 / 100 | 1,829 | 66 |
Reproduced twice independently, and on a different kernel/distro than an earlier run. Credit where due: under endpoint-only churn ingress-nginx reloads zero times (its Lua balancer handles that path) and also keeps 100/100 — so this is specifically about Ingress spec changes.
Steady state, c64, median of 3 interleaved 30s runs:
| RPS | CPU per request | RSS | |
|---|---|---|---|
| ramjet (io_uring engine) | 12,355 | 69 µs | 15.2 MiB |
| ramjet (default engine) | 10,858 | 100 µs | 10.5 MiB |
| ingress-nginx | 7,971 | 187 µs | 67.6 MiB |
| no proxy (baseline) | 22,234 | — | — |
Time from kubectl apply to the route serving traffic: 372 ms vs 2,762 ms (median, 10 trials).
Caveats, because they matter: one 4-vCPU burstable box with the load generator sharing those cores, so nobody was measured in isolation and the engine margins are compressed. ingress-nginx ran at chart defaults with access logging disabled (a choice that favours it) and no memory limit, while ramjet ran under its chart’s 256Mi cap. Full methodology, raw JSON, and the harness are in the repo — including two claims from an earlier run that this one retracted after arm-order rotation showed they were measurement artifacts.
Features that came out of the immutable-snapshot design
- Instant rollback.
POST /admin/rollback {"generation": 41}republishes an earlier table and freezes publication;DELETEresumes. Sub-millisecond, no restart. The controller keeps watching and recording while pinned. - Semantic audit trail. Every publish emits a structured diff — routes added/removed, backends changed with endpoint deltas, certs rotated — as logs, Kubernetes Events, and an optional webhook. “Why did traffic shift at 14:32” is answerable.
- Canary auto-promotion, built in. Annotate a canary Ingress and it ramps 5→10→25→50→100 on healthy windows, holds when there isn’t enough traffic to judge, and rolls back to weight 0 on an error-rate or latency breach, writing the reason onto the object. No extra component to install.
- Traffic mirroring. Shadow live traffic to another Service, fire-and-forget, with a hard invariant that the primary is never slowed — verified with a dead shadow backend (40/40 served, no added latency).
- Two data planes, one binary. The default is hyper/tokio.
--engine uringruns a completion-based reactor (io_uringon Linux,kqueueon BSD). One:443listener serves both: the ClientHello is inspected before a config is chosen, and HTTP/2 clients are handed to the other engine mid-connection with their bytes intact. - HTTP/3 over QUIC, experimental and off by default.
- Per-route live stats over a JSON admin API, plus
ramjet-top— a terminal cockpit showing per-route RPS, error rates, latency and the generation timeline.
What it does not do yet
- No leader election. The chart hard-codes
replicas: 1. Multiple replicas would fight over Ingress status writes. This is the biggest gap. - gRPC works via
backend-protocol: GRPC(h2c to the pod), but GRPCS and TLS-to-upstream are not implemented. io_uringis usually blocked in practice. containerd’s default seccomp profile deniesio_uring_setup, so on a stock cluster--engine uringlogs a warning and falls back to the default engine. Turning it on meansseccompProfile: Unconfined— a real security tradeoff, and the reason the default engine is the one that ships enabled.- An idle keep-alive connection costs ~20 KiB against nginx’s ~4.4 KiB. Attributed and documented; ~16 KiB of it is a floor in the HTTP library’s buffer sizing.
- The
io_uringengine wins throughput and median latency but consistently loses p99 by ~7%. Unexplained, so no tail-latency claims are being made for it.
Alpha. It passes 1,250 tests, runs a full e2e against a real cluster, and is serving real traffic behind Cloudflare on my test box — but it hasn’t been through anyone else’s production.
- Repo: GitHub - rowbench/ramjet-ingress · GitHub
- Docs: Introduction - ramjet-ingress
- Image:
sofelia/ramjet-ingress - License: MIT OR Apache-2.0
What I’d most like feedback on: is the zero-reload property something that has actually bitten you in production, or is it a problem people have already engineered around? And for anyone running ingress on bare metal — is hostNetwork on :80/:443 the right default, or do you expect NodePort?