Skip to main content
RunBook Academy

← All labs in Kubernetes

Lab · advanced · ~120 min

Lab 16: Ingress and the Gateway API

B · Nested virtualisationA · Physical hardware

Objectives

  • Install ingress-nginx on a cluster with no cloud load balancer and reach it over a NodePort
  • Produce, and then diagnose, an Ingress that the API server accepts and no controller serves
  • Prove host-based routing and TLS termination from outside the cluster with curl and openssl s_client
  • Express the same routing as a GatewayClass, a Gateway and an HTTPRoute, and read the status conditions that say whether it is live
  • Explain why both APIs fail silently when the class field does not match an installed controller

Prerequisites

Objective

By the end of this lab you will have served the same two applications through two different edge APIs on one kubeadm cluster: first through an Ingress and an ingress-nginx controller, then through a Gateway and an HTTPRoute. Along the way you will have produced, deliberately, the failure both APIs share — a resource the API server accepts, stores, and never serves, with no error anywhere — and you will be able to say which single field caused it in each case and which command exposes it.

That failure is the reason this lab exists. Everything else here is API syntax you can look up.

Architecture

One namespace with two trivial HTTP backends, reached from your workstation through whichever edge implementation is installed at the time.

flowchart LR
    W[Workstation curl] -->|NodePort on any node IP| C{Edge implementation}
    C -->|Ingress path| N[ingress-nginx controller]
    C -->|Gateway API path| G[Gateway implementation]
    N -->|Host: billing.lab.example.com| SB[Service billing]
    N -->|Host: auth.lab.example.com| SA[Service auth]
    G -->|HTTPRoute billing| SB
    G -->|HTTPRoute auth| SA
    SB --> PB[Pod billing nginx]
    SA --> PA[Pod auth nginx]

There is no cloud load balancer in this picture, and that is the point. On a kubeadm cluster a Service of type LoadBalancer stays Pending forever because nothing implements it. The bare-metal deployment of ingress-nginx uses a NodePort Service instead, so the entry point is a high port on a node’s existing address. Everything you learn about routing rules is unchanged; only the first hop differs.

Requirements

  • A disposable kubeadm cluster, one control-plane node and at least one worker, Kubernetes 1.34.x, with a CNI installed and all nodes Ready.
  • kubectl 1.34.x configured with cluster-admin, run from a workstation that can reach the node addresses directly.
  • curl and openssl on the workstation.
  • Outbound internet access from the cluster (to pull nginx:alpine) and from the workstation (to fetch two upstream manifests).
  • Roughly 1 GiB of spare memory across the workers. Both backends are single nginx Pods; the ingress controller is one more.
  • No out-of-band access requirement. Nothing in this lab reconfigures a node’s interfaces, routes, firewall or SSH daemon. Your session cannot be locked out by any step here.

Scenario

Two teams share one cluster. Billing and auth each own a Service, and both need to be reachable from outside on their own hostname. There is one public address between them, so the split has to happen at the HTTP layer — by Host header — rather than by giving each team its own IP.

The cluster is being migrated: new work is meant to land on the Gateway API, but everything in production today is an Ingress, and both will run side by side for a quarter. You have been asked to prove the two paths are equivalent for this workload before anyone commits to a migration date.

Tasks

Task 1: Record what is there before you touch anything

Cleanup is only honest if you know the starting state. Capture it now.

WORKDIR="$HOME/k8s-edge-lab"
mkdir -p "$WORKDIR"
cd "$WORKDIR"

kubectl get ingressclass -o wide > before-ingressclass.txt 2>&1
kubectl get crd -o name | grep gateway.networking.k8s.io > before-gateway-crds.txt 2>&1 || true
kubectl get ns > before-namespaces.txt 2>&1
kubectl get pods -A -o wide > before-pods.txt

cat before-ingressclass.txt before-gateway-crds.txt

On a stock kubeadm cluster both of the first two files are empty or say No resources found. That is the expected starting state, and it is also the first fact of the lab: a cluster with no ingress controller still accepts Ingress objects. Nothing in the API server refuses them.

Task 2: Deploy the two backends and prove them before touching the edge

Write backends.yaml:

apiVersion: v1
kind: Namespace
metadata:
  name: edge-lab
---
apiVersion: v1
kind: ConfigMap
metadata:
  name: site-billing
  namespace: edge-lab
data:
  index.html: |
    billing backend, revision 1
---
apiVersion: v1
kind: ConfigMap
metadata:
  name: site-auth
  namespace: edge-lab
data:
  index.html: |
    auth backend, revision 1
---
apiVersion: apps/v1
kind: Deployment
metadata:
  name: billing
  namespace: edge-lab
spec:
  replicas: 1
  selector:
    matchLabels:
      app: billing
  template:
    metadata:
      labels:
        app: billing
    spec:
      containers:
        - name: nginx
          image: nginx:alpine
          ports:
            - containerPort: 80
          volumeMounts:
            - name: site
              mountPath: /usr/share/nginx/html
      volumes:
        - name: site
          configMap:
            name: site-billing
---
apiVersion: v1
kind: Service
metadata:
  name: billing
  namespace: edge-lab
spec:
  selector:
    app: billing
  ports:
    - name: http
      port: 80
      targetPort: 80
---
apiVersion: apps/v1
kind: Deployment
metadata:
  name: auth
  namespace: edge-lab
spec:
  replicas: 1
  selector:
    matchLabels:
      app: auth
  template:
    metadata:
      labels:
        app: auth
    spec:
      containers:
        - name: nginx
          image: nginx:alpine
          ports:
            - containerPort: 80
          volumeMounts:
            - name: site
              mountPath: /usr/share/nginx/html
      volumes:
        - name: site
          configMap:
            name: site-auth
---
apiVersion: v1
kind: Service
metadata:
  name: auth
  namespace: edge-lab
spec:
  selector:
    app: auth
  ports:
    - name: http
      port: 80
      targetPort: 80

nginx:alpine is a floating tag, used here so the lab does not go stale. In production you pin a digest; a floating tag means two nodes can be running two different images with the same name.

Configuration changeworkstation
$ kubectl apply -f backends.yaml

Now prove the backends work before any edge component exists. This is the cheapest check available and it is deliberately first: the single most common Ingress symptom in production is a 503, and a 503 means the controller could not reach an endpoint. If you have not established that the endpoints exist, you will spend the next hour reading controller logs about a problem that is not in the controller.

kubectl -n edge-lab rollout status deploy/billing --timeout=120s
kubectl -n edge-lab rollout status deploy/auth --timeout=120s

kubectl -n edge-lab get endpointslices -o wide
Read-only / Safeworkstation
$ kubectl -n edge-lab run probe --rm -it --restart=Never --image=nginx:alpine -- curl -sS http://billing.edge-lab.svc.cluster.local/
billing backend, revision 1
pod "probe" deleted

Illustrative output

Repeat for auth. Two facts are now established and will not need to be re-established: the Services resolve, and they have ready endpoints. Anything that breaks later is at the edge.

Task 3: Install ingress-nginx, and read the manifest before applying it

The bare-metal deployment is one manifest. It is also several hundred lines that create cluster-scoped RBAC, so read what it makes before you run it.

# Pick a release from the ingress-nginx releases page - each release lists the
# Kubernetes versions it supports. Set the tag here, then verify the URL
# resolves before applying anything: a tag that does not exist gives a 404,
# which is the fastest confirmation that you picked a real one.
#   https://github.com/kubernetes/ingress-nginx/releases
INGRESS_NGINX_TAG=controller-v1.12.0
BASE=https://raw.githubusercontent.com/kubernetes/ingress-nginx

curl -fsSL "$BASE/$INGRESS_NGINX_TAG/deploy/static/provider/baremetal/deploy.yaml" \
  -o ingress-nginx.yaml

grep -cE '^kind:' ingress-nginx.yaml
grep -E '^kind:' ingress-nginx.yaml | sort | uniq -c

The counts tell you what you are agreeing to: a Namespace, a Deployment, a Service, an IngressClass, ServiceAccount, Role/RoleBinding, ClusterRole/ClusterRoleBinding, ConfigMap, and admission-webhook Jobs. The ClusterRole is worth a minute of your attention — an ingress controller reads Secrets across the whole cluster, because that is where TLS certificates live.

Cluster-wide riskworkstation
$ kubectl apply -f ingress-nginx.yaml
kubectl -n ingress-nginx rollout status deploy/ingress-nginx-controller --timeout=300s
kubectl -n ingress-nginx get pods
kubectl get ingressclass -o wide

Read two things from the output. First, the IngressClass the manifest created: its name (nginx) and its CONTROLLER value (k8s.io/ingress-nginx). That controller string is what the running controller matches itself against. Second, whether the class is marked default:

kubectl get ingressclass nginx \
  -o jsonpath='{.metadata.annotations.ingressclass\.kubernetes\.io/is-default-class}{"\n"}'

An empty line means there is no default class in this cluster. Remember that; Task 4 depends on it.

Finally, find the entry point. On bare metal the controller’s Service is a NodePort, so the address is a node plus a high port:

kubectl -n ingress-nginx get svc ingress-nginx-controller -o wide

NODE_IP=$(kubectl get nodes \
  -o jsonpath='{.items[0].status.addresses[?(@.type=="InternalIP")].address}')
HTTP_PORT=$(kubectl -n ingress-nginx get svc ingress-nginx-controller \
  -o jsonpath='{.spec.ports[?(@.name=="http")].nodePort}')
HTTPS_PORT=$(kubectl -n ingress-nginx get svc ingress-nginx-controller \
  -o jsonpath='{.spec.ports[?(@.name=="https")].nodePort}')

echo "$NODE_IP $HTTP_PORT $HTTPS_PORT"

Any node’s address works. NodePort opens the port on every node and kube-proxy forwards to wherever the controller Pod actually runs.

Task 4: Create an Ingress that is never served, then fix it

This is the task the lab is built around. Write ingress-unclassed.yaml:

apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
  name: edge
  namespace: edge-lab
spec:
  rules:
    - host: billing.lab.example.com
      http:
        paths:
          - path: /
            pathType: Prefix
            backend:
              service:
                name: billing
                port:
                  number: 80

Note what is missing: spec.ingressClassName. Apply it and watch.

Configuration changeworkstation
$ kubectl apply -f ingress-unclassed.yaml
Read-only / Safeworkstation
$ kubectl -n edge-lab get ingress
NAME   CLASS    HOSTS                     ADDRESS   PORTS   AGE
edge   <none>   billing.lab.example.com             80      45s

Illustrative output

The CLASS column reads <none> and the ADDRESS column is empty. Now run the command everyone runs next, and notice how little it gives you:

kubectl -n edge-lab describe ingress edge

The rules are there, the backend is named, and the Events section is empty. No warning, no rejection, no controller comment. The object is valid; it simply belongs to nobody.

Confirm the consequence from outside:

curl -sS -o /dev/null -w '%{http_code}\n' \
  --resolve "billing.lab.example.com:$HTTP_PORT:$NODE_IP" \
  "http://billing.lab.example.com:$HTTP_PORT/"

A 404 comes back — from ingress-nginx’s default backend, because the controller is answering on that port for a hostname it has no rule for.

Fix it the explicit way, which is also the right way in a multi-controller cluster:

kubectl -n edge-lab patch ingress edge \
  --type merge -p '{"spec":{"ingressClassName":"nginx"}}'

kubectl -n edge-lab get ingress
kubectl -n edge-lab describe ingress edge | tail -15

Within a few seconds the CLASS column shows nginx, the ADDRESS column gains an address, and describe now shows a Sync event from the controller. That event appearing is the proof that a controller has adopted the object — it is the signal that was missing before, and it is the thing to look for first next time.

Task 5: Prove host-based routing from outside

Add the second host. Write ingress-two-hosts.yaml:

apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
  name: edge
  namespace: edge-lab
spec:
  ingressClassName: nginx
  rules:
    - host: billing.lab.example.com
      http:
        paths:
          - path: /
            pathType: Prefix
            backend:
              service:
                name: billing
                port:
                  number: 80
    - host: auth.lab.example.com
      http:
        paths:
          - path: /
            pathType: Prefix
            backend:
              service:
                name: auth
                port:
                  number: 80
kubectl apply -f ingress-two-hosts.yaml

for HOST in billing.lab.example.com auth.lab.example.com; do
  printf '%s -> ' "$HOST"
  curl -sS --resolve "$HOST:$HTTP_PORT:$NODE_IP" "http://$HOST:$HTTP_PORT/"
done

printf 'unknown host -> '
curl -sS -o /dev/null -w '%{http_code}\n' \
  --resolve "nothing.lab.example.com:$HTTP_PORT:$NODE_IP" \
  "http://nothing.lab.example.com:$HTTP_PORT/"

--resolve is doing the work that DNS would do in production: it pins the hostname to an address for this request only, without touching /etc/hosts or any resolver. The Host header still carries the real name, which is the only thing the controller routes on.

Two backends answer with their own text on one port; an unmatched host gets 404. That is host-based virtual hosting, and it is the entire value proposition of an ingress controller over one Service per application.

Task 6: Terminate TLS, and prove which certificate is served

Before you create any certificate, look at what the controller already serves on the HTTPS port:

echo | openssl s_client -connect "$NODE_IP:$HTTPS_PORT" \
  -servername billing.lab.example.com 2>/dev/null \
  | openssl x509 -noout -subject -issuer -dates

ingress-nginx answers with its own self-signed placeholder certificate. It is serving TLS on that port whether or not you have configured a certificate, which is why “the site is up on 443” proves nothing about your certificate configuration.

Make a real one for the hostname, and hand it to the controller:

openssl req -x509 -newkey rsa:2048 -nodes -days 30 \
  -keyout billing.key -out billing.crt \
  -subj "/CN=billing.lab.example.com" \
  -addext "subjectAltName=DNS:billing.lab.example.com"

kubectl -n edge-lab create secret tls billing-tls \
  --cert=billing.crt --key=billing.key

Add the tls block to the Ingress — the hosts list must contain the exact hostname, because that is what the controller matches the SNI against:

spec:
  ingressClassName: nginx
  tls:
    - hosts:
        - billing.lab.example.com
      secretName: billing-tls
  rules:
    - host: billing.lab.example.com
      http:
        paths:
          - path: /
            pathType: Prefix
            backend:
              service:
                name: billing
                port:
                  number: 80

Apply it, then run the same s_client command again. The subject changes to your CN. Then fetch over HTTPS:

curl -sS -k --resolve "billing.lab.example.com:$HTTPS_PORT:$NODE_IP" \
  "https://billing.lab.example.com:$HTTPS_PORT/"

echo | openssl s_client -connect "$NODE_IP:$HTTPS_PORT" \
  -servername auth.lab.example.com 2>/dev/null \
  | openssl x509 -noout -subject

-k is required because the certificate is self-signed and nothing trusts it; that flag is a lab affordance and never belongs in a health check, where it turns a certificate outage into a silent pass.

The second s_client is the more interesting one. auth.lab.example.com has no tls entry, so it falls back to the placeholder certificate. Two hostnames on one listener, two different certificates, selected by SNI — and the Ingress object never mentions SNI, because the mapping from hostname to Secret is the tls list.

Task 7: Express the same routing as Gateway API, with no implementation installed

Install the CRDs only. This is the standard channel — the set of resources that has reached GA — and installing it changes nothing about traffic.

# The Part XLIII lessons use v1.0.0. Check the releases page for the current
# release before doing this for real; the URL shape does not change.
#   https://github.com/kubernetes-sigs/gateway-api/releases
GATEWAY_API_VERSION=v1.0.0
GW_BASE=https://github.com/kubernetes-sigs/gateway-api/releases/download

kubectl apply -f "$GW_BASE/$GATEWAY_API_VERSION/standard-install.yaml"
kubectl get crd -o name | grep gateway.networking.k8s.io

Now write gateway.yaml — three objects, mapping one to one onto the three roles Part XLIII describes:

apiVersion: gateway.networking.k8s.io/v1
kind: GatewayClass
metadata:
  name: lab-gateway
spec:
  # Take this string from your chosen implementation's documentation. It is the
  # exact identifier the controller matches itself against, and a typo here is
  # indistinguishable from the controller not being installed.
  controllerName: gateway.nginx.org/nginx
---
apiVersion: gateway.networking.k8s.io/v1
kind: Gateway
metadata:
  name: edge-gateway
  namespace: edge-lab
spec:
  gatewayClassName: lab-gateway
  listeners:
    - name: http
      port: 80
      protocol: HTTP
      allowedRoutes:
        namespaces:
          from: Same
---
apiVersion: gateway.networking.k8s.io/v1
kind: HTTPRoute
metadata:
  name: billing
  namespace: edge-lab
spec:
  parentRefs:
    - name: edge-gateway
      sectionName: http
  hostnames:
    - billing.lab.example.com
  rules:
    - matches:
        - path:
            type: PathPrefix
            value: /
      backendRefs:
        - name: billing
          port: 80
Configuration changeworkstation
$ kubectl apply -f gateway.yaml

Every one of those is accepted. Now read the status of each — this is the whole exercise:

kubectl get gatewayclass lab-gateway -o yaml | sed -n '/^status:/,$p'
kubectl -n edge-lab get gateway edge-gateway -o yaml | sed -n '/^status:/,$p'
kubectl -n edge-lab get httproute billing -o yaml | sed -n '/^status:/,$p'

With no controller running there is nothing to report: no Accepted condition on the GatewayClass, no addresses or listener status on the Gateway, and an empty or absent parents list on the HTTPRoute. The table view says the same thing more briefly:

Read-only / Safeworkstation
$ kubectl -n edge-lab get gateway
NAME           CLASS         ADDRESS   PROGRAMMED   AGE
edge-gateway   lab-gateway                           40s

Illustrative output

Task 8: Install an implementation and watch the same three objects come alive

Gateway API is a specification; the data plane is a separate project. Pick one from the implementations page in References — the two most commonly used with plain nginx or Envoy data planes both publish a manifest install — and follow that project’s own installation page. This lab deliberately does not reproduce the command: the manifest path and the supported install method change between releases, and a stale copy here would be worse than a link.

Two things you must carry across from this lab when you do:

  1. The controllerName in your GatewayClass must be the exact string that implementation documents. Fix gateway.yaml if it differs, and re-apply.
  2. Like ingress-nginx, an implementation installed on a kubeadm cluster with no cloud load balancer will expose itself on a NodePort (or ask you to choose). Find its Service the same way you found the ingress-nginx one.

Then run exactly the same three status reads as Task 7:

kubectl get gatewayclass lab-gateway -o wide
kubectl -n edge-lab get gateway edge-gateway -o wide
kubectl -n edge-lab get httproute billing -o yaml | sed -n '/^status:/,$p'

What to look for, in order:

  • GatewayClass: an Accepted condition with status True. If this stays absent, the controllerName does not match; nothing downstream can work.
  • Gateway: an address, and Programmed with status True. The listener status also carries attachedRoutes — the number of routes bound to that listener, which is the fastest way to tell “my route did not attach” from “my backend is broken”.
  • HTTPRoute: status.parents now has an entry naming the Gateway, with Accepted and ResolvedRefs conditions. ResolvedRefs=False means the route attached but its backendRefs do not resolve — a missing Service, or a cross-namespace reference with no ReferenceGrant.

Then prove traffic, using the new Service’s NodePort in place of $HTTP_PORT:

GW_PORT=$(kubectl -n edge-lab get gateway edge-gateway \
  -o jsonpath='{.spec.listeners[0].port}')
echo "listener port $GW_PORT - map this to the implementation Service NodePort"

curl -sS --resolve "billing.lab.example.com:$HTTP_PORT:$NODE_IP" \
  "http://billing.lab.example.com:$HTTP_PORT/"

Validation

The lab is complete when all of these hold.

  1. kubectl -n edge-lab get endpointslices lists ready addresses for both billing and auth.
  2. curl with --resolve returns billing backend, revision 1 for billing.lab.example.com and auth backend, revision 1 for auth.lab.example.com, on the same port.
  3. curl for an unmatched hostname on that same port returns 404.
  4. kubectl -n edge-lab get ingress shows CLASS nginx and a non-empty ADDRESS, and describe shows a Sync event from the controller.
  5. openssl s_client -servername billing.lab.example.com reports a subject of CN=billing.lab.example.com, while the same command with -servername auth.lab.example.com reports the controller’s placeholder certificate.
  6. Before installing an implementation, the Gateway shows an empty PROGRAMMED column and its status block carries no conditions.
  7. After installing one, the GatewayClass reports Accepted=True, the Gateway reports Programmed=True with an address, and the HTTPRoute status.parents names edge-gateway with Accepted=True and ResolvedRefs=True.

Point 6 is a validation step, not a failure. A lab that only ever shows the working state teaches you nothing about the state you will actually be paged for.

Expected Outcome

k8s-edge-lab/
├── backends.yaml
├── before-gateway-crds.txt
├── before-ingressclass.txt
├── before-namespaces.txt
├── before-pods.txt
├── billing.crt
├── billing.key
├── gateway.yaml
├── ingress-nginx.yaml
├── ingress-two-hosts.yaml
├── ingress-unclassed.yaml
└── notes.md

In the cluster: an edge-lab namespace with two backends reachable by hostname through one port, TLS terminated for one of the two hostnames with a certificate you can name, and a Gateway API expression of the same routing whose status you can read in either direction.

Troubleshooting

curl hangs instead of returning. The NodePort is not reachable from your workstation. On a nested-virtualisation setup this is usually the host firewall or the VM network mode rather than anything in Kubernetes. Test from a node itself with the node’s own address before suspecting the cluster.

503 Service Temporarily Unavailable instead of the backend text. The controller adopted the Ingress and could not reach an endpoint. Re-run the Task 2 checks: kubectl -n edge-lab get endpointslices. An empty endpoint list means the Service selector does not match the Pod labels, or the Pods are not ready.

404 on a hostname you configured. The Host header did not match a rule. With --resolve the header is the hostname in the URL, so check for a typo against kubectl -n edge-lab get ingress -o yaml. A trailing dot or an extra port in the header will also miss.

The ingress-nginx Pod stays Pending. No node has capacity, or the manifest’s admission webhook Jobs have not completed. kubectl -n ingress-nginx get pods and kubectl -n ingress-nginx describe pod name which.

kubectl apply of the ingress-nginx manifest fails on the webhook. The ValidatingWebhookConfiguration created by the manifest rejects Ingress objects while the controller is not yet ready. Wait for the rollout to finish and re-apply.

The Gateway never programs after installing an implementation. In order: controllerName on the GatewayClass does not match the implementation’s string; the implementation’s controller Pod is not Running; the gatewayClassName on the Gateway does not match the GatewayClass name. All three produce the same symptom — absent status — and the three checks take under a minute.

ResolvedRefs=False on the HTTPRoute. The backendRefs target does not resolve. In this lab that means a typo in the Service name; across namespaces it means a missing ReferenceGrant.

Cleanup

Remove in the reverse order of creation. Cleanup here is genuinely destructive at the CRD step, so read the warning before running it.

kubectl delete -f gateway.yaml --ignore-not-found
kubectl delete -f ingress-two-hosts.yaml --ignore-not-found
kubectl -n edge-lab delete secret billing-tls --ignore-not-found
if [ ! -s before-gateway-crds.txt ]; then
  kubectl delete -f "$GW_BASE/$GATEWAY_API_VERSION/standard-install.yaml" \
    --ignore-not-found
else
  echo "Gateway API CRDs pre-existed this lab - leaving them in place"
fi

If you installed a Gateway implementation in Task 8, remove it with the same manifest or Helm release its documentation names, before deleting the CRDs.

Then the ingress controller and the workload:

Cluster-wide riskworkstation
$ kubectl delete -f ingress-nginx.yaml --ignore-not-found
Destructiveworkstation
$ kubectl delete -f backends.yaml --ignore-not-found

Verify the cluster is back where it started, rather than assuming:

kubectl get ingressclass
kubectl get crd -o name | grep gateway.networking.k8s.io || echo "no gateway CRDs"
kubectl get ns edge-lab 2>&1 | tail -1
kubectl get validatingwebhookconfigurations | grep -i ingress || echo "no ingress webhook"

The last check matters more than it looks. A leftover ValidatingWebhookConfiguration pointing at a Service that no longer exists makes every future Ingress create fail with a webhook timeout, in a cluster where nothing named ingress appears to be installed. It is one of the more confusing states to inherit.

The local directory holds a private key. Delete it deliberately:

ls -la "$HOME/k8s-edge-lab"
shred -u "$HOME/k8s-edge-lab/billing.key" 2>/dev/null \
  || rm -f "$HOME/k8s-edge-lab/billing.key"

Production notes

Map this exercise onto a real change window.

Installing or upgrading an ingress controller is a cluster-wide change, and its blast radius is every HTTP service behind it. Task 3’s manifest replaces the Deployment, the ConfigMap and the admission webhook in one apply. In production that belongs in a window, with a rollback plan that is the previous manifest rather than a hope, and with the controller running at least two replicas across two nodes before you start — which the bare-metal default does not give you.

“Hold” is a real option. If the controller upgrade window opens and the cluster is already carrying an incident, the correct action is to hold: the current controller is serving traffic, and an upgrade converts a partial outage into a total one. A hold needs an owner and an end time, not just a decision.

The equivalent of Task 4 in production is a change that appears to apply and does nothing. A team ships an Ingress with no class, or with the class of a controller that was decommissioned last quarter, and the deploy pipeline goes green because kubectl apply succeeded. The check that catches it is not YAML linting; it is asserting after the apply that the object has an ADDRESS and a controller Sync event, or for Gateway API that Programmed=True. Add it to the pipeline.

Migrating from Ingress to Gateway API runs both at once, which means two controllers, two entry points, and a period where DNS decides who is serving what. Cut over one hostname at a time, keep the Ingress object in place until the Gateway has served real traffic for a full business cycle, and do not set a default IngressClass while the migration is running.

What You Learned

  • Both edge APIs accept configuration they cannot serve, silently. The API server validates shape, not whether an implementation exists. You produced this state twice on purpose.
  • Ingress announces adoption weakly, through a populated ADDRESS, a CLASS that is not <none>, and a controller Sync event. Those three are what to check after every apply.
  • Gateway API announces adoption through conditions, and their absence — not a failure condition, but no conditions at all — is what an unclaimed object looks like.
  • Controller selection is one field in each API: ingressClassName against an IngressClass name, and controllerName on a GatewayClass against the string the implementation documents. A typo in either is indistinguishable from the controller not being installed.
  • A default IngressClass removes one failure mode and adds another, and is the wrong choice during a migration.
  • TLS is per hostname, selected by SNI, from a Secret in the application’s namespace — which is why the controller holds cluster-wide read access to Secrets, and why one team’s expired certificate is a cluster-wide incident.
  • Prove the backend before you debug the edge. endpointslices first, then the controller. Reversing that order is the most expensive habit in this layer.

Deliverables

  • · A working Ingress serving two hostnames through one NodePort, with the curl transcripts that prove it
  • · The status output of a Gateway and an HTTPRoute before and after an implementation exists
  • · A one-page note recording which field each API uses for controller selection, and what the failure looks like when it is wrong

Verification status

Last reviewed
2026-08-18
Executed end to end
not yet run on hardware

The commands and configuration here have been reviewed against the verified software versions, but nobody has run this lab start to finish on a system meeting its prerequisites. Treat the Expected Outcome as the intended result rather than an observed one, and keep the Cleanup section to hand.