Session 07 demos – Services, DNS, Ingress
Demos 1–4 belong to Block A (Services, DNS, EndpointSlices, NodePort), Demos 5–7
to Block B (Ingress, Gateway API, NetworkPolicy). Everything runs in the
demo namespace of the pinboard kind cluster. Files next to this one:
| File | Used in |
|---|---|
svc-clusterip.yaml |
Demos 1–2 — the default Service type |
svc-nodeport.yaml |
Demo 3 — NodePort, and why kind hides it |
web-deploy.yaml + svc-web.yaml |
Demo 4 — the front end and its Service |
ingress.yaml |
Demo 5 — path routing on http://localhost |
httproute.yaml |
Demo 6 — Gateway API example, optional, never applied |
netpol-deny-all.yaml |
Demo 7 — default-deny baseline |
Before the session
-
Cluster, images, namespace (≈ 4 min):
cd ~/docker-kubernetes-training/demos/session07 kubectl get nodes docker image ls | grep -E 'pinboard-(api|web)' docker pull busybox:1.37 && kind load docker-image busybox:1.37 --name pinboard kind load docker-image pinboard-api:1.0 pinboard-web:1.0 --name pinboard kubectl create namespace demo --dry-run=client -o yaml | kubectl apply -f - kubectl config set-context --current --namespace=demo -
Bring up the API from the Session 06 manifest, then delete its Service — Demo 1 builds that from scratch:
kubectl apply -f ../session06/api-deploy-v1.yaml kubectl -n demo delete svc pinboard-api kubectl -n demo rollout status deploy/pinboard-api -
Pre-pull the ingress controller images so Demo 5 does not stall on a download:
kubectl apply -f https://raw.githubusercontent.com/kubernetes/ingress-nginx/main/deploy/static/provider/kind/deploy.yaml kubectl -n ingress-nginx wait --for=condition=Available deploy/ingress-nginx-controller --timeout=180s kubectl delete namespace ingress-nginx # images stay cached on the nodesIf the room has no internet, keep the controller installed and skip step 1 of Demo 5 — the rest of that demo is unchanged.
-
Two panes: 1 for typing, 2 running
watch -n 1 kubectl -n demo get pods,endpointslices.
Demo 1: One name in front of three Pods (≈ 6 min)
What to show / say
A Service is a label selector plus a stable virtual IP. It is not a proxy process you can point at; it is a rule kube-proxy programs on every node.
Steps
-
Three Pods, three IPs, all mortal:
kubectl -n demo get pods -o wideNAME READY STATUS IP NODE pinboard-api-7d9c8f6b45-4kq2r 1/1 Running 10.244.1.7 pinboard-worker pinboard-api-7d9c8f6b45-9xnzt 1/1 Running 10.244.2.5 pinboard-worker2 pinboard-api-7d9c8f6b45-vc7hd 1/1 Running 10.244.1.8 pinboard-worker👉 flat network: every Pod has a routable IP, on any node, no NAT between them. 👉 and every one of those IPs changes on the next rollout. That is the problem.
-
kubectl apply -f svc-clusterip.yaml kubectl -n demo get svc pinboard-apiNAME TYPE CLUSTER-IP EXTERNAL-IP PORT(S) AGE pinboard-api ClusterIP 10.96.180.42 <none> 8080/TCP 3s -
The object the Service actually produces:
kubectl -n demo get endpointslices -o wideNAME ADDRESSTYPE PORTS ENDPOINTS AGE pinboard-api-x7lq2 IPv4 8080 10.244.1.7,10.244.2.5,10.244.1.8 12s👉 the same three Pod IPs. The selector in
svc-clusterip.yamlmatched the Pod labels, and the endpointslice controller wrote them here.Endpoints(the old singular object) is deprecated — EndpointSlices scale to thousands of backends. -
Show that the ClusterIP is not a machine anywhere:
kubectl -n demo get svc pinboard-api -o jsonpath='{.spec.clusterIP}{"\n"}' docker exec pinboard-worker sh -c 'ip addr | grep -c 10.96.180.42'10.96.180.42 0👉 nothing owns that address. It only exists as an iptables/IPVS rule that kube-proxy installed on every node.
Demo 2: DNS from inside the cluster (≈ 6 min)
What to show / say
Service discovery is DNS. Four names, one Service, and the rule for choosing.
Steps
-
kubectl -n demo run debug -it --rm --image=busybox:1.37 --restart=Never -- sh -
Inside the Pod:
nslookup pinboard-apiServer: 10.96.0.10 Address: 10.96.0.10:53 Name: pinboard-api.demo.svc.cluster.local Address: 10.96.180.42👉 the short name worked because
/etc/resolv.confhas a search path:cat /etc/resolv.confsearch demo.svc.cluster.local svc.cluster.local cluster.local nameserver 10.96.0.10 options ndots:5👉 same namespace →
pinboard-api; other namespace →pinboard-api.demo; scripts and config files → the FQDNpinboard-api.demo.svc.cluster.local. -
Call it, in a loop, and watch the load balancing:
while true; do wget -qO- http://pinboard-api:8080/ ; sleep 0.5; donePinboard API 1.0 on pinboard-api-7d9c8f6b45-4kq2r (store: memory) Pinboard API 1.0 on pinboard-api-7d9c8f6b45-vc7hd (store: memory) Pinboard API 1.0 on pinboard-api-7d9c8f6b45-9xnzt (store: memory)👉 the hostnames rotate: kube-proxy picks a backend per connection, at random — not round-robin, and not per HTTP request on a keep-alive connection. That last point bites people with gRPC.
-
Ctrl-C the loop but keep the shell open, then in pane 1:
kubectl -n demo scale deploy/pinboard-api --replicas=6👉 pane 2: the EndpointSlice grows to six addresses within a second of the Pods turning Ready. Restart the loop in the debug Pod — the new hostnames appear with nobody reconfiguring anything.
-
Scale back down and watch the reverse:
kubectl -n demo scale deploy/pinboard-api --replicas=3👉 addresses are removed from the slice before the Pods die — that is the readiness gate plus
terminationGracePeriodSeconds: 15doing its job. - Exit the debug Pod (
exit);--rmdeletes it.
Demo 3: NodePort, and why localhost:30080 does nothing (≈ 5 min)
What to show / say
NodePort is the cheapest way out of the cluster and the most misunderstood one — especially on kind, where “the node” is a Docker container.
Steps
-
kubectl apply -f svc-nodeport.yaml kubectl -n demo get svcNAME TYPE CLUSTER-IP EXTERNAL-IP PORT(S) AGE pinboard-api ClusterIP 10.96.180.42 <none> 8080/TCP 9m pinboard-api-nodeport NodePort 10.96.201.13 <none> 8080:30080/TCP 2s👉 read
8080:30080out loud: 8080 is the ClusterIP port, 30080 is open on every node. -
Try the obvious thing and let it fail:
curl --max-time 3 http://localhost:30080/ ; echo "exit=$?"curl: (28) Connection timed out after 3001 milliseconds exit=28 -
Now prove the Service is fine — from inside a node:
docker exec pinboard-control-plane curl -s http://localhost:30080/ docker exec pinboard-worker2 curl -s http://localhost:30080/Pinboard API 1.0 on pinboard-api-7d9c8f6b45-vc7hd (store: memory) Pinboard API 1.0 on pinboard-api-7d9c8f6b45-4kq2r (store: memory)If the node image has no
curl, usedocker exec pinboard-control-plane wget -qO- http://localhost:30080/.👉 any node answers, even the one running no API Pod — kube-proxy forwards.
-
The explanation, in the file the students already have:
grep -A6 extraPortMappings ../../labs/kind-config.yamlextraPortMappings: - containerPort: 80 hostPort: 80👉 only 80 and 443 were published from the node container to the host, because that is what the Ingress needs. On a cloud provider you would not use NodePort directly either —
type: LoadBalancerprovisions a real load balancer that targets those node ports for you. On kind,type: LoadBalancerstays<pending>forever unless you install something like MetalLB orcloud-provider-kind. -
kubectl -n demo delete -f svc-nodeport.yaml
Demo 4: The web tier, talking to the API by name (≈ 4 min)
What to show / say
Config is a DNS name, not an IP. This is the whole payoff of the last two demos.
Steps
-
kubectl apply -f web-deploy.yaml -f svc-web.yaml kubectl -n demo rollout status deploy/pinboard-web -
grep -A2 API_URL web-deploy.yaml- name: API_URL value: http://pinboard-api:8080 # short name: same namespace -
Prove the proxy hop works, web → api:
kubectl -n demo run debug --rm -it --image=busybox:1.37 --restart=Never -- \ wget -qO- http://pinboard-web:8080/api/info{"app":"pinboard-api","greeting":"Welcome to Pinboard","hostname":"pinboard-api-7d9c8f6b45-9xnzt","store":"memory","theme":"emerald","uptime":"14m2s","version":"1.0"}👉 two Services, two hops, zero IP addresses written down anywhere.
Demo 5: Ingress — one entry point, path routing (≈ 8 min)
What to show / say
A Service per app does not scale to the outside world. An Ingress is a routing table; the controller is the thing that actually listens on port 80.
Steps
-
Install the controller (kind flavour — a Deployment pinned by a node selector to the node labelled
ingress-ready=true, bindinghostPort80/443 on it):kubectl apply -f https://raw.githubusercontent.com/kubernetes/ingress-nginx/main/deploy/static/provider/kind/deploy.yamlnamespace/ingress-nginx created serviceaccount/ingress-nginx created ... ingressclass.networking.k8s.io/nginx created -
Wait for it properly instead of re-running
get pods:kubectl -n ingress-nginx wait --for=condition=Ready pod \ -l app.kubernetes.io/component=controller --timeout=180spod/ingress-nginx-controller-7c6f88b9d5-jzs4t condition met -
It brought its own IngressClass — that is what
ingressClassName: nginxrefers to:kubectl get ingressclassNAME CONTROLLER PARAMETERS AGE nginx k8s.io/ingress-nginx <none> 47s -
kubectl apply -f ingress.yaml kubectl -n demo get ingressNAME CLASS HOSTS ADDRESS PORTS AGE pinboard nginx * localhost 80 20s👉
ADDRESSstays empty for a few seconds — the controller fills it in when it has accepted the rules. An empty ADDRESS forever means no controller matched the class. -
The payoff — same host, two backends:
curl -s http://localhost/api/info curl -s -o /dev/null -w '%{http_code} %{content_type}\n' http://localhost/{"app":"pinboard-api","greeting":"Welcome to Pinboard","hostname":"pinboard-api-7d9c8f6b45-4kq2r",...} 200 text/html👉 open
http://localhost/in the browser: the Pinboard UI, served bypinboard-web, calling/api/*on the same origin, routed topinboard-api. That is why the paths are ordered most-specific first iningress.yaml. -
Show that it really is nginx, generated:
kubectl -n ingress-nginx exec deploy/ingress-nginx-controller -- \ cat /etc/nginx/nginx.conf | grep -A3 'location /api'👉 the Ingress object was translated into an nginx config and reloaded. Different controller (Traefik, HAProxy, cloud) → same object, different implementation.
Demo 6: Gateway API, the successor (optional, ≈ 5 min)
What to show / say
Ingress is frozen. Its replacement splits the object by who owns it — and it is CRDs, so the API is not even in the cluster until you install it.
Steps
-
Prove it is absent first:
kubectl get httprouteserror: the server doesn't have a resource type "httproutes" -
Install the standard channel CRDs:
kubectl apply -f https://github.com/kubernetes-sigs/gateway-api/releases/latest/download/standard-install.yamlcustomresourcedefinition.apiextensions.k8s.io/gatewayclasses.gateway.networking.k8s.io created customresourcedefinition.apiextensions.k8s.io/gateways.gateway.networking.k8s.io created customresourcedefinition.apiextensions.k8s.io/httproutes.gateway.networking.k8s.io created ... -
kubectl explainworks on CRDs because they carry an OpenAPI schema:kubectl explain httproute.spec kubectl explain httproute.spec.rules.matches.path👉 point at
parentRefs(“which Gateway do I attach to”) and atbackendRefs(“a Service — or anything else the controller supports”). Then show the file:cat httproute.yaml👉 the two objects and the two owners: Gateway = platform team (listener, port, TLS cert), HTTPRoute = application team (paths, headers, weights). With Ingress, both lived in one object, in one namespace, glued together with controller-specific annotations.
-
The honest ending:
kubectl get gatewayclassNo resources found👉 the API exists, nothing implements it. ingress-nginx does not; you would install NGINX Gateway Fabric, Envoy Gateway, Istio or Cilium — or get one from your cloud provider. Do not apply
httproute.yaml: it would sit atProgrammed=Falseforever. Say the useful sentence: “an object in etcd with no controller behind it does nothing at all.”
Demo 7: NetworkPolicy — deny all, then allow (≈ 6 min)
What to show / say
A namespace with no policy is default-allow. One policy flips the Pods it selects into default-deny — and that is the only way to get a whitelist.
Steps
-
Baseline: anything can reach the API.
kubectl -n demo run probe --rm -it --image=busybox:1.37 --restart=Never -- \ wget -qO- -T 3 http://pinboard-api:8080/Pinboard API 1.0 on pinboard-api-7d9c8f6b45-4kq2r (store: memory) -
kubectl apply -f netpol-deny-all.yaml kubectl -n demo get netpol -
Same probe, different world:
kubectl -n demo run probe --rm -it --image=busybox:1.37 --restart=Never -- \ wget -qO- -T 3 http://pinboard-api:8080/ ; echo "exit=$?"wget: download timed out exit=1👉 and
curl http://localhost/api/infofrom the host now hangs too — the ingress controller is just another client. Point at that: a deny-all lands on your own ingress path first. -
Open exactly what the app needs — nothing more:
kubectl apply -f - <<'EOF' apiVersion: networking.k8s.io/v1 kind: NetworkPolicy metadata: name: api-allow-web-and-ingress namespace: demo spec: podSelector: matchLabels: app.kubernetes.io/name: pinboard-api policyTypes: ["Ingress"] ingress: - from: - podSelector: matchLabels: app.kubernetes.io/name: pinboard-web - namespaceSelector: matchLabels: kubernetes.io/metadata.name: ingress-nginx ports: - port: 8080 EOF -
Verify both sides of the whitelist:
# allowed: through the web tier / the ingress curl -s http://localhost/api/info | head -c 60 ; echo # still denied: a random Pod kubectl -n demo run probe --rm -it --image=busybox:1.37 --restart=Never -- \ wget -qO- -T 3 http://pinboard-api:8080/ ; echo "exit=$?"{"app":"pinboard-api","greeting":"Welcome to Pinboard","hostname" wget: download timed out exit=1👉 two things to name:
namespaceSelectoruses the automatickubernetes.io/metadata.namelabel, and the twofromentries are an OR (a list of sources) whilefrom+portsis an AND. Getting that wrong is the single most common NetworkPolicy bug.
Reset
Lab 07 has students install ingress-nginx and build these Services themselves in
their own pinboard namespace, so leave the controller in place (a re-apply is
idempotent) but empty the demo namespace and free port 80.
kubectl -n demo delete -f netpol-deny-all.yaml --ignore-not-found
kubectl -n demo delete netpol api-allow-web-and-ingress --ignore-not-found
kubectl delete -f ingress.yaml -f svc-web.yaml -f web-deploy.yaml -f svc-clusterip.yaml --ignore-not-found
kubectl delete -f ../session06/api-deploy-v1.yaml --ignore-not-found
kubectl -n demo delete pod debug probe --ignore-not-found
kubectl -n demo get all,ingress,netpol
No resources found in demo namespace.
curl -s -o /dev/null -w '%{http_code}\n' http://localhost/ # 404 = controller up, no rules
kubectl config set-context --current --namespace=default
If you installed the Gateway API CRDs and want the cluster back exactly as it was:
kubectl delete -f https://github.com/kubernetes-sigs/gateway-api/releases/latest/download/standard-install.yaml