Zero-Downtime Migration from Ingress to Kubernetes Gateway API in ACE

The Kubernetes Gateway API is the designated successor to Ingress. It offers richer routing semantics, native TCP/UDP support, explicit timeout configuration, upstream TLS policies, and a cleaner separation between infrastructure owners and application teams — all without relying on controller-specific annotations. For kubedb-platform, migrating to Gateway API was a clear improvement. The hard part was doing it without breaking anything for our existing self-hosted users.

This post explains the three-phase strategy we used to achieve a fully transparent, zero-downtime migration. In the full demonstration, Lets assume “dbaas.kubedb.cloud” is the domain user is using.


Phase 1: The Starting Point — Ingress Only

In the initial state, all HTTP(S) traffic is routed through nginx Ingress using the nginx-ace IngressClass. There are four Ingress objects in the ace namespace:

Ingress Purpose
ace Main routing — 7 path-prefix rules across all ACE services
ace-home Redirect / to /accounts/selfhost-home
ace-nats Proxy WebSocket/HTTP traffic to NATS (regex path, upstream TLS)
service-backend Route /bind/ to the service backend

The main ace Ingress maps URL paths to backend services:

rules:
- http:
    paths:
    - path: /accounts   → ace-platform-api:80
    - path: /api        → ace-platform-api:80
    - path: /console    → ace-cluster-ui:80
    - path: /db         → ace-kubedb-ui:80
    - path: /id         → ace-platform-ui:80
    - path: /grafana    → ace-grafana:80
    - path: /prometheus → ace-trickster:4000

The ace-home Ingress uses nginx.ingress.kubernetes.io/rewrite-target: /accounts/selfhost-home to redirect root traffic. The ace-nats Ingress relies on two nginx-specific annotations — backend-protocol: HTTPS for upstream TLS and use-regex: "true" with a capture group in the path for stripping the /nats prefix before forwarding to the NATS service.

DNS is managed by an ExternalDNS resource named ace-ingress, which watches Ingress objects and registers:

dbaas.kubedb.cloud → <nginx LB IP>

This is the URL users know and use.


Phase 2: Dual-Stack — Gateway API Running Alongside Ingress

Before cutting over, we deploy the full Gateway API configuration alongside the existing Ingress objects. Both serve live traffic from their own load balancers during this phase.

The Gateway Resource

apiVersion: gateway.networking.k8s.io/v1
kind: Gateway
metadata:
  name: ace
  namespace: ace
spec:
  gatewayClassName: ace   # Envoy Gateway
  listeners:
  - name: http
    port: 80
    protocol: HTTP
  - name: https
    port: 443
    protocol: HTTPS
    tls:
      mode: Terminate
      certificateRefs:
      - name: ace-gw-cert
  - name: nats-tcp
    port: 4222
    protocol: TCP
  - name: s3proxy-tcp
    port: 4224
    protocol: TCP

The https listener terminates TLS using a certificate provisioned specifically for the Gateway. The two TCP listeners handle NATS and S3 proxy traffic at the transport layer — something Ingress simply cannot do.

HTTPRoutes Mirror the Ingress Rules

Each Ingress object has a corresponding HTTPRoute. The translation makes the implicit explicit.

Main routing (ace HTTPRoute):

rules:
- matches:
  - path: {type: PathPrefix, value: /api}
  backendRefs:
  - name: ace-platform-api
    port: 80
  timeouts:
    request: 2m
    backendRequest: 2m
# ... same pattern for /accounts, /console, /db, /id, /grafana, /prometheus

Explicit timeouts per route — not possible with standard Ingress.

Root redirect (ace-home HTTPRoute):

rules:
- matches:
  - path: {type: PathPrefix, value: /}
  filters:
  - type: URLRewrite
    urlRewrite:
      path:
        type: ReplaceFullPath
        replaceFullPath: /accounts/selfhost-home
  backendRefs:
  - name: ace-platform-api
    port: 80

The nginx.ingress.kubernetes.io/rewrite-target annotation is replaced by a standard URLRewrite filter.

HTTP→HTTPS redirect (ace-http-redirect HTTPRoute):

rules:
- matches:
  - path: {type: PathPrefix, value: /}
  filters:
  - type: RequestRedirect
    requestRedirect:
      scheme: https
      statusCode: 301

No annotation needed — this is a first-class Gateway API primitive.

NATS routing (ace-nats HTTPRoute):

rules:
- matches:
  - path: {type: PathPrefix, value: /nats}
  filters:
  - type: URLRewrite
    urlRewrite:
      path:
        type: ReplacePrefixMatch
        replacePrefixMatch: /
  backendRefs:
  - name: ace-nats
    port: 443

The regex path + capture group from Ingress is replaced by ReplacePrefixMatch. Upstream TLS validation (previously backend-protocol: HTTPS) is expressed as a separate BackendTLSPolicy resource that references the CA certificate and validates the upstream hostname — a proper policy object rather than an opaque annotation.

Two DNS Entries Coexist

A second ExternalDNS resource (ace-gw) watches the Envoy proxy Service (filtered by label) and registers:

ace.ace.dbaas.kubedb.cloud → <Envoy LB IP>

During Phase 2, both domains are active:

Domain Points To Managed By
dbaas.kubedb.cloud nginx LB ace-ingress ExternalDNS
ace.ace.dbaas.kubedb.cloud Envoy LB ace-gw ExternalDNS

Users can validate the new Gateway URL independently before any cutover.


Phase 3: The Switching Moment — Zero-Downtime Cutover

When the user is ready, they disable the ingress feature in their selfhost installer values and trigger an ACE upgrade or reconfigure:

# Before
ingress-dns:
  enabled: true

# After
ingress-dns:
  enabled: false

On the next upgrade, the ACE upgrader job automates the cutover in three steps.

Step 1: Helm Upgrade Deletes Ingress Resources

upgrader.UpdateACEInstaller() runs a helm upgrade with the new values. Since ingress-dns.enabled is now false, helm removes the ace-ingress ExternalDNS resource and all four Ingress objects from the cluster. The nginx LB still exists momentarily, but DNS no longer points to it.

Step 2: Upgrader Detects the Transition

The upgrader checks the installer values:

gatewayDNSEnabled := getNestedBool(installerValues, ..., "gateway-dns", "enabled")
ingressDNSEnabled := getNestedBool(installerValues, ..., "ingress-dns", "enabled")

if gatewayDNSEnabled && !ingressDNSEnabled {
    err = syncExternalDNS(kc)
}

Step 3: syncExternalDNS Performs the DNS Handoff

This is the critical piece. It cannot simply create the new DNS record immediately — the old ace-ingress ExternalDNS was just deleted by helm, but the deletion may not be complete yet. If both ExternalDNS resources try to own the same DNS record simultaneously, they will conflict.

The function waits for the deletion to complete before proceeding:

func waitForIngressExternalDNSToBeDeleted(kc client.Client) error {
    return wait.PollUntilContextTimeout(ctx, 3*time.Second, 20*time.Minute, true,
        func(ctx context.Context) (bool, error) {
            var ingDNS extdnsv1a1.ExternalDNS
            if err := kc.Get(ctx, ..., &ingDNS); errors.IsNotFound(err) {
                return true, nil   // gone, proceed
            }
            if ingDNS.Labels[meta_util.ManagedByLabelKey] == "Helm" {
                return false, nil  // still exists, keep waiting
            }
            return true, nil
        },
    )
}

Once deleted, copyExternalDNS creates a new ace-ingress ExternalDNS modeled on the existing ace-gw spec, but targeting the base domain instead of the ace.ace.* subdomain. It strips the ace.ace. prefix from the gateway’s DNS record to derive the base FQDN:

ace.ace.dbaas.kubedb.cloud  →  dbaas.kubedb.cloud

The new ExternalDNS watches the same Envoy proxy Service, so:

dbaas.kubedb.cloud → <Envoy LB IP>

The user’s existing URL now routes to Gateway — without any change on their end.

Cutover Sequence

Helm upgrade
  └─ delete Ingress objects (ace, ace-home, ace-nats, service-backend)
  └─ delete ace-ingress ExternalDNS

Upgrader polls: waiting for ace-ingress ExternalDNS to disappear...

  └─ create new ace-ingress ExternalDNS (source: Envoy Service)
       └─ DNS: dbaas.kubedb.cloud → Envoy LB IP  ✓

Users continue using dbaas.kubedb.cloud — now served by Gateway

Ingress vs Gateway API: What Changed

Capability Ingress Gateway API
Path rewrite nginx.ingress.kubernetes.io/rewrite-target annotation URLRewrite filter on HTTPRoute
Upstream TLS nginx.ingress.kubernetes.io/backend-protocol: HTTPS annotation BackendTLSPolicy resource
HTTP→HTTPS redirect Controller-specific annotation RequestRedirect filter on HTTPRoute
Per-route timeouts Not supported Native timeouts field on HTTPRoute rule
TCP/UDP routing Not supported TCPRoute / UDPRoute + TCP/UDP listener
Regex path matching ImplementationSpecific + use-regex annotation Not needed — ReplacePrefixMatch covers the common case

Summary

The migration involved three stages: running ingress-only, running both in parallel, and then atomically handing off DNS at upgrade time. The dual-stack phase gave us a safe window to validate Gateway behavior before committing. The upgrader job’s syncExternalDNS function handled the DNS handoff automatically — waiting for the old record owner to be gone before claiming it, ensuring no conflict and no interruption for users.

Gateway API is now the default for all new ACE installations, and existing users migrate transparently on their next upgrade.


TAGS

Get Up and Running Quickly

Deploy, manage, upgrade Kubernetes on any cloud and automate deployment, scaling, and management of containerized applications.