Lesson 36 of 66 · 18 min read · Updated
Exposing .NET gRPC Services with GRPCRoute
On this page
In the previous article you used HTTPRoute weights to run canary and blue/green releases for kiosk-api. Every route so far has carried plain HTTP/1.1 requests from curl. A lot of .NET back-end traffic is not shaped like that. Service-to-service calls are often gRPC: binary Protobuf messages over HTTP/2, with a method name instead of a URL.
You could push gRPC through an HTTPRoute, because on the wire a gRPC call is just an HTTP/2 POST to /package.Service/Method. But then you would be writing path prefixes that mimic gRPC names and hoping nobody renames a package. Gateway API has a dedicated resource for this: GRPCRoute. It matches on the gRPC service and method directly, and it tells the gateway that the backend speaks HTTP/2.
We will cover the following topics:
- What GRPCRoute adds over HTTPRoute, and when to use which
- How a gRPC call looks on the wire, and why HTTP/2 cleartext matters
- Running an ASP.NET Core gRPC service on HTTP/2 without TLS inside the cluster
- The
appProtocolfield on a Service port and whether Envoy Gateway needs it - Matching on service, method and headers
- Exposing gRPC server reflection so
grpcurlcan discover your API - Calling the service through the gateway from a .NET client
Technical Requirements
- The minikube cluster, Envoy Gateway v1.9.2 and the
storefront-gatewayGateway from the earlier articles in this series. - The .NET 10 SDK and Docker, to build the gRPC service and the client.
- grpcurl, a command-line gRPC client. On macOS:
brew install grpcurl. - If gRPC itself is new to you, the gRPC With .Net category on this site covers Protobuf, service definitions and streaming in depth. This article focuses on the Kubernetes side.
Core Concepts
A gRPC Call on the Wire
Every unary gRPC call is an HTTP/2 request with three fixed traits:
| Part | Value |
|---|---|
| Method | Always POST |
| Path | /<package>.<Service>/<Method>, for example /catalog.v1.Catalog/GetProduct |
| Content type | application/grpc |
The status of the call does not live in the HTTP status code. The HTTP response is almost always 200; the real result travels in the grpc-status trailer. That is why a generic HTTP proxy that does not understand HTTP/2 trailers will silently break gRPC.
Why GRPCRoute Instead of HTTPRoute
An HTTPRoute can forward gRPC traffic, but it does not know it is gRPC. GRPCRoute does, and that changes three things:
| Concern | HTTPRoute | GRPCRoute |
|---|---|---|
| Match on | Path, headers, query, HTTP method | gRPC service and method, headers |
| Upstream protocol | HTTP/1.1 unless the Service says otherwise | HTTP/2 to the backend |
| Readability | PathPrefix: /catalog.v1.Catalog/ | service: catalog.v1.Catalog |
| Filters | Full HTTP filter set (redirects, rewrites, ...) | Header modifiers and mirroring; no redirects or rewrites |
GRPCRoute has been part of the Standard channel since Gateway API v1.1, so it uses gateway.networking.k8s.io/v1 like HTTPRoute.
One rule to remember: a GRPCRoute and an HTTPRoute must not claim the same hostname on the same listener. The specification leaves the result undefined, and implementations will reject one of them. Give gRPC its own hostname, such as grpc.storefront.local.
HTTP/2 Cleartext Inside the Cluster
gRPC requires HTTP/2. On the public internet, HTTP/2 is almost always negotiated through TLS (ALPN). Inside a cluster, behind the gateway, most teams run the backend on HTTP/2 cleartext (often called h2c or prior knowledge): the client simply starts talking HTTP/2 without a TLS handshake.
Kestrel listens on HTTP/1.1 and HTTP/2 by default. Without TLS there is no ALPN, so Kestrel cannot tell which protocol the client wants and falls back to HTTP/1.1. The ASP.NET Core gRPC documentation is explicit: an unsecured gRPC endpoint must be set to Http2 only, or gRPC calls fail. In a container the simplest way is an environment variable:
The double underscore maps to the Kestrel:EndpointDefaults:Protocols configuration key, so no code change is needed.
The appProtocol Field
How does the gateway know a backend speaks HTTP/2? Gateway API defines the answer: a Service port can declare it with the standard Kubernetes field appProtocol. The value kubernetes.io/h2c means "HTTP/2 over cleartext".
Does Envoy Gateway need it? Not for GRPCRoute. Envoy Gateway always uses HTTP/2 towards the backends of a GRPCRoute, whatever the Service says. The field matters in two other cases:
- When an HTTPRoute points at an HTTP/2-only backend. Envoy Gateway reads
kubernetes.io/h2cand switches that upstream to HTTP/2. - When you want your manifests to be portable. Other implementations, such as NGINX Gateway Fabric, rely on
appProtocolto decide the upstream protocol.
So set it anyway. It costs one line and documents the port's real protocol for every tool and every reader.
Matching on Service and Method
A GRPCRoute rule matches with a method block and, optionally, headers:
| Field | Meaning |
|---|---|
method.service | Fully qualified service name, e.g. catalog.v1.Catalog |
method.method | Method name, e.g. GetProduct |
method.type | Exact (default) or RegularExpression |
headers | gRPC metadata travels as HTTP/2 headers, so you can match on it |
You may omit method.method to match every method of a service, or omit service to match a method name across services. A rule with no matches at all catches everything that reached the route. When several rules match, the most specific one wins.
Server Reflection
grpcurl normally needs the .proto files to build a request. With server reflection the service describes itself at runtime, and grpcurl list works with no files at all. In ASP.NET Core this is the Grpc.AspNetCore.Server.Reflection package plus two lines of code.
Reflection is itself a gRPC service, grpc.reflection.v1.ServerReflection (older clients use grpc.reflection.v1alpha.ServerReflection). If your GRPCRoute only matches catalog.v1.Catalog, reflection calls never reach the Pod. Either add a match for the reflection services, or deliberately leave them out in production so the gateway does not advertise your API surface.
Hands-On: Kubernetes Commands
List GRPCRoutes
GRPCRoutes are a separate resource type; kubectl get httproute does not show them.
Check that a GRPCRoute was accepted
Look for Accepted=True and ResolvedRefs=True under the parent Gateway.
Show the appProtocol of a Service port
An empty result means the field is not set.
Discover services through the gateway
-plaintext skips TLS; -authority sets the HTTP/2 :authority, the gRPC equivalent of the Host header.
Describe a service
Prints the methods and their request and response types.
Call a method with a JSON body
grpcurl converts JSON to Protobuf for you.
Send gRPC metadata
-H adds metadata, which the gateway sees as an HTTP/2 header.
Step-by-Step Example
The Scenario
The storefront team is adding a product catalog. Other services will call it over gRPC, and the mobile team wants to reach it through the same gateway as kiosk-api. You will:
- build a .NET 10 gRPC service,
catalog-grpc, withGetProductandListProducts - expose it under its own hostname,
grpc.storefront.local, throughstorefront-gateway - use reflection and
grpcurlto explore it - route a header-tagged subset of calls to a second version
- call it from a .NET console client
Step 1 — Create the gRPC Service
Start from the gRPC template and add two packages: server reflection, and the standard gRPC health service that the Kubernetes probe will call.
The first dotnet add package upgrades the template's Grpc.AspNetCore reference to the latest version. The .NET 10 template still pins an older release whose bundled protoc compiler is x64-only on macOS, so on an Apple Silicon Mac without Rosetta a local dotnet build fails with "Bad CPU type in executable". Current versions ship a native arm64 compiler.
Create Protos/catalog.proto:
In CatalogGrpc.csproj, replace the template's Protobuf item so it points to the new file:
Step 2 — Implement the Service
Create Services/CatalogService.cs. Each product reports the Pod and the version that served it, so you can see the gateway's decisions.
Replace Program.cs:
Do not skip the AddCheck line. The gRPC health service reports the aggregate of the registered ASP.NET Core health checks. With none registered, it answers UNKNOWN for the whole server, and the Kubernetes gRPC probe keeps the Pods at 0/1 READY forever with the event Readiness probe failed: service unhealthy (responded with "UNKNOWN"). In a real service the checks would test the database and other dependencies.
NotFound is a gRPC status, not an HTTP status. It travels in the grpc-status trailer, which is exactly why the gateway must speak real HTTP/2.
Step 3 — Containerise with HTTP/2 Cleartext
Add a Dockerfile. The Kestrel__EndpointDefaults__Protocols variable is the important line.
Build the image straight into minikube's Docker daemon so no registry is needed.
On Docker Desktop's built-in kind cluster skip the eval line; a plain docker build is enough. On a plain kind cluster run kind load docker-image catalog-grpc:1.0 afterwards. In both cases, if you rebuild the image under the same tag, nodes that already have it keep running the old copy with imagePullPolicy: IfNotPresent. Bump the tag (1.1) and update the Deployment, or the fix you just made will never reach the Pods.
Step 4 — Deployment and Service
Create 1-catalog-grpc.yaml. The Service declares appProtocol: kubernetes.io/h2c.
The readiness probe uses the native gRPC probe type, which calls the standard grpc.health.v1.Health/Check method. That is the service MapGrpcHealthChecksService() added in Step 2. Apply the manifest:
1/1 READY proves two things at once: the container listens on HTTP/2 cleartext, and the gRPC health service answers. If Kestrel were still on Http1AndHttp2, the probe would fail.
Step 5 — The GRPCRoute
Create 2-catalog-grpcroute.yaml. It attaches to the existing http listener of storefront-gateway, claims its own hostname, and exposes both the Catalog service and reflection.
The three entries under matches are ORed: a call matching any of them is forwarded. Apply and check the status:
You should see Accepted and ResolvedRefs both with Status: True.
Step 6 — Explore the API with grpcurl
Port-forward to the Envoy Service exactly as in the earlier articles. Keep this terminal open.
In a second terminal, list the services. The gateway forwards the reflection call to a Pod.
Current versions of the reflection package register both the v1 and the older v1alpha reflection service, and different clients use different ones. That is why the route matches both names.
Call GetProduct twice and watch the Pod change:
grpcurl prints field names as they are written in the .proto file, so the field shows up as served_by_pod; the C# property is ServedByPod.
Now ask for a product that does not exist:
The gRPC status and message survived the trip through Envoy. That is the trailer handling at work.
Finally, try the wrong hostname. No route claims it on this listener, so Envoy answers before any Pod is involved:
Envoy answered the reflection call with HTTP 404, which a gRPC client sees as Unimplemented, and grpcurl reports it as missing reflection support. The message is misleading; the real cause is that no GRPCRoute claims this hostname.
Step 7 — Route by Method
Suppose ListProducts is expensive and the team wants a dedicated pool for it. Deploy a second copy of the same image, labelled pool: list, and a matching Service.
Note that the original Service selects version: v1 and the new Pods do not carry that label, so the two pools stay separate. Save the YAML above as 3-catalog-grpc-list.yaml, apply it, then add a second, more specific rule to the rules list of the GRPCRoute:
GetProduct still goes to the original Pods, because it only matches the broader service-level rule. The order of rules in the YAML does not decide this; the specificity of the match does. A service-plus-method match beats a service-only match.
Step 8 — Route by Metadata Header
gRPC metadata is carried as HTTP/2 headers, so GRPCRoute can match it just like HTTPRoute matches headers. To keep the example short, v2 reuses the same image and only changes CATALOG_VERSION. Callers who opt in with the x-catalog-version: v2 metadata header go to it.
Save it as 4-catalog-grpc-v2.yaml and add a third rule to the GRPCRoute's rules:
Without the header the call still lands on v1. This is the gRPC version of the header-based canary you built for kiosk-api. Weighted backendRefs work on GRPCRoute too, so the weight-based canary from the previous article also carries over unchanged.
Step 9 — Call Through the Gateway from .NET
grpcurl is fine for exploration; your services will use a .NET client. Create a console app that references the same .proto file as a client:
Add the proto to CatalogClient.csproj with GrpcServices="Client":
The gateway routes on hostname, but you are connecting to localhost:8888. A small DelegatingHandler sets the Host header, which HttpClient sends as the HTTP/2 :authority. Replace Program.cs:
With an http:// address, GrpcChannel talks HTTP/2 with prior knowledge, which is exactly what Envoy's plain-HTTP listener accepts for gRPC. Run it while the port-forward is still open:
All three routing rules show up in one run: ListProducts went to the list pool, the tagged call went to v2, and the error kept its gRPC status code.
In a real deployment the client inside the cluster would usually call http://catalog-grpc.storefront:8080 directly. You route through the gateway when the caller is outside the cluster, or when you want the gateway's traffic rules (canary, header routing, policies) to apply.
Step 10 — Clean Up
Remove the gRPC resources and keep the Gateway for the next article.
Summary
- A gRPC call is an HTTP/2
POSTto/<package>.<Service>/<Method>, and its real status travels in thegrpc-statustrailer. - GRPCRoute matches on gRPC
service,methodand metadata headers, and is Standard since Gateway API v1.1 (gateway.networking.k8s.io/v1). - Give gRPC its own hostname. An HTTPRoute and a GRPCRoute must not claim the same hostname on one listener.
- Behind the gateway, run Kestrel on HTTP/2 cleartext with
Kestrel__EndpointDefaults__Protocols=Http2. Mixed HTTP/1.1 and HTTP/2 without TLS silently falls back to HTTP/1.1. - Set
appProtocol: kubernetes.io/h2con the Service port. Envoy Gateway always uses HTTP/2 for GRPCRoute backends, but the field matters for HTTPRoutes and for other implementations. - A service-plus-method match beats a service-only match; header matches let you build gRPC canaries, and weights work exactly as on HTTPRoute.
- Reflection is just another gRPC service. Expose it through the route when you want
grpcurl listto work, and leave it out when you do not want to advertise your API. - A .NET client sets the
:authoritythrough theHostheader, so it can reach a hostname-routed gateway through a port-forward.
In the next article we move from one team's namespace to a shared Gateway, and use ReferenceGrant to let routes and certificates cross namespace boundaries safely.