LabHub

Blog

Kubernetes Gateway API and Ingress Migration Guide

한국어English日本語

Kubernetes Gateway API

Introduction

For a long time the Ingress resource has been the de facto standard for routing external traffic to services inside a Kubernetes cluster. However, because Ingress was designed with nothing but simple HTTP routing in mind, the features production demands - advanced routing, traffic splitting, multi-protocol support - had no choice but to rely on per-controller annotations. That has produced a chronic portability problem: an annotation written for Ingress NGINX does not work on Traefik or HAProxy.

The Gateway API is the next-generation service networking API Kubernetes SIG-Network designed to solve those limitations at the root. Starting with the v1.0 GA in October 2023, v1.2 in November 2024 added WebSocket, timeout and retry support, and v1.4 in November 2025 promoted BackendTLSPolicy and ReferenceGrant to the Standard Channel, bringing it to production-level maturity. In particular, the Kubernetes community has declared the official end of life for Ingress NGINX as March 2026, after which no security patches or bug fixes will be provided. Migrating to the Gateway API is no longer a choice but a necessity.

In this article, we cover the whole picture: the Gateway API's architectural principles, the core resources in detail, the strategy for migrating from Ingress, TLS configuration, traffic splitting, and troubleshooting and failure recovery in day-to-day operations.

Gateway API vs Ingress: An Architectural Comparison

The Limits of Ingress

The Ingress resource mixes load balancer configuration and routing rules into a single resource. The concerns of the cluster operator and the application developer are not separated, and advanced features can only be implemented through non-standardized annotations.

The Gateway API's Role-based Design

The Gateway API adopts a role-oriented design that cleanly separates the concerns of the infrastructure provider, the cluster operator and the application developer.

ComparisonIngressGateway API
Resource structureA single Ingress resourceSplit into GatewayClass, Gateway and HTTPRoute
Role separationNone (all mixed into one resource)Split across infrastructure provider / cluster operator / developer
Protocol supportHTTP/HTTPS onlyHTTP, HTTPS, TCP, UDP, gRPC and TLS supported
Routing capabilityHost/path based onlyMatching on headers, query parameters and methods
Traffic splittingDepends on annotations (non-standard)Native weight-based splitting
TLS configurationBasic termination onlyTerminate, Passthrough, BackendTLSPolicy
Cross-namespaceNot possibleSafely supported through ReferenceGrant
PortabilityNeeds per-controller annotationsPortable through a standard API spec
Multi-tenancyWeakPer-Gateway namespace isolation supported
Status managementLimitedAccepted, Programmed and ResolvedRefs conditions

Architecture Diagram

Infrastructure Provider
  └─ GatewayClass: defines which controller implements the Gateway
Cluster Operator
  └─ Gateway: configures the listeners (port, protocol, TLS)
Application Developer
  └─ HTTPRoute / GRPCRoute / TCPRoute: defines the routing rules
  └─ ServicePod: handles the actual traffic

The Core Resources in Detail

1. GatewayClass

GatewayClass is a cluster-scoped resource defined by the infrastructure provider. It specifies which controller manages the Gateway, and it plays a role similar to Kubernetes' StorageClass.

apiVersion: gateway.networking.k8s.io/v1
kind: GatewayClass
metadata:
  name: envoy-gateway-class
spec:
  controllerName: gateway.envoyproxy.io/gatewayclass-controller
  description: 'Production gateway class based on Envoy Gateway'

The controllerName value for the main controllers:

ControllercontrollerName
Envoy Gatewaygateway.envoyproxy.io/gatewayclass-controller
NGINX Gateway Fabricgateway.nginx.org/nginx-gateway-controller
Istioistio.io/gateway-controller
Ciliumio.cilium/gateway-controller
Traefiktraefik.io/gateway-controller
Kongkonghq.com/kic-gateway-controller

2. Gateway

Gateway is a namespace-scoped resource managed by the cluster operator. Through its listeners it defines the port, protocol, hostname and TLS settings by which traffic enters.

apiVersion: gateway.networking.k8s.io/v1
kind: Gateway
metadata:
  name: production-gateway
  namespace: gateway-infra
  annotations:
    cert-manager.io/cluster-issuer: 'letsencrypt-prod'
spec:
  gatewayClassName: envoy-gateway-class
  listeners:
    - name: http
      protocol: HTTP
      port: 80
      allowedRoutes:
        namespaces:
          from: All
    - name: https
      protocol: HTTPS
      port: 443
      hostname: '*.example.com'
      tls:
        mode: Terminate
        certificateRefs:
          - kind: Secret
            name: wildcard-tls-cert
            namespace: gateway-infra
      allowedRoutes:
        namespaces:
          from: Selector
          selector:
            matchLabels:
              gateway-access: 'enabled'

The allowedRoutes setting forms the key security boundary in a multi-tenant environment. from: All means Routes from every namespace can bind to this listener, while from: Selector allows only namespaces carrying a particular label.

3. HTTPRoute

HTTPRoute is the routing-rule resource managed by the application developer. It provides a rich set of features as a standard API: path matching, header filtering, traffic splitting, redirects and URL rewriting.

apiVersion: gateway.networking.k8s.io/v1
kind: HTTPRoute
metadata:
  name: api-route
  namespace: backend-app
spec:
  parentRefs:
    - name: production-gateway
      namespace: gateway-infra
      sectionName: https
  hostnames:
    - 'api.example.com'
  rules:
    - matches:
        - path:
            type: PathPrefix
            value: /v2/users
          headers:
            - name: X-API-Version
              value: '2'
      filters:
        - type: RequestHeaderModifier
          requestHeaderModifier:
            add:
              - name: X-Forwarded-By
                value: 'gateway-api'
      backendRefs:
        - name: users-service-v2
          port: 8080
          weight: 100
    - matches:
        - path:
            type: PathPrefix
            value: /v1
      backendRefs:
        - name: api-service-v1
          port: 8080
          weight: 90
        - name: api-service-v2
          port: 8080
          weight: 10

4. ReferenceGrant (Cross-namespace References)

In the Gateway API, cross-namespace references are blocked by default. They have to be allowed explicitly through a ReferenceGrant. Promoted to v1 in v1.4, this resource enables flexible configuration while preserving the security boundary.

apiVersion: gateway.networking.k8s.io/v1
kind: ReferenceGrant
metadata:
  name: allow-gateway-to-backend-secrets
  namespace: gateway-infra
spec:
  from:
    - group: gateway.networking.k8s.io
      kind: Gateway
      namespace: gateway-infra
  to:
    - group: ''
      kind: Secret

Migrating from Ingress to the Gateway API

Overview of the Migration Strategy

The migration must be done in stages. The Gateway API controller can run in parallel with the existing Ingress controller in the same cluster, so switching over service by service and validating as you go is the safe approach.

Step 1: Install the Gateway API CRDs and the Controller

# install the standard Gateway API CRDs (v1.4.x)
kubectl apply -f https://github.com/kubernetes-sigs/gateway-api/releases/download/v1.4.1/standard-install.yaml

# install with the experimental features (includes TCPRoute, UDPRoute, BackendTLSPolicy)
kubectl apply -f https://github.com/kubernetes-sigs/gateway-api/releases/download/v1.4.1/experimental-install.yaml

# confirm the CRDs are installed
kubectl get crd | grep gateway.networking.k8s.io

# example output:
# gatewayclasses.gateway.networking.k8s.io
# gateways.gateway.networking.k8s.io
# httproutes.gateway.networking.k8s.io
# referencegrants.gateway.networking.k8s.io
# grpcroutes.gateway.networking.k8s.io

How you install the controller depends on the implementation you chose. Here is the Envoy Gateway example:

# install Envoy Gateway
helm install envoy-gateway oci://docker.io/envoyproxy/gateway-helm \
  --version v1.3.0 \
  -n envoy-gateway-system \
  --create-namespace

# confirm the installation
kubectl get pods -n envoy-gateway-system
kubectl get gatewayclass

Step 2: Automatic Conversion with the ingress2gateway Tool

The ingress2gateway tool analyzes your existing Ingress resources and converts them into Gateway API resources automatically. Roughly 30-40% of annotations need manual conversion, however, so you must review the output.

# install ingress2gateway
go install github.com/kubernetes-sigs/ingress2gateway@latest

# convert the current cluster's Ingress resources
ingress2gateway print --providers ingress-nginx \
  --all-namespaces > gateway-resources.yaml

# review the converted resources (a manual check is mandatory)
cat gateway-resources.yaml

# apply the converted resources to the staging environment first
kubectl apply -f gateway-resources.yaml --dry-run=server
kubectl apply -f gateway-resources.yaml -n staging

Step 3: Parallel Operation and Traffic Cutover

Run the existing Ingress and the Gateway API side by side, switching over gradually, service by service.

# check the Gateway status - confirming Programmed: True is mandatory
kubectl get gateway production-gateway -n gateway-infra -o jsonpath='{.status.conditions}'

# check the HTTPRoute status
kubectl get httproute -A

# test before pointing DNS at the Gateway API endpoint
GATEWAY_IP=$(kubectl get gateway production-gateway -n gateway-infra \
  -o jsonpath='{.status.addresses[0].value}')
curl -H "Host: api.example.com" https://$GATEWAY_IP/v1/health --resolve "api.example.com:443:$GATEWAY_IP"

# once verified, change DNS (CNAME or A record)
# delete the old Ingress resources once every service has been switched over
kubectl delete ingress api-ingress -n backend-app

Step 4: Clean Up the Old Ingress

Once every service has moved to the Gateway API, clean up the old Ingress controller. You must delete them one service at a time and confirm reachability after each one.

# delete the Ingress resources one at a time and validate
kubectl delete ingress api-ingress -n backend-app
# test service access immediately
curl -I https://api.example.com/v1/health

# once every Ingress is gone, remove the controller
kubectl get ingress -A  # confirm no Ingress remains
helm uninstall ingress-nginx -n ingress-nginx
kubectl delete namespace ingress-nginx

TLS Configuration and Certificate Management

Downstream TLS (Client to Gateway)

Configure TLS termination on the Gateway listener. Integrating with cert-manager automates certificate issuance and renewal.

apiVersion: gateway.networking.k8s.io/v1
kind: Gateway
metadata:
  name: tls-gateway
  namespace: gateway-infra
  annotations:
    cert-manager.io/cluster-issuer: 'letsencrypt-prod'
spec:
  gatewayClassName: envoy-gateway-class
  listeners:
    - name: https-wildcard
      protocol: HTTPS
      port: 443
      hostname: '*.example.com'
      tls:
        mode: Terminate
        certificateRefs:
          - kind: Secret
            name: wildcard-example-tls
      allowedRoutes:
        namespaces:
          from: All
    - name: https-specific
      protocol: HTTPS
      port: 443
      hostname: 'admin.internal.com'
      tls:
        mode: Terminate
        certificateRefs:
          - kind: Secret
            name: admin-tls-cert
      allowedRoutes:
        namespaces:
          from: Same

cert-manager detects the annotation on the Gateway resource, creates a Certificate resource automatically, and stores the issued certificate in the Secret named in certificateRefs. Automatic renewal happens 30 days before the certificate expires.

Upstream TLS (Gateway to Backend)

BackendTLSPolicy, promoted to the Standard Channel in v1.4, lets you configure the TLS connection from the Gateway to the backend Pods. That is how you implement end-to-end encryption.

apiVersion: gateway.networking.k8s.io/v1alpha3
kind: BackendTLSPolicy
metadata:
  name: backend-tls
  namespace: backend-app
spec:
  targetRefs:
    - group: ''
      kind: Service
      name: secure-backend-service
  validation:
    caCertificateRefs:
      - name: backend-ca-cert
        group: ''
        kind: ConfigMap
    hostname: secure-backend.backend-app.svc.cluster.local

The BackendTLSPolicy and its target Service must be in the same namespace. A cross-namespace BackendTLSPolicy is not supported because of the trust boundary problem.

TLS Passthrough

This is the mode in which the Gateway does not terminate TLS and passes it straight through to the backend. Use it when the backend application has to handle TLS itself.

apiVersion: gateway.networking.k8s.io/v1
kind: Gateway
metadata:
  name: passthrough-gateway
  namespace: gateway-infra
spec:
  gatewayClassName: envoy-gateway-class
  listeners:
    - name: tls-passthrough
      protocol: TLS
      port: 443
      hostname: 'secure-app.example.com'
      tls:
        mode: Passthrough
      allowedRoutes:
        namespaces:
          from: All

Traffic Splitting and Weighted Routing

Canary Deployment Traffic Splitting

Setting a weight on the HTTPRoute's backendRefs distributes traffic by ratio. Because the weight is a proportion, the values do not have to sum to 100; each backend's share is computed against the total of all weights.

apiVersion: gateway.networking.k8s.io/v1
kind: HTTPRoute
metadata:
  name: canary-route
  namespace: backend-app
spec:
  parentRefs:
    - name: production-gateway
      namespace: gateway-infra
  hostnames:
    - 'app.example.com'
  rules:
    - matches:
        - path:
            type: PathPrefix
            value: /
      backendRefs:
        # stable version: 90% of the traffic
        - name: app-stable
          port: 8080
          weight: 90
        # canary version: 10% of the traffic
        - name: app-canary
          port: 8080
          weight: 10

A Script for Progressive Traffic Cutover

Here is an example automation script that adjusts the traffic ratio in stages during a canary deployment.

#!/bin/bash
# script that increases canary traffic progressively

ROUTE_NAME="canary-route"
NAMESPACE="backend-app"
STAGES=(10 25 50 75 100)
WAIT_MINUTES=5

for canary_weight in "${STAGES[@]}"; do
  stable_weight=$((100 - canary_weight))

  echo "[$(date)] canary traffic ratio: ${canary_weight}%"

  kubectl patch httproute $ROUTE_NAME -n $NAMESPACE --type='json' \
    -p="[
      {\"op\": \"replace\", \"path\": \"/spec/rules/0/backendRefs/0/weight\", \"value\": $stable_weight},
      {\"op\": \"replace\", \"path\": \"/spec/rules/0/backendRefs/1/weight\", \"value\": $canary_weight}
    ]"

  echo "waiting ${WAIT_MINUTES} minutes... (monitoring the error rate)"
  sleep $((WAIT_MINUTES * 60))

  # check the error rate (example Prometheus query)
  ERROR_RATE=$(kubectl exec -n monitoring prometheus-0 -- \
    promtool query instant \
    'rate(http_requests_total{service="app-canary",code=~"5.."}[5m]) / rate(http_requests_total{service="app-canary"}[5m]) * 100' \
    2>/dev/null | grep -oP '[0-9.]+' | head -1)

  if (( $(echo "$ERROR_RATE > 5" | bc -l 2>/dev/null) )); then
    echo "error rate ${ERROR_RATE}% exceeded! rolling back"
    kubectl patch httproute $ROUTE_NAME -n $NAMESPACE --type='json' \
      -p='[
        {"op": "replace", "path": "/spec/rules/0/backendRefs/0/weight", "value": 100},
        {"op": "replace", "path": "/spec/rules/0/backendRefs/1/weight", "value": 0}
      ]'
    echo "rollback complete. canary deployment aborted."
    exit 1
  fi
done

echo "canary deployment complete. 100% of traffic switched over successfully."

Header-based Traffic Splitting

Routing only requests carrying a particular header to the new version lets the QA team or internal users validate it first.

apiVersion: gateway.networking.k8s.io/v1
kind: HTTPRoute
metadata:
  name: header-based-route
  namespace: backend-app
spec:
  parentRefs:
    - name: production-gateway
      namespace: gateway-infra
  hostnames:
    - 'app.example.com'
  rules:
    # rule 1: route to v2 when the X-Canary header is present
    - matches:
        - headers:
            - name: X-Canary
              value: 'true'
      backendRefs:
        - name: app-v2
          port: 8080
    # rule 2: route the default traffic to v1
    - matches:
        - path:
            type: PathPrefix
            value: /
      backendRefs:
        - name: app-v1
          port: 8080

Cautions for Day-to-day Operations

Monitoring Gateway Status

You must monitor the status conditions of the Gateway API resources. There are three key conditions.

ConditionMeaningWhen to check it
AcceptedThe resource is syntactically/semantically valid and the controller accepted itRight after the resource is created
ProgrammedThe configuration is fully reflected in the data planeA mandatory check before cutting over traffic
ResolvedRefsEvery external object it references (Secret, Service and so on) is validWith TLS configuration and cross-namespace references
# check the Gateway status conditions
kubectl describe gateway production-gateway -n gateway-infra

# check the HTTPRoute status
kubectl get httproute -A -o custom-columns=\
'NAME:.metadata.name,HOSTNAMES:.spec.hostnames[*],PARENT:.spec.parentRefs[0].name,ACCEPTED:.status.parents[0].conditions[?(@.type=="Accepted")].status'

Managing the allowedRoutes Setting

In production you must use from: Selector or from: Same instead of from: All, so that which namespaces' Routes may bind to the Gateway is restricted explicitly. Ignoring this allows a malicious or mistaken HTTPRoute to bind to the production Gateway, which can result in traffic hijacking.

Avoiding Listener Hostname Collisions

Defining duplicate listeners with the same hostname on the same Gateway produces unpredictable behavior. Listener names must be unique, and you have to understand the precedence rules when a wildcard hostname (*.example.com) and a specific hostname (api.example.com) coexist. The specific hostname takes precedence over the wildcard.

The Order of Resource Cleanup

The order matters when cleaning up resources after the migration. Deleting the HTTPRoute first cuts traffic immediately. You must confirm the DNS change first, and delete the old Ingress resources before anything else.

Troubleshooting

Problem 1: The Gateway Is Stuck at Programmed: False

Cause: the GatewayClass controller is not running, or the TLS certificate reference is not valid.

# check the controller Pod status
kubectl get pods -n envoy-gateway-system

# check the Gateway events
kubectl describe gateway production-gateway -n gateway-infra | tail -20

# check whether the TLS Secret exists
kubectl get secret wildcard-tls-cert -n gateway-infra

# check the GatewayClass status
kubectl get gatewayclass envoy-gateway-class -o yaml

Fix: if the controller Pod is in CrashLoopBackOff, check its logs. If the TLS Secret is missing, check the cert-manager logs and inspect the Certificate resource's status.

Problem 2: The HTTPRoute Is Accepted but Traffic Is Not Routed

Cause: the Service named in backendRef does not exist, the port number does not match, or the Pods are not healthy.

# check the ResolvedRefs condition on the HTTPRoute status
kubectl get httproute api-route -n backend-app -o yaml | grep -A5 "ResolvedRefs"

# check whether the backend Service exists
kubectl get svc users-service-v2 -n backend-app

# check the endpoints (whether there are healthy Pods)
kubectl get endpoints users-service-v2 -n backend-app

# check the Pod status and logs
kubectl get pods -n backend-app -l app=users-service-v2
kubectl logs -n backend-app -l app=users-service-v2 --tail=50

Problem 3: A Cross-namespace Reference Fails

Cause: the ReferenceGrant is not configured correctly.

# list the ReferenceGrants
kubectl get referencegrant -A

# inspect the ReferenceGrant in a specific namespace
kubectl describe referencegrant -n gateway-infra

Fix: check that the namespace, group and kind in the from field match the referencing resource exactly. The ReferenceGrant has to be created in the namespace where the reference target (to) lives.

Problem 4: No Certificate Is Issued with the cert-manager Integration

# check the Certificate resource status
kubectl get certificate -n gateway-infra

# check the cert-manager logs
kubectl logs -n cert-manager deploy/cert-manager --tail=100

# check the Challenge status (ACME HTTP-01)
kubectl get challenge -A

# check the Order status
kubectl get order -A

Fix: check that the ClusterIssuer is configured correctly, that the ACME server is reachable, and that the port 80 listener needed for the HTTP-01 challenge is open.

Failure Cases and Recovery Procedures

Case 1: Traffic Lost During the DNS Cutover

Situation: the Ingress was deleted while the DNS TTL was still high, and traffic from some clients was lost.

Recovery procedure:

  1. Recreate the deleted Ingress resource immediately to restore the old path.
  2. Lower the DNS TTL to 300 seconds (5 minutes) or less, then wait at least 2 times the previous TTL.
  3. Reconfirm that the Gateway's Programmed status is True.
  4. Point DNS at the Gateway API endpoint.
  5. Monitor for at least 24 hours before deleting the old Ingress.

Prevention: lower the DNS TTL to 60-300 seconds before the migration and allow enough propagation time before cutting over.

Case 2: A Full Traffic Outage from a Weight Misconfiguration

Situation: during a canary deployment every backendRefs weight was set to 0, which produced 503 errors.

Recovery procedure:

# restore 100% of the traffic to the stable version immediately
kubectl patch httproute canary-route -n backend-app --type='json' \
  -p='[
    {"op": "replace", "path": "/spec/rules/0/backendRefs/0/weight", "value": 100},
    {"op": "replace", "path": "/spec/rules/0/backendRefs/1/weight", "value": 0}
  ]'

# confirm it applied
kubectl get httproute canary-route -n backend-app -o yaml

Prevention: include validation logic in the automation script so that at least one backend keeps a weight greater than 0 whenever the weights change.

Case 3: A TLS Certificate Reference Fails Because a ReferenceGrant Is Missing

Situation: the Gateway and the TLS Secret were in different namespaces and it was deployed without a ReferenceGrant, so the Gateway never reached the Programmed state.

Recovery procedure:

  1. Check the ResolvedRefs condition in the Gateway's status.conditions.
  2. Create the missing ReferenceGrant in the namespace where the Secret lives.
  3. Confirm that the Gateway transitions to Programmed: True.

Case 4: Data Plane Interruption During a Controller Upgrade

Situation: while upgrading the Gateway API controller with Helm, the data plane Pods restarted and traffic was briefly interrupted.

Recovery procedure:

  1. You must back up the Gateway resources as YAML before upgrading.
  2. Check the controller's RollingUpdate strategy.
  3. Run a Helm rollback if an interruption occurs.
# back up before upgrading
kubectl get gateway,httproute,referencegrant -A -o yaml > gateway-backup.yaml

# Helm rollback
helm rollback envoy-gateway -n envoy-gateway-system

# check the data plane Pod status
kubectl get pods -n envoy-gateway-system -w

Prevention: set a PodDisruptionBudget for the controller upgrade, and validate it in a staging environment first.

Migration Checklist

Here is a checklist of what to confirm before, during and after the migration.

Preparation

During the Migration

After the Migration

Conclusion

The Gateway API is the future of Kubernetes networking. Its role-based design cleanly separates the concerns of infrastructure operators and application developers, its standardized API guarantees portability between controllers, and it provides native traffic splitting and advanced routing. With BackendTLSPolicy and ReferenceGrant promoted to GA in v1.4, nearly everything production needs is now in the Standard Channel.

With the official end of support for Ingress NGINX scheduled for March 2026, now is the time if you have not started migrating yet. Use the staged migration strategy, the ingress2gateway tool and the parallel operation approach covered in this article to make the switch safely. Most important of all: you must confirm the Gateway is at Programmed: True before you cut any traffic over. Not rushing, and instead switching over service by service while validating, is the safest approach.

References

Comments

No comments yet.

Sign in to leave a comment