Containerskubernetespod ipserviceclusteripkube-proxy

Kubernetes Networking, Just Enough

Kubernetes gives every pod a routable IP with no NAT between pods, turns a Service into a virtual IP that the node kernel rewrites to a live pod, exposes L7 routing as an ingress proxy, resolves names through cluster DNS, and lets a network policy be a namespaced firewall — all implemented by a pluggable CNI.

LinuxConceptual
Interview question
Progress

The problem

Three replicas of api run on three nodes. Each restarts with a new IP whenever it is rescheduled. Another pod wants to call api by one stable name and address, have the call reach a healthy replica, and be told nothing about nodes, restarts or which of the three answered. What has to exist in the node kernels and in DNS for that to be true?

The model: every pod has an IP, no NAT pod-to-pod

Kubernetes fixes the addressing model and leaves the implementation open. Every pod gets its own IP from the cluster’s pod range, and any pod can reach any other pod by that IP directly, across nodes, without NAT — the source address a pod sees is the real pod that sent the packet. Containers within a pod share one network namespace (Network Namespaces): they talk over localhost and must not bind the same port. The node itself can reach every pod, and every pod can reach the node.

This is the opposite of Docker’s per-host private bridge with masquerading (Container Networking). The cost is that pod IPs must be routable between nodes; how that is done is the job of the CNI plugin. Flannel builds a VXLAN overlay; Calico advertises pod prefixes with BGP so the underlying network routes them natively, or uses IP-in-IP; the AWS VPC CNI hands pods real VPC addresses from the node’s elastic interfaces; Cilium does routing and policy in eBPF. Each node typically owns a /24 of pod addresses (its podCIDR), so the routing question reduces to "which node owns this /24" — one route per node.

A pod-to-pod packet across nodes (overlay CNI)
  1. Pod A (10.244.1.7) on node 1sends to 10.244.2.9 — the real pod IP, no NAT
  2. Pod veth → node 1 bridge/routingroute 10.244.2.0/24 via node 2 (or via the VXLAN device)
  3. Encapsulate (VXLAN) or route natively (BGP / VPC)the CNI’s choice; MTU tax if encapsulated
  4. Node 2 decapsulates / forwardslocal route 10.244.2.9 → pod veth
  5. Pod B (10.244.2.9)sees source 10.244.1.7; NetworkPolicy evaluated here by the CNI

Services: a virtual IP rewritten in the node kernel

Linux

Pods are ephemeral; a Service is the stable name in front of them. A ClusterIP service gets a virtual IP from the service range (say 10.96.0.12) that is not assigned to any interface anywhere. It works because every node’s kernel rewrites it: kube-proxy watches the API for Services and their EndpointSlices — the current list of ready pod IPs — and programs the node so that a packet to 10.96.0.12:80 is DNAT-ed to one of 10.244.1.7:8080, 10.244.2.9:8080, 10.244.3.4:8080. This is the L4 balancer pattern of Load Balancers: L4 vs L7, built from the NAT: Many Private Hosts Behind One Public Address machinery, running on every node at once.

In iptables mode kube-proxy emits a KUBE-SERVICES chain with one rule per service jumping to a KUBE-SVC-… chain, which picks an endpoint with the statistic module (random, probability 1/n, then 1/(n−1), …) and DNATs; conntrack keeps the choice for the rest of the connection. Thousands of services mean tens of thousands of rules evaluated linearly, and the full table is rewritten on every endpoint change — the known scaling limit. IPVS mode uses the kernel’s IPVS hash tables instead: O(1) lookup, real scheduling algorithms (rr, least-connection, source-hash). eBPF implementations such as Cilium bypass both and do the service translation in a socket-level hook, so a pod’s connect() to a ClusterIP is rewritten before the packet exists.

NodePort reserves a port from 30000–32767 on every node and DNATs it to the service, so any node’s IP reaches it — with a source NAT on the way that hides the client unless externalTrafficPolicy: Local restricts it to nodes that host a pod. LoadBalancer asks the cloud for an external L4 balancer whose targets are the NodePorts (or, with modern CNIs, the pods directly). A headless service (clusterIP: None) has no VIP: DNS returns the pod IPs themselves, for clients such as database drivers that want to choose.

The rewrite, as iptables-mode kube-proxy programs it (abridged)
-A KUBE-SERVICES -d 10.96.0.12/32 -p tcp --dport 80 -j KUBE-SVC-API
-A KUBE-SVC-API -m statistic --mode random --probability 0.33333 -j KUBE-SEP-1
-A KUBE-SVC-API -m statistic --mode random --probability 0.50000 -j KUBE-SEP-2
-A KUBE-SVC-API -j KUBE-SEP-3
-A KUBE-SEP-1 -p tcp -j DNAT --to-destination 10.244.1.7:8080
-A KUBE-SEP-2 -p tcp -j DNAT --to-destination 10.244.2.9:8080
-A KUBE-SEP-3 -p tcp -j DNAT --to-destination 10.244.3.4:8080
# NodePort: -A KUBE-NODEPORTS -p tcp --dport 31080 -j KUBE-SVC-API  (+ SNAT unless externalTrafficPolicy: Local)

Names: cluster DNS

Every pod’s /etc/resolv.conf points at the cluster DNS service (CoreDNS behind its own ClusterIP, typically 10.96.0.10). A service api in namespace shop is api.shop.svc.cluster.local → its ClusterIP; a headless service returns pod addresses; individual pods have records too. The kubelet also writes a search listshop.svc.cluster.local svc.cluster.local cluster.local — and ndots:5, so that api alone resolves within the namespace and api.shop across namespaces.

The ndots:5 rule has a famous cost: any name with fewer than five dots is tried with each search suffix first. curl https://api.stripe.com from a pod generates api.stripe.com.shop.svc.cluster.local, api.stripe.com.svc.cluster.local, api.stripe.com.cluster.local — three NXDOMAIN round trips to CoreDNS, each often as A and AAAA in parallel — before the real query. A trailing dot (api.stripe.com.) or a lower ndots in the pod spec avoids it; see Following One Lookup Through Every Cache for why the resolver behaves this way.

Ingress, network policy, and where the CNI fits

Services are L4. An Ingress (or its successor, the Gateway API) is a set of L7 routing rules — host and path → service — that an ingress controller turns into configuration for a reverse proxy (Forward and Reverse Proxies) it runs: the nginx ingress controller runs nginx, Contour and Istio run Envoy, Traefik runs itself. The proxy pods are exposed with a LoadBalancer service; they terminate TLS with certificates stored as Secrets and forward to pod IPs, watching endpoints to stay current. Everything from the Load Balancers: L4 vs L7 lesson about health checks, draining and timeout alignment applies unchanged, with the added twist that the ingress proxy’s timeouts, the cloud balancer’s timeouts and the pod’s all have to agree.

By default any pod can reach any pod. A NetworkPolicy is a namespaced allow-list: it selects pods by label and states which ingress sources and egress destinations they may talk to, by pod selector, namespace selector or CIDR, plus ports. Once a policy selects a pod, everything not allowed is denied — the same default-deny-once-you-start principle as Firewalls. Policies are enforced by the CNI in the node kernel (Calico and Cilium do; a bare Flannel does not — the objects are accepted and silently ignored), which is why "we added a policy and nothing changed" is the first thing to check.

The CNI plugin is therefore the pluggable implementation of everything above the pod: it creates the pod’s namespace and veth, assigns the IP, makes pod prefixes routable between nodes (overlay or native), optionally replaces kube-proxy, and enforces NetworkPolicy. Kubernetes specifies the contract — pod IPs, no NAT, Services, DNS, policies — and the CNI decides the packets.

  • Pod: an IP, a shared namespace for its containers. Service: a VIP rewritten in every node kernel (kube-proxy iptables / IPVS / eBPF). Ingress: an L7 reverse proxy configured by objects.
  • DNS: svc.cluster.local names with a search list and ndots:5. NetworkPolicy: namespaced allow-lists, enforced by the CNI, ignored by CNIs that do not support it.
  • CNI: creates the veth, assigns the IP, routes pod prefixes between nodes, and enforces policy.

Key points

  • Every pod has a routable IP; pod-to-pod traffic is not NATed; containers in one pod share a namespace and localhost.
  • A ClusterIP is a virtual address that exists only as DNAT rules (iptables), IPVS entries or eBPF maps in each node kernel, kept current from EndpointSlices.
  • NodePort opens a port on every node; LoadBalancer fronts NodePorts (or pods) with a cloud L4 balancer; headless services return pod IPs.
  • Cluster DNS names are svc.namespace.svc.cluster.local; the ndots:5 search list makes external lookups cost several extra queries.
  • Ingress is an L7 reverse proxy (nginx, Envoy, Traefik) configured from Ingress/Gateway objects.
  • NetworkPolicy is a namespaced firewall allow-list; it is enforced by the CNI, and only by CNIs that implement it.
  • The CNI plugin implements the model: namespaces, IPs, inter-node routing, optionally service translation and policy.

Why does this exist?

Mechanisms are answers to constraints. Open each question before reading the answer.

Why routable pod IPs instead of Docker-style NAT?

So that any pod can address any pod uniformly and see real source addresses. Port-mapping per host would make every pod address a (node, port) pair and every service a mapping problem.

Why a virtual IP that no interface owns?

Because a stable address must outlive any pod. Implementing it as a rewrite in every node kernel makes it reachable from everywhere with no single box to fail or to route through.

Why does the ingress exist if Services already balance?

Services are L4: one VIP per service, no host/path routing, no TLS termination. Ingress puts an L7 proxy in front so one external address can serve many services by name and path.

Why is policy enforced by the CNI rather than by Kubernetes itself?

Kubernetes only stores objects. Packets are dropped in node kernels, and the CNI is the component that programs those kernels.

How it fails

What the failure looks like from inside real software.

  • Service resolves and connects, but always to the same pod, or one pod gets all traffic: long-lived keep-alive connections are balanced once at connect time; L4 VIPs cannot rebalance requests — put an L7 proxy in front or use client-side balancing (gRPC).
  • Intermittent connection resets during a rolling deploy: pods terminate before kube-proxy and the ingress see the endpoint removal; add a preStop sleep and readiness gates, and drain in the ingress.
  • External API calls from pods are slow by ~10–50 ms: ndots:5 search-list expansion; use FQDNs with a trailing dot or set dnsConfig.options ndots:2.
  • NetworkPolicy applied, traffic still flows: the CNI does not implement policy (Flannel), or the policy selects no pods because of a label typo.
  • Pods on one node cannot reach pods on another; same-node works: inter-node route or VXLAN port 4789 blocked by a security group; MTU mismatch on the overlay.
  • Client IPs in logs are all node addresses: NodePort/LoadBalancer with default externalTrafficPolicy: Cluster SNATs the source; set Local or use proxy protocol / X-Forwarded-For at the ingress.