Kubernetes Networking Internals: kube-proxy, iptables vs eBPF, CNI Plugins, and DNS
Kubernetes networking has a reputation for being mysterious. Services seem to magically route traffic to pods, DNS names resolve to cluster IPs that don’t exist on any interface, and yet somehow packets find their way. This guide tears open the black box: how the pod network model works, what kube-proxy actually does with iptables, how eBPF is replacing it, what CNI plugins do, and how CoreDNS resolves names inside the cluster.
Understanding this isn’t just academic. It’s the difference between guessing when a NetworkPolicy blocks unexpected traffic, and knowing exactly which iptables rule or eBPF map entry is responsible.
The Kubernetes Network Model
Kubernetes enforces a flat network model with three requirements:
- All pods can communicate with all other pods without NAT
- All nodes can communicate with all pods without NAT
- The IP a pod sees for itself is the same IP other pods see for it
This sounds simple but has profound implications. There’s no inherent NAT between pods — if pod A has IP 10.244.1.5, any other pod reaching 10.244.1.5 reaches pod A directly. No port mapping, no masquerading.
This is different from Docker’s default networking where containers get private IPs behind a NAT bridge. Kubernetes requires that each pod gets a real, routable IP on a cluster-wide pod network.
The Three Distinct Networks
A Kubernetes cluster has three address spaces that never overlap:
Node Network: 192.168.1.0/24 (physical/VM network)
Pod Network: 10.244.0.0/16 (virtual, one /24 per node)
Service Network: 10.96.0.0/12 (virtual, only in iptables/eBPF maps)
Node network: real IP addresses on real network interfaces — how nodes communicate with each other.
Pod network: virtual IP addresses assigned to pods. Each node gets a subnet (e.g., node 1 gets 10.244.1.0/24, node 2 gets 10.244.2.0/24). Pods on the same node communicate via a local bridge. Pods on different nodes need a mechanism to route across nodes — this is what CNI plugins provide.
Service network: the most mysterious one. ClusterIPs live in this range but don’t exist on any network interface anywhere. They only exist as rules in iptables or entries in eBPF maps. When a pod sends a packet to a ClusterIP, the kernel intercepts it and rewrites the destination to a real pod IP before the packet leaves the host.
How Pods Get Network Interfaces
When kubelet creates a pod, it calls the configured CNI plugin to set up networking. The sequence:
- kubelet creates the pod’s network namespace (
/var/run/netns/<pod-id>) - kubelet calls the CNI plugin’s
ADDcommand with the namespace path - CNI plugin creates a veth pair — one end inside the pod namespace, one end on the host
- CNI plugin assigns the pod its IP address (from the node’s pod subnet)
- CNI plugin sets up routes so traffic to other pods is forwarded correctly
- CNI plugin installs any necessary iptables rules
|
|
Inside the pod namespace:
eth0: 10.244.1.5/24 ← pod's veth end
default via 10.244.1.1 ← gateway is the bridge on the host
On the host:
cni0: 10.244.1.1/24 ← bridge (gateway for all pods on this node)
veth3a2b1c: ... ← host end of the veth pair for this pod
CNI Plugins: How Pods on Different Nodes Communicate
The hardest problem in the pod network: pod on node 1 (10.244.1.5) sending to pod on node 2 (10.244.2.7). Node 1’s kernel needs to know how to reach 10.244.2.0/24. CNI plugins solve this in three main ways.
Overlay Networks (Flannel VXLAN, Calico VXLAN)
An overlay wraps the pod IP packet in an outer packet. The inner packet has pod IPs; the outer packet uses node IPs that the underlying network understands.
Inner packet: src=10.244.1.5 dst=10.244.2.7
Outer packet: src=192.168.1.10 dst=192.168.1.11 (node IPs)
VXLAN header (UDP port 8472)
Node 1’s VXLAN device (flannel.1) intercepts packets destined for 10.244.2.0/24, encapsulates them in UDP/VXLAN, and sends to node 2. Node 2’s VXLAN device decapsulates and forwards to the pod bridge.
Pros: works on any network — the overlay handles routing regardless of the underlay topology. Cons: encapsulation overhead (50+ bytes per packet), slightly higher CPU usage for encap/decap.
|
|
Native Routing (Calico BGP, Cilium native)
Routes are distributed directly — no encapsulation. Each node advertises its pod subnet via BGP (Calico) or by programming the kernel routing table directly (Cilium with native routing).
Node 1 routing table:
10.244.1.0/24 dev cni0 # Local pods — via bridge
10.244.2.0/24 via 192.168.1.11 # Remote node 2's pods — direct route
10.244.3.0/24 via 192.168.1.12 # Remote node 3's pods — direct route
When a pod sends to 10.244.2.7, the kernel routes directly to node 2 without any encapsulation. The underlying network must be able to route between pod subnets — typically requiring the nodes to be on the same L2 segment, or a cloud VPC with routes programmed.
Pros: no overhead, full MTU for pods, simpler packet path. Cons: requires underlay network cooperation (not always possible).
|
|
WireGuard Encryption (Calico, Cilium)
Some CNI plugins can encrypt pod-to-pod traffic using WireGuard at the CNI level — transparent to applications.
|
|
CNI Plugin Comparison
| Plugin | Data Plane | Overlay | Native Routing | NetworkPolicy | L7 Policy | Performance |
|---|---|---|---|---|---|---|
| Flannel | iptables | VXLAN | No | No | No | Good |
| Calico | iptables/eBPF | VXLAN | BGP | Yes | No | Very Good |
| Cilium | eBPF | VXLAN | Yes | Yes | Yes (L7) | Excellent |
| Weave | iptables | VXLAN | No | Yes | No | Good |
| Canal | iptables | VXLAN (Flannel) | No | Yes (Calico) | No | Good |
For new clusters: Cilium is generally the best choice. For existing clusters with simple needs: Calico is battle-tested and widely supported.
kube-proxy: Making Services Work
Services are Kubernetes’ load balancing abstraction. A Service gets a stable ClusterIP that doesn’t change even as pods come and go. kube-proxy is responsible for making that ClusterIP work.
kube-proxy watches the Kubernetes API for Service and Endpoints (or EndpointSlice) objects. When a Service is created or its endpoints change, kube-proxy updates the local data plane on every node.
iptables Mode (Default)
In iptables mode, kube-proxy translates Services into iptables NAT rules. When a pod sends to a ClusterIP, iptables intercepts the packet in the PREROUTING chain, performs DNAT (Destination NAT) to a real pod IP, and forwards it.
Let’s trace a ClusterIP Service:
|
|
|
|
The iptables Scaling Problem
iptables rules are evaluated sequentially in kernel space. With 10,000 Services and 3 pods each, that’s ~30,000 NAT rules. Every new packet traverses the entire chain until a match is found. At thousands of Services, this becomes measurable latency and CPU overhead. Adding or removing a single rule requires locking and rewriting the entire ruleset.
Benchmarks show iptables rule processing time growing roughly linearly — at 10,000 Services, rule traversal can add 10-20µs per packet.
IPVS Mode
IPVS (IP Virtual Server) is a kernel load balancer designed exactly for this use case. Instead of chains of rules, it uses hash tables — O(1) lookup regardless of the number of Services.
|
|
IPVS supports multiple load balancing algorithms: round-robin (rr), least connections (lc), source hashing (sh), and more. kube-proxy in IPVS mode uses rr by default.
Caveat: IPVS still uses iptables for some operations (masquerade, NodePort). It’s not a complete replacement but significantly better at scale.
kube-proxy Replacement with Cilium eBPF
Cilium can replace kube-proxy entirely using eBPF maps instead of either iptables or IPVS. eBPF maps are hash tables in kernel memory — O(1) lookup, lock-free updates, no full-table rewrites.
|
|
The eBPF approach handles Service load balancing at the socket layer (SOCK_OPS hook) — before the packet is even formed. This means connection-level load balancing happens at the syscall boundary, with zero packet-level overhead.
|
|
NodePort and LoadBalancer Services
NodePort
A NodePort Service opens the same port on every node. Traffic arriving on any node’s IP at that port is forwarded to the Service’s pods.
|
|
LoadBalancer
For LoadBalancer type Services, kube-proxy (or Cilium) doesn’t provision the actual load balancer — that’s done by a cloud controller manager or MetalLB for bare-metal. Once the external IP is assigned, traffic flows: External IP → Node → NodePort → Pod.
|
|
DNS Resolution in Kubernetes
Every pod in Kubernetes gets /etc/resolv.conf configured to point at CoreDNS:
# /etc/resolv.conf inside a pod
nameserver 10.96.0.10 # CoreDNS ClusterIP
search default.svc.cluster.local svc.cluster.local cluster.local
options ndots:5
The Search Domain Chain
ndots:5 means: if a name has fewer than 5 dots, try appending the search domains before attempting an absolute lookup. For a lookup of my-service from a pod in the default namespace:
1. my-service.default.svc.cluster.local → resolves ✓ (stop here)
2. my-service.svc.cluster.local → would try next if (1) failed
3. my-service.cluster.local → would try next
4. my-service. → would try last (absolute)
This is why curl http://my-service works from within the same namespace — the search domain appends .default.svc.cluster.local and CoreDNS resolves it.
For cross-namespace: curl http://my-service.other-namespace also works because the search domain appends .svc.cluster.local:
my-service.other-namespace.default.svc.cluster.local→ failsmy-service.other-namespace.svc.cluster.local→ resolves ✓
The fully qualified form always works and avoids the search domain chain:
curl http://my-service.other-namespace.svc.cluster.local
ndots:5 Performance Problem
ndots:5 causes excessive DNS lookups for external names. api.stripe.com has 2 dots → triggers 4 failed lookups before the final correct one:
api.stripe.com.default.svc.cluster.local → NXDOMAIN
api.stripe.com.svc.cluster.local → NXDOMAIN
api.stripe.com.cluster.local → NXDOMAIN
api.stripe.com. → resolves ✓
Fix with fully qualified names in application configs (trailing dot) or override per-pod:
|
|
CoreDNS Internals
CoreDNS is a plugin-based DNS server. Its configuration (Corefile) is a ConfigMap in kube-system:
|
|
Default Corefile:
.:53 {
errors
health {
lameduck 5s
}
ready
kubernetes cluster.local in-addr.arpa ip6.arpa {
pods insecure
fallthrough in-addr.arpa ip6.arpa
ttl 30
}
prometheus :9153
forward . /etc/resolv.conf {
max_concurrent 1000
}
cache 30
loop
reload
loadbalance
}
Key plugins:
kubernetes: handles*.cluster.localand reverse DNS for pod/service IPs. Talks to the Kubernetes API to get Service/Endpoint records.forward: forwards non-cluster queries to the upstream resolver (node’s/etc/resolv.conf)cache: caches responses for up to 30 seconds (TTL)loadbalance: randomizes the order of A/AAAA records in responses
DNS Record Types
|
|
Custom DNS with Stub Zones
Override DNS for specific domains — useful for on-premises split DNS or service discovery outside the cluster:
|
|
|
|
Debugging Kubernetes Networking
Pod-to-Pod Connectivity
|
|
Service Connectivity
|
|
DNS Debugging
|
|
Packet Capture Inside a Pod
|
|
NetworkPolicy Troubleshooting
|
|
eBPF vs iptables: The Future of Kubernetes Networking
The industry is clearly moving toward eBPF-based networking for Kubernetes. The advantages compound:
| iptables | IPVS | eBPF (Cilium) | |
|---|---|---|---|
| Service lookup | O(n) rules | O(1) hash | O(1) hash |
| Rule updates | Full rewrite | Incremental | Incremental |
| Connection tracking | conntrack (all traffic) | conntrack | Optional per-flow |
| Load balancing | Per-packet random | Per-connection | Per-connection (socket-level) |
| Observability | None | None | Full flow visibility (Hubble) |
| L7 policy | No | No | Yes (HTTP, gRPC, DNS) |
| Encryption | No | No | WireGuard transparent |
| Network policy | Via iptables | Via iptables | Native eBPF |
At 1,000+ Services or 10,000+ pods, the iptables performance cliff becomes measurable. At 10,000+ Services, it’s a serious problem. eBPF-based solutions scale linearly or better.
The migration path: you don’t have to start fresh. Cilium can be installed in existing clusters in migration mode, replacing kube-proxy incrementally. Run cilium install --set kubeProxyReplacement=true on a new cluster, or follow Cilium’s kube-proxy migration guide for existing clusters.
Quick Reference
|
|
Kubernetes networking is built from standard Linux primitives — namespaces, veth pairs, bridges, iptables, and increasingly eBPF. Once you see the layers clearly, the “magic” disappears and you’re left with familiar tools you already know how to debug.
Comments