| |

Docker 100 🐳 Docker Master Class: Hand-Tracing a Packet from Physical NIC through Bridge to Container App Core

⏱️ Estimated read: 11 min | |

This chapter traces a single TCP SYN packet from the moment it arrives on the host’s physical network interface to the moment it is delivered to the application listening inside a container. The path is not a straight line. It crosses the host’s network stack, passes through the Netfilter framework where Docker’s NAT rules rewrite the destination, enters a Linux bridge, traverses a veth pair, and finally arrives in the container’s network namespace. Each step has a specific kernel construct, a specific inspection command, and a specific failure mode. The goal is to make the path visible so that a packet that does not arrive can be located at the step where it was lost.

Key point: The container’s application does not see the host’s port. A packet addressed to host-ip:8080 is rewritten by DNAT to container-ip:80 before it reaches the bridge. The container’s eth0 only ever sees traffic addressed to its own IP and the application’s listening port. The host’s port mapping exists only in the host’s Netfilter rules.


Why the path matters

The NAT rewrite problem. When a container publishes a port with -p 8080:80, Docker installs a DNAT rule in the host’s nat table. The rule matches packets arriving on the host intended for port 8080 and rewrites the destination to the container’s IP on port 80 . A packet capture on the host’s physical interface shows the original destination; a capture on the container’s eth0 shows the rewritten destination. If the rewrite does not happen, the packet never reaches the container, and the application never sees a connection.

The bridge forwarding problem. The Linux bridge (docker0 or a user-defined br-<id>) is a virtual switch. It forwards frames between the veth interfaces attached to it based on MAC addresses. The host’s routing table directs the packet to the bridge, and the bridge forwards it to the correct veth . If the bridge is not forwarding, or if the veth is not attached, the packet is dropped at the bridge.

The veth pair problem. A veth pair is a virtual cable with two ends. One end is in the host’s namespace, attached to the bridge; the other end is in the container’s namespace, named eth0. A packet sent into one end emerges from the other . If the veth pair is misconfigured—one end down, or the wrong end in the wrong namespace—the packet does not cross.


a. The Host’s Physical NIC and Netfilter

The packet arrives on the physical NIC. The NIC’s driver delivers it to the kernel’s network stack, where it enters the PREROUTING chain of the nat table. This is where Docker’s DNAT rule lives.

# Inspect the NAT rules on the host
sudo iptables -t nat -L -n -v | head -30
# Look for the DOCKER chain:
# DNAT tcp -- !docker0 any anywhere anywhere tcp dpt:8080 to:172.17.0.2:80

The rule says: for TCP packets arriving on any interface except docker0, destined for port 8080, rewrite the destination to 172.17.0.2:80 . The !docker0 exclusion prevents hairpin traffic from being rewritten twice. After the rewrite, the packet’s destination is the container’s IP, and the host’s routing table determines that the packet should go to the docker0 bridge.

The docker-proxy process is a separate mechanism. It listens on the host port and forwards traffic to the container in userspace. It runs alongside the DNAT rules, not instead of them. The DNAT handles traffic from outside the host; the proxy handles cases like loopback traffic on some configurations and hairpin connections . If the DNAT rule is missing, the proxy may still forward the packet, but the performance is lower because the packet crosses into userspace.


b. The Linux Bridge and the veth Pair

After the DNAT rewrite, the packet is routed to the bridge. The bridge is a Layer 2 device. It does not have an IP address for forwarding purposes; it forwards frames based on the destination MAC address. The container’s IP (172.17.0.2) is reachable through the veth interface that is attached to the bridge.

# List the veth interfaces on the host
ip link show type veth

# Show the bridge and its attached interfaces
ip link show master docker0

# Get the container's PID and the host-side veth name
CONTAINER_PID=$(docker inspect -f '{{.State.Pid}}' mycontainer)
CONTAINER_IFLINK=$(sudo nsenter -t "$CONTAINER_PID" -n -- cat /sys/class/net/eth0/iflink)
HOST_IFACE=$(ip -o link | awk -F': ' -v iflink="$CONTAINER_IFLINK" '$1 == iflink {print $2}' | cut -d@ -f1)
echo "Host-side veth: $HOST_IFACE"

The iflink value in the container’s eth0 is the index of the host-side veth. The ip -o link command finds the interface with that index. The host-side veth is attached to the bridge, and the bridge forwards the frame to it . The veth pair delivers the frame to the container’s eth0.


c. Inside the Container’s Network Namespace

The container’s eth0 receives the frame. The container’s kernel network stack processes it. The destination IP is the container’s IP, and the destination port is 80. The application listening on port 80 accepts the connection.

# Capture inside the container's namespace
CONTAINER_PID=$(docker inspect -f '{{.State.Pid}}' mycontainer)
sudo nsenter -t "$CONTAINER_PID" -n -- tcpdump -nn -i eth0 port 80

The capture shows the packet with the rewritten destination 172.17.0.2:80. The source is the client’s IP and port. The container’s application sees a connection from the client, not from the host’s port 8080. The return packet follows the reverse path: from the container’s eth0, through the veth pair, to the bridge, through the host’s Netfilter conntrack module, which reverses the NAT, and out the physical NIC to the client .


Complete Example Session

# ============================================
# PART 1: IDENTIFY THE CONTAINER AND ITS PORT MAPPING
# ============================================
docker ps
# CONTAINER ID   IMAGE   PORTS
# abc123         nginx   0.0.0.0:8080->80/tcp


# ============================================
# PART 2: INSPECT THE DNAT RULE
# ============================================
sudo iptables -t nat -L DOCKER -n -v
# DNAT tcp -- !docker0 any anywhere anywhere tcp dpt:8080 to:172.17.0.2:80


# ============================================
# PART 3: CAPTURE ON THE PHYSICAL NIC
# ============================================
# Terminal 1: capture arriving packets
sudo tcpdump -i eth0 -nn port 8080
# Run curl from a remote host to host-ip:8080
# Output: client-ip -> host-ip:8080 SYN


# ============================================
# PART 4: CAPTURE ON THE BRIDGE
# ============================================
# Terminal 2: capture on docker0
sudo tcpdump -i docker0 -nn
# Output: client-ip -> 172.17.0.2:80 SYN
# The destination has been rewritten


# ============================================
# PART 5: FIND THE HOST-SIDE VETH
# ============================================
CONTAINER_PID=$(docker inspect -f '{{.State.Pid}}' mycontainer)
CONTAINER_IFLINK=$(sudo nsenter -t "$CONTAINER_PID" -n -- cat /sys/class/net/eth0/iflink)
HOST_IFACE=$(ip -o link | awk -F': ' -v iflink="$CONTAINER_IFLINK" '$1 == iflink {print $2}' | cut -d@ -f1)
echo "Host-side veth: $HOST_IFACE"


# ============================================
# PART 6: CAPTURE ON THE HOST-SIDE VETH
# ============================================
# Terminal 3: capture on the veth
sudo tcpdump -i "$HOST_IFACE" -nn
# Output: client-ip -> 172.17.0.2:80 SYN


# ============================================
# PART 7: CAPTURE INSIDE THE CONTAINER
# ============================================
# Terminal 4: capture in the container's namespace
sudo nsenter -t "$CONTAINER_PID" -n -- tcpdump -nn -i eth0 port 80
# Output: client-ip -> 172.17.0.2:80 SYN


# ============================================
# PART 8: VERIFY THE RETURN PATH
# ============================================
# The SYN-ACK follows the reverse path
# The container's eth0 -> veth -> bridge -> conntrack reverses NAT -> eth0
# Capture on eth0 shows: host-ip:8080 -> client-ip SYN-ACK

The ten parts trace the packet from the physical NIC through the NAT rewrite, the bridge, the veth pair, and the container’s namespace. Parts 3, 4, 6, and 7 show the same packet at four different observation points, each with a different destination address.


Quick Reference

Packet Path Stages

StageConstructObservation Point
1Physical NICtcpdump -i eth0
2Netfilter PREROUTING (DNAT)iptables -t nat -L DOCKER
3Linux bridgetcpdump -i docker0
4Host-side vethtcpdump -i vethXXX
5Container eth0nsenter ... tcpdump -i eth0
6Applicationss -tlnp inside container

Key Commands

CommandPurpose
iptables -t nat -L DOCKER -n -vShow DNAT rules
ip link show master docker0Show bridge members
nsenter -t $PID -n -- cat /sys/class/net/eth0/iflinkGet host veth index
ip -o link | awk ...Resolve veth name
nsenter -t $PID -n -- tcpdumpCapture in container

Address Transformation

PointSourceDestination
Physical NICclient-iphost-ip:8080
Bridgeclient-ipcontainer-ip:80
Container eth0client-ipcontainer-ip:80
Return (container)container-ip:80client-ip
Return (physical)host-ip:8080client-ip

Best Practices

✅ Do This:

# Inspect the DNAT rule to confirm the port mapping
iptables -t nat -L DOCKER -n -v                                        # ✅
# Capture at multiple points to isolate the failure
tcpdump -i eth0 -nn port 8080 && tcpdump -i docker0 -nn                # ✅
# Use nsenter to capture inside the container without installing tcpdump
nsenter -t $PID -n -- tcpdump -nn -i eth0                             # ✅

❌ Don’t Do This:

# Assume the container sees the host port
# It only sees the container IP and the internal port                     // ❌
# Capture only on the physical NIC and conclude the packet is lost
# The NAT rewrite changes the destination after PREROUTING                // ❌
# Ignore docker-proxy
# It handles loopback and hairpin cases that DNAT does not                 // ❌

Common Pitfalls

PitfallWhy It HappensFix
No packet on docker0DNAT rule missingCheck iptables -t nat -L
No packet on container eth0veth not attached to bridgeCheck ip link show master docker0
Connection refusedApplication not listeningss -tlnp inside container
Loopback works, remote failsMissing DNAT for external interfaceCheck the !docker0 rule
Hairpin failsdocker-proxy disabledEnable --userland-proxy=true

Visual

The Packet Path

┌──────────────────────────────────────────────────────────────┐
│  REMOTE CLIENT                                               │
│         │                                                    │
│         ▼                                                    │
│  ┌─────────────────┐                                        │
│  │ PHYSICAL NIC    │  client-ip -> host-ip:8080             │
│  │ (eth0)          │                                        │
│  └────────┬────────┘                                        │
│           ▼                                                  │
│  ┌─────────────────┐                                        │
│  │ NETFILTER       │  DNAT: host-ip:8080 -> 172.17.0.2:80   │
│  │ PREROUTING      │                                        │
│  └────────┬────────┘                                        │
│           ▼                                                  │
│  ┌─────────────────┐                                        │
│  │ LINUX BRIDGE    │  Forwards to veth                      │
│  │ (docker0)       │                                        │
│  └────────┬────────┘                                        │
│           ▼                                                  │
│  ┌─────────────────┐                                        │
│  │ HOST-SIDE VETH  │  One end of the pair                   │
│  └────────┬────────┘                                        │
│           │ virtual cable                                   │
│           ▼                                                  │
│  ┌─────────────────┐                                        │
│  │ CONTAINER eth0  │  172.17.0.2:80                         │
│  └────────┬────────┘                                        │
│           ▼                                                  │
│  ┌─────────────────┐                                        │
│  │ APPLICATION     │  Listens on port 80                    │
│  └─────────────────┘                                        │
│                                                              │
│  The container never sees the host's port 8080.              │
│                                                              │
└──────────────────────────────────────────────────────────────┘

The veth Pair

┌──────────────────────────────────────────────────────────────┐
│  HOST NAMESPACE              CONTAINER NAMESPACE            │
│                                                              │
│  ┌─────────────────┐          ┌─────────────────┐           │
│  │ vethXXXX        │◄────────►│ eth0            │           │
│  │ (attached to    │  virtual │ 172.17.0.2      │           │
│  │  docker0)       │  cable   │                 │           │
│  └─────────────────┘          └─────────────────┘           │
│                                                              │
│  Packet in one end emerges from the other.                   │
│  The host-side veth is found via the container's iflink.     │
│                                                              │
└──────────────────────────────────────────────────────────────┘

Address Transformation

┌──────────────────────────────────────────────────────────────┐
│  OBSERVATION POINT        SOURCE        DESTINATION          │
│                                                              │
│  Physical NIC             client        host:8080           │
│                                                              │
│  docker0 bridge           client        container:80        │
│                                                              │
│  Container eth0           client        container:80        │
│                                                              │
│  ─────────────────────────────────────────────────────────  │
│                                                              │
│  Return (container)       container     client              │
│                                                              │
│  Return (physical)        host:8080     client              │
│                                                              │
│  conntrack reverses the NAT for the return path.             │
│                                                              │
└──────────────────────────────────────────────────────────────┘

Four Capture Points

┌──────────────────────────────────────────────────────────────┐
│  Terminal 1: eth0                                            │
│  client -> host:8080                                         │
│                                                              │
│  Terminal 2: docker0                                         │
│  client -> 172.17.0.2:80                                     │
│                                                              │
│  Terminal 3: vethXXXX                                        │
│  client -> 172.17.0.2:80                                     │
│                                                              │
│  Terminal 4: container eth0                                  │
│  client -> 172.17.0.2:80                                     │
│                                                              │
│  If Terminal 1 shows the packet but Terminal 2 does not,     │
│  the DNAT rule is the failure point.                        │
│                                                              │
└──────────────────────────────────────────────────────────────┘

Summary

ItemValue
Entry pointPhysical NIC
NAT rewriteiptables -t nat DOCKER chain
Bridgedocker0 or br-<id>
Host-side vethFound via container’s iflink
Container interfaceeth0 inside namespace
ApplicationListens on container’s internal port
Return pathconntrack reverses NAT
docker-proxyHandles loopback and hairpin

Key takeaways:

  • The packet is rewritten before it reaches the bridge. The host’s DNAT rule changes the destination from host-ip:8080 to container-ip:80. The container’s eth0 only sees the rewritten address. A capture on the physical NIC and a capture on the container’s interface show different destination addresses for the same packet .
  • The bridge forwards to the veth, and the veth crosses the namespace boundary. The Linux bridge is a Layer 2 switch. It forwards the frame to the host-side veth, and the veth pair delivers it to the container’s eth0. The container’s eth0 is the other end of the pair .
  • The host-side veth is identified through the container’s iflink. The container’s eth0 has an iflink value that is the index of the host-side veth. The ip -o link command resolves the name. Capturing on that interface shows the packet after the NAT rewrite and before the namespace crossing .
  • The return path uses conntrack to reverse the NAT. The container’s SYN-ACK is addressed to the client’s IP. As it passes through the host’s Netfilter, the conntrack module reverses the DNAT, rewriting the source to host-ip:8080. The client sees a response from the address it sent the request to .
  • docker-proxy is a separate mechanism. It handles loopback traffic and hairpin connections that the DNAT rules do not cover. It runs alongside the DNAT rules, not instead of them. Disabling it can break connections from the host itself to the published port .

Remember: The container does not see the host’s network. It sees its own network namespace, with its own eth0, its own routing table, and its own IP address. The host’s port mapping exists in the host’s Netfilter rules, and the bridge and veth pair are the physical path between the two namespaces. Tracing a packet means capturing at each observation point and confirming that the address transformation happened at the expected stage. If the packet disappears between the physical NIC and the bridge, the DNAT rule is the suspect. If it disappears between the bridge and the container, the veth pair is the suspect. If it arrives at the container but the application does not respond, the application’s listening socket is the suspect.

Was this helpful?


Stop using slow, ad-bloated tool sites! 🤮

🔎 Search “KandZ Tools” on Google to use many professional utilities for free.

KandZ.me is the ultimate minimalist hub for:
✅ Finance (Mortgage, Interest, Inflation)
✅ Tech (Base64, JSON, Dev Suite, IP)
✅ Health (BMI, BMR, TDEE)
✅ Productivity (Timer, Workspace, QR)

⚡️ Fast & Private
🔒 No data leaves your device
💎 100% Free

🔗 Use it now: https://tools.kandz.me
🔖 Bookmark it—you’ll need it later!