Here’s something that most Kubernetes tutorials won’t tell you: most engineers can run kubectl expose. Fewer than 10% understand what happens when they do.

I’ve debugged Kubernetes networking issues at more than 10 companies. The same knowledge gaps appear every time. Engineers don’t understand how ClusterIP works under the hood. They don’t understand why Pods in different namespaces can talk to each other by default. And they don’t understand what a CNI plugin actually does at the kernel level.

This tutorial is the fix. You’ll learn how Kubernetes networking works from the bottom up: how Pod IPs are assigned and why they work across nodes, how kube-proxy implements ClusterIP using iptables rules, how Ingress controllers route external traffic through a single load balancer, how Network Policies enforce micro-segmentation for SOC2 compliance, and how Cilium uses eBPF to replace all of this with a faster, more observable, and more secure alternative.

By the end of this guide, you’ll be able to debug “why can’t my pod talk to that service?”, implement default-deny Network Policies that satisfy SOC2 CC6.1, and choose the right CNI for your cluster with confidence.

Table of Contents

What You’ll Learn

  • Pod IPs, the container network model, and how the CNI assigns addresses
  • How kube-proxy implements ClusterIP with iptables and why eBPF is faster
  • Ingress controllers: routing all external traffic through a single load balancer
  • Network Policies: default-deny and per-service allow rules for zero-trust networking
  • CNI comparison: Cilium vs Calico vs AWS VPC CNI and when to use each
  • Service mesh: Cilium vs Istio vs Linkerd for mTLS and observability

Prerequisites

Before following along, you should have:

Knowledge:

  • Basic Kubernetes familiarity: you can deploy a Pod and create a Service
  • Basic Linux networking concepts: you know what an IP address and a port are
  • A general understanding of what a load balancer does

Tools and access:

  • A running Kubernetes cluster (EKS, GKE, or a local cluster via kind)
  • kubectl configured and pointing at your cluster
  • helm 3 installed (for Cilium installation in Part 4)
  • For Part 4 onwards: Cilium installed on your cluster (helm install cilium cilium/cilium)

A note on CNI: Parts 1–3 apply to any Kubernetes cluster regardless of CNI. Parts 4–6 use Cilium-specific resources (CiliumNetworkPolicy, Hubble). If you’re on a different CNI, the concepts are identical and only the YAML syntax differs.

Part 1: Pod IPs and the Container Network Model

1.1 Why Every Pod Gets Its Own IP

The Kubernetes networking model has one foundational rule: every Pod gets its own unique IP address, and every Pod can communicate with every other Pod using those IPs — without Network Address Translation (NAT).

This is different from how Docker works by default, where containers share the host network or use port mapping. In Kubernetes, there’s no port mapping between pods. Pod A at IP 10.244.1.2 can directly reach Pod B at 10.244.2.3 across a different node, and the source IP is preserved.

Verify this for your cluster:

# List all pods across all namespaces with their IP addresses and node placement
kubectl get pods -o wide --all-namespaces

Expected output:

NAMESPACE     NAME                                READY   STATUS    IP            NODE
production    payment-api-5d6b8d8c4f-abc12        1/1     Running   10.244.1.2    node-1
production    user-api-5d6b8d8c4f-def34           1/1     Running   10.244.2.3    node-2
production    redis-master-0                      1/1     Running   10.244.1.4    node-1

Each pod has a unique IP. The payment-api on node-1 and the user-api on node-2 can reach each other directly at those IPs. Notice that the IPs come from the 10.244.0.0/16 CIDR: this is the Pod network, separate from the node network.

1.2 What the CNI Plugin Actually Does

The Container Network Interface (CNI) is the plugin responsible for making the Kubernetes networking model work. When a new Pod is scheduled on a node, the Kubernetes kubelet calls the CNI plugin, which performs four operations:

  1. Creates a new network namespace for the Pod: an isolated networking environment
  2. Creates a virtual Ethernet pair (veth): one end inside the Pod’s namespace, one end on the node
  3. Assigns an IP address from the cluster’s Pod CIDR to the Pod’s end of the veth pair
  4. Adds routing rules so the node knows how to reach every Pod IP in the cluster

Without the CNI, pods would have no network connectivity. With it, the flat Pod network model becomes reality.

Check which CNI plugin is installed on your cluster:

# List the CNI binaries installed on a node
ls /opt/cni/bin/

Here are some common CNI plugins and when to use each:

CNIDefault on?Primary Use Case
AWS VPC CNIYes (EKS)Pods get real VPC IPs. Best for AWS-native integration
CalicoNoAdvanced network policies with BGP routing
CiliumNoeBPF-based networking, Layer 7 policies, service mesh, SOC2 evidence

1.3 Verifying Pod-to-Pod Communication

The most fundamental networking test: exec into one Pod and ping another by IP.

# Step 1: Get the IP of a target pod
TARGET_IP=$(kubectl get pod redis-master-0 -o jsonpath='{.status.podIP}')
echo "Target IP: $TARGET_IP"

# Step 2: Exec into another pod and ping the target
kubectl exec -it payment-api-5d6b8d8c4f-abc12 -- ping -c 3 $TARGET_IP

Expected output:

PING 10.244.1.4 (10.244.1.4): 56 data bytes
64 bytes from 10.244.1.4: icmp_seq=0 ttl=62 time=0.8ms
64 bytes from 10.244.1.4: icmp_seq=1 ttl=62 time=0.7ms
64 bytes from 10.244.1.4: icmp_seq=2 ttl=62 time=0.9ms

If this succeeds, the CNI is working correctly. If it fails, check whether a Network Policy is blocking ICMP traffic (Part 4 covers this).

The one rule to remember: every Pod gets an IP. Pods can communicate directly using those IPs. The CNI plugin makes both of these things true.

Part 2: Services — ClusterIP, NodePort, and LoadBalancer

2.1 The Problem: Pod IPs Are Not Stable

Pod IPs change every time a Pod restarts. If you deploy a new version of your payment API, the old Pods are deleted and new Pods are created with new IPs. Any service that was configured to call the old IPs now has dead references.

Here’s the incorrect approach: hardcoding a Pod IP.

# Bad: Direct Pod IP in application configuration
# This IP will stop working the next time the database Pod restarts
apiVersion: v1
kind: Pod
metadata:
  name: payment-api
spec:
  containers:
  - name: api
    env:
    - name: DATABASE_HOST
      value: "10.244.1.4"  # Pod IP — will change on next restart

This is fragile in development and catastrophic in production. A routine Pod restart — from a node drain, an OOM kill, or a deployment rollout — will break any application that hardcoded the old IP.

2.2 How Services Solve the Stability Problem

A Kubernetes Service provides two things that Pod IPs can’t: a stable IP address (the ClusterIP) that never changes as long as the Service exists, and a stable DNS name that other Pods can use regardless of the IP.

When you create a Service, Kubernetes assigns it a virtual ClusterIP from the service CIDR (for example, 10.100.0.0/16), creates a DNS record in CoreDNS as <service-name>.<namespace>.svc.cluster.local, and configures kube-proxy on every node to add iptables rules that load-balance traffic from the ClusterIP to the healthy Pod IPs behind it.

Here’s the correct implementation: a ClusterIP Service.

# Good: ClusterIP Service provides a stable IP and DNS name
# redis.production.svc.cluster.local always resolves to 10.100.0.1
# regardless of which Redis pods are running behind it
apiVersion: v1
kind: Service
metadata:
  name: redis
  namespace: production
spec:
  selector:
    app: redis
    role: master   # Only pods with these labels receive traffic
  ports:
  - port: 6379        # Port the Service listens on
    targetPort: 6379  # Port the Pod actually runs on
  type: ClusterIP     # Default: accessible only inside the cluster

When a Service is created, kube-proxy adds iptables rules to every node in the cluster. These rules intercept traffic destined for the ClusterIP and redirect it to one of the healthy Pod IPs behind the Service, effectively implementing load balancing at the kernel level without any userspace overhead. This mechanism is what makes ClusterIP work transparently across all nodes in the cluster.