Frontend Labs
⋯

Chapter 58 — Kubernetes networking: Services, Ingress, NetworkPolicy, CNI

In one sentence

Kubernetes networking is normal networking with more layers: pod IPs, Service virtual IPs, kube-proxy or eBPF rules, ingress controllers, cluster DNS, and a CNI plugin, each with its own failure modes.

The problem

A request that fails inside a cluster could have died at any of six layers, most of them invisible to kubectl get pods. Teams new to Kubernetes tend to restart pods until the problem goes away. Teams that understand the packet path find the broken layer in minutes.

The model: four rules

Kubernetes requires every network plugin (CNI) to provide:

  1. Every pod gets its own IP address.
  2. Pods can reach all other pods without NAT.
  3. Nodes can reach all pods (and vice versa) without NAT.
  4. A pod sees itself with the same IP that others use to reach it.

How the CNI meets these rules varies: routing pod CIDRs directly (Calico in BGP mode, cloud VPC CNIs that give pods real VPC addresses) or an overlay (VXLAN/Geneve, Chapter 54). Overlays explain many MTU problems in clusters.

The packet path to remember

client
  -> cloud LoadBalancer / Ingress controller / Gateway   (L7: host + path routing)
  -> Service (a virtual ClusterIP, e.g. 10.96.12.34:80)   (exists only as rules!)
  -> kube-proxy rules (iptables / IPVS / nftables) or eBPF (Cilium): DNAT to ...
  -> EndpointSlice: the list of READY pod IPs
  -> Pod IP:containerPort (through the node's veth into the pod namespace)

The key insight: a ClusterIP isn't bound to any interface. No process listens on it, and you can't ping it in most clusters. It exists only as NAT rules on every node that rewrite the destination to a pod IP chosen from the EndpointSlice. That's why a Service with zero ready endpoints answers with "connection refused" or a timeout, depending on the implementation.

Debug in this order, or reverse it from inside the pod.

How it actually works: the details that cause incidents

1. Cluster DNS and ndots:5. A pod's /etc/resolv.conf usually looks like:

nameserver 10.96.0.10
search default.svc.cluster.local svc.cluster.local cluster.local
options ndots:5

ndots:5 means "a name with fewer than 5 dots is tried with every search suffix first". So api.github.com (2 dots) becomes, in order:

api.github.com.default.svc.cluster.local   -> NXDOMAIN
api.github.com.svc.cluster.local           -> NXDOMAIN
api.github.com.cluster.local               -> NXDOMAIN
api.github.com                             -> answer

That's 4 lookups (×2 for A and AAAA) for every external name. On a busy service this is a large share of CoreDNS load and adds latency. Fixes: use a trailing dot (api.github.com.), lower ndots in the pod's dnsConfig, or run NodeLocal DNSCache.

2. The conntrack DNS race. glibc sends the A and AAAA queries in parallel from the same UDP socket. Both packets race through conntrack's NAT on the node, one can lose the insert, and it is silently dropped. The client then waits its full 5-second DNS timeout. If a cluster shows requests taking "exactly 5 s more" now and then, suspect this. Mitigations: NodeLocal DNSCache, options single-request-reopen, or a newer kernel/CNI that fixes the race.

3. externalTrafficPolicy. For LoadBalancer/NodePort Services:

Cluster (default) Local
Which nodes accept traffic all only nodes running a ready pod
Extra hop possibly (node → another node's pod) no
Client source IP lost (SNAT to the node's IP) preserved
Balance even across pods uneven if pods are unevenly spread

If your app logs show node IPs instead of client IPs, this is why.

4. Headless Services (clusterIP: None) skip the virtual IP. DNS returns the pod IPs directly. StatefulSets use them for stable per-pod names, and gRPC clients use them for client-side load balancing (Chapter 57's long-lived connection problem).

5. NetworkPolicy. If no policy selects a pod, all traffic is allowed. Once any policy selects a pod, only traffic that some policy allows gets through. And the CNI must enforce NetworkPolicy, which not every CNI does. A default-deny starting point for a namespace:

apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
  name: default-deny
  namespace: shop
spec:
  podSelector: {}                 # every pod in the namespace
  policyTypes: [Ingress, Egress]
  egress:                         # ...but still allow DNS, or nothing will resolve
  - to:
    - namespaceSelector: {}
      podSelector:
        matchLabels: {k8s-app: kube-dns}
    ports:
    - {protocol: UDP, port: 53}
    - {protocol: TCP, port: 53}

Forgetting the DNS exception is the most common NetworkPolicy outage: everything "times out", because nothing can resolve a name.

Commands that answer real questions

kubectl get pod -o wide                          # pod IPs and nodes
kubectl get svc,endpointslices -l app=web        # does the Service have READY endpoints?
kubectl describe ingress web                     # rules, backends, events
kubectl get networkpolicy -A                     # anything selecting these pods?
kubectl logs -n kube-system -l k8s-app=kube-dns  # CoreDNS errors

A throwaway debugging pod with every network tool in it:

kubectl run -it --rm netshoot --image=nicolaka/netshoot -- bash
# inside:
cat /etc/resolv.conf                              # search domains and ndots
dig web.default.svc.cluster.local +short          # Service ClusterIP
dig +search web                                   # same, via search path
curl -sv http://web:80/healthz                    # through the Service
curl -sv http://<pod-ip>:8080/healthz             # bypass the Service: is it the pod or the Service?

On a node (or a kind node container): iptables-save | grep KUBE-SVC | head shows the NAT rules that are the Services.

Practice (45 min): see a Service is just NAT rules

With kind (Linux guide, Chapter 53 has the setup):

kind create cluster --name net
kubectl create deployment web --image=nginx --replicas=3
kubectl expose deployment web --port=80
kubectl get svc web                                      # note the CLUSTER-IP
docker exec net-control-plane iptables-save | grep -E 'KUBE-SVC|KUBE-SEP' | grep -m6 web
kubectl scale deployment web --replicas=1
docker exec net-control-plane iptables-save | grep -c KUBE-SEP   # fewer endpoint rules
kind delete cluster --name net

You'll see one KUBE-SVC-… chain for the Service and one KUBE-SEP-… ("service endpoint") chain per pod, chosen with --probability rules: random load balancing implemented in the firewall.

Common fingerprints

Symptom Likely cause
Service has no endpoints label selector doesn't match pods, or pods not Ready
Pod can call by IP but not by name CoreDNS down, wrong namespace in the name, NetworkPolicy blocking port 53
Random +5 s latency on outbound calls conntrack DNS race; ndots:5 amplification
Ingress returns 404 host/path rule mismatch, wrong ingress class
Connection times out only across namespaces NetworkPolicy
Client IPs in logs are all node IPs externalTrafficPolicy: Cluster SNAT
Large responses fail in cluster overlay MTU / PMTU issue (Chapter 54)
New pods get no gRPC traffic long-lived HTTP/2 connections through a ClusterIP (L4) Service

Build it in Go (20 min) — client-side balancing over a headless Service

Use what you built in Chapters 18 and 57: resolve a headless Service with net.DefaultResolver.LookupHost(ctx, "web-headless.default.svc.cluster.local") to get every ready pod IP, then round-robin requests across them yourself and re-resolve every 30 s. This is what gRPC's dns:/// resolver with the round_robin policy does, and it fixes the "new pods get no traffic" row above. Run it in-cluster from a golang pod, scale the Deployment, and watch the IP list change.

Common confusions

  • "I can ping the Service IP." Usually you can't. There's no interface behind it to answer ICMP. Test with the real port (curl, nc -vz).
  • "Kubernetes load-balances my gRPC calls." A ClusterIP Service balances connections. One long-lived HTTP/2 connection carries all calls to one pod.
  • "NetworkPolicy is enforced everywhere." Only if the CNI supports it. Test a deny rule before relying on it.

Check yourself

  1. Why can't a ClusterIP be pinged, and what actually makes it work?
  2. Explain ndots:5 and the cost of resolving api.github.com from a pod.
  3. When would you choose externalTrafficPolicy: Local, and what does it cost?
  4. Write a NetworkPolicy that denies all egress except DNS.

Further reading

  • Docs: kubernetes.io, "Services, Load Balancing, and Networking" and "Debug Services".
  • Article: "Racy conntrack and DNS lookup timeouts" (Weave Works blog), the original write-up of the 5-second DNS timeout.
  • Across the series: the Linux guide's Kubernetes chapters cover Services and Pods from the workload side, and the Go guide's Kubernetes chapter covers what a Go service must do to behave well there (probes, graceful shutdown, resource limits).

Generated from real-life-guide-v1.md · 8 Oct 2026 16:36