Container Networking

A container’s network is not magic or a special kind of interface. It is a handful of ordinary Linux primitives wired together. Once you have built it by hand, Docker networks, Kubernetes pods and CNI plugins stop being black boxes.

The primitives

PrimitiveWhat it isDeep dive
Network namespaceA private copy of the network stack: interfaces, routes, firewall rules, portsNetwork namespace
veth pairA virtual cable: what enters one end leaves the other
BridgeA virtual layer-2 switch inside the kernel
RoutingWhere to send a packet nextLinux routing
netfilter / NATRewriting addresses and filtering packetsiptables

Build it by hand

# 1. A namespace standing in for a container
sudo ip netns add c1
 
# 2. A virtual cable; move one end into the namespace
sudo ip link add veth-host type veth peer name veth-c1
sudo ip link set veth-c1 netns c1
 
# 3. A bridge on the host, with the host end plugged into it
sudo ip link add br0 type bridge
sudo ip addr add 172.30.0.1/24 dev br0
sudo ip link set br0 up
sudo ip link set veth-host master br0 up
 
# 4. Address and default route inside the namespace
sudo ip netns exec c1 ip addr add 172.30.0.2/24 dev veth-c1
sudo ip netns exec c1 ip link set veth-c1 up
sudo ip netns exec c1 ip link set lo up
sudo ip netns exec c1 ip route add default via 172.30.0.1
 
# 5. Let it reach the outside world: forward, and masquerade its source address
sudo sysctl -w net.ipv4.ip_forward=1
sudo iptables -t nat -A POSTROUTING -s 172.30.0.0/24 ! -o br0 -j MASQUERADE
 
sudo ip netns exec c1 ping -c1 1.1.1.1

Add a second namespace on the same bridge and the two can reach each other directly. That is, in essence, everything Docker’s default network does. The commands are explained in [[Linux/networking/ip-command|the ip command]].

┌──────────── host network namespace ─────────────┐
│                                                 │
│   eth0 ◄── MASQUERADE ── br0 (172.30.0.1)       │
│                           │        │            │
│                      veth-host   veth-host2     │
└───────────────────────────│────────│────────────┘
                       veth-c1     veth-c2
                    ┌──────────┐ ┌──────────┐
                    │ c1  .2   │ │ c2  .3   │
                    └──────────┘ └──────────┘

Reaching a container from outside

A container’s address is private to the host. Publishing a port adds a destination-NAT rule that forwards a host port to it:

docker run -p 8080:80 nginx
# roughly: iptables -t nat -A PREROUTING -p tcp --dport 8080 -j DNAT --to-destination 172.17.0.2:80

Details that cause surprises:

  • -p 8080:80 binds on all host interfaces. Use -p 127.0.0.1:8080:80 to keep it local.
  • Docker inserts its rules ahead of host firewall rules, so a published port can bypass ufw or firewalld policies.
  • A container reaching its own published port through the host’s address needs “hairpin” NAT.
  • EXPOSE in a Dockerfile is documentation; it publishes nothing.

Check what is actually listening and translated with [[Linux/networking/ss|ss]] and iptables -t nat -L -n -v; port basics are in ports and NAT in NAT.

Docker’s network drivers

DriverBehaviourUse
bridgeThe setup above. User-defined bridges add DNS by container name.Single-host development
hostNo network namespace: the container uses the host’s stackMaximum performance, no isolation, port conflicts
noneOnly loopbackBatch jobs with no network
overlayVXLAN tunnels between hostsSwarm multi-host networking
macvlan / ipvlanThe container gets an address on the physical networkAppliances, legacy integration
container:<id>Join another container’s namespaceSidecars — and exactly what a Kubernetes pod does

Always create a user-defined network for Compose-style setups. The default bridge network has no name resolution between containers; user-defined ones run an embedded DNS server at 127.0.0.11.

DNS inside containers

The runtime writes /etc/resolv.conf into the container. On Docker it points at the embedded resolver or copies the host’s; on Kubernetes it points at the cluster DNS Service with search domains for the pod’s namespace. Slow or failing lookups inside a container while the host resolves fine is nearly always this file — see DNS resolution and Kubernetes DNS.

From one host to a cluster

The bridge-and-NAT model breaks down across many hosts: container addresses overlap between hosts, and everything outbound is hidden behind NAT. Kubernetes replaces it with a simple requirement:

Every pod gets its own IP address, and every pod can reach every other pod without NAT.

How that is achieved is left to a CNI plugin. When the runtime creates a pod sandbox, it calls the plugin with the path of the new network namespace, and the plugin does the equivalent of the manual steps above — create an interface, assign an address, set up routes.

ApproachHow pods on different nodes reach each otherExamples
OverlayEncapsulate pod packets (VXLAN, Geneve) inside node-to-node packetsFlannel, Calico VXLAN, Cilium tunnel
Native routingThe network itself routes pod CIDRs (BGP or cloud route tables)Calico BGP, Cilium native
Cloud-native addressesPods get real VPC addresses from the cloud networkAWS VPC CNI, Azure CNI, GKE VPC-native
eBPF data pathReplaces bridges and iptables with kernel programsCilium — see eBPF

A pod is several containers sharing one network namespace, which is why they talk over localhost and cannot bind the same port. Services then add stable virtual IPs and load balancing on top, implemented by kube-proxy with iptables, IPVS or nftables rules, or by eBPF. That next layer is covered in Kubernetes networking, CNI, Services and the packet-level deep dive.

Debugging

Work from inside the container outwards.

# The container's view, using the host's tools (works for images with no shell)
PID=$(docker inspect -f '{{.State.Pid}}' web)      # or: crictl inspect <id> | jq .info.pid
sudo nsenter -t $PID -n ip addr
sudo nsenter -t $PID -n ip route
sudo nsenter -t $PID -n ss -tlnp
sudo nsenter -t $PID -n cat /etc/resolv.conf
 
# The host side
ip link show type veth
bridge link
sudo iptables -t nat -L -n -v | less
sudo conntrack -L | grep 172.17.0.2
sudo tcpdump -ni any host 172.17.0.2
SymptomCheck
Connection refused from another containerIs the process listening on 0.0.0.0, or only on 127.0.0.1 inside its namespace?
Works by IP, not by nameDNS: default bridge network, or a broken resolv.conf
Container cannot reach the internetip_forward, the MASQUERADE rule, host firewall, corporate proxy
Intermittent drops under loadConntrack table full (nf_conntrack_max); ephemeral port exhaustion
Large transfers stall, small ones workMTU: an overlay adds headers, so the inner MTU must be smaller
Published port unreachable from another machineBound to 127.0.0.1, or blocked by a cloud security group

“Listening on localhost inside the container” is the single most common mistake: the process works when tested inside and is unreachable from everywhere else.

Across the wiki