上周帮一个朋友排查容器环境偶发超时的问题,查到最后才发现根源竟然很尴尬:两个本该完全隔离的项目环境被放在了同一个默认 bridge 网络里,业务之间一抢带宽,整个集群就跟着抖。这件事让我下定决心把 Docker 网络这部分彻底理清楚——隔离网络怎么做、路由表怎么读、iptables 到底拦截了什么、自定义网络驱动是怎么挂上去的。这篇文章就是我踩完一轮坑之后的完整复盘,适合已经能熟练使用 Docker 基础命令、正准备迈向网络进阶的开发者、运维和 SRE 参考。
先说结论:Docker 的网络并不复杂,但它把 Linux 内核里最要命的几样东西——网络命名空间、veth pair、网桥、路由、iptables 全部组合了起来。你只要把这几块拼图看懂,绝大多数网络问题都能自己定位,而不是靠重启容器碰运气。
1. 为什么值得花时间搞懂Docker网络
1.1 一次线上事故:两个环境竟然"互通"了
我之前在某公司的测试环境里遇到过一次诡异故障:开发环境有一个定时任务,每次执行都会把一批数据写到测试库,而测试环境的另一个服务又会莫名其妙收到大量本应发给生产环境的请求。一开始大家怀疑是代码配置写错了,翻了几圈配置中心都没发现问题。后来我拉出所有容器的 IP,逐个对照才发现:开发环境和测试环境用的都是默认 bridge 网络,网段都是 172.17.0.0/16,两边的容器 IP 互相可以 ping 通,端口也能直连。
这种"假隔离"在规模小的时候完全看不出来,一旦容器数量一多,IP 冲突、端口占用、流量互相干扰就全来了。我当时就在想,如果从一开始就用 Docker 提供的自定义网络隔离能力,这种事根本不会发生。所以对任何要上生产环境的人来说,Docker 网络不是一个"有空再学"的知识点,而是迟早要补的必修课。
1.2 Docker网络模型的本质:CNM三件套
要理解 Docker 网络,先记住三个核心概念,它们来自 Docker 官方的 CNM(Container Network Model)模型:
- Sandbox(沙箱):可以理解成一个容器独享的网络堆栈,包含网卡、路由表、iptables 规则。在 Linux 里它对应的就是网络命名空间(network namespace)。容器之所以能拥有独立的 IP,就是因为每个容器都有自己的一份路由表和防火墙规则。
- Endpoint(接入点):容器内网卡和某个网络之间的连接点。典型体现就是容器里的 eth0,以及它在宿主机上对应的一根 veth 虚拟网线。
- Network(网络):一组 Endpoint 的集合,网络内部的 Endpoint 可以直接通信,网络之间默认隔离。
然后就是驱动。Docker 官方自带了好几种内置驱动:bridge、host、none、overlay、macvlan、ipvlan。最常用的是 bridge 和 overlay。你打开 Docker 文档能看到的默认网络就只有三条:bridge、host、none,其中 bridge 就是默认驱动,对应宿主机上的 docker0 网桥。
理解这套模型最大的好处是:你再看到docker network create -d bridge xxx的时候,就知道它实际上是在宿主机上创建了一个新的 Linux bridge,然后把容器的 veth 网线插到这个 bridge 上。后面所有"通不通"的问题,本质都是路由表和防火墙规则的问题。
2. 隔离网络实战:从默认bridge到自定义网络
2.1 为什么默认bridge满足不了生产环境
很多人前期用 Docker 都是直接docker run,容器落进默认的 bridge 网络里。这个网络能通信,但代价不小:
- 完全没有隔离。只要在同一个宿主机上,任何容器用 IP 都能访问其他容器,不管你愿不愿意。
- 没有服务发现。默认 bridge 里不提供 DNS 解析,靠容器名访问是不行的,早期只能靠
--link单向注入环境变量和 hosts 记录,维护成本极高。 - 不可控。默认网络的网段、网关都是 Docker 自己分配的,你没法指定容器 IP,也没法决定子网大小。
我见过最典型的翻车场景是:两个项目组在同一台机器上做联调,都偷懒用了默认 bridge,结果两边部署的服务端口全是 8080,每次联调都精准打到对方的服务上。用自定义网络把两个项目隔开,这个问题的成本几乎为零,只是很多人不知道。
2.2 用三条命令创建隔离网络
自定义网络的第一步,是创建一个属于自己的 bridge 网络。建议指定子网和网关,方便后面排查:
docker network create \ --driver bridge \ --subnet 172.20.0.0/16 \ --gateway 172.20.0.1 \ prod-net这里我特意把子网选在 172.20.0.0/16,而不是默认的 172.17.0.0/16,是为了避开和 docker0 网段冲突。规划容器网段时,一定要先看宿主机上已有的网段,否则后面会出现路由碰撞,这部分的坑我在第 5 节会专门展开。
然后分别把容器挂到不同网络:
docker run -d --name app-a --network prod-net --ip 172.20.0.10 nginx docker run -d --name app-b --network prod-net --ip 172.20.0.11 nginx docker network create \ --driver bridge \ --subnet 172.21.0.0/16 \ --gateway 172.21.0.1 \ dev-net docker run -d --name app-c --network dev-net --ip 172.21.0.10 nginx现在验证一下隔离效果:
# 同一网络内,可以通 docker exec app-a ping -c 2 172.20.0.11 # 不同网络之间,不通 docker exec app-a ping -c 2 172.21.0.10实际执行时你会发现,不同网络之间的 ping 会一直卡在那里直到超时。这就是我们要的效果:业务上不相干的容器,在网络上老死不相往来。如果想临时放行,可以用docker network connect prod-net app-c把某个容器再接入另一个网络,但除非有明确需要,不建议这么干。
2.3 为什么不同网络不通:内核里的"墙"
隔离不是靠魔法实现的,它靠的是两层机制。
第一层是网桥和路由。每个自定义 bridge 网络,Docker 都会创建一个独立的 Linux bridge,在宿主机上体现为br-xxxx接口。app-a 的 veth 网线插在 prod-net 对应的 bridge 上,app-c 的 veth 网线插在 dev-net 对应的 bridge 上。两个网桥之间默认没有转发路径,桥下接口之间不连通。
第二层是 iptables。就算有人手动加路由让流量能走通,Docker 还有一道专门的"隔离墙"挡在中间。你可以在宿主机上执行:
iptables -L -n -t filter | grep DOCKER-ISOLATION会看到 Docker 为隔离网段生成了一条条规则,凡是源地址和目标地址分属不同 docker 网桥的流量,一律 DROP。所以"不同网络不通"是被内核防火墙明确强制的结果,不是碰巧。
这里有一个很多人容易漏掉的点:宿主机上 Linux 默认的 FORWARD 策略往往不是 ACCEPT,Docker 会把自己的规则插在 FORWARD 链的最前面,并把 Docker 管理的网段放行。如果你手动动过 iptables,很容易把 Docker 的链搞乱,后面第五节的排查顺序会提到这个。
3. 路由表检查:让数据包的走向一目了然
3.1 拿到钥匙:进入容器的网络命名空间
排查网络问题,第一件事就是看路由表,但很多人只会用docker exec进容器执行route -n。这没问题,不过有时候容器里的镜像精简到连 iproute2 都没装,route 命令根本不存在。更稳的办法是在宿主机上直接进入容器的网络命名空间:
# 拿到容器主进程的 PID docker inspect app-a --format '{{.State.Pid}}' # 进入该 PID 的网络命名空间执行命令 nsenter -t <PID> -n route -n执行nsenter -t PID -n route -n后,你会看到类似这样的输出:
Kernel IP routing table Destination Gateway Genmask Flags Metric Ref Use Iface 0.0.0.0 172.20.0.1 0.0.0.0 UG 0 0 0 eth0 172.20.0.0 0.0.0.0 255.255.0.0 U 0 0 0 eth0解释一下这两条路由:
172.20.0.0/16这条是本网段直连路由,目标是本网段的包直接走 eth0,不需要经过网关。0.0.0.0这条是默认路由,所有去往其他网段的包,下一跳都是 172.20.0.1,也就是这个 bridge 网络的网关。
看路由表的意义在于:当你发现容器能 ping 通本网段的另一个容器,却 ping 不通另一台宿主机上的容器时,问题往往就出在"下一跳是谁、路由宣告到了哪里"上面。很多跨主机网络的排查,最后都会落到"宿主机之间能不能路由到这个容器网段"这一个问题上。
3.2 宿主机视角:网桥、veth和iptables
回到宿主机上,用ip link show能看到一大堆 vethxxx 和 br-xxxx,这就是容器网络在宿主机侧的实体。再看iptables的两张表:
先看 filter 表:
iptables -L -n -t filter --line-numbers重点关注 FORWARD 链。Docker 容器之间的流量、容器访问宿主机外部的流量,都会经过 FORWARD 链。Docker 会往前插入 DOCKER 和 DOCKER-USER 链,DOCKER-USER 是留给你自定义规则的地方,凡是放这里面的规则,不会被 Docker 重启覆盖。
再看 nat 表:
iptables -t nat -S一般在 POSTROUTING 链里能看到一条 Docker 自动加的 MASQUERADE 规则:
-A POSTROUTING -s 172.20.0.0/16 ! -o br-xxxx -j MASQUERADE这条规则的意思是:容器访问外网的时候,把源地址换成宿主机出口网卡的地址,也就是 SNAT。没有这条规则,容器发出的包到了外部网络,回来的包根本找不到回去的路。这也是为什么有些环境里容器能互相访问,却死活上不了外网,十有八九就是 MASQUERADE 被清掉了。
3.3 数据包从A到B的完整旅程
把上面几块拼起来,一次容器 A 访问容器 B 的完整链路是这样的:
- 容器 A 的应用发出请求,目标 IP 是 172.20.0.11。
- 容器 A 的协议栈查自己的路由表,发现目标是本网段,于是通过 eth0 直接发送,同时查 ARP 表找 172.20.0.11 对应的 MAC。
- 数据包通过容器 A 的 eth0,进入 veth 虚拟网线,到达宿主机上的 br-prod 网桥。
- 网桥查 MAC 地址表,发现目标 MAC 对应的是另一根 veth,直接在内核里把包转发过去。
- 数据包从 vethB 进入容器 B 的 eth0,容器 B 的协议栈解析并接收。
整个过程在到达网桥之后是二层转发,根本不查 IP 路由表。但一旦目标 IP 不在同一个网段,比如 172.21.0.10,容器 A 就会把包交给默认网关 172.20.0.1。这个网关本质上是宿主机上的一个接口,宿主机内核查自己的路由表,发现目标网段虽然存在,但对应的是另一个 bridge,于是尝试转发,结果被 DOCKER-ISOLATION 规则拦下,直接 DROP。这就是"跨网络不通"在内核里的完整解释。
排查的时候用tcpdump -i br-prod在宿主机上看包是否到达网桥,再tcpdump -i vethB看是否进入目标容器,每一步都能精确定位丢包点,非常直观。
4. 自定义网络驱动:从看懂到写一个最小的驱动
4.1 驱动到底是怎么被Docker调用的
很多人看到"自定义网络驱动"就发怵,其实它的原理并不神秘。Docker 的网络功能由 libnetwork 提供,libnetwork 定义了一套 Driver 接口。内置的 bridge、overlay 也都是这套接口的具体实现。Docker daemon 启动时会扫描/run/docker/plugins/目录下的 socket 文件,当用户执行docker network create -d xxx时,daemon 就会通过 HTTP 协议调用 xxx 驱动提供的接口完成网络创建、端点创建、端点加入等操作。
一个驱动至少要实现以下方法:
| 接口方法 | 作用 |
|---|---|
| GetCapabilities | 向 Docker 声明自己的特性,比如是否作用于整个集群 |
| CreateNetwork | 创建网络时被调用,通常在这里记录网段信息 |
| DeleteNetwork | 删除网络时回收资源 |
| CreateEndpoint | 创建接入点,核心动作是创建 veth pair 并分配 IP |
| DeleteEndpoint | 释放接入点资源 |
| Join | 把 veth 的一端放进容器的网络命名空间,配置 IP 和路由 |
| Leave | 从容器的网络命名空间里移除网卡 |
看到这个列表,你会发现驱动本质上就是一个管理 Linux 网络对象的控制程序,Docker 只是一个调用方。
4.2 写一个最小可用的驱动骨架
下面我用 Go 写一个教学用的最小驱动骨架,只聚焦三个核心方法:CreateNetwork、CreateEndpoint、Join。
package main import ( "encoding/json" "log" "net" "net/http" ) type Driver struct { networks map[string]*Network endpoints map[string]*Endpoint } type Network struct { ID string Name string Subnet *net.IPNet Bridge string } type Endpoint struct { ID string NetworkID string IP net.IP VethHost string VethPeer string ContainerPID int } // CreateNetwork 记录网段信息,真实项目里还要创建 linux bridge func (d *Driver) CreateNetwork(r *CreateNetworkRequest) error { n := &Network{ID: r.NetworkID, Name: r.Name} if r.IPv4Data != nil && len(r.IPv4Data) > 0 { _, subnet, _ := net.ParseCIDR(r.IPv4Data[0].Pool) n.Subnet = subnet } d.networks[r.NetworkID] = n return nil } // CreateEndpoint 创建 veth pair 并分配 IP,这里是核心 func (d *Driver) CreateEndpoint(r *CreateEndpointRequest) (string, error) { endpointID := r.EndpointID // 真实实现:netlink 创建 veth pair,IPAM 分配地址 d.endpoints[endpointID] = &Endpoint{ ID: endpointID, NetworkID: r.NetworkID, } return endpointID, nil } // Join 把 veth 的一端移入容器网络命名空间并配置 IP func (d *Driver) Join(r *JoinRequest) error { // 真实实现:setns 进入容器 netns, // 将 veth 另一端改名 eth0,设置 IP 和默认路由 return nil }真实驱动里,CreateEndpoint 要用 netlink 创建 veth pair,那么 Join 要用setns进入容器网络命名空间,把网卡移进去并设置 IP、路由,中间还要处理 IPAM(IP 地址分配)、DNS 等一堆细节。但对理解机制来说,知道这三个步骤就够了:记录网络、建网线、把网线插进容器。
4.3 把驱动挂到Docker上
驱动编译好之后,让它监听一个 socket 文件,例如/run/docker/plugins/my-driver.sock,然后用下面的方式供 Docker 调用:
mkdir -p /run/docker/plugins sudo ln -s /path/to/my-driver.sock /run/docker/plugins/my-driver.sock sudo systemctl restart docker重启后执行:
docker network create -d my-driver --subnet 192.168.56.0/24 mynet docker run -d --network mynet --name demo nginx如果驱动协议通信正常,容器就能拿到驱动分配的 IP。不过我要强调一句:教学骨架能跑通和能上生产是两回事,真实环境还要考虑异常恢复、多节点一致性、网络安全组等问题。但亲手挂过一遍驱动之后,你对 Docker 网络的掌控感会完全不同,以后看任何第三方网络方案,你都能明白它是在哪一层、用什么方法实现的。
4.4 生产环境驱动选型建议
不是所有场景都需要自己写驱动,大多数时候选对内置驱动就够了。我根据自己的经验整理了一个选型维度:
| 驱动 | 使用范围 | 适用场景 | 注意点 |
|---|---|---|---|
| bridge | 单机 | 单机上多容器互联 | 建议用自定义子网,别用默认 |
| overlay | 跨主机 | 集群内跨节点互通 | 需要提前配置底层 MTU |
| macvlan | 跨主机 | 容器需要独立二层地址 | 依赖交换机,IP 规划要谨慎 |
| host | 单机 | 追求极致性能、不需要隔离 | 直接共享宿主机网络栈 |
| 自定义 | 取决于你 | 固定 IP 池、自定义路由策略 | 需要维护协议兼容性和稳定性 |
挑选时,第一看是否跨主机,第二看隔离粒度,第三看底层网络是否允许你分配网段。很多团队为了图省事把所有容器都塞进一个 overlay,结果一到流量高峰期网络性能就崩,其实换成分段 bridge + 网关互访会更稳定。
5. 排查实录与避坑指南
5.1 容器外网不通:按这个顺序查
生产环境里最常见的故障就是"容器内网正常,外网出不去"。我以前也乱猜过 DNS 问题、防火墙问题,后来总结出一个固定排查顺序,效率高很多:
| 排查步骤 | 命令 | 期望结果 |
|---|---|---|
| 检查内核转发开关 | sysctl net.ipv4.ip_forward | 值为 1 |
| 检查容器能否访问网关 | docker exec app-a ping -c2 172.20.0.1 | 通 |
| 检查 MASQUERADE 规则 | iptables -t nat -S | grep 172.20.0.0 | 存在对应规则 |
| 检查 DNS | docker exec app-a cat /etc/resolv.conf | 看到有效 nameserver |
一个很常见的坑是:在某云环境里,云平台自己改了宿主机的 iptables,把 FORWARD 策略设成 DROP,结果 Docker 容器全部断网。解决办法是在 DOCKER-USER 链里放行必要流量,而不是直接改 FORWARD 默认策略。记住,DOCKER-USER链就是留给运维自定义规则的地方,Docker 不会清它。
5.2 自定义子网的"网段冲突"是隐形炸弹
规划自定义网络时,网段选不好会留下非常大的隐患。我见过某团队把容器网段直接设成 10.10.0.0/16,结果公司内部专线也有一个 10.10.0.0/16,容器访问内部系统时,数据包的下一跳直接被路由到了错误的网关,大量请求超时。
合理的规划方式是:先收集宿主机路由表里已有的所有网段,避开172.17.0.0/16(docker0)、云 VPC 的网段、办公网网段、其他环境占用的网段。容器网段尽量单独划一个大段,比如把生产、测试、开发分别放在 172.20.0.0/16、172.21.0.0/16、172.22.0.0/16,互不重叠。顺手维护一张网络规划表,写清楚每个环境的子网、网关、用途。这个习惯在集群规模超过 20 台之后,能帮你省下无数排查时间。
5.3 Overlay网络与MTU的爱恨情仇
跨主机通信的 overlay 网络,数据包外面要套一层 VXLAN 封装,默认 MTU 会被压到 1450。如果你的底层物理网络 MTU 是 1500,容器内发出的包大小超过 1450 时就要分片。HTTP 这类应用一般还好,但数据库迁移、大数据同步这类大包传输会明显变慢。
遇到这类问题,先确认应用协议是否带了不允许分片标记,再考虑调整:
docker network create -d overlay \ --subnet 10.20.0.0/16 \ --opt encrypted \ --attachable \ --mtu 1400 \ ov-netMTU 设置偏小会导致吞吐下降,偏大会导致黑洞。最稳妥的做法是先测量宿主机之间的实际 MTU,再减去 VXLAN 的开销(一般是 50 字节)。这个数值在不同网络环境里差异很大,不要照抄别人的配置。
5.4 多网络接入带来的"双默认路由"问题
用docker network connect把容器同时接入两个网络,可以解决一部分互通需求,但副作用是容器内可能出现两条默认路由。Linux 默认只使用第一条,结果就是:明明两个网络都能访问,偏偏其中一个不通,或者访问速度极慢。
我遇到过一次很隐蔽的故障:容器同时接了管理网络和数据网络,默认路由指到了管理网,导致所有对外服务都超时。排查方法很简单:
nsenter -t <PID> -n route -n看到两条0.0.0.0的默认路由时,就要考虑策略路由。我的建议是:一个容器尽量只挂一个网络,跨网络通信让网关或服务网格去做,别在容器内部堆多个网卡。隔离性、可维护性和性能都会更好。
6. 写在最后:三个让我少加班的小习惯
把 Docker 网络的原理和实操都过了一遍之后,我复盘了一下,真正让我在后续排查里少踩坑的其实是三个小习惯。
第一个习惯是:动任何网络之前,先按顺序把三样东西快照下来——宿主机的route -n、ip link、iptables -S。很多问题不是你改坏的,而是别人在你之前改过,有了快照才能对比出差异。
第二个习惯是:能用 tcpdump 抓包验证,就不要靠猜。在宿主机对应网桥上抓一下包,数据包有没有到、到了之后被谁丢了,一眼就能看出来。这个习惯帮我找到了很多"看起来像网络问题,其实是应用层问题"的疑难杂症。
第三个习惯是:把网络规划文档当成代码一样维护。每个环境的子网、网关、驱动选型、特殊策略,都写在文档里,踩过的坑也记上一笔。后来者照着文档建网络,不再需要重复踩我踩过的那些坑。
说实话,Docker 网络的东西看着多,核心就那几块:命名空间给隔离,veth 做连接,bridge 做汇聚,路由和 iptables 做控制。把这几块捋顺了,不管以后用的是内置驱动还是第三方方案,你都能快速看懂它到底在干什么。希望这篇文章能帮你少走点弯路。