Spent some time on this against the source. Here’s what I propose, plus the three alternatives I discarded so we don’t relitigate them later.
What I propose
Replace the regional gateway with HAProxy and enforce the per-tenant ceiling there, using a shared bwlim-out filter keyed by the tenant hostname.
Why this one:
- It’s a true aggregate. One limiter per tenant across every connection, so it holds under concurrency. That’s the property path A has and path B doesn’t, it’s the decisive test in the description, and nothing else on the table passes it at comparable cost.
- It covers both protocols. HTTP and REAPI/ByteStream both traverse the gateway, so there’s no protocol-shaped gap.
- It survives replica handover. The gateway is the aggregation point, so the two-replica overlap during primary handover doesn’t turn into two independent buckets.
- Tenant attribution already exists. Each hostname resolves to exactly one backend and each cert carries only that hostname, so keying on it is safe and needs no new identity plumbing.
- It’s the only option with a credible path to real wire-level enforcement. HAProxy’s
set-fc-marksets the mark on packets sent to the client from an L7 expression, which is the prerequisite for a classful qdisc keyed by tenant. Nothing else gets us there.
The cost should be priced up front: this is a gateway migration on four regions, including the regex path split for /google\.bytestream\. and the REAPI prefixes, the backend-protocol split, and the HTTP/2 window and timeout tuning. On why nginx can’t stay: there’s no standard directive that sets SO_MARK on the downstream socket from request context (proxy_bind transparent marks the upstream connection, which is the wrong socket), so keeping it would mean a custom dynamic module and a patched image, and permanent coupling to nginx internals.
What this deliberately does not cover: TLS overhead, TCP retransmissions, bytes already queued in socket buffers, replication, and anything else on the box. It’s a forwarded-data ceiling, not a physical one, and we shouldn’t describe it as the latter.
If that residual turns out to matter, the follow-on is set-fc-mark plus a classful HTB tree at the physical interface, with Cilium’s bandwidth manager disabled on these pools rather than coexisting with it. Since all customer traffic already goes through the host-network proxy, pod-level pacing contributes nothing to the customer read path today, so we lose almost nothing and we remove an ownership conflict that would otherwise silently revert on a Cilium upgrade. It’s enabled globally in infra/k8s/mgmt/bootstrap/cilium-values.yaml, so it needs a CiliumNodeConfig override plus an agent restart, the tree needs a single privileged DaemonSet as its sole owner rather than being hand-managed, and it’s worth benchmarking whether one classful root goes CPU bound at 3 Gbit/s before committing. I’d make that its own decision once we have the observability to tell whether it’s needed.
What I discarded
limit_rate on the generated ingress (option 1 in the description). It’s a no-op as currently configured, not merely weak. The ingress-nginx limit-rate annotation requires response buffering, and streamingIngressAnnotations sets proxy-buffering: off on both generated ingresses (infra/kura-controller/controllers/kurainstance_controller.go:1949). nginx’s non-buffered upstream path disables limit_rate, and grpc_pass is non-buffered by nature, so the directive does nothing on either location. Enabling buffering would make the HTTP side active but it stays per request, so eight streams get eight limits, which is the path B behaviour we’re trying to remove. It would also put buffering back into a streaming path we unbuffered on purpose.
An aggregate token bucket inside Kura. This was my first instinct and I wanted it to win: instance identity is tenant identity, it’s topology invariant, and it would apply equally on the NodePort and ClusterIP regions. It doesn’t hold up, for reasons that aren’t fixable by implementing it well:
- Kura counts a payload byte once. Under loss the wire carries it repeatedly, with no fixed payload-to-wire multiplier, so a payload counter can’t make a claim about a physical NIC.
- Kura can write into gateway and kernel buffers while the client-facing path is stalled. On recovery the gateway releases the accumulated backlog, which is bounded by memory and connection count rather than by the configured bucket depth, and grows with concurrency.
- There isn’t one write path. Ordinary HTTP, gRPC ByteStream, and the accelerated HTTP/1
sendfile/splicetransfers are separate implementations (kura/src/accelerated_file_serving.rs,kura/src/app.rs:361), so a response-body wrapper misses the fast path by construction. - An instance is two replicas. During primary handover, established connections drain on the old primary while new ones reach the new one, so two process-local buckets permit roughly 2C.
If anyone reaches for this anyway, don’t reuse BandwidthLimiter as is: acquire advances next_available before it sleeps (kura/src/bandwidth.rs:46), so a cancelled request leaves reservation debt that stalls later traffic.
Bypassing the host-network proxy for the data plane (option 3 in the description). Moving TLS into Kura disables the sendfile accelerator (kura/src/accelerated_file_serving.rs:296), though a TLS sidecar sharing the pod’s network namespace would avoid that specific problem. The real blocker is everything else: per-instance public addressing on OVH and Dedibox, DNS lifecycle, and the per-replica aggregation problem coming straight back. Largest change of the three for the smallest marginal gain over the proposal above.
Related things I found while digging
- The peer plane has the same bypass.
peer_demux_controller.goruns a host-network L4 SNI-passthrough demux on :7443, so bootstrap responses take an equivalent local-proxy path. Any fix that only covers :443 leaves this open. - Kura’s replication limiter can’t protect the NIC either. The default is 512 MiB/s (
kura/src/config.rs:97), roughly 4.3 Gbit/s, above both the ~1 Gbit and ~3 Gbit interfaces. - The 25 Mbit enterprise floor is requested per replica, so a two-replica instance consumes two reservations in the scheduler’s bin-packing. Separate bug, worth fixing before we add real floors.
- The observability gap has a specific address:
infra/helm/k8s-monitoring/values.yamlsetsuseDefaultAllowList: falsewith an explicit metric list carrying no TCP retransmission and no disk-operation series.
Sequencing
- Observability first. Gateway metrics and access logs, TCP retransmit and disk metrics, an external synthetic probe. Right now we can’t tell whether a fix works or regresses.
- HAProxy with shared
bwlim-outon one regional gateway, deployed alongside nginx on a test hostname before any cutover. - Roll to the remaining regions.
- Kernel-level enforcement (marking plus HTB, Cilium BWM off) as a separate decision, informed by what step 1 shows.
- Floors last, after the per-replica double-count is fixed.
Acceptance has to measure at the physical interface over short intervals, not as a whole-run client-side average, and it has to be rerun after restarting the gateway, Cilium and the node. The failure mode here wasn’t a wrong value, it was a correct configuration that traffic stopped traversing, so the test needs to be shaped to catch exactly that. Alerting should cover both halves: the configuration disappeared, and the configuration is still present but bytes are landing outside it.
The thing that shouldn’t survive in any case is the current state, where egress_burst_mbps reads as an enforced ceiling and enforces nothing on four regions.