环境 orbstack 两台 vm

d1: client

d2: docker + caddy

基础信息

d1

$ ip addr show eth0 | grep inet
    inet 192.168.139.208/24 metric 100 brd 192.168.139.255 scope global dynamic eth0
$ curl -s -v 192.168.139.184|head
*   Trying 192.168.139.184:80...
* Connected to 192.168.139.184 (192.168.139.184) port 80
> GET / HTTP/1.1
> Host: 192.168.139.184
> User-Agent: curl/8.5.0
> Accept: */*
>
< HTTP/1.1 200 OK
< Accept-Ranges: bytes
< Content-Length: 18753
< Content-Type: text/html; charset=utf-8
< Etag: "dhu1h5rv30g0egx"
< Last-Modified: Wed, 15 Apr 2026 21:16:48 GMT
< Server: Caddy
< Vary: Accept-Encoding
< Date: Mon, 04 May 2026 07:21:36 GMT

d2

$ docker run -it --rm -p 80:80 caddy
$ ip addr show eth0 | grep inet
    inet 192.168.139.184/24 metric 100 brd 192.168.139.255 scope global dynamic eth0
    
$ ip addr show docker0 | grep inet
    inet 172.17.0.1/16 brd 172.17.255.255 scope global docker0
    
$ ip route
default via 192.168.139.1 dev eth0 proto dhcp src 192.168.139.184 metric 100
0.250.250.200 via 192.168.139.1 dev eth0 proto dhcp src 192.168.139.184 metric 100
172.17.0.0/16 dev docker0 proto kernel scope link src 172.17.0.1
192.168.139.0/24 dev eth0 proto kernel scope link src 192.168.139.184 metric 100
192.168.139.1 dev eth0 proto dhcp scope link src 192.168.139.184 metric 100
$ sudo docker inspect 47981fb7a43f|grep IPAddress
                    "IPAddress": "172.17.0.2",

filter

$ sudo iptables -S
-P INPUT ACCEPT
-P FORWARD ACCEPT
-P OUTPUT ACCEPT
-N DOCKER
-N DOCKER-BRIDGE
-N DOCKER-CT
-N DOCKER-FORWARD
-N DOCKER-INTERNAL
-N DOCKER-USER
-A FORWARD -j DOCKER-USER
-A FORWARD -j DOCKER-FORWARD
-A DOCKER -d 172.17.0.2/32 ! -i docker0 -o docker0 -p tcp -m tcp --dport 80 -j ACCEPT
-A DOCKER ! -i docker0 -o docker0 -j DROP
-A DOCKER-BRIDGE -o docker0 -j DOCKER
-A DOCKER-CT -o docker0 -m conntrack --ctstate RELATED,ESTABLISHED -j ACCEPT
-A DOCKER-FORWARD -j DOCKER-CT
-A DOCKER-FORWARD -j DOCKER-INTERNAL
-A DOCKER-FORWARD -j DOCKER-BRIDGE
-A DOCKER-FORWARD -i docker0 -j ACCEPT

nat

$ sudo iptables -S -t nat
-P PREROUTING ACCEPT
-P INPUT ACCEPT
-P OUTPUT ACCEPT
-P POSTROUTING ACCEPT
-N DOCKER
-A PREROUTING -m addrtype --dst-type LOCAL -j DOCKER
-A OUTPUT ! -d 127.0.0.0/8 -m addrtype --dst-type LOCAL -j DOCKER
-A POSTROUTING -s 172.17.0.0/16 ! -o docker0 -j MASQUERADE
-A DOCKER ! -i docker0 -p tcp -m tcp --dport 80 -j DNAT --to-destination 172.17.0.2:80

主要看这四条,filter允许 172.17.0.2 80端口访问,nat POSTROUTING 出网时做 DNAT

-A DOCKER -d 172.17.0.2/32 ! -i docker0 -o docker0 -p tcp -m tcp --dport 80 -j ACCEPT

-A PREROUTING -m addrtype --dst-type LOCAL -j DOCKER
-A POSTROUTING -s 172.17.0.0/16 ! -o docker0 -j MASQUERADE
-A DOCKER ! -i docker0 -p tcp -m tcp --dport 80 -j DNAT --to-destination 172.17.0.2:80

tcpdump/conntrack

d2

$ tcpdump -i eth0 -s0 -X -nn 'tcp port 80' -w docker/d2-eth0.pcap --print

eth0.pcap

$ tcpdump -i docker0 -s0 -X -nn 'tcp port 80' -w docker/d2-docker0.pcap --print

docker0.pcap

$ tcpdump -i veth4535c98 -s0 -X -nn 'tcp port 80' -w docker/d2-veth4535c98.pcap --print

veth4535c98.pcap

能看到每个网卡 Time 部分都不一样,从 veth -> docker0 -> eth0 是越来越大的,可以简单理解为转发损耗

再次访问,通过 conntrack 能看到是 src=172.17.0.2 就是容器的 ip

$ conntrack -L | grep 80
conntrack v1.4.8 (conntrack-tools): 1 flow entries have been shown.
tcp      6 119 TIME_WAIT src=192.168.139.208 dst=192.168.139.184 sport=59290 dport=80 src=172.17.0.2 dst=192.168.139.208 sport=80 dport=59290 [ASSURED] mark=0 use=1

通过抓包也能看到容器回包是 veth -> docker0 -> eth0

总结

放一个 ai 总结,会更加清楚

  完整路径还原

  容器内 eth0
    ↓  (回包发出,二层走 veth pair)
  veth4535c98 (host 端)         ← 第 1 次被抓到
    ↓  (这个 veth 本身就是 docker0 的一个 port)
  docker0 桥                      ← 第 2 次被抓到(同一包)
    ↓  (Linux 网桥决定:目的 IP 192.168.139.208 不在桥本地网段,
         走三层路由 → 路由表指向 eth0)
  内核路由 / POSTROUTING
    ↓  (在 POSTROUTING 链做反向 NAT:
         conntrack 还原 src 172.17.0.2:80 → 192.168.139.184:80)
  eth0 出去                       ← 第 3 次被抓到
    ↓
  vm-client

  你之前问的"veth 是直接回还是要经过 docker0"

  从抓包铁证:

  1. 数据链路层上看,veth 的 host 端就是 docker0 的一个端口——这俩在同一个 Linux bridge 上。包从容器侧 veth 出来,自动就在 docker0 桥上了。
  2. 但是包不会在 docker0 上"消失"或"反弹"。docker0 是一个二层桥,桥要做转发决策:
    • 目的 MAC 是不是桥上其他端口能到?→ 不是(vm-client 的 MAC 不在桥上)
    • 目的 IP 是不是 docker0 自己(172.17.0.1)?→ 不是
    • 那就走三层路由,交给内核的路由子系统
  3. 内核路由查表:192.168.139.208 → 走 eth0
  4. 在 eth0 出去之前,POSTROUTING 链触发,conntrack 反向 NAT 把 src 改回 vm-docker IP

  所以"veth → docker0 → eth0"这个顺序是对的,但要理解:
  • veth host 端和 docker0 是同一个桥域,包跨这两个抓包点其实是"桥内部转发",没有真正的物理跳跃
  • 真正的"跳"是从 docker0 上行到 IP 路由层 → 再下行到 eth0——这一步包做了 NAT 改写

  你看到时间戳几乎相同的现象

  ┌─────────┬────────┬───────┐
  │ 抓包点  │ 时间戳 │ Δ     │
  ├─────────┼────────┼───────┤
  │ veth    │ 676821 │ -     │
  │ docker0 │ 676834 │ +13µs │
  │ eth0    │ 676842 │ +8µs  │
  └─────────┴────────┴───────┘

  整个 veth → docker0 → eth0 全程 21 微秒。这说明三个抓包点在数据流上是串行的同一个包,不是分别独立产生的。

  还有一个有趣的细节

  请求方向(S 第一行):

  docker0:  192.168.139.208 > 172.17.0.2.80      ← DNAT 后 (dst 已改)
  veth:     192.168.139.208 > 172.17.0.2.80      ← DNAT 后 (dst 已改)
  eth0:     192.168.139.208 > 192.168.139.184.80 ← DNAT 前 (dst 还是 vm-docker)

  入向 DNAT 发生在 PREROUTING(在 eth0 之后、docker0 之前),所以你看到 eth0 抓到的包目的 IP 还是 vm-docker,而 docker0/veth 抓到时已经被改成容器 IP 了。

  回包方向(S. 第二行):

  veth:     172.17.0.2.80 > 192.168.139.208      ← 反向 NAT 前
  docker0:  172.17.0.2.80 > 192.168.139.208      ← 反向 NAT 前
  eth0:     192.168.139.184.80 > 192.168.139.208 ← 反向 NAT 后 (src 已改)

  回包反向 NAT 发生在 POSTROUTING(在 docker0/veth 之后、eth0 之前),所以 docker0 上看到的源 IP 还是容器 IP,eth0 上看到的源 IP 已经是 vm-docker IP 了。

  这跟我之前讲的 NAT 时机完美对应。你这个实验做得非常漂亮,三个抓包点正好把 NAT 的"前/中/后"三个状态都看到了。