Kubernetes in Depth · The flat network, the CNI spec & kube-proxy

Networking & the CNI

Every Pod gets an IP address the moment it's scheduled, and every other Pod on any node can reach it directly, with nothing translating the address in between. Kubernetes itself does none of the work that makes that true — it publishes a small, non-negotiable network model and a plugin contract called the CNI (Container Network Interface), then delegates the actual wiring to whichever plugin you install. Once a Pod has an IP, a second and entirely separate problem starts: giving a shifting set of Pods behind a Service one stable virtual address, which is kube-proxy's job, done by programming the node's own packet-forwarding rules — classically iptables, increasingly IPVS, and on modern clusters sometimes skipped entirely in favor of eBPF. Services & Networking covers what the CKA blueprint expects you to operate; this page goes underneath it, into how the CNI spec actually works, exactly how kube-proxy's two dataplanes differ, and how to choose between Flannel, Calico, and Cilium for a real reason instead of a coin flip.

☺ Explain it like I'm 10

Imagine a new kid moves into a huge neighborhood. Kubernetes' rule is simple: every house gets its own street address, and any house can walk to any other house's front door directly — no forwarding through a post office box. But Kubernetes doesn't send out the road crew itself. It just hires one (the CNI plugin) and hands them a checklist: dig a driveway from this house to the street, give the house an address from the neighborhood's address book, and update the map. Once the house has an address, a second worker shows up — the phone company (kube-proxy) — who makes sure that dialing the neighborhood's one shared "pizza place" number always rings whichever pizza house happens to be open right now, even though the actual pizza houses keep moving to new addresses every week.

🐦Your host for this topic: Pip the Hummingbird — the fastest wings in the cluster, who cares about exactly one thing: that the right bytes reach the right Pod, every single time, no matter how many times it's moved since lunch.

The flat network model: IP-per-Pod, no NAT

☺ Like you're 10: Kubernetes doesn't pave the roads itself — it just insists that every house gets its own address and any house can reach any other, then leaves the actual paving to someone else.

Kubernetes is famous for being unopinionated about networking, but it isn't silent about it. It publishes a small set of hard requirements known as the Kubernetes network model, and any implementation that satisfies them counts as valid, regardless of how it's built underneath. The three rules: every Pod gets a unique, cluster-wide routable IP address; every Pod can reach every other Pod on any node without NAT; and a node's own agents (kubelet, system daemons) can reach every Pod scheduled to that node. The shorthand for all three together is IP-per-Pod on a flat network, and the "no NAT" clause is the quiet load-bearing part: when Pod A talks to Pod B, B sees the connection arriving from A's real address, not from some translated stand-in. That single guarantee is what makes a Pod IP a genuinely usable identity — for logs, for security policy, for a service mesh's mTLS identity — instead of a leaky abstraction you have to work around.

A Pod is not one container sharing a hostname with its neighbors; it's a small group of containers sharing one network namespace. When kubelet starts a Pod, the container runtime first creates a nearly-empty sandbox that owns the Pod's network namespace and its IP, then every application container in the Pod joins that same namespace. That's why containers inside one Pod talk to each other over localhost and share one port space — and it's exactly why a sidecar proxy in a service mesh can transparently intercept a Pod's traffic without any cooperation from the application container: it's sitting inside the identical namespace.

◆ Key idea

Something still has to make "flat" real across many physical machines that know nothing about Pod IPs, and there are exactly two dominant strategies. An overlay network encapsulates each Pod packet inside a node-to-node packet — commonly VXLAN or Geneve — so the physical network only ever sees ordinary traffic between real node IPs, at the cost of CPU overhead and a smaller usable MTU. Native routing, usually via BGP, has every node advertise "I own this slice of the Pod CIDR" straight to the network fabric, so packets route with no encapsulation at all — faster, but only where the underlying network (or your cloud's own routing) will cooperate. Which strategy a given cluster uses is a property of the CNI plugin installed on it, not of Kubernetes itself.

The CNI spec: a contract, not an implementation

☺ Like you're 10: CNI isn't the road itself — it's the one-page work order every road crew has to accept: here's the house, here's the street, ADD a driveway, DEL it if the house gets torn down.

The Container Network Interface is deliberately tiny: a plugin contract between the container runtime and whatever networking implementation you've installed, governed by the containernetworking project and tightly coupled to Kubernetes' own SIG Network. When kubelet asks the runtime (containerd or CRI-O) to bring up a Pod's sandbox, the runtime invokes the CNI plugin configured on that node with a verb — ADD to wire the Pod up, DEL to tear it down, CHECK to verify an existing wiring is still correct — passing the Pod's network namespace and a JSON config over stdin and reading a JSON result back over stdout. Everything about how the flat model actually gets built underneath that one JSON exchange is left entirely to the plugin.

Concretely, on ADD a CNI plugin: allocates a free IP for the Pod from that node's slice of the cluster's Pod CIDR, using its own IPAM (IP Address Management) module; creates a veth pair — a connected pair of virtual Ethernet interfaces — moving one end into the Pod's network namespace as eth0 and leaving the other end in the node's root namespace; programs routes on the node so packets addressed to that Pod IP land in the right namespace; and records the allocation so the same IP isn't handed out twice. DEL reverses every one of those steps and returns the IP to the pool. Real clusters usually chain more than one plugin for a single Pod — a main network plugin (Calico, Cilium, Flannel) plus a small helper like portmap for hostPort support — which is exactly what the plugins array in a CNI config file represents.

kubelet Pod sandbox needed container runtime containerd / CRI-O CNI plugin ADD — JSON via stdin calico / cilium / flannel allocates + wires + routes Pod network namespace eth0 10.244.1.7/32 one interface, this side host root network namespace cali1a2b3c@if7 the other side of the pair route: 10.244.1.7 dev cali1a2b3c veth pair — one interface, two ends IP allocated by IPAM from this node's Pod CIDR slice, 10.244.1.0/24 — DEL reverses every step
// /etc/cni/net.d/10-calico.conflist — plugins run in order for every ADD
{
  "cniVersion": "1.0.0",
  "name": "k8s-pod-network",
  "plugins": [
    {
      "type": "calico",
      "log_level": "info",
      "datastore_type": "kubernetes",
      "mtu": 1440,
      "ipam": { "type": "calico-ipam" },
      "policy": { "type": "k8s" },
      "kubernetes": { "kubeconfig": "/etc/cni/net.d/calico-kubeconfig" }
    },
    {
      "type": "portmap",
      "capabilities": { "portMappings": true }
    }
  ]
}
# Inside the Pod's own network namespace — this is the ADD result
kubectl exec checkout-7d4f9 -- ip addr show eth0
# 3: eth0@if42: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1440
#     inet 10.244.1.7/32 scope global eth0

# On the node, the OTHER end of that exact same veth pair (index 42 ↔ 3)
ip link show | grep '^42'
# 42: cali1a2b3c@if3: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1440
⚠ Mind the MTU

The single most common "everything works until a big response comes back" CNI bug is an MTU mismatch. If the physical network's MTU is 1500 and an overlay adds a 50-byte VXLAN header on top, Pod traffic needs an MTU around 1450, or large packets fragment — or silently drop, if "don't fragment" is set anywhere in the path. Small requests sail through; large responses hang or time out. Always size the CNI's configured MTU to the fabric's MTU minus the encapsulation overhead, and re-check the math whenever you cross into jumbo-frame or cloud-provider territory.

kube-proxy: turning a Service into real forwarding rules

☺ Like you're 10: Pods keep moving addresses; a Service is one phone number that never changes. kube-proxy is the operator who keeps updating, behind the scenes, exactly which desk that number currently rings.

A Pod IP is real and routable, but Pods are cattle — created, killed, and rescheduled constantly, and every new one gets a fresh address. A Service gives callers a stable virtual identity, a ClusterIP that never changes for the life of the object, sitting in front of a moving set of backing Pods selected by label. Nothing routes packets to a ClusterIP on its own — it isn't attached to any real network interface anywhere. Something has to intercept traffic addressed to it and rewrite the destination to a real, currently healthy Pod, and on most clusters that something is kube-proxy: a DaemonSet-managed process running on every node, watching kube-apiserver for Service and EndpointSlice objects and reprogramming that node's packet-forwarding rules every time either one changes.

kube-proxy has shipped three dataplane modes over Kubernetes' history. The original userspace mode actually proxied every connection through a kube-proxy process itself — correct, but slow enough that it's been effectively retired. iptables mode has been the long-standing default: for every Service, kube-proxy writes a chain of iptables rules that the kernel's netfilter evaluates in-line for every packet, no userspace hop required. IPVS mode, newer and built for scale, instead loads Services into the kernel's IP Virtual Server module as proper virtual servers backed by a hash table, giving O(1) lookup instead of iptables' linear rule scan. On a cluster running a handful of Services either mode is indistinguishable in practice; the difference becomes very real once a cluster carries thousands of them.

iptables mode KUBE-SERVICES — match ClusterIP:port KUBE-SVC-xpgd46qr KUBE-SEP-a1 prob. 1/3 KUBE-SEP-b2 prob. 1/2 KUBE-SEP-c3 remainder Pod A Pod B Pod C linear scan — cost grows with Service count IPVS mode IPVS virtual-server hash table ClusterIP:port → {Pod A, Pod B, Pod C} scheduler: rr / lc / sh … Pod A Pod B Pod C O(1) hash lookup — flat cost regardless of Service count
# iptables mode — the same Service, as real rules (abridged)
sudo iptables-save | grep -A2 checkout-svc
# -A KUBE-SERVICES -d 10.96.12.4/32 -p tcp --dport 80 -j KUBE-SVC-XPGD46QRK7WJZT7O
# -A KUBE-SVC-XPGD46QRK7WJZT7O -m statistic --mode random --probability 0.33333 \
#     -j KUBE-SEP-A1B2C3D4E5F6G7H8
# -A KUBE-SEP-A1B2C3D4E5F6G7H8 -p tcp -j DNAT --to-destination 10.244.1.7:8080

# IPVS mode — the same Service, one virtual server entry
sudo ipvsadm -Ln
# TCP  10.96.12.4:80 rr
#   -> 10.244.1.7:8080              Masq    1      0          0
#   -> 10.244.2.3:8080              Masq    1      0          0
#   -> 10.244.3.9:8080              Masq    1      0          0
🐦 Pip's-eye view

"A Service's ClusterIP being unreachable and a Pod's own IP being unreachable are two completely different failures, and I used to debug them as if they were one. If curl to a Pod IP directly fails, that's the CNI's flat network — check the plugin, check routes, check the veth. If curl to the Pod IP works fine but the Service's ClusterIP times out, the Pod network is healthy and the problem is one node behind: kube-proxy either isn't running there or its rules are stale. I once burned an hour 'fixing' Calico for a kube-proxy DaemonSet that had simply crash-looped on one node — completely different component, completely different fix, same symptom from where I was standing."

⚠ NetworkPolicy needs a CNI that enforces it

Kubernetes accepts a NetworkPolicy object at the API regardless of what's installed — kube-apiserver has no opinion on whether anything actually enforces it. Enforcement is the CNI plugin's job, and plain Flannel doesn't do it at all: apply a picture-perfect default-deny policy on top of it and traffic keeps flowing exactly as before, with no error anywhere telling you it was ignored. Confirming your CNI plugin actually implements NetworkPolicy — Calico, Cilium, and Antrea all do — is step one, not an afterthought. The full policy-design side of this lives in DevSecOps' NetworkPolicy design for default-deny namespaces, and in this course's own RBAC & Admission Control.

Choosing a CNI plugin: Flannel, Calico, Cilium

☺ Like you're 10: Three different road crews, three different budgets — a bare road that just connects houses, a road crew that also checks IDs at every driveway, and a road crew with cameras on every corner.

Flannel is the minimalist: a simple VXLAN overlay that delivers exactly the flat network the model requires and essentially nothing else — no NetworkPolicy engine of its own. It's easy to reason about, easy to run on a laptop cluster, and a perfectly fine choice for learning or small non-production clusters, but it stops precisely where policy and deep observability begin. Calico pairs a fast datapath — native BGP routing by default, with overlay and eBPF modes available where the network won't cooperate with BGP — with a mature, well-tested NetworkPolicy engine, including extensions (global policy, ordered rules) beyond the core Kubernetes API. It's the common default for on-prem and hybrid clusters that need real policy enforcement without committing to eBPF everywhere. Cilium is the modern heavyweight: its entire datapath is built on eBPF, giving it identity-based policy instead of IP-based rules, optional L7-aware filtering (HTTP methods and paths, not just ports), a full kube-proxy-replacement mode, and Hubble for live flow observability — capability that costs real operational complexity to run and tune correctly.

DimensionFlannelCalicoCilium
DatapathVXLAN overlayBGP native routing (overlay optional); eBPF mode availableeBPF, in-kernel
NetworkPolicynone — needs a partner pluginrich, incl. global & L3/L4 extensionsrich — identity-based, L3/L4 and L7
kube-proxy replacementnopartial, in eBPF modeyes — full eBPF replacement
Observabilityminimalflow logsHubble live flow visibility
Reaches for it whena lab cluster, or "just make Pods reach each other"on-prem/hybrid, BGP fabric, policy is a hard requirementmulti-cluster mesh, L7 policy, deep observability is the point
◆ Key idea

Every one of these plugins satisfies the exact same Kubernetes network model — flat, no-NAT, IP-per-Pod. The choice between them is never about correctness, it's entirely about which extra capabilities you need enough to accept the operational cost of running them: policy enforcement, kube-proxy replacement, L7 awareness, and cross-cluster mesh are all optional add-ons stacked on top of the one mandatory foundation.

Beyond kube-proxy: eBPF and kube-proxy-replacement mode

☺ Like you're 10: Instead of a phone operator manually forwarding every call, imagine wiring the phone company's own switchboard straight into the exchange — same result, one fewer hop, and it can see a lot more about the call along the way.

Both iptables and IPVS still work by intercepting packets after the kernel's normal networking stack has already done some of its own work. eBPF plugins take a different approach: small, verified programs are attached directly at points in the kernel's packet path — at the network interface (XDP) or the traffic-control layer (tc) — and can rewrite a packet's destination before it ever reaches the iptables or IPVS machinery at all. Cilium's and Calico's eBPF modes use exactly this to implement kube-proxy-replacement: kube-proxy stops running entirely, and Service load-balancing happens as one eBPF program instead of a chain of iptables rules or an IPVS lookup. The upside is fewer hops and richer context per packet — the program can see identities, not just IPs, which is what makes L7-aware policy and Hubble's flow visibility possible in the first place.

This page stops at "eBPF exists and here's why it changes the dataplane." A full treatment of eBPF program types, Cilium's identity model, and the Cilium-specific certification track lives outside this course's Kubestronaut scope, in the sibling Golden Astronaut course, alongside the rest of the wider CNCF certification ladder. If you want the platform-engineering view of this same stack — how a service mesh, an Ingress controller, and multi-cluster networking all sit on top of what this page covers — Platform Engineering's Networking & Service Connectivity goes further still.

✎ Try it

On a kind cluster running Calico or Cilium, create a Deployment and a ClusterIP Service in front of it, then find your kube-proxy mode: kubectl -n kube-system logs -l k8s-app=kube-proxy | grep -i "proxy mode". If it says iptables, run sudo iptables-save | grep <service-name> from a node and match what you see against the chain names above. Now scale the Deployment to five replicas and re-run the same command — count how many KUBE-SEP- chains appear versus how many lines change in ipvsadm -Ln if you switch modes. That difference in how much re-programming a single scale event triggers is the whole practical argument for IPVS on a large cluster.

The wiring on this page is what everything else in Kubernetes Architecture assumed already existed the moment it described kube-proxy and the container runtime. Layer a service mesh's sidecar proxies on top of this exact Pod network in Service Mesh Fundamentals, or head to Troubleshooting and A Troubleshooting Methodology for a structured way to work out which layer — CNI or kube-proxy — actually broke the next time "the Service is unreachable" turns out to mean something different than you first guessed.

🎬 At the Pod Squad
🐦

Pip the Hummingbird: Something's wrong. curl to the checkout Pod's own IP works fine from another Pod — but curl to the checkout Service's ClusterIP just hangs, from that same Pod.

👺

Gizmo the Gremlin: Easy fix — set hostNetwork: true on every Pod so they all just share the node's real network stack. No Services, no ClusterIPs, no more of this to debug at all. 🤑

🐢

Timmy the Turtle: Absolutely not. Every Pod on a node fighting over the same port space, no more Pod-level network isolation, and you'd break the very Service abstraction that lets a Deployment roll out without every caller's address changing. That's not a fix, that's throwing away the model.

🦊

Foxy: Hang on — Pip said the Pod IP works. So the CNI's flat network is fine. What's different about the path that only Services take?

🐦

Pip the Hummingbird: kube-proxy. Checking now — kubectl -n kube-system get pods -l k8s-app=kube-proxy -o wide… the one on this exact node is CrashLoopBackOff. No forwarding rules ever got programmed here at all.

🦉

Professor Owl: Which is exactly why "Pod networking" and "Service networking" are two different failure domains with two different owners — the CNI plugin and kube-proxy don't even have to agree on which node is healthy.

🦫

Benny the Beaver: Restarting the kube-proxy Pod now. Once its rules get rewritten for this node, that ClusterIP should resolve to a real backend again — no YAML changes needed anywhere else.

🐢 Timmy's checkpoint

1. State the Kubernetes network model's three rules in one sentence each, and name the one clause that makes a Pod IP trustworthy as a real identity. 2. What does a CNI plugin actually do on ADD, concretely, in terms of veth pairs, IPAM, and routes? 3. A picture-perfect NetworkPolicy is applied to a cluster running plain Flannel. What happens, and why is that dangerous? 4. Name kube-proxy's two current dataplane modes and the one structural reason IPVS scales better than iptables as Service count grows. 5. A Pod's own IP is reachable but its Service's ClusterIP isn't, from the same caller. Which component is most likely broken, and why doesn't that also mean the CNI is broken? 6. What does "kube-proxy-replacement" mode actually replace, and what makes it possible?

Check your answers
  1. Every Pod gets a unique, cluster-wide routable IP; every Pod can reach every other Pod on any node without NAT; and every node's own agents can reach the Pods scheduled to it. The no-NAT clause is what makes a Pod IP trustworthy, because the destination always sees the real source address rather than a translated stand-in.
  2. It allocates a free IP from the node's slice of the Pod CIDR via its IPAM module, creates a veth pair with one end placed in the Pod's network namespace as eth0 and the other left in the node's root namespace, and programs a route on the node so packets addressed to that Pod IP land in the right namespace. DEL reverses all of it.
  3. Nothing happens — the policy is silently unenforced, with no error anywhere. That's dangerous because it produces a false sense of security: the object exists in the API and looks correct, but traffic flows exactly as if it were never applied, since enforcement is entirely the CNI plugin's job and plain Flannel implements none of it.
  4. iptables and IPVS (userspace mode is effectively retired). iptables evaluates a chain of sequential rules for every packet — a linear scan whose cost grows with the number of Services — while IPVS loads Services into a kernel hash table with O(1) lookup, so its cost stays flat regardless of how many Services exist.
  5. kube-proxy, on that specific node — most likely crashed, not running, or carrying stale rules, so no forwarding entry exists there for the Service's ClusterIP. The CNI plugin is a separate component solving a separate problem (Pod-to-Pod reachability), and it can be completely healthy while kube-proxy on one node is not, because the two never have to agree with each other about anything.
  6. It replaces kube-proxy's iptables or IPVS dataplane itself — Service load-balancing is done as an eBPF program attached directly in the kernel's packet path instead of a separate proxy process rewriting packets after the fact. It's possible because eBPF programs can be attached at the network interface (XDP) or traffic-control (tc) layer and rewrite a packet's destination before it ever reaches the older iptables/IPVS machinery.