View on GitHub

Containers & Kubernetes Tutorial

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

  1. 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
    
  2. 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
    
  3. 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 nodes
    

    If the room has no internet, keep the controller installed and skip step 1 of Demo 5 — the rest of that demo is unchanged.

  4. 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

  1. Three Pods, three IPs, all mortal:

    kubectl -n demo get pods -o wide
    
    NAME                            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.

  2. kubectl apply -f svc-clusterip.yaml
    kubectl -n demo get svc pinboard-api
    
    NAME           TYPE        CLUSTER-IP      EXTERNAL-IP   PORT(S)    AGE
    pinboard-api   ClusterIP   10.96.180.42    <none>        8080/TCP   3s
    
  3. The object the Service actually produces:

    kubectl -n demo get endpointslices -o wide
    
    NAME                 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.yaml matched the Pod labels, and the endpointslice controller wrote them here. Endpoints (the old singular object) is deprecated — EndpointSlices scale to thousands of backends.

  4. 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

  1. kubectl -n demo run debug -it --rm --image=busybox:1.37 --restart=Never -- sh
    
  2. Inside the Pod:

    nslookup pinboard-api
    
    Server:    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.conf has a search path:

    cat /etc/resolv.conf
    
    search 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 FQDN pinboard-api.demo.svc.cluster.local.

  3. Call it, in a loop, and watch the load balancing:

    while true; do wget -qO- http://pinboard-api:8080/ ; sleep 0.5; done
    
    Pinboard 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.

  4. 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.

  5. 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: 15 doing its job.

  6. Exit the debug Pod (exit); --rm deletes 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

  1. kubectl apply -f svc-nodeport.yaml
    kubectl -n demo get svc
    
    NAME                    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:30080 out loud: 8080 is the ClusterIP port, 30080 is open on every node.

  2. 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
    
  3. 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, use docker exec pinboard-control-plane wget -qO- http://localhost:30080/.

    👉 any node answers, even the one running no API Pod — kube-proxy forwards.

  4. The explanation, in the file the students already have:

    grep -A6 extraPortMappings ../../labs/kind-config.yaml
    
    extraPortMappings:
      - 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: LoadBalancer provisions a real load balancer that targets those node ports for you. On kind, type: LoadBalancer stays <pending> forever unless you install something like MetalLB or cloud-provider-kind.

  5. 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

  1. kubectl apply -f web-deploy.yaml -f svc-web.yaml
    kubectl -n demo rollout status deploy/pinboard-web
    
  2. grep -A2 API_URL web-deploy.yaml
    
    - name: API_URL
      value: http://pinboard-api:8080   # short name: same namespace
    
  3. 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

  1. Install the controller (kind flavour — a Deployment pinned by a node selector to the node labelled ingress-ready=true, binding hostPort 80/443 on it):

    kubectl apply -f https://raw.githubusercontent.com/kubernetes/ingress-nginx/main/deploy/static/provider/kind/deploy.yaml
    
    namespace/ingress-nginx created
    serviceaccount/ingress-nginx created
    ...
    ingressclass.networking.k8s.io/nginx created
    
  2. 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=180s
    
    pod/ingress-nginx-controller-7c6f88b9d5-jzs4t condition met
    
  3. It brought its own IngressClass — that is what ingressClassName: nginx refers to:

    kubectl get ingressclass
    
    NAME    CONTROLLER             PARAMETERS   AGE
    nginx   k8s.io/ingress-nginx   <none>       47s
    
  4. kubectl apply -f ingress.yaml
    kubectl -n demo get ingress
    
    NAME       CLASS   HOSTS   ADDRESS     PORTS   AGE
    pinboard   nginx   *       localhost   80      20s
    

    👉 ADDRESS stays 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.

  5. 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 by pinboard-web, calling /api/* on the same origin, routed to pinboard-api. That is why the paths are ordered most-specific first in ingress.yaml.

  6. 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

  1. Prove it is absent first:

    kubectl get httproutes
    
    error: the server doesn't have a resource type "httproutes"
    
  2. Install the standard channel CRDs:

    kubectl apply -f https://github.com/kubernetes-sigs/gateway-api/releases/latest/download/standard-install.yaml
    
    customresourcedefinition.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
    ...
    
  3. kubectl explain works 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 at backendRefs (“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.

  4. The honest ending:

    kubectl get gatewayclass
    
    No 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 at Programmed=False forever. 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

  1. 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)
    
  2. kubectl apply -f netpol-deny-all.yaml
    kubectl -n demo get netpol
    
  3. 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/info from 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.

  4. 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
    
  5. 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: namespaceSelector uses the automatic kubernetes.io/metadata.name label, and the two from entries are an OR (a list of sources) while from + ports is 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