GKE Gateway API with Multi-Cluster Ingress: A Working Setup From Scratch

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)
  • gcloud authenticated with container.admin, gkehub.admin, and compute.networkAdmin roles
  • 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.

👁 Views: 122,297 · Unique visitors: 46,347