文章摘要
飞宇 GPT

Docker 容器网络模型深度剖析与生产实践

在我们把一个 Python Flask 小服务从虚拟机搬上 Docker 的第一个礼拜里,就遇到了三件怪事:容器之间 ping 不通、宿主机能访问但外部访问不了、压测 QPS 直接腰斩。这三件事逼着我把 Docker 的网络模型从头到尾啃了一遍。后来在做一个跨三台物理机的容器化推送平台时,又把 overlay、macvlan 走了一遍,甚至一度想把 Docker 原生网络整体换掉。

这篇文章是我们这几年在 Docker 网络上踩坑的复盘。我会从 CNM 模型讲起,逐个拆解单机的四种网络模式(bridge / host / none / container),再到跨主机的 overlay、macvlan、ipvlan,配合 iptables 规则和 tcpdump 抓包演示,最后落到生产环境里我们到底怎么选型,以及 Docker 网络和 K8s CNI 之间的边界。

一、CNM 模型与 libnetwork:先统一概念

在动手敲 docker run 之前,必须先把 Docker 的网络抽象模型理清楚,否则后面所有的命令都是黑盒。

Docker 在 2015 年发布了 CNM(Container Network Model) 规范,并由 libnetwork 这个库来实现。CNM 把容器网络拆成三个核心对象:

  • Sandbox(沙箱):对应一个容器的网络栈,包含接口、路由表、DNS 配置。一个 Sandbox 就是一个独立的 network namespace。
  • Endpoint(端点):一个 Sandbox 连接到网络上的连接点,对应一个 veth pair 的一端。Endpoint 负责把 Sandbox 接入 Network。
  • Network(网络):一组可以互相通信的 Endpoint 的集合,对应一个 Linux bridge(或 VLAN、VXLAN 等)。

这三者的关系简单说就是:Network 是交换机,Endpoint 是网线,Sandbox 是主机。一个 Sandbox(容器)可以通过多个 Endpoint 接入多个 Network,就像一台服务器插多块网卡。

libnetwork 在此基础上实现了多种驱动(driver):bridge、host、null、overlay、remote、macvlan、ipvlan。驱动是 Network 的实现方式,同一个 CNM 接口下可以插不同的驱动。这种设计让 Docker 网络既能跑单机 bridge,也能通过 remote 驱动对接 K8s 的 CNI 插件。

理解这点很重要:我们平时说的 docker network createdocker network connect,操作的都是 CNM 这套抽象,而真正干活的(建 bridge、拉 veth、写 iptables)是底层驱动。

二、单机网络驱动:从 bridge 说起

单机场景下,Docker 默认提供四种网络模式。先 docker network ls 看一眼:

1
2
3
4
5
$ docker network ls
NETWORK ID NAME DRIVER SCOPE
a1b2c3d4e5f6 bridge bridge local
7g8h9i0j1k2l host host local
3m4n5o6p7q8r none null local

这三个就是开箱即用的默认网络。下面逐个拆。

2.1 bridge 模式:docker0 + veth pair + iptables NAT

bridge 是 Docker 的默认驱动,也是绝大多数人最先接触、也最先踩坑的那个。它的核心是三个 Linux 内核机制:Linux bridge(网桥)、veth pair(虚拟以太网设备对)、iptables NAT

装好 Docker 后,宿主机上会多出一个 docker0 网卡:

1
2
3
4
$ ip addr show docker0
3: docker0: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 ...
link/ether 02:42:6c:9a:3e:1f brd ff:ff:ff:ff:ff:ff
inet 172.17.0.1/16 brd 172.17.255.255 scope global docker0

docker0 本质上是一个 Linux bridge,默认网段 172.17.0.0/16docker0 自己占 172.17.0.1。当你 docker run 启动一个容器时,Docker 会做一连串事情:

  1. 在宿主机上创建一个 veth pair(一对虚拟网卡,从一端塞进去的数据会从另一端出来)。
  2. 把 veth pair 的一端(vethXXXXXX)接到 docker0 bridge 上,留在宿主机命名空间。
  3. 把另一端移到容器的 network namespace 里,重命名为 eth0,并从 172.17.0.0/16 里分配一个 IP(比如 172.17.0.2)。
  4. 给容器配上默认路由:default via 172.17.0.1

用一个 ASCII 图表示就是:

1
2
3
4
5
6
7
8
9
10
11
12
宿主机 network namespace              容器 network namespace
┌─────────────────────────┐ ┌────────────────────┐
│ │ │ │
│ eth0 (10.0.0.10) │ │ eth0 (172.17.0.2)│
│ ▲ │ │ ▲ │
│ │ │ │ │ │
│ ┌────┴─────┐ │ │ ┌────┴─────┐ │
│ │ docker0 │◄──vethA──┼──────────┼──►│ vethB │ │
│ │172.17.0.1│ (veth │ veth │ │ │ │
│ └──────────┘ pair) │ pair │ └──────────┘ │
│ │ │ │
└─────────────────────────┘ └────────────────────┘

veth pair 就是那根”网线”,docker0 就是那台”交换机”。容器到容器、容器到宿主机的二层通信全靠它。

容器访问外网这部分是关键,也是 NAT 真正干活的地方。当容器里的进程访问 8.8.8.8 时,源 IP 是 172.17.0.2,这个私网地址在公网上是路由不通的,必须做 SNAT(源地址转换)。Docker 启动时会在宿主机的 iptables 里写一条 masquerade 规则:

1
2
$ iptables -t nat -S DOCKER
-A POSTROUTING -s 172.17.0.0/16 ! -o docker0 -j MASQUERADE

这条规则的意思是:所有从 172.17.0.0/16 出来、且不是从 docker0 回流的数据包,出去时把源 IP 改成宿主机出口网卡的 IP(MASQUERADE 是动态 SNAT)。

外部访问容器则靠端口映射 + DNAT。docker run -p 8080:80 会在 iptables 里写两条规则:

1
2
-A DOCKER ! -i docker0 -p tcp -m tcp --dport 8080 -j DNAT --to-destination 172.17.0.2:80
-A POSTROUTING -s 172.17.0.2/32 -d 172.17.0.2/32 -p tcp -m tcp --dport 80 -j MASQUERADE

第一条是 DNAT:把访问宿主机 8080 端口的流量改写目的地址为容器的 172.17.0.2:80;第二条是 hairpin NAT,处理容器内部通过宿主机 IP 回访自己的场景(不然会有源 IP 和目的 IP 都是容器 IP、回包走错路的问题)。

我们用 tcpdump 在 docker0 上抓一次 -p 8080:80 的外部访问,能清楚看到 DNAT 改写后的流量:

1
2
3
$ tcpdump -i docker0 -nn port 80
14:22:01.123456 IP 10.0.0.50.54321 > 172.17.0.2.80: Flags [S], ...
14:22:01.123478 IP 172.17.0.2.80 > 10.0.0.50.54321: Flags [S.], ...

注意源 IP 是客户端真实 IP 10.0.0.50,目的 IP 已经是容器 IP 172.17.0.2——这就是 DNAT 在内核 netfilter 钩子上完成的工作。

2.2 自定义 bridge 与内置 DNS

默认的 docker0(即 bridge 这个默认网络)有一个非常坑的特性:没有内置 DNS。两个容器互相用容器名去 ping 是不通的,只能用 IP。这在容器 IP 会变的场景下基本不可用。

解决办法是用自定义 bridge

1
2
3
4
5
$ docker network create --driver bridge app-net
$ docker run -d --name web --network app-net nginx
$ docker run -d --name api --network app-net myapi
$ docker exec -it web ping -c1 api
PING api (172.20.0.3) 56(84) bytes of data.

自定义 bridge 网络会自动启用 Docker 内置的 DNS 服务(127.0.0.11),容器之间可以用容器名、服务别名互相解析。这也是服务发现在单机 Docker 里的最朴素形态——同一个自定义 bridge 内,容器名就是域名。

顺带一提,docker0 不内置 DNS 是一个历史遗留的兼容性决策,目的是不让默认网络的容器互相”自动可见”。生产里我们几乎从不用默认 bridge,一律自定义。

2.3 host 模式:直接共用宿主机网络栈

--network host 让容器直接共用宿主机的 network namespace,没有 veth pair、没有 NAT、没有独立的 IP。容器里 ip addr 看到的就是宿主机的网卡。

好处是性能无损——没有 bridge 转发、没有 iptables NAT 的开销,带宽和延迟基本等于裸机进程。坏处也很明显:端口冲突。宿主机上 80 端口被占了,容器就不能再用 80。

我们会在两种场景下用 host 网络:一是对性能极其敏感的 sidecar / agent 类容器(比如 node-exporter、监控采集器);二是一些特殊协议(比如 mDNS、某些 P2PTCP 应用)在 NAT 后面会出问题,只能用 host 网络绕开。

2.4 none 模式与 container 模式

  • none 模式:容器只有一个 lo 回环网卡,完全断网。我们一般用它来跑离线计算、或者后续手动 docker network connect 接入特定网络的安全隔离容器。
  • container 模式--network container:<另一个容器>):新容器和指定容器共用同一个 network namespace。典型用法是 sidecar / debug 容器需要”借用”主容器的网络栈来抓包或注入流量。Pod 这个概念其实就是从这种模式来的——K8s 的 Pod 内所有容器共享一个 pause 容器的网络栈,本质就是 container 模式。

三、跨主机网络:overlay、macvlan、ipvlan

单机 bridge 只能解决一台宿主机内的通信。一旦容器分布在多台物理机上,就得跨主机组网。Docker 原生提供了三种跨主机方案。

3.1 overlay 网络:VXLAN 隧道

overlay 是 Docker Swarm 默认的跨主机网络方案,底层是 VXLAN(Virtual eXtensible LAN)。VXLAN 是一种二层的”套娃”协议——把容器发出的二层以太网帧,整个塞进一个 UDP 包里,再用宿主机之间的三层 IP 网络传输。这个 UDP 包默认走 4789 端口。

1
2
3
4
5
6
7
8
9
10
11
12
13
主机 A (10.0.0.10)                          主机 B (10.0.0.11)
┌──────────────────────┐ ┌──────────────────────┐
│ 容器1 172.21.0.2 │ │ 容器2 172.21.0.3 │
│ ▲ │ │ ▲ │
│ ┌─┴────┐ │ │ ┌─┴────┐ │
│ │vethA │ │ │ │vethC │ │
│ └──┬───┘ │ │ └──┬───┘ │
│ ┌──┴───────────┐ │ VXLAN 隧道 │ ┌──┴───────────┐ │
│ │ br-xxx (VXLAN)│◄───┼──── UDP 4789 ─────┼─►│ br-xxx (VXLAN)│ │
│ └──────────────┘ │ (10.0.0.x 之间) │ └──────────────┘ │
│ ▲ │ │ ▲ │
│ eth0 10.0.0.10 │ │ eth0 10.0.0.11 │
└──────────────────────┘ └──────────────────────┘

容器 1(172.21.0.2)ping 容器 2(172.21.0.3)时,数据包在主机 A 上被 VXLAN 封装成一个目的端口 4789、目的 IP 10.0.0.11 的 UDP 包,主机 B 收到后解封装,还原成原始的二层帧送到容器 2。

overlay 的好处是对底层网络零要求——只要主机之间三层可达就能组网。坏处是有封装开销:每个包多出 50 字节(外层以太网头 14 + IP 头 20 + UDP 头 8 + VXLAN 头 8),并且 CPU 要做封解包,吞吐会比 bridge 模式再低一截。

我们的实测数据(iperf3,单 TCP 流,万兆物理网)大致是

网络模式 带宽 CPU 开销
host 9.4 Gbps 基线
bridge(自定义) 7.8 Gbps +8%
overlay(跨主机) 4.1 Gbps +35%
macvlan 9.1 Gbps +2%

可以看到 overlay 在跨主机场景下性能损失相当明显,带宽几乎只有 host 的一半。CPU 开销主要来自内核态的 VXLAN 封解包。

3.2 macvlan:让容器直接出现在二层网络里

macvlan 是 Linux 内核提供的一个驱动,允许在一个物理网卡上虚拟出多个子接口,每个子接口有独立的 MAC 地址,在二层网络里看起来就像独立的物理机。

1
2
3
4
$ docker network create -d macvlan \
--subnet=10.0.0.0/24 \
--gateway=10.0.0.1 \
-o parent=eth0 macvlan-net

macvlan 的优势是零封装、性能接近裸机。但代价是它对底层网络有要求:

  1. 物理交换机必须允许同一个端口出现多个 MAC(开启 promiscuous / MAC 学习),云厂商的虚拟网络通常不允许,所以在阿里云/腾讯云上 macvlan 经常直接不通。
  2. 容器和宿主机默认不能通信(出于 MAC 隔离的安全策略),需要额外建一个 bridge 模式的 macvlan 子接口给宿主机用,绕来绕去很麻烦。
  3. 会消耗二层网络的 IP 地址池,每个容器一个真实的下联 IP。

我们在自建机房里给一组高性能日志采集容器用过 macvlan,吞吐确实比 bridge 高一截;但上了公有云之后就放弃了。

3.3 ipvlan:用 IP 而不是 MAC 区分

ipvlan 是 macvlan 的姊妹驱动,区别在于:ipvlan 的所有子接口共用宿主机的 MAC 地址,靠 IP 层来区分流量。它解决了 macvlan 在云环境里”多 MAC 不被允许”的问题(很多云虚拟交换机限制每台虚拟机的 MAC 数量)。ipvlan 有 L2 和 L3 两种模式,L3 模式更接近路由模型。

ipvlan 我们用的不多,这里只点到为止,知道它是 macvlan 在公有云上的”替代选项”即可。

四、生产网络方案选型:我们到底怎么选

讲了这么多驱动,回到最实际的问题:生产环境里该选哪个? 直接说结论——在我们这套以稳定性和可运维性为第一优先级的体系里,绝大多数业务容器跑在自定义 bridge 上,对外服务走 host 网络 + 外部负载均衡(HAProxy / Nginx / 云 SLB),跨主机通信要么走 overlay(小规模),要么干脆上 K8s。

4.1 自定义 bridge + host 网络 + 外部负载均衡

这是我们在一个 6 台物理机的小集群里稳定跑了两年多的方案

  • 业务容器全部接入一个自定义 bridge(app-net),容器之间用容器名互相调用,享受内置 DNS 服务发现。
  • 对外 Web 服务--network host + 外部 HAProxy 做四层负载均衡。host 网络避免了 bridge NAT 的性能损耗,HAProxy 负责 health check、会话保持、SSL 卸载。
  • 需要跨主机的少量调用(比如推送服务跨机房级联)走了一条 overlay 网络,规模不大、性能够用。

这套方案的优点是简单、可调试性强、出问题能快速定位。缺点是它本质上还是单机为主,跨主机能力有限,规模上去之后必须上编排系统。

4.2 与 K8s CNI 的边界

老实说,一旦容器规模超过几台机器,或者需要滚动发布、健康检查、自动伸缩,Docker 原生网络的”天花板”就到了。这个时候我们会直接上 Kubernetes,网络交给 CNI(Container Network Interface) 插件。

Docker 网络和 CNI 不是一回事,甚至有一段时间 Docker 和 K8s 在网络抽象上是有竞争的(CNM vs CNI)。最终 CNI 赢了——它更轻量、更通用。K8s 里容器创建后,kubelet 调用 CNI 插件来配置网络,Docker 这一层只管运行时。

我们在生产里用过的两个 CNI:

  • Calico:基于 BGP 的三层网络,每个节点向集群广播自己容器 IP 的路由。没有 overlay 封装,性能接近 host,并且自带一套很强的 NetworkPolicy(基于 iptables / eBPF 的访问控制)。这是我们 K8s 集群的默认 CNI。
  • Cilium:基于 eBPF 的下一代 CNI,把 kube-proxy 的 iptables 规则整体替换成 eBPF 程序,在 Service 负载均衡、可观测性、NetworkPolicy 上性能都更好。我们在一个对网络延迟敏感的 AI 推理集群上用了 Cilium,Service 到 Service 的延迟比 Calico + kube-proxy 又低了大约 0.3ms

简单总结边界:单机或小规模、不上编排——Docker 自定义 bridge / host / overlay 完全够用;多机编排、需要 Service 模型和 NetworkPolicy——直接上 K8s + CNI(Calico 起步,延迟敏感场景考虑 Cilium),不要在 Docker Swarm + overlay 上投入太多,社区活力已经不在那边。

五、踩坑复盘:那些让我们加班到凌晨的事

理论讲完了,真正记得住的是踩过的坑。下面四个都是我们真实在生产里遇到过的。

5.1 bridge NAT 性能损耗:压测 QPS 腰斩

最早把一个 Go 写的 HTTP 网关容器化时,压测 QPS 从宿主机版的 12k 直接掉到 7k 。一开始怀疑是 Go runtime 的问题,折腾半天才发现瓶颈在 Docker 的 bridge NAT:每个进出的包都要过一遍 docker0 的 bridge 转发和 iptables 的 conntrack 模块,短连接场景下 conntrack 表查找成了瓶颈。

排查命令:

1
2
$ conntrack -L | wc -l      # conntrack 表条目数
$ cat /proc/net/stat/nf_conntrack # 看 insert failed / drop

解决:把这台对延迟敏感的网关换成 --network host,QPS 立刻回到 11.8k 。教训是——高 PPS / 短连接的服务,能上 host 网络就上 host 网络,bridge 的 NAT 开销在万兆+小包场景下会被放大。

5.2 端口冲突:-p 8080:80 没报错却访问不了

一次发布后,某个容器 docker run -p 8080:80 没报错,但 8080 端口死活访问不通。最后查出来宿主机上另一个 Java 进程(一个老业务)也在监听 8080,docker run 不会校验宿主机端口占用,只是默默把 iptables DNAT 规则写进去了,结果两个进程抢端口,流量随机乱串。

排查:

1
2
$ ss -tlnp | grep 8080      # 看宿主机上谁在听
$ iptables -t nat -S | grep 8080 # 看 Docker 写的 DNAT

解决:Docker 不会主动报端口冲突,要么改映射端口,要么把那个老 Java 进程迁走。后续我们在 CI 里加了 ss 检查,防止重复映射。host 网络模式下这个问题更隐蔽——容器直接占宿主机端口,根本没有任何报错。

5.3 跨主机不通:VXLAN 被防火墙拦了

搭 overlay 网络时,两个主机的容器死活 ping 不通,但宿主机之间 ping 没问题。抓包发现 UDP 4789 的 VXLAN 包发出去就石沉大海。最后定位到是机房防火墙在主机之间拦了 4789 端口——overlay 的前提是”主机之间 4789/7946(Swim gossip)/4789(VXLAN)可达”,少一个都不行。

1
2
$ tcpdump -i eth0 udp port 4789   # 在目标主机抓,看不到包就是被拦了
$ nc -uvz <对端IP> 4789 # 快速验证 4789 可达性

解决:找网络组把三台主机之间的 4789、7946 打通,overlay 立刻通了。教训——overlay 不是”零网络配置”,它对底层有特定端口要求,云环境的安全组规则也要同步放行。

5.4 MTU 不匹配:大包丢失,小包正常

这是最阴的一个坑。一个文件上传服务容器化后,用户反馈”小文件能传,大文件(超过约 1400 字节)就卡住超时”。SSH、ping、curl 小请求全部正常,折腾了一整天才发现是 MTU。

overlay 网络(VXLAN 封装)会让每个包多 50 字节的开销,如果底层物理网卡 MTU 是 1500,那容器的 MTU 必须设成 1450(1500 - 50),否则会出现”容器发出 1500 字节的大包,封装后变成 1550,超过物理网卡 MTU 被分片或直接丢弃”的情况,而小包不受影响,所以表现为”小包正常、大包丢失”。

1
2
3
$ ip link show eth0 | grep mtu    # 宿主机 MTU
$ docker exec <c> ip link show eth0 | grep mtu # 容器 MTU
$ ping -M do -s 1472 <target> # 测大包,-M do 禁止分片

解决:创建 overlay 网络时显式指定 MTU:

1
$ docker network create -d overlay --opt com.docker.network.driver.mtu=1400 app-overlay

预留一点余量给可能的额外封装层。云环境(尤其是 VXLAN/Geneve 封装的 VPC)更要小心,有些云的虚拟网卡本身就占了 50 字节,再叠一层 Docker overlay 就要再减。

六、写在最后

Docker 网络看起来命令很简单(docker run --network xxx),但底下的 CNM 模型、libnetwork 驱动、Linux bridge / veth / iptables / VXLAN 每一层都有自己的坑。理解这些底层机制,不是为了去手搓网络,而是为了出问题的时候能定位到是哪一层在作怪——是 NAT 性能?是 DNS 解析?是防火墙拦了 VXLAN?还是 MTU?

回头看我们的演进路径其实很清晰:先用自定义 bridge 把单机组好,性能敏感的用 host,跨主机小规模用 overlay,规模一大就交给 K8s + Calico/Cilium。Docker 原生网络的天花板就是单机和小规模集群,认清这条边界,能省下大量加班时间。希望这篇复盘能帮到正在被容器网络折磨的同学。