If you’ve been running production workloads on GKE for more than a year, you’ve felt the friction. The old networking.k8s.io/v1 Ingress resource is a paper abstraction bolted over vendor-specific behavior. You end up with 40-line annotation blocks on your Ingress objects just to do basic things like header rewrites or traffic splitting. Nginx, GCE, Kong — each controller interprets the same YAML differently. Debugging becomes archaeology.
The Kubernetes Gateway API was built to fix exactly this. It’s not a drop-in replacement; it’s a rethink of how traffic enters and flows through a cluster. And in GKE, the implementation goes further: the Gateway API is the native path to multi-cluster load balancing backed by Google’s global infrastructure.
This guide walks through a real setup: two GKE clusters registered to a Fleet, a shared Gateway deployed to one of them, and HTTPRoutes controlling traffic across both. No fluff, no screenshots of the GCP console — just working configs and the hard-won lessons that come from running this in production.
Official Gateway API spec lives at github.com/kubernetes-sigs/gateway-api. GKE’s implementation docs are at cloud.google.com/kubernetes-engine/docs/concepts/gateway-api.
Why Gateway API Beats Ingress (and When It Doesn’t)
The core problem with Ingress is that the spec is intentionally minimal. rules, tls, backend — that’s about it. Everything interesting lives in metadata.annotations, which means every controller defines its own dialect. You’re not writing portable Kubernetes config; you’re writing Nginx config (or Envoy config, or HAProxy config) dressed up as Kubernetes YAML.
Gateway API separates concerns properly:
- GatewayClass — owned by the infrastructure team, defines the controller (GKE’s internal LB, Nginx, etc.)
- Gateway — owned by the platform team, defines a listener (port 443, TLS cert, hostnames)
- HTTPRoute / TCPRoute / GRPCRoute — owned by the app team, defines routing rules for their service
This maps to real org structure. Application teams don’t need to touch TLS config or load balancer parameters. Platform teams don’t need to know every app’s routing rules. The API enforces the boundary.
The catch: Gateway API is more complex to initially set up. If you have one cluster, three services, and a simple Ingress with cert-manager, you might not need this yet. Where it pays off immediately is multi-cluster traffic management, canary deployments at the LB layer, and any setup where you need consistent cross-cluster routing behavior.
Architecture You’re Building
Two GKE clusters — cluster-eu (europe-west1) and cluster-us (us-central1) — registered to a GCP Fleet. A gke-l7-global-external-managed-mc GatewayClass (the multi-cluster variant) creates a single global HTTPS load balancer. HTTPRoutes deployed on the fleet config cluster define how traffic splits between backends in both regions.
Traffic flow: User → Cloud Load Balancer (anycast IP) → Backend service endpoint group (per cluster) → Pod.
The load balancer is external, globally distributed, and uses Google’s Premium Tier network. Latency-based routing and weighted traffic splitting happen at the LB layer — not in your app, not in a sidecar.
Prerequisites
# Versions this guide was tested with
gcloud version # >= 493.0.0
kubectl version --client # >= 1.30
# GKE clusters should be >= 1.29 for stable Gateway API support
You need:
- Two GKE clusters (Standard or Autopilot, both work)
- Both clusters in the same GCP project (cross-project fleet setups are possible but out of scope here)
gcloudauthenticated withcontainer.admin,gkehub.admin, andcompute.networkAdminroles- A registered domain and a Google-managed SSL certificate (or you can use a self-signed for testing)
Step 1: Register Clusters to a Fleet
Multi-cluster Gateway API requires GKE Fleet. Without this, the multi-cluster GatewayClass simply won’t be available.
# Enable required APIs — do this once per project
gcloud services enable \
container.googleapis.com \
gkehub.googleapis.com \
multiclusterservicediscovery.googleapis.com \
multiclusteringress.googleapis.com \
trafficdirector.googleapis.com \
--project=YOUR_PROJECT_ID
# Register clusters to the fleet
gcloud container fleet memberships register cluster-eu \
--gke-cluster=europe-west1/cluster-eu \
--enable-workload-identity \
--project=YOUR_PROJECT_ID
gcloud container fleet memberships register cluster-us \
--gke-cluster=us-central1/cluster-us \
--enable-workload-identity \
--project=YOUR_PROJECT_ID
# Verify
gcloud container fleet memberships list --project=YOUR_PROJECT_ID
Enable multi-cluster services and gateway features on the fleet:
gcloud container fleet multi-cluster-services enable \
--project=YOUR_PROJECT_ID
gcloud container fleet ingress enable \
--config-membership=projects/YOUR_PROJECT_ID/locations/global/memberships/cluster-eu \
--project=YOUR_PROJECT_ID
The --config-membership flag designates cluster-eu as the config cluster. This is the cluster where you’ll deploy your Gateway and HTTPRoute objects. The fleet controller reads them from here and propagates the LB configuration globally.
Gotcha #1: You can only have one config cluster per fleet for multi-cluster ingress. Choose carefully — if you need to change it later, you’ll need to disable and re-enable the fleet ingress feature, which causes a brief LB disruption.
Step 2: Enable Gateway API on Both Clusters
GKE doesn’t ship Gateway API CRDs by default on older clusters. Check first:
kubectl get crd gateways.gateway.networking.k8s.io 2>/dev/null || echo "CRDs not installed"
For GKE 1.29+, enable it at cluster creation or update time:
# For an existing cluster
gcloud container clusters update cluster-eu \
--gateway-api=standard \
--region=europe-west1 \
--project=YOUR_PROJECT_ID
gcloud container clusters update cluster-us \
--gateway-api=standard \
--region=us-central1 \
--project=YOUR_PROJECT_ID
The standard channel gives you HTTPRoute, Gateway, GatewayClass, and ReferenceGrant. The experimental channel adds TCPRoute, GRPCRoute, and HTTPRoute features like session persistence that are still in beta.
After enabling, verify the GatewayClasses that GKE provisions:
kubectl get gatewayclasses
# NAME CONTROLLER
# gke-l7-global-external-managed networking.gke.io/gateway
# gke-l7-global-external-managed-mc networking.gke.io/gateway
# gke-l7-regional-internal-managed networking.gke.io/gateway
# gke-l7-rilb networking.gke.io/gateway
For multi-cluster you want gke-l7-global-external-managed-mc. The -mc suffix is the multi-cluster variant backed by a global external Application Load Balancer.
Step 3: Deploy the Gateway (on Config Cluster)
Set your kubecontext to cluster-eu (the config cluster) for everything in steps 3–5.
kubectl config use-context cluster-eu
First, create the namespace and the managed certificate:
# namespace.yaml
apiVersion: v1
kind: Namespace
metadata:
name: gateway-infra
# managed-cert.yaml
apiVersion: networking.gke.io/v1
kind: ManagedCertificate
metadata:
name: app-tls
namespace: gateway-infra
spec:
domains:
- app.yourdomain.com
Now the Gateway itself:
# gateway.yaml
apiVersion: gateway.networking.k8s.io/v1
kind: Gateway
metadata:
name: external-gateway
namespace: gateway-infra
annotations:
# Static IP — create this beforehand with gcloud compute addresses create
networking.gke.io/certmap: "" # leave empty if using ManagedCertificate
networking.gke.io/static-ip: "gke-gateway-ip"
spec:
gatewayClassName: gke-l7-global-external-managed-mc
listeners:
- name: https
protocol: HTTPS
port: 443
tls:
mode: Terminate
options:
networking.gke.io/pre-shared-certs: app-tls # references the ManagedCertificate name
allowedRoutes:
namespaces:
from: All # allow HTTPRoutes from any namespace; tighten this in prod
- name: http
protocol: HTTP
port: 80
allowedRoutes:
namespaces:
from: All
Reserve the static IP before applying:
gcloud compute addresses create gke-gateway-ip \
--global \
--project=YOUR_PROJECT_ID
Apply and check status:
kubectl apply -f namespace.yaml -f managed-cert.yaml -f gateway.yaml
kubectl describe gateway external-gateway -n gateway-infra
The Gateway will sit in Programmed: False for several minutes while GCP provisions the load balancer. This is normal — global LB provisioning takes 5–10 minutes on first creation. The status conditions will tell you exactly what’s happening.
Gotcha #2: If you delete and recreate a Gateway with the same static IP, GCP sometimes holds the IP in a transitional state for a few minutes. Don’t panic and don’t retry obsessively — just wait.
Step 4: Export Services with ServiceExport
For the multi-cluster LB to route to your pods in both clusters, each cluster needs to export its services via ServiceExport. This is a Multi-cluster Services API (MCS) object.
Deploy this in both clusters:
# app-deployment.yaml — deploy this to BOTH clusters
apiVersion: apps/v1
kind: Deployment
metadata:
name: my-app
namespace: default
spec:
replicas: 3
selector:
matchLabels:
app: my-app
template:
metadata:
labels:
app: my-app
spec:
containers:
- name: app
image: us-docker.pkg.dev/google-samples/containers/gke/hello-app:2.0
ports:
- containerPort: 8080
resources:
requests:
cpu: 100m
memory: 128Mi
limits:
cpu: 500m
memory: 256Mi
---
apiVersion: v1
kind: Service
metadata:
name: my-app
namespace: default
spec:
selector:
app: my-app
ports:
- port: 8080
targetPort: 8080
# service-export.yaml — deploy to BOTH clusters
apiVersion: net.gke.io/v1
kind: ServiceExport
metadata:
name: my-app
namespace: default
The ServiceExport object tells the fleet controller: "this service is available from this cluster and should be visible as a multi-cluster service endpoint." Once exported from both clusters, a ServiceImport object is automatically created in both.
Verify the import exists (on config cluster):
kubectl get serviceimport my-app -n default
# NAME TYPE IP AGE
# my-app ClusterSetIP [10.x.x.x] 2m
Step 5: Define HTTPRoutes
HTTPRoute objects live on the config cluster and reference the ServiceImport (not the regular Service):
# httproute-main.yaml
apiVersion: gateway.networking.k8s.io/v1
kind: HTTPRoute
metadata:
name: my-app-route
namespace: default
spec:
parentRefs:
- name: external-gateway
namespace: gateway-infra
sectionName: https
hostnames:
- "app.yourdomain.com"
rules:
- matches:
- path:
type: PathPrefix
value: /
backendRefs:
# Use ServiceImport for multi-cluster routing
- group: net.gke.io
kind: ServiceImport
name: my-app
port: 8080
weight: 100
For HTTP → HTTPS redirect, a separate route on the http listener:
# httproute-redirect.yaml
apiVersion: gateway.networking.k8s.io/v1
kind: HTTPRoute
metadata:
name: http-redirect
namespace: default
spec:
parentRefs:
- name: external-gateway
namespace: gateway-infra
sectionName: http
hostnames:
- "app.yourdomain.com"
rules:
- filters:
- type: RequestRedirect
requestRedirect:
scheme: https
statusCode: 301
This is cleaner than any Ingress annotation you’ve ever written. The intent is explicit in the spec — no nginx.ingress.kubernetes.io/ssl-redirect: "true" guessing.
Weighted Traffic Splitting (Canary the Right Way)
Say you want to roll out a new version to 10% of traffic while the rest hits stable. No more fighting with Nginx split clients or Istio VirtualServices if you don’t have a service mesh:
# httproute-canary.yaml
apiVersion: gateway.networking.k8s.io/v1
kind: HTTPRoute
metadata:
name: my-app-canary
namespace: default
spec:
parentRefs:
- name: external-gateway
namespace: gateway-infra
sectionName: https
hostnames:
- "app.yourdomain.com"
rules:
- matches:
- path:
type: PathPrefix
value: /api
backendRefs:
- group: net.gke.io
kind: ServiceImport
name: my-app-stable
port: 8080
weight: 90
- group: net.gke.io
kind: ServiceImport
name: my-app-canary
port: 8080
weight: 10
The weights are distributed at the load balancer layer — before traffic even hits your clusters. This is not pod-level, not a sidecar decision, not a DNS trick. Actual LB-layer traffic splitting with Google’s infrastructure doing the math.
Gotchas You Will Hit
Gotcha #3 — ServiceExport namespace must match exactly. If your service is in namespace production on one cluster and default on the other, MCS won’t merge them. The namespace, service name, and port must be identical across clusters.
Gotcha #4 — ManagedCertificate provisioning requires DNS to resolve first. Google’s certificate automation does an HTTP challenge via the load balancer IP. If your DNS isn’t pointing to the static IP when you create the cert, it’ll sit in Provisioning forever. Point DNS first, create the cert second. Set a low TTL while you’re setting up.
Gotcha #5 — Gateway updates are not instant. Any change to a Gateway or HTTPRoute triggers a LB config update that can take 2–5 minutes to propagate globally. Don’t test a config change and conclude it’s broken after 30 seconds. Watch kubectl describe httproute <name> for the Accepted and ResolvedRefs conditions instead of hammering curl.
Gotcha #6 — Health check paths. The global LB creates backend health checks automatically. By default it hits / with a GET request. If your app returns 4xx on / (common for API services), the backends will fail health checks and traffic will stop. Fix this with a BackendPolicy or ensure / (or /healthz) returns 200.
# backend-policy.yaml — override the health check
apiVersion: networking.gke.io/v1
kind: GCPBackendPolicy
metadata:
name: my-app-backend-policy
namespace: default
spec:
default:
healthCheck:
config:
type: HTTP
httpHealthCheck:
port: 8080
requestPath: /healthz
targetRef:
group: net.gke.io
kind: ServiceImport
name: my-app
Gotcha #7 — ReferenceGrant is required for cross-namespace routes. If your HTTPRoute is in team-a namespace and references the Gateway in gateway-infra, you need a ReferenceGrant in gateway-infra that allows it. Without this, the route silently doesn’t attach.
# reference-grant.yaml — deploy to the gateway namespace
apiVersion: gateway.networking.k8s.io/v1beta1
kind: ReferenceGrant
metadata:
name: allow-httproutes
namespace: gateway-infra
spec:
from:
- group: gateway.networking.k8s.io
kind: HTTPRoute
namespace: team-a
to:
- group: ""
kind: Service
Production Hardening
Lock down allowedRoutes. The from: All setting in the Gateway spec is convenient during setup. In production, scope it to specific namespaces owned by teams you trust. Otherwise any team with cluster access can attach routes to your load balancer.
Use hostnames selectors on the Gateway listener, not just on HTTPRoutes. This creates a double enforcement: the listener only accepts routes for declared hostnames, and the HTTPRoute further constrains it.
Set resource limits on your GatewayClass. GKE’s LB has a soft limit of 50 URL map rules per Gateway. If you have many teams, consider whether they need separate Gateways or whether route consolidation is possible. More routes = more LB config complexity = slower updates.
Monitor with Cloud Monitoring. GKE Gateway-backed LBs automatically push metrics to Cloud Monitoring under the loadbalancing.googleapis.com prefix. Create alerting policies on request_count filtered by response_code_class=500 before you go live.
Test failover deliberately. Scale a deployment to zero replicas in one cluster and verify that the LB shifts traffic to the other. Health check interval is 5 seconds by default; you should see failover in under 30 seconds. If you don’t, check that ServiceExport exists in the remaining cluster and the health check configuration is correct.
Migrating from Classic Multi-cluster Ingress
If you’re running networking.gke.io/v1beta1 MultiClusterIngress, migration to Gateway API is not automatic. The objects coexist, so you can run them in parallel during migration. The recommended path: deploy the Gateway, attach HTTPRoutes pointing to the same backends, verify traffic, then delete the old MultiClusterIngress object.
One important note: MultiClusterIngress uses MultiClusterService objects. Gateway API uses ServiceExport/ServiceImport (MCS API). These are different mechanisms — you need to create ServiceExport objects as part of the migration, even if your services are already used by MultiClusterService.
Wrapping It Up
The Gateway API setup on GKE is more upfront work than slapping an Ingress on a cluster, but the payoff is a proper separation of concerns between infra, platform, and app teams, plus actual LB-layer traffic management features that Ingress annotations could never reliably deliver. Multi-cluster routing that leverages Google’s global network, with health checks and failover handled by the LB — not your app code, not a sidecar, not kube-proxy.
The config cluster concept is the piece most people miss initially: you write Gateway and HTTPRoute objects in one place, and the fleet controller handles propagating backend configuration to all registered clusters. Once that mental model clicks, the rest falls into place.
Run kubectl describe gateway and kubectl describe httproute obsessively while setting this up. The status conditions on those objects will tell you everything you need to debug — far more reliably than curl.