Scenario 06 · gRPC routing

"Canary one RPC — not the whole service."

gRPC is method-level RPC over HTTP/2, and its status rides in trailers. Forcing it through a path-prefix HTTPRoute throws away method granularity and risks an ingress that downgrades HTTP/2 or mangles trailers. Native GRPCRoute routes by service and method, splits traffic per method, and keeps HTTP/2 and grpc-status intact end to end.

Architecture

Match the method, split the traffic

gRPC client HTTP/2 · POST pay.v1.Payments/Charge ZenoIngress · GRPCRoute ① method match type: Exact pay.v1.Payments/Charge ② weighted split 90 / 10 · per method HTTP/2 to backend payments-stable h2 · :50051 Charge 90% · Refund 100% payments-canary h2 · :50051 · Charge 10% HTTP/2 90% 10%

A call to pay.v1.Payments/Charge is an HTTP/2 POST to /pay.v1.Payments/Charge. GRPCRoute matches it by exact service + method and weight-splits it — while Refund stays entirely on stable. Trailers, including grpc-status, pass through untouched.

The setup

Route by service and method — and canary one of them

A single GRPCRoute carries two rules: send 10% of the Charge method to a canary backend, and keep Refund at 100% on stable. No URL-path guesswork — the match is the gRPC service and method:

grpcroute.yaml gateway.networking.k8s.io/v1
apiVersion: gateway.networking.k8s.io/v1
kind: GRPCRoute
metadata:
  name: payments
  namespace: default
spec:
  parentRefs:
    - name: zeno-gateway
  hostnames:
    - pay.api.internal
  rules:
    # 10% of Charge → canary, matched by exact service + method
    - matches:
        - method:
            type: Exact
            service: pay.v1.Payments
            method: Charge
      backendRefs:
        - name: payments-stable
          port: 50051
          weight: 90
        - name: payments-canary
          port: 50051
          weight: 10
    # Refund stays 100% on stable
    - matches:
        - method:
            type: Exact
            service: pay.v1.Payments
            method: Refund
      backendRefs:
        - name: payments-stable
          port: 50051

The backends are plain gRPC Services on HTTP/2 (h2c or TLS). ZenoIngress speaks HTTP/2 to them — no protocol downgrade — so streaming, multiplexing, and trailers work end to end.

What happens

From RPC to the right backend

  1. The client opens one HTTP/2 connection and calls pay.v1.Payments/Charge — on the wire, an HTTP/2 POST to path /pay.v1.Payments/Charge.
  2. ZenoIngress matches the GRPCRoute rule by exact service + method — not a hand-written path prefix. method-level
  3. The matching rule weight-splits: roughly 1 in 10 Charge calls is dispatched to payments-canary, the rest to payments-stable — and Refund is untouched, because the split is per method.
  4. The proxy speaks HTTP/2 to the chosen backend and streams the response back; gRPC trailers — grpc-status, grpc-message — pass through unmodified. trailers preserved
  5. The controller writes Accepted / ResolvedRefs status onto the GRPCRoute, exactly like an HTTPRoute — so kubectl shows whether it attached and its backends resolved.
  6. The entire HTTP/2 and trailer path is 100% safe Rust under a workspace-wide #![forbid(unsafe_code)]. memory-safe

Outcome

A first-class gRPC route, not a path-prefix hack

method-levelmatch by service + method
HTTP/2 e2eno protocol downgrade
grpc-status ✓trailers preserved
Why ZenoIngress

Native GRPCRoute on par with HTTPRoute — method-granular matching and weighted canaries, HTTP/2 and gRPC trailers preserved end to end, and full Accepted/ResolvedRefs status — all in a memory-safe data plane. No URL-prefix guesswork, no mangled trailers.

Honest status: GRPCRoute is implemented and tested end to end through the proxy. The upstream Gateway API GRPCRoute conformance suite has not yet been run here — we'll publish those rows when they're green.

Run this yourself

Watch the canary, and the trailers

Apply the route, then call the methods with grpcurl and watch which method gets split:

terminal — same service, method-level split
$ # call Charge a few times — ~1 in 10 lands on the canary
$ grpcurl -d '{"amount":1200}' pay.api.internal:443 pay.v1.Payments/Charge
{ "servedBy": "payments-stable", "ok": true }
$ grpcurl -d '{"amount":1200}' pay.api.internal:443 pay.v1.Payments/Charge
{ "servedBy": "payments-canary", "ok": true }   # the 10%

$ # Refund is never split — always stable
$ grpcurl -d '{"id":"r_91"}' pay.api.internal:443 pay.v1.Payments/Refund
{ "servedBy": "payments-stable", "ok": true }

$ # gRPC status rides in HTTP/2 trailers — preserved end to end
$ grpcurl -v -d '{"id":"r_91"}' pay.api.internal:443 pay.v1.Payments/Refund 2>&1 | grep -i grpc-status
trailer: grpc-status: 0

$ # the route reports status, same as HTTPRoute
$ kubectl get grpcroute payments -o jsonpath='{.status.parents[0].conditions[*].type}'
Accepted ResolvedRefs

gRPC, routed properly.

Native GRPCRoute, HTTP/2 end to end, in one memory-safe binary. Free for one production cluster.

Deploy Free