Network namespaces and bridges: what 'docker run' actually wires up
A container's network is a Linux netns with its own interfaces and routes, joined to the host by a veth pair on the docker0 bridge, with iptables MASQUERADE NATing egress. Knowing the four primitives turns most networking bugs into a one-command diagnosis.
The on-call alert was vague: one service couldn’t reach another, but only sometimes. The team had spent an hour staring at application logs, retry counts, and DNS config before someone ran ip netns on the host and found the real story. A container’s whole network — its interfaces, its routes, its ARP table, its iptables view — lives in a Linux network namespace, a kernel object as concrete as a process. Inside the failing container, ip addr showed eth0 with an address in 172.17.0.0/16, but ip route was missing the default route to the bridge. A botched custom entrypoint had flushed the routing table on startup. The “intermittent” pattern was just which container had restarted last. The fix took thirty seconds once they stopped debugging the application and started debugging the namespace — because everything Docker does to networking is four kernel primitives you can inspect by hand: the namespace, the veth pair, the bridge, and the NAT rule.
The namespace is the boundary
A container is mostly a set of namespaces, and the network namespace (netns) is the one that owns networking. It has its own interface list, routing table, ARP cache, /proc/net, conntrack view, and iptables rules — fully isolated from the host and from sibling containers. When docker run starts a container on the default bridge network, the daemon creates a fresh netns; inside it the container sees exactly two interfaces:
$ docker run --rm alpine ip addr
1: lo: <LOOPBACK,UP> mtu 65536 # loopback, 127.0.0.1
2: eth0@if47: <UP> mtu 1500 # one end of a veth pair
inet 172.17.0.2/16 ... # an address out of docker0's subnetThe host is the source of truth here, not the container’s self-report. When you see eth0@if47, that notation is the tell: eth0 is one half of a veth pair, and if47 is the interface index of its other half, living on the host. From the host, ip netns and nsenter --net=/proc/<pid>/ns/net ip addr let you step into the container’s network view without a shell in the container at all — the diagnostic that ends most “is it the app or the network” arguments.
veth pairs and the bridge
A veth pair is a virtual Ethernet cable: two interfaces wired back-to-back, where a frame written into one comes out the other. One end (eth0) sits inside the container’s netns; the other end (named like vethXXXX) stays in the host netns and is enslaved to the docker0 bridge. The bridge is a software Layer-2 switch: every container’s host-side veth plugs into it, so containers on the same bridge can reach each other directly by IP at L2, no routing or NAT involved.
The default numbers worth memorizing: docker0 carries subnet 172.17.0.0/16 with the bridge itself at 172.17.0.1 (the containers’ default gateway), and every veth interface defaults to MTU 1500 to match standard Ethernet. That MTU is a classic failure source: run Docker inside an overlay or VPN whose effective MTU is 1450, leave the veth at 1500, and large packets get silently dropped or fragmented — TLS handshakes hang at exactly the point the certificate (a big frame) is sent. The bridge driver does not auto-discover the underlay MTU; you set com.docker.network.driver.mtu or it bites you.
A container shows eth0@if47 with 172.17.0.2/16 but cannot reach anything outside the host. ip route inside the container is empty. What single primitive is broken?
▸Why this works
Why a bridge plus veth pairs instead of just handing each container a host interface? Isolation and density. The netns gives each container its own private L3 stack so port 8080 in one never collides with port 8080 in another, and the bridge lets hundreds of containers share one host NIC while still talking to each other at L2 — all in software, no extra hardware, no IP from the physical network. The cost is the extra hop through the bridge and the NAT rule on egress, which is exactly the machinery the next section covers.
MASQUERADE: how egress leaves the host
The bridge subnet 172.17.0.0/16 is private and not routable on the physical network, so a container talking to the internet needs its source address rewritten to the host’s. Docker installs an iptables MASQUERADE rule in the nat table’s POSTROUTING chain:
-A POSTROUTING -s 172.17.0.0/16 ! -o docker0 -j MASQUERADERead it precisely: for any packet sourced from the bridge subnet that is leaving by an interface other than docker0 (i.e. heading out the real NIC), rewrite the source IP to that NIC’s address. MASQUERADE is SNAT that picks the outbound interface’s current IP automatically — right for DHCP hosts. The kernel’s conntrack table remembers each flow so replies are un-NATed back to the correct container. That table is finite (net.netfilter.nf_conntrack_max, often 262144 on a default host): a box running tens of thousands of short-lived outbound connections per second can exhaust it, at which point new connections fail with nf_conntrack: table full, dropping packet even though CPU and memory look idle. The egress that “randomly stops working under load” is usually conntrack, not the app.
Two containers on the default bridge talk to each other by IP with no NAT, but each one's traffic to the internet is NATed to the host IP. Why the asymmetry?
- 01Trace what 'docker run' wires up for a container on the default bridge, naming all four primitives and the default numbers.
- 02Give the failure mode and one-line diagnosis for each primitive: routing, veth/MTU, and conntrack.
Container networking is not a black box; it is four Linux kernel primitives you can inspect by hand. The network namespace is the boundary — each container gets its own interfaces, routing table, ARP cache, conntrack view, and iptables rules, isolated from the host and from siblings, which is why ports never collide across containers. A veth pair is the cable joining that namespace to the host: one end is the container’s eth0 with an address from 172.17.0.0/16, the other is a host-side vethXXXX enslaved to the docker0 bridge, a software L2 switch at 172.17.0.1 that lets same-bridge containers reach each other directly with no NAT. Both veth ends default to MTU 1500, and a mismatch against a smaller underlay MTU is a classic silent-drop bug that hangs TLS handshakes. Egress to the outside world is SNATed by an iptables MASQUERADE rule in nat/POSTROUTING that only fires for packets leaving via the real NIC (the ’! -o docker0’ guard is why same-subnet traffic is untouched), and the kernel’s conntrack table remembers each flow so replies return to the right container — a finite table (default ~262144) that exhausts under high connection churn and drops new connections while the box looks idle. The senior move is to stop debugging the application and identify which primitive failed: a missing route, a dead veth, an MTU mismatch, or conntrack exhaustion — each has a one-command diagnosis from the host. Now when you see a container that “can’t reach anything,” your first instinct is ip route inside the netns, not another hour in the application logs.
Practice
Start at the top. Tasks go easiest → hardest: recall a fact, apply it to a case, then a senior-level stretch. Open one, attempt it, then reveal.
Something unclear?
Ask a question about this lesson. Questions are anonymous and go straight to the author to make the lesson better.