Hive
feat(kura): serve HTTP and REAPI gRPC on a single co-hosted listener
GitHub issue · Closed
What & why
Kura used to serve its HTTP cache API and its REAPI gRPC surface on two separate listeners (container ports 4000 and 50051). That’s invisible behind the public ingress, where nginx co-hosts both protocols on :443 via path-based backend routing — but the private runner-cache data plane (NodePort / cluster-DNS) bypasses nginx and exposes the raw ports, while dispatch hands a runner a single cache endpoint (TUIST_CACHE_ENDPOINT). tuist bazel setup derives its gRPC target straight from that URL’s host:port, so its REAPI GetCapabilities handshake landed on the plain-HTTP listener and returned “Unexpected non-200 HTTP Status Code” → .bazelrc.tuist was never written → runner builds fell back to a cold cache.
This PR makes “one endpoint speaks both protocols” the only model Kura has, in the runtime and in every deployment surface:
KURA_PORT(plaintext, default 4000) — one listener serving the HTTP cache API and h2c REAPI gRPC, dispatched by path.KURA_HTTPS_PORT(default 4443, bound only whenKURA_PUBLIC_TLS_CERT_PATH/KURA_PUBLIC_TLS_KEY_PATHare set) — the same co-hosted surface over TLS, ALPN-negotiated (h2for gRPC,http/1.1for HTTP).- Removed: the dedicated gRPC listener,
KURA_GRPC_PORTandKURA_GRPC_TLS_CERT_PATH/KURA_GRPC_TLS_KEY_PATHas runtime config, and port 50051 everywhere in the deployment wiring (Services, NodePorts, NetworkPolicies, firewall carve-outs, tailscale ACLs, docs).
We chose in-process co-hosting over the alternatives (an nginx sidecar per Kura pod, or a Cilium CiliumEnvoyConfig L7 route) because it keeps Kura’s zero-copy artifact-serving path in the data plane — no extra proxy hop — and because HTTP and gRPC are equally important here, so neither should pay a proxy tax.
How it works (runtime)
- Routing by path. tonic 0.14’s
Routesis itself anaxum::Routermounting each gRPC service at/{service}/{*rest}; those paths never collide with the HTTP cache routes, socohosted_router()merges the REAPI routes intopublic_router. The gRPC request-accounting layer rides along, so gRPC shows up in inflight/latency metrics and counts toward the shutdown drain. - Fallback semantics correct for both protocols. tonic’s router carries a fallback answering any unmatched path with HTTP 200 +
grpc-status: Unimplemented, andRouter::mergeadopts it — which would leak 200s onto the plain-HTTP surface (/_internal/statuson the public port must 404; the peer-mTLS e2e spec asserts it). The merged router overrides the fallback with a protocol-aware one:application/grpc*requests get tonic’s exact Unimplemented response, everything else gets 404. - No HTTP performance loss. The plaintext listener runs through
accelerated_file_serving::serve_public_http, so HTTP/1 artifact GETs keep the sendfile/splice kernel zero-copy fast path.classify_routeonly accelerates HTTP/1.1 GETs on Linux; gRPC (h2c POST), HTTP/2, and TLS fall through to hyper. - No gRPC performance loss. The hyper builder advertises fixed REAPI-sized HTTP/2 windows (4 MiB stream / 16 MiB connection). Fixed, never adaptive, deliberately: hyper’s
adaptive_windowoverrides the fixed windows and ramps a single stream from ~64 KiB via BDP estimation, which halves lone REAPI uploads under WAN latency (measured 9.84 vs 20.65 MB/s at 100 ms RTT; fixed windows measure at parity, 21.43 vs 20.30 MB/s). - gRPC-grade connection behavior on the shared transport. Every accept path sets
TCP_NODELAY(chatty unary REAPI calls likeFindMissingBlobsare latency-bound; Nagle + delayed ACK stalls them). The hyper path recycles connections afterCONNECTION_MAX_AGE(300s) with a GOAWAY and a 900s grace for in-flight streams — without recycling, a long-lived Bazel channel pins to a demoted-but-alive NodePort primary indefinitely after failover. Draining sends the same GOAWAY to established HTTP/2 connections and stops HTTP/1 keep-alive reuse, so rolling deploys hand connections off gracefully inside the drain budget.
Deployment wiring (hard cutover, by design)
Every deployment surface knows only the single co-hosted port:
- kura-controller renders one
httpcontainerPort, onehttpService/NodePort port.status.nodePortGRPCis gone from the CRD (dispatch only ever consumednodePortHTTP— a single URL is exactly what the co-hosted listener makes correct). The public gRPC Ingress remains but backends thehttpService port: it exists solely so ingress-nginx renders the REAPI path prefixes withgrpc_pass(backend-protocol: GRPC) instead ofproxy_pass; publicgrpcs://dial addresses don’t change. - One transitional exception: the controller still sets
KURA_GRPC_PORT=50051in the pod env. Images at or below the last dedicated-listener release hard-require it and would crash-loop without it — taking the HTTP cache down, not just gRPC — the moment the controller rewrites their pod template on its reconcile loop. With the env present, a still-pinned instance starts cleanly and only loses in-cluster gRPC reachability (no Service port routes there), which is the accepted blast radius: Kura is a cache and Tuist is its only operator. Co-hosted images ignore the variable; a follow-up removes it once the fleet is fully past the floor. - Standalone Helm chart: single
httpport through statefulset/services/ingress;service.grpcPortvalue removed; the chart requires a co-hosted Kura image (releases afterkura@0.10.15). - Network surfaces (runner-namespace NetworkPolicy, macOS VM-egress pf rules, tailscale ACL grants, capi provider flag): carve-outs are port 4000 only.
- Self-host: compose,
.env.example, and guides configure one cache port; the TLS table documentsKURA_PUBLIC_TLS_*/KURA_HTTPS_PORT. - Rollout sequencing: a controller deploy paired with a pre-cohosted runtime image (or the reverse) leaves gRPC unreachable until both sides land — accepted for a cache. Staging deploys both from the same commit; for canary/prod, cut the kura release together with the server release.
User / developer impact
- Runner-cache builds (
tuist bazel setupbehindTUIST_CACHE_ENDPOINT) get a working REAPI target from the single URL — the original bug. - Public CLI/Bazel users keep their dial addresses: same host,
:443, same TLS; only the ingress→pod hop changes. - Self-hosters upgrading past
kura@0.10.15must dropKURA_GRPC_PORT/KURA_GRPC_TLS_*and point gRPC clients at the cache port (4000 by default).
Validation
- Unit: 279 Rust lib tests. Co-hosted-listener tests for plaintext and TLS (self-signed cert through the real
build_public_rustls_configpath) assert HTTP/up, REAPIGetCapabilitiesover h2c, 404 for unmatched HTTP paths, andgrpc-status: Unimplementedfor unknown gRPC services; a transport test drives a live h2c connection through a drain and asserts both ends observe a clean GOAWAY close.cargo clippy -D warningsand the Bazel gates (mise run clippy,mise run test-unit) pass. - E2E (shellspec, all through the co-hosted
kura:4000):clients_spec(Bazel remote cache + Buck2 REAPI),accelerated_serving_spec(sendfile fast path),mtls_spec(internal-endpoint 404 + peer replication),rollout_spec(drain cycle + writer fencing) — all green. - Throughput harness (
test/e2e/grpc-upload-throughput, toxiproxy @ 100 ms RTT, 16 MB): direct co-hosted uploads at parity with a dedicated tonic listener; nginx window comparison unchanged. - Deployment: kura-controller
go test/vet/gofmt(incl. the transitional-env assertion),helm lint+ template for both charts, full kura chart render passes a server-side dry-run against a real apiserver, macos-host-bootstrap and capi provider builds/tests,acls.jsonparses, repo-wide50051/KURA_GRPCsweep leaves only the transitional env var and its docs/tests. - README,
docs/architecture.md, andinfra/kura-controller/AGENTS.mdupdated to the final model.
🤖 Generated with Claude Code
Investigate, reproduce, or fix this item in an isolated repository. Each Flight preserves its outcome and agent session.
Start a Flight and preserve its objective, outcome, and session here.
No GitHub comments yet.