Lesson 36 of 66 · 18 min read · Updated

Exposing .NET gRPC Services with GRPCRoute

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:

  1. What GRPCRoute adds over HTTPRoute, and when to use which
  2. How a gRPC call looks on the wire, and why HTTP/2 cleartext matters
  3. Running an ASP.NET Core gRPC service on HTTP/2 without TLS inside the cluster
  4. The appProtocol field on a Service port and whether Envoy Gateway needs it
  5. Matching on service, method and headers
  6. Exposing gRPC server reflection so grpcurl can discover your API
  7. Calling the service through the gateway from a .NET client

Technical Requirements

  1. The minikube cluster, Envoy Gateway v1.9.2 and the storefront-gateway Gateway from the earlier articles in this series.
  2. The .NET 10 SDK and Docker, to build the gRPC service and the client.
  3. grpcurl, a command-line gRPC client. On macOS: brew install grpcurl.
  4. 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:

PartValue
MethodAlways POST
Path/<package>.<Service>/<Method>, for example /catalog.v1.Catalog/GetProduct
Content typeapplication/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:

ConcernHTTPRouteGRPCRoute
Match onPath, headers, query, HTTP methodgRPC service and method, headers
Upstream protocolHTTP/1.1 unless the Service says otherwiseHTTP/2 to the backend
ReadabilityPathPrefix: /catalog.v1.Catalog/service: catalog.v1.Catalog
FiltersFull 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:

Kestrel__EndpointDefaults__Protocols=Http2

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".

ports:
- name: grpc
port: 8080
targetPort: 8080
appProtocol: kubernetes.io/h2c

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:

  1. When an HTTPRoute points at an HTTP/2-only backend. Envoy Gateway reads kubernetes.io/h2c and switches that upstream to HTTP/2.
  2. When you want your manifests to be portable. Other implementations, such as NGINX Gateway Fabric, rely on appProtocol to 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:

FieldMeaning
method.serviceFully qualified service name, e.g. catalog.v1.Catalog
method.methodMethod name, e.g. GetProduct
method.typeExact (default) or RegularExpression
headersgRPC 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.

kubectl get grpcroute -n storefront

Check that a GRPCRoute was accepted

Look for Accepted=True and ResolvedRefs=True under the parent Gateway.

kubectl describe grpcroute catalog-grpc -n storefront

Show the appProtocol of a Service port

An empty result means the field is not set.

kubectl get svc catalog-grpc -n storefront -o jsonpath='{.spec.ports[*].appProtocol}'

Discover services through the gateway

-plaintext skips TLS; -authority sets the HTTP/2 :authority, the gRPC equivalent of the Host header.

grpcurl -plaintext -authority grpc.storefront.local localhost:8888 list

Describe a service

Prints the methods and their request and response types.

grpcurl -plaintext -authority grpc.storefront.local localhost:8888 describe catalog.v1.Catalog

Call a method with a JSON body

grpcurl converts JSON to Protobuf for you.

grpcurl -plaintext -authority grpc.storefront.local -d '{"id": 2}' localhost:8888 catalog.v1.Catalog/GetProduct

Send gRPC metadata

-H adds metadata, which the gateway sees as an HTTP/2 header.

grpcurl -plaintext -authority grpc.storefront.local -H 'x-catalog-version: v2' -d '{"id": 2}' localhost:8888 catalog.v1.Catalog/GetProduct

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:

  1. build a .NET 10 gRPC service, catalog-grpc, with GetProduct and ListProducts
  2. expose it under its own hostname, grpc.storefront.local, through storefront-gateway
  3. use reflection and grpcurl to explore it
  4. route a header-tagged subset of calls to a second version
  5. 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.

dotnet new grpc -n CatalogGrpc
cd CatalogGrpc
dotnet add package Grpc.AspNetCore
dotnet add package Grpc.AspNetCore.Server.Reflection
dotnet add package Grpc.AspNetCore.HealthChecks
rm Protos/greet.proto Services/GreeterService.cs

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:

syntax = "proto3";

option csharp_namespace = "CatalogGrpc";

package catalog.v1;

service Catalog {
rpc GetProduct (GetProductRequest) returns (Product);
rpc ListProducts (ListProductsRequest) returns (ListProductsResponse);
}

message GetProductRequest {
int32 id = 1;
}

message ListProductsRequest {
int32 page_size = 1;
}

message Product {
int32 id = 1;
string name = 2;
double price = 3;
string served_by_pod = 4;
string version = 5;
}

message ListProductsResponse {
repeated Product products = 1;
}

In CatalogGrpc.csproj, replace the template's Protobuf item so it points to the new file:

<ItemGroup>
<Protobuf Include="Protos\catalog.proto" GrpcServices="Server" />
</ItemGroup>

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.

using Grpc.Core;

namespace CatalogGrpc.Services;

public class CatalogService : Catalog.CatalogBase
{
private static readonly string PodName =
Environment.GetEnvironmentVariable("POD_NAME") ?? Environment.MachineName;
private static readonly string Version =
Environment.GetEnvironmentVariable("CATALOG_VERSION") ?? "v1";

private static readonly (int Id, string Name, double Price)[] Products =
[
(1, "Espresso", 2.50),
(2, "Flat White", 3.40),
(3, "Croissant", 2.90)
];

public override Task<Product> GetProduct(GetProductRequest request, ServerCallContext context)
{
var match = Products.FirstOrDefault(p => p.Id == request.Id);
if (match == default)
{
throw new RpcException(new Status(StatusCode.NotFound, $"Product {request.Id} not found"));
}

return Task.FromResult(ToProduct(match));
}

public override Task<ListProductsResponse> ListProducts(ListProductsRequest request, ServerCallContext context)
{
var size = request.PageSize > 0 ? request.PageSize : Products.Length;
var response = new ListProductsResponse();
response.Products.AddRange(Products.Take(size).Select(ToProduct));
return Task.FromResult(response);
}

private static Product ToProduct((int Id, string Name, double Price) p) => new()
{
Id = p.Id,
Name = p.Name,
Price = p.Price,
ServedByPod = PodName,
Version = Version
};
}

Replace Program.cs:

using CatalogGrpc.Services;
using Microsoft.Extensions.Diagnostics.HealthChecks;

var builder = WebApplication.CreateBuilder(args);

builder.Services.AddGrpc();
builder.Services.AddGrpcReflection();
builder.Services.AddGrpcHealthChecks()
.AddCheck("self", () => HealthCheckResult.Healthy());

var app = builder.Build();

app.MapGrpcService<CatalogService>();
app.MapGrpcReflectionService();
app.MapGrpcHealthChecksService();

app.Run();

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.

FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build
WORKDIR /src
COPY . .
RUN dotnet publish CatalogGrpc.csproj -c Release -o /app

FROM mcr.microsoft.com/dotnet/aspnet:10.0
WORKDIR /app
COPY --from=build /app .
ENV ASPNETCORE_HTTP_PORTS=8080
ENV Kestrel__EndpointDefaults__Protocols=Http2
EXPOSE 8080
ENTRYPOINT ["dotnet", "CatalogGrpc.dll"]

Build the image straight into minikube's Docker daemon so no registry is needed.

eval $(minikube docker-env)
docker build -t catalog-grpc:1.0 .

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.

apiVersion: apps/v1
kind: Deployment
metadata:
name: catalog-grpc
namespace: storefront
spec:
replicas: 2
selector:
matchLabels:
app: catalog-grpc
version: v1
template:
metadata:
labels:
app: catalog-grpc
version: v1
spec:
containers:
- name: catalog-grpc
image: catalog-grpc:1.0
imagePullPolicy: IfNotPresent
ports:
- name: grpc
containerPort: 8080
env:
- name: POD_NAME
valueFrom:
fieldRef:
fieldPath: metadata.name
- name: CATALOG_VERSION
value: v1
readinessProbe:
grpc:
port: 8080
periodSeconds: 5
---
apiVersion: v1
kind: Service
metadata:
name: catalog-grpc
namespace: storefront
spec:
selector:
app: catalog-grpc
version: v1
ports:
- name: grpc
port: 8080
targetPort: 8080
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:

kubectl apply -f 1-catalog-grpc.yaml
kubectl get pods -n storefront -l app=catalog-grpc
NAME READY STATUS RESTARTS AGE
catalog-grpc-6d9c7b8f5d-8xk2p 1/1 Running 0 14s
catalog-grpc-6d9c7b8f5d-qv7lm 1/1 Running 0 14s

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.

apiVersion: gateway.networking.k8s.io/v1
kind: GRPCRoute
metadata:
name: catalog-grpc
namespace: storefront
spec:
parentRefs:
- name: storefront-gateway
sectionName: http
hostnames:
- grpc.storefront.local
rules:
- matches:
- method:
service: catalog.v1.Catalog
- method:
service: grpc.reflection.v1.ServerReflection
- method:
service: grpc.reflection.v1alpha.ServerReflection
backendRefs:
- name: catalog-grpc
port: 8080

The three entries under matches are ORed: a call matching any of them is forwarded. Apply and check the status:

kubectl apply -f 2-catalog-grpcroute.yaml
kubectl get grpcroute -n storefront
NAME HOSTNAMES AGE
catalog-grpc ["grpc.storefront.local"] 6s
kubectl describe grpcroute catalog-grpc -n storefront

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.

export ENVOY_SERVICE=$(kubectl get svc -n envoy-gateway-system \
--selector=gateway.envoyproxy.io/owning-gateway-namespace=storefront,gateway.envoyproxy.io/owning-gateway-name=storefront-gateway \
-o jsonpath='{.items[0].metadata.name}')
kubectl -n envoy-gateway-system port-forward service/${ENVOY_SERVICE} 8888:80

In a second terminal, list the services. The gateway forwards the reflection call to a Pod.

grpcurl -plaintext -authority grpc.storefront.local localhost:8888 list
catalog.v1.Catalog
grpc.health.v1.Health
grpc.reflection.v1.ServerReflection
grpc.reflection.v1alpha.ServerReflection

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 -plaintext -authority grpc.storefront.local -d '{"id": 2}' localhost:8888 catalog.v1.Catalog/GetProduct
{
"id": 2,
"name": "Flat White",
"price": 3.4,
"served_by_pod": "catalog-grpc-6d9c7b8f5d-qv7lm",
"version": "v1"
}

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:

grpcurl -plaintext -authority grpc.storefront.local -d '{"id": 99}' localhost:8888 catalog.v1.Catalog/GetProduct
ERROR:
Code: NotFound
Message: Product 99 not found

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:

grpcurl -plaintext -authority storefront.local localhost:8888 list
Failed to list services: server does not support the reflection API

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.

apiVersion: apps/v1
kind: Deployment
metadata:
name: catalog-grpc-list
namespace: storefront
spec:
replicas: 1
selector:
matchLabels:
app: catalog-grpc
pool: list
template:
metadata:
labels:
app: catalog-grpc
pool: list
spec:
containers:
- name: catalog-grpc
image: catalog-grpc:1.0
imagePullPolicy: IfNotPresent
ports:
- name: grpc
containerPort: 8080
env:
- name: POD_NAME
valueFrom:
fieldRef:
fieldPath: metadata.name
- name: CATALOG_VERSION
value: v1-list-pool
---
apiVersion: v1
kind: Service
metadata:
name: catalog-grpc-list
namespace: storefront
spec:
selector:
app: catalog-grpc
pool: list
ports:
- name: grpc
port: 8080
targetPort: 8080
appProtocol: kubernetes.io/h2c

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:

- matches:
- method:
service: catalog.v1.Catalog
method: ListProducts
backendRefs:
- name: catalog-grpc-list
port: 8080
kubectl apply -f 3-catalog-grpc-list.yaml
kubectl apply -f 2-catalog-grpcroute.yaml
grpcurl -plaintext -authority grpc.storefront.local -d '{"page_size": 1}' localhost:8888 catalog.v1.Catalog/ListProducts
{
"products": [
{
"id": 1,
"name": "Espresso",
"price": 2.5,
"served_by_pod": "catalog-grpc-list-7f5c9d6b4-2lq8n",
"version": "v1-list-pool"
}
]
}

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.

apiVersion: apps/v1
kind: Deployment
metadata:
name: catalog-grpc-v2
namespace: storefront
spec:
replicas: 1
selector:
matchLabels:
app: catalog-grpc
version: v2
template:
metadata:
labels:
app: catalog-grpc
version: v2
spec:
containers:
- name: catalog-grpc
image: catalog-grpc:1.0
imagePullPolicy: IfNotPresent
ports:
- name: grpc
containerPort: 8080
env:
- name: POD_NAME
valueFrom:
fieldRef:
fieldPath: metadata.name
- name: CATALOG_VERSION
value: v2
---
apiVersion: v1
kind: Service
metadata:
name: catalog-grpc-v2
namespace: storefront
spec:
selector:
app: catalog-grpc
version: v2
ports:
- name: grpc
port: 8080
targetPort: 8080
appProtocol: kubernetes.io/h2c

Save it as 4-catalog-grpc-v2.yaml and add a third rule to the GRPCRoute's rules:

- matches:
- method:
service: catalog.v1.Catalog
headers:
- type: Exact
name: x-catalog-version
value: v2
backendRefs:
- name: catalog-grpc-v2
port: 8080
kubectl apply -f 4-catalog-grpc-v2.yaml
kubectl apply -f 2-catalog-grpcroute.yaml
grpcurl -plaintext -authority grpc.storefront.local -H 'x-catalog-version: v2' -d '{"id": 1}' localhost:8888 catalog.v1.Catalog/GetProduct
{
"id": 1,
"name": "Espresso",
"price": 2.5,
"served_by_pod": "catalog-grpc-v2-5b8d7c9f6-mx4tw",
"version": "v2"
}

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:

cd ..
dotnet new console -n CatalogClient
cd CatalogClient
dotnet add package Grpc.Net.Client
dotnet add package Google.Protobuf
dotnet add package Grpc.Tools
mkdir Protos
cp ../CatalogGrpc/Protos/catalog.proto Protos/

Add the proto to CatalogClient.csproj with GrpcServices="Client":

<ItemGroup>
<Protobuf Include="Protos\catalog.proto" GrpcServices="Client" />
</ItemGroup>

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:

using CatalogGrpc;
using Grpc.Core;
using Grpc.Net.Client;

var handler = new AuthorityHandler("grpc.storefront.local")
{
InnerHandler = new SocketsHttpHandler()
};

using var channel = GrpcChannel.ForAddress("http://localhost:8888", new GrpcChannelOptions
{
HttpHandler = handler
});

var client = new Catalog.CatalogClient(channel);

var all = await client.ListProductsAsync(new ListProductsRequest());
foreach (var p in all.Products)
{
Console.WriteLine($"{p.Id} {p.Name,-12} {p.Price,6:0.00} {p.Version} @ {p.ServedByPod}");
}

var headers = new Metadata { { "x-catalog-version", "v2" } };
var v2 = await client.GetProductAsync(new GetProductRequest { Id = 3 }, headers);
Console.WriteLine($"v2 call -> {v2.Name} {v2.Version} @ {v2.ServedByPod}");

try
{
await client.GetProductAsync(new GetProductRequest { Id = 99 });
}
catch (RpcException ex)
{
Console.WriteLine($"Expected failure: {ex.StatusCode} - {ex.Status.Detail}");
}

sealed class AuthorityHandler(string authority) : DelegatingHandler
{
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
{
request.Headers.Host = authority;
return base.SendAsync(request, cancellationToken);
}
}

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:

dotnet run
1 Espresso 2.50 v1-list-pool @ catalog-grpc-list-7f5c9d6b4-2lq8n
2 Flat White 3.40 v1-list-pool @ catalog-grpc-list-7f5c9d6b4-2lq8n
3 Croissant 2.90 v1-list-pool @ catalog-grpc-list-7f5c9d6b4-2lq8n
v2 call -> Croissant v2 @ catalog-grpc-v2-5b8d7c9f6-mx4tw
Expected failure: NotFound - Product 99 not found

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.

kubectl delete grpcroute catalog-grpc -n storefront
kubectl delete deployment catalog-grpc catalog-grpc-list catalog-grpc-v2 -n storefront
kubectl delete svc catalog-grpc catalog-grpc-list catalog-grpc-v2 -n storefront

Summary

  1. A gRPC call is an HTTP/2 POST to /<package>.<Service>/<Method>, and its real status travels in the grpc-status trailer.
  2. GRPCRoute matches on gRPC service, method and metadata headers, and is Standard since Gateway API v1.1 (gateway.networking.k8s.io/v1).
  3. Give gRPC its own hostname. An HTTPRoute and a GRPCRoute must not claim the same hostname on one listener.
  4. 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.
  5. Set appProtocol: kubernetes.io/h2c on the Service port. Envoy Gateway always uses HTTP/2 for GRPCRoute backends, but the field matters for HTTPRoutes and for other implementations.
  6. A service-plus-method match beats a service-only match; header matches let you build gRPC canaries, and weights work exactly as on HTTPRoute.
  7. Reflection is just another gRPC service. Expose it through the route when you want grpcurl list to work, and leave it out when you do not want to advertise your API.
  8. A .NET client sets the :authority through the Host header, 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.