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. kubectl1.34.x configured with cluster-admin, run from a workstation that can reach the node addresses directly.curlandopensslon 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.
$ kubectl apply -f backends.yamlNow 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
$ 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" deletedIllustrative 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.
$ kubectl apply -f ingress-nginx.yamlkubectl -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.
$ kubectl apply -f ingress-unclassed.yaml$ kubectl -n edge-lab get ingressNAME CLASS HOSTS ADDRESS PORTS AGE
edge <none> billing.lab.example.com 80 45sIllustrative 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
$ kubectl apply -f gateway.yamlEvery 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:
$ kubectl -n edge-lab get gatewayNAME CLASS ADDRESS PROGRAMMED AGE
edge-gateway lab-gateway 40sIllustrative 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:
- The
controllerNamein yourGatewayClassmust be the exact string that implementation documents. Fixgateway.yamlif it differs, and re-apply. - 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 itsServicethe 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: anAcceptedcondition with statusTrue. If this stays absent, thecontrollerNamedoes not match; nothing downstream can work.Gateway: an address, andProgrammedwith statusTrue. The listener status also carriesattachedRoutes— 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.parentsnow has an entry naming theGateway, withAcceptedandResolvedRefsconditions.ResolvedRefs=Falsemeans the route attached but itsbackendRefsdo not resolve — a missing Service, or a cross-namespace reference with noReferenceGrant.
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.
kubectl -n edge-lab get endpointsliceslists ready addresses for bothbillingandauth.curlwith--resolvereturnsbilling backend, revision 1forbilling.lab.example.comandauth backend, revision 1forauth.lab.example.com, on the same port.curlfor an unmatched hostname on that same port returns404.kubectl -n edge-lab get ingressshowsCLASSnginxand a non-emptyADDRESS, anddescribeshows aSyncevent from the controller.openssl s_client -servername billing.lab.example.comreports a subject ofCN=billing.lab.example.com, while the same command with-servername auth.lab.example.comreports the controller’s placeholder certificate.- Before installing an implementation, the
Gatewayshows an emptyPROGRAMMEDcolumn and itsstatusblock carries no conditions. - After installing one, the
GatewayClassreportsAccepted=True, theGatewayreportsProgrammed=Truewith an address, and theHTTPRoutestatus.parentsnamesedge-gatewaywithAccepted=TrueandResolvedRefs=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:
$ kubectl delete -f ingress-nginx.yaml --ignore-not-found$ kubectl delete -f backends.yaml --ignore-not-foundVerify 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.
Ingressannounces adoption weakly, through a populatedADDRESS, aCLASSthat is not<none>, and a controllerSyncevent. 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:
ingressClassNameagainst anIngressClassname, andcontrollerNameon aGatewayClassagainst the string the implementation documents. A typo in either is indistinguishable from the controller not being installed. - A default
IngressClassremoves one failure mode and adds another, and is the wrong choice during a migration. - TLS is per hostname, selected by SNI, from a
Secretin the application’s namespace — which is why the controller holds cluster-wide read access toSecrets, and why one team’s expired certificate is a cluster-wide incident. - Prove the backend before you debug the edge.
endpointslicesfirst, then the controller. Reversing that order is the most expensive habit in this layer.