Session 07 · 2h
Networking: Services, DNS & Ingress
How a Pod that changes IP every rollout still gets a stable address — and how the outside world reaches Pinboard.
Pod networking
Services & DNS
Ingress
Gateway API
Lab 07
Session 07
What we'll cover today
1 · The Pod network
One IP per Pod, a flat network, CNI — and why that alone is not enough to call an application.
2 · Services & DNS
ClusterIP, NodePort, LoadBalancer, ExternalName, headless; EndpointSlices, kube-proxy and CoreDNS names.
3 · Ingress & policy
HTTP routing with ingress-nginx on kind, TLS in one slide, Gateway API as the successor, NetworkPolicy.
4 · Lab 07
Expose Pinboard: Services for api and web, a DNS test from a debug Pod, then an Ingress on http://localhost.
Two theory blocks: everything in-cluster first (Services, EndpointSlices, DNS), then everything from outside (Ingress, Gateway API) plus a NetworkPolicy closer. Tell them the lab is the pay-off: at the end of the session they open Pinboard in a browser for the first time on Kubernetes.
01
The Pod network model
Every Pod gets a real IP on a flat network — and loses it the moment it is replaced.
Set the frame: Kubernetes networking is simple in its rules and complicated in its implementations. We only need the rules today. Ask who has fought with Docker port mapping in session 03 — this model removes most of that pain.
The rules
One IP per Pod, no NAT
An IP per Pod Not per container and not per node. All containers in a Pod share one network namespace and talk over localhost.
A flat network Every Pod reaches every other Pod on any node directly , without NAT and without port mapping.
Nodes are in it too Agents on a node (kubelet, kube-proxy) can reach the Pods on it — that is how probes work.
The IP is disposable Rescheduled, restarted, scaled or rolled out → new Pod, new IP. Never hard-code one.
kubectl get pods -o wide shows the Pod IPs. Watch them all change during a rollout restart.
Contrast with Docker: no -p, no bridge NAT between containers on different hosts. The container port is the port other Pods dial. Stress the last card — the whole rest of the session exists because Pod IPs are not addresses you can write down.
Who implements it
CNI: the plugin that wires Pods up
Kubernetes defines the contract ; a CNI plugin makes it true on your infrastructure.
Assigns Pod IPs (IPAM) and creates the veth pair into the Pod
Programs routes / overlay so cross-node traffic works
Usually also enforces NetworkPolicy — not all plugins do
Common plugins kindnet (our kind cluster) · Calico · Cilium (eBPF) · Flannel · cloud-native ones: AWS VPC CNI, Azure CNI, GKE.
Pick the CNI when you build the cluster. Swapping it later means re-IP-ing every Pod.
Keep this short — infra people love this rabbit hole. Say that on managed clusters the CNI is usually chosen for you, and that the choice mainly affects performance, IPAM (do Pods get VPC IPs?) and policy features. On kind it is kindnet, which does enforce NetworkPolicy on current versions.
The problem
Why we need Services
Talking to Pods directly3 replicas = 3 IPs to discover and track Every rollout invalidates all of them No load balancing, no health awareness Web would need to watch the API server
Talking to a ServiceOne stable virtual IP and one DNS name Membership follows a label selector Only ready Pods receive traffic Traffic is spread across the replicas
A Service is the stable front door for a set of Pods: pinboard-web → http://pinboard-api:8080, forever.
Tie back to session 06: readiness probes are what make this safe — a Pod that fails readiness is removed from the Service without being killed. That is the sentence to repeat: "readiness controls Service membership, liveness controls restarts".
The object
Anatomy of a Service
selector — which Pods, by label, in this namespace
port — what callers dial on the Service
targetPort — the container port; a named port survives renumbering
type defaults to ClusterIP
Service and Deployment are independent objects — the selector is the only link.
apiVersion: v1
kind: Service
metadata:
name: pinboard-api
namespace: pinboard
spec:
type: ClusterIP
selector:
app.kubernetes.io/name: pinboard-api
ports:
- name: http
port: 8080
targetPort: http
This is the Service from 20-api-deployment.yaml, unchanged. Point out that targetPort: http refers to the ports.name of the container in the Deployment — the same trick the probes use. A typo in the selector is the classic outage: the Service exists, has an IP, and zero endpoints.
Under the hood
From selector to packets
01
Selector Service names labels, not Pods
→
02
EndpointSlice Controller lists the ready Pod IPs in discovery.k8s.io/v1 objects
→
03
kube-proxy Programs nftables / iptables / IPVS rules on every node
→
04
DNAT ClusterIP:port is rewritten to one Pod IP:targetPort
EndpointSlices (max 100 endpoints each) replaced the old Endpoints object, which is deprecated since Kubernetes 1.33. Read kubectl get endpointslices, not get endpoints.
Say it plainly: there is no proxy process in the data path for a ClusterIP — the kernel rewrites the destination address. That is why a Service has no logs and cannot do HTTP-aware things. Mention that kube-proxy's nftables mode is the modern default direction, IPVS for very large clusters, and that some CNIs (Cilium) replace kube-proxy entirely.
Live Demo
One Service in front of three API Pods
Create the ClusterIP, watch the EndpointSlice follow kubectl scale, and curl the Service from inside the cluster — hostnames rotate.
What you'll see · endpoints appear and disappear liveFiles · demos/session07/svc-clusterip.yamlDuration · 6 min
Everything runs in the demo namespace. Type: kubectl apply -f demos/session07/svc-clusterip.yaml, then kubectl -n demo get svc,endpointslices -o wide and match the three addresses against kubectl -n demo get pods -o wide. Loop from a throwaway Pod: kubectl -n demo run -it --rm debug --image=busybox:1.37 --restart=Never -- sh -c 'while true; do wget -qO- http://pinboard-api:8080/; sleep 0.5; done'. In a second terminal kubectl -n demo scale deploy/pinboard-api --replicas=6 and show the slice growing, then scale back to 3 and watch the addresses leave before the Pods do.
02
Service types & cluster DNS
Five ways to publish a Service — and the names CoreDNS gives them.
Warn them up front that only one of these five is used for in-cluster traffic (ClusterIP); the rest are about exposure or about opting out of load balancing. That framing stops the usual "which one should I always use" confusion.
Choose one
The five Service flavours
ClusterIP Default. A virtual IP reachable only inside the cluster. Everything Pinboard does internally.
NodePort Same, plus a port 30000–32767 open on every node . Blunt, but works anywhere.
LoadBalancer NodePort plus a real cloud load balancer, provisioned by the cloud controller manager.
ExternalName No proxying at all — CoreDNS returns a CNAME to an external host.
Headless clusterIP: None. DNS returns the Pod IPs themselves. Used by StatefulSets.
Rule of thumb ClusterIP for everything, and one Ingress or Gateway at the edge.
Emphasise that the types are cumulative: LoadBalancer is a NodePort is a ClusterIP. So a LoadBalancer Service still has an internal name that other Pods should use — do not send in-cluster traffic out to the public IP and back.
The three ports
port vs targetPort vs nodePort
port — on the Service's virtual IP
targetPort — on the Pod ; number or port name
nodePort — on every node's real IP (NodePort/LoadBalancer only)
Leave nodePort empty and let Kubernetes allocate it — hard-coded ports collide across teams.
spec:
type: NodePort
selector:
app.kubernetes.io/name: pinboard-web
ports:
- name: http
port: 8080 # ClusterIP:8080
targetPort: http # container port 8080
nodePort: 30080 # node:30080
Callers inside: pinboard-web:8080. From your laptop with kind: localhost:30080 if mapped.
Draw the three hops on the whiteboard if it helps: laptop → node:30080 → ClusterIP:8080 → Pod:8080. Note that port and targetPort being equal is a convention, not a requirement, and that using the named port keeps the Service correct when the container port changes.
Reality check
NodePort and LoadBalancer in practice
On a managed cluster
type: LoadBalancer makes the cloud controller manager create an AWS NLB / Azure LB / GCP forwarding rule and fill in status.loadBalancer.
One public IP — and one bill — per Service.
On kind
There is no cloud, so a LoadBalancer Service stays <pending> forever. Options: NodePort, kubectl port-forward, MetalLB / cloud-provider-kind, or — what we do — an Ingress on the mapped ports 80/443 .
Never expose dozens of NodePorts to users: no TLS, no host or path routing, ugly ports, and every node is a target.
Remind them of labs/kind-config.yaml: the control-plane node publishes 80 and 443 to localhost and carries the ingress-ready=true label. That is exactly the hook the ingress-nginx kind manifest needs, and it is why the lab can end in a browser.
The two special cases
Headless and ExternalName
Headless (clusterIP: None): no virtual IP, no kube-proxy rules. DNS returns one A record per ready Pod — the client decides.
Stable per-Pod names for StatefulSets (session 08)
Clients that do their own pooling: databases, gRPC, Kafka
ExternalName : a DNS alias to something outside the cluster — swap a managed database for an in-cluster one without touching the app.
apiVersion: v1
kind: Service
metadata:
name: pinboard-db
namespace: pinboard
spec:
clusterIP: None # headless
selector:
app.kubernetes.io/name: pinboard-db
ports:
- name: postgres
port: 5432
targetPort: postgres
This is the headless Service from 10-db-statefulset.yaml — they will apply it next session, so plant it now. Mention that with a headless Service pinboard-db resolves to Pod IPs and pinboard-db-0.pinboard-db resolves to exactly one Pod.
Service discovery
Cluster DNS: the names you actually type
CoreDNS runs in kube-system; kubelet points every Pod at it
Same namespace → the short name is enough
Cross-namespace → add the namespace
Headless → A records for the Pods; SRV records for named ports
Pinboard web is configured with API_URL=http://pinboard-api:8080 — nothing else.
pinboard-api
pinboard-api.pinboard
pinboard-api.pinboard.svc
pinboard-api.pinboard.svc.cluster.local
# a headless Pod, by ordinal
pinboard-db-0.pinboard-db.pinboard.svc.cluster.local
# <service>.<namespace>.svc.<cluster-domain>
Explain the search list in /etc/resolv.conf and ndots:5: short names work because of search domains, which is also why external lookups from Pods can take several extra queries. Show the FQDN once so nobody thinks cluster.local is magic — it is just the cluster domain.
Live Demo
DNS from a debug Pod, and a NodePort on kind
Resolve the Service by short name and by FQDN, look at the resolver config, then reach a NodePort from the host.
What you'll see · CoreDNS answers, search domains, node portsFiles · none — all imperativeDuration · 6 min
Type: kubectl -n demo run -it --rm debug --image=busybox:1.37 --restart=Never -- sh, then inside: nslookup pinboard-api, nslookup pinboard-api.demo.svc.cluster.local, cat /etc/resolv.conf, wget -qO- pinboard-api:8080/api/info. Exit, then kubectl apply -f demos/session07/svc-nodeport.yaml: curl --max-time 3 http://localhost:30080/ from the laptop times out, while docker exec pinboard-worker2 wget -qO- http://localhost:30080/ answers from any node. Delete the NodePort Service afterwards.
03
Ingress, Gateway API & NetworkPolicy
One entry point for HTTP — and a first rule about who may talk to whom.
Block B. Everything so far was layer 4 inside the cluster; now we go layer 7 and cross the cluster boundary, then close with the policy object that puts the boundary back inside.
The gap
Why Services are not enough at the edge
A Service is layer 4 . It cannot read a URL.
No host routing (api.example.com vs www.example.com)
No path routing (/api vs /)
No TLS termination, no redirects, no rewrites
One cloud load balancer per Service gets expensive fast
Ingress
One HTTP(S) entry point in front of many Services, with host and path rules — expressed as a normal Kubernetes object.
Pinboard needs exactly this: / → web, /api → api, on one address.
Ask what they would do with 20 microservices and LoadBalancer Services — 20 public IPs, 20 certificates. The Ingress answer is one address, one certificate, rules in Git. Also note Ingress is HTTP/HTTPS only; for raw TCP you are back to NodePort/LoadBalancer or Gateway API.
Two halves
The rule and the thing that enforces it
01
Ingress object Your host/path rules. Inert on its own.
→
02
IngressClass ingressClassName picks which controller owns it
→
03
Controller ingress-nginx, Traefik, HAProxy, cloud controllers — Pods that watch the API
→
04
Proxy config Reloaded live; traffic goes straight to Pod IPs
No controller installed → your Ingress is accepted, gets no address, and nothing happens. Always install one first.
The single most common support question: "I applied the Ingress and nothing works". Nine times out of ten it is a missing controller or a missing ingressClassName. Mention that most controllers bypass the Service's ClusterIP and send traffic directly to endpoints, which is why sticky sessions and canaries work at that layer.
Manifest
Pinboard's Ingress
networking.k8s.io/v1 — the only version since 1.22
ingressClassName: nginx, never the old annotation
pathType: Prefix, Exact or ImplementationSpecific
The second rule sends / (Prefix) to pinboard-web port http
Longest path wins, so /api beats /
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: pinboard
namespace: pinboard
spec:
ingressClassName: nginx
rules:
- http:
paths:
- path: /api
pathType: Prefix
backend:
service: { name: pinboard-api, port: { name: http } }
This is 40-ingress.yaml with the second rule left off for space — say so, and show the full file in the lab. No host: here means "match any Host header", which is what makes http://localhost work on kind. Backends can reference the Service port by name or by number.
On our cluster
ingress-nginx on kind
# the kind node already publishes 80/443 and carries ingress-ready=true
NGINX=https://raw.githubusercontent.com/kubernetes/ingress-nginx
kubectl apply -f $NGINX/main/deploy/static/provider/kind/deploy.yaml
kubectl -n ingress-nginx wait --for=condition=ready pod \
--selector=app.kubernetes.io/component=controller --timeout=120s
kubectl -n pinboard apply -f 40-ingress.yaml
curl -s http://localhost/api/info
ingress-nginx is in maintenance and the project has announced its retirement — for new platforms in 2026, start on Gateway API . It is still the best thing to learn Ingress on.
Explain the kind-specific manifest: it runs the controller as a Deployment pinned by a node selector to the node labelled ingress-ready, binding hostPort 80/443 on it, which our kind config maps to localhost. Be honest about the retirement notice — students will read about it — and say the concepts transfer one-to-one to Gateway API.
One slide only
TLS termination
The certificate lives in a kubernetes.io/tls Secret (tls.crt + tls.key)
The controller terminates TLS; traffic to Pods is plain HTTP inside the cluster
cert-manager issues and renews Let's Encrypt certificates automatically
For mTLS between Pods you want a service mesh, not an Ingress
spec:
ingressClassName: nginx
tls:
- hosts: ["pinboard.example.com"]
secretName: pinboard-tls
rules:
- host: pinboard.example.com
http:
paths: [ ... ]
In the lab we stay on plain HTTP over localhost.
Mention that the Secret must live in the same namespace as the Ingress. One sentence on cert-manager: a controller that watches Ingresses, solves an ACME challenge and writes the TLS Secret for you — this is the standard in 2026.
The successor
Gateway API: role-oriented routing
GatewayClass Installed by the infrastructure provider — "this kind of gateway exists".
Gateway The cluster operator's listener: addresses, ports, TLS, which routes may attach.
HTTPRoute The app team's rules: matches, header/weight based splitting, filters, backends.
GA since Gateway API v1.0 — gateway.networking.k8s.io/v1, installed as CRDs
Native traffic splitting, header matching, rewrites — no annotation soup
GRPCRoute, TCPRoute and cross-namespace attachment via ReferenceGrant
The pitch: Ingress's extension point was vendor annotations, so every controller became a dialect. Gateway API moves those features into the API and splits it by who owns what. Implementations to name-drop: Envoy Gateway, Istio, Cilium, Traefik, NGINX Gateway Fabric, and the cloud gateways.
Same routing, new API
Pinboard as an HTTPRoute
parentRefs attaches the route to a Gateway
matches replaces pathType
backendRefs points straight at Services — add weight for canaries
A second route for / → pinboard-web completes it
Ingress keeps working. Migrate route by route, not cluster by cluster.
apiVersion: gateway.networking.k8s.io/v1
kind: HTTPRoute
metadata:
name: pinboard
namespace: pinboard
spec:
parentRefs:
- name: pinboard-gw
rules:
- matches:
- path: { type: PathPrefix, value: /api }
backendRefs:
- name: pinboard-api
port: 8080
Compare side by side with the Ingress two slides back: same intent, explicit structure. Point at backendRefs being a list — adding weight: 90 and weight: 10 is a canary with no annotations and no second controller.
Live Demo
Pinboard in a browser, through the Ingress
Install ingress-nginx on kind, apply the Ingress, open http://localhost — then peek at the same rule expressed as an HTTPRoute.
What you'll see · path routing, controller logs, 404 vs 503Files · demos/session07/ingress.yaml · httproute.yamlDuration · 8 min
Apply the controller and the Ingress, then kubectl -n demo get ingress until ADDRESS is filled. Open http://localhost and post a note. Then break it on purpose: kubectl -n demo edit ingress pinboard and change the backend service name — show the 503 and the ingress-nginx log line. Fix it. If time remains, kubectl apply -f the Gateway API CRDs and show kubectl explain httproute.spec.rules.
Who may talk to whom
NetworkPolicy: the default is allow-all
Flat means flat Without policies any Pod in the cluster can reach your database Pod on 5432. Namespaces are not a boundary.
Selecting = denying The moment one policy selects a Pod for a direction, everything not explicitly allowed in that direction is dropped.
Additive & namespaced Policies union; there is no "deny" rule and no ordering. Write allow-lists per workload.
The CNI enforces it No policy-capable plugin → the objects are stored and ignored. Always test that a denied call really fails.
The classic starting point is a namespace-wide default-deny ingress policy (empty podSelector, policyTypes: [Ingress]) plus explicit allows. Warn about DNS: an egress default-deny that forgets UDP 53 to kube-system breaks every name lookup in the namespace.
Manifest
Only the API may reach the database
podSelector — the Pods this policy protects
policyTypes — which directions it governs
from — podSelector, namespaceSelector or ipBlock
The companion policy lets only web and ingress-nginx reach the API on 8080
Result: web → api → db, and nothing else.
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata: { name: db-only-from-api, namespace: pinboard }
spec:
podSelector:
matchLabels: { app.kubernetes.io/name: pinboard-db }
policyTypes: ["Ingress"]
ingress:
- from:
- podSelector:
matchLabels: { app.kubernetes.io/name: pinboard-api }
ports:
- port: 5432
This is 50-networkpolicy.yaml, first document. Note the gotcha in that file's companion policy: traffic from the ingress controller comes from another namespace, so it needs a namespaceSelector on kubernetes.io/metadata.name: ingress-nginx. This lands as a capstone stretch goal in session 10.
Lab 07 · 30–40 min
Expose Pinboard
Give the API a ClusterIP, prove DNS works from a throwaway Pod, deploy the web tier pointing at http://pinboard-api:8080, install ingress-nginx on kind and route / and /api — then open the app in your browser for the first time.
You'll practise: ClusterIP Services · nslookup from a debug Pod · EndpointSlices · ingress-nginx on kind · path routing · troubleshooting a 503
Point out the continuity: the Deployment from lab 06 is still running, so they only add Services, the web Deployment and one Ingress. Tell them to keep a curl loop on http://localhost/api/info in a second terminal — it makes every mistake visible immediately.
Session 07
Key takeaways
Pod IPs are disposableFlat network, one IP per Pod, provided by the CNI — never an address you write down.
A Service is a name + a selectorEndpointSlices track the ready Pods; kube-proxy does the DNAT; CoreDNS gives you svc.ns.svc.cluster.local.
One edge, not manyClusterIP inside, one Ingress (or Gateway) outside. Gateway API is where the API is going.
Next upSession 08 — ConfigMaps, Secrets, volumes and a real database with a StatefulSet.
Close the loop on the session promise: Pinboard is now reachable from a browser, with no hard-coded IP anywhere. The one thing still missing is state — which is exactly session 08.
Questions?
Up next — Session 08: Configuration & storage . Keep the cluster running, we add the database next.