有一次我在宿主机上部署一套内部服务,同一个网段里起了三个容器:A、B、C。A 能 ping 通 B,也能 curl 通 B 的接口,但到 C 就是超时。三个容器明明都在同一台机器上,逻辑上都是邻居,为什么表现完全不一样?后来顺着容器的命名空间、veth 和 bridge 网络一步步查下去,才发现问题出在它们挂在不同的 bridge 网络上。这台机器上的经历让我把同宿主机下不同命名空间的容器间网络通信彻底理了一遍,本文就是这次排查过程中沉淀下来的案例和原理。
这篇文章适合所有在单台宿主机上跑多容器、却搞不清“为什么有的能通、有的不能通”的人。我会先从 namespace 和 veth 的原理讲起,再拆四个真实场景:同一 bridge 下互通、跨 bridge 互通、host 网络与 bridge 网络互通,以及不用 Docker 纯手工模拟 netns 互联。最后附一份我自己的排错清单。
1. 先理清一件事:容器“命名空间”到底隔离了什么
1.1 容器里的网络世界是一套独立视图
容器隔离的核心不是进程本身,而是 namespace。Linux 里一个进程从创建开始,就活在若干 namespace 中,包括 PID、Mount、UTS、IPC、User 和 Network 这几类。对网络通信来说,真正起作用的是 network namespace,简称 netns。
netns 隔离的资源比很多人想的多:它不光是隔离 IP 地址,还隔离网卡设备、路由表、ARP 表、/proc/net 下的连接状态,以及 netfilter 规则(也就是 iptables 规则)。换句话说,同一个宿主机上的两个容器,它们看到的网络世界是各自独立的。容器 A 里执行ip addr只能看到自己 netns 里的网卡,看不到容器 B 的;容器 A 里加一条路由,也完全不影响容器 B 的路由表。
这就是为什么经常有人说容器不算是“虚拟机”但又比普通进程更隔离——进程只是共享宿主机的网络栈,而容器拥有自己的一套网络栈。理解这个,后面所有案例就都有基础了。
我见过不少新手在宿主机上执行ip addr,看到一堆 veth 开头的网卡,以为容器网卡丢了或者系统被污染了。其实那些 veth 网卡只是 veth pair 在宿主机这一端的“线头”,真正的“线尾”已经插进对应容器的 netns 里了。宿主机看到的这些,只是连接宿主侧网络设备的一根根线缆。
1.2 怎么进入另一个命名空间看真相
排查容器网络问题时,不要满足于docker exec。docker exec确实能进容器,但它默认只进入了容器的 Mount、PID 等命名空间,网络视图其实也是容器自己的,所以没问题。但如果容器里没有ip命令(很多精简镜像没有),你就需要换一种方式。
我常用的方法是用nsenter直接进入容器的 netns:
# 先拿到容器的 PID docker inspect -f '{{.State.Pid}}' container-name # 再进入它的 netns 查网络信息 nsenter -t <PID> -n ip addr nsenter -t <PID> -n ip route nsenter -t <PID> -n iptables -L这个操作特别有用,因为一些基础镜像里的/proc/net信息是残缺的,而 nsenter 使用的是宿主机上的工具,只是把网络视角切到了目标容器的 netns。我在一次排查中发现容器里的路由表多了条奇怪的黑洞路由,容器内ip route和ip netns list都看不到,就是用 nsenter 进入后才发现的。
注意一点:普通的生产容器默认不会出现在ip netns list里,因为 Docker 没有把容器的 netns 挂载到/var/run/netns。如果想让某个容器出现在ip netns list里,需要手动创建软链:ln -s /proc/<PID>/ns/net /var/run/netns/<name>,不过排查时直接用 nsenter 就够了。
1.3 veth pair:连接两个网络命名空间的“虚拟网线”
容器里的 eth0 不是真的物理网卡,它是一个虚拟以太网设备。这种设备总是成对出现:一端在容器 netns 里,叫 eth0;另一端在宿主机或 bridge 上,名字形如 vethxxxxxx。这对设备就是 veth pair,可以把它想象成一根虚拟网线——从 eth0 发出的数据包,会原封不动地从 vethxxxxxx 这端出来;反过来也一样。
如果用更生活化的类比,veth pair 就像一根网线的两个水晶头:一个插在容器的“网口”上,另一个插在宿主机桥接设备的“网口”上。数据包在两头之间传输,不需要经过物理线路,也不产生真实流量。
理解了 veth pair,再回头看 Docker 的默认 bridge 网络,一切就通顺了。Docker 会创建一个 Linux bridge(默认叫 docker0,自定义网络则是一个br-xxxxxxxx设备),然后把每个容器的 veth 宿主机端插到这个 bridge 上。于是所有插在同一个 bridge 上的容器,虽然 netns 彼此独立,却共享一个二层广播域,通信链路是完整的。
2. 案例一:同一 bridge 下,两个不同命名空间的容器为什么直接能互通
2.1 实验准备:两个容器不映射端口也能互访
很多人对容器互访有个固有印象:A 访问 B 必须做端口映射。其实这完全错误。同一个 bridge 网络下的容器,直接用容器 IP 就能互通,端口映射只是给宿主机外部(或其它网络)访问用的。
先做实验。我建了一个自定义网络,启动两个容器:
docker network create -d bridge demo-net docker run -itd --name app-a --network demo-net nginx:alpine docker run -itd --name app-b --network demo-net alpine sleep 3600查一下 app-a 的 IP:
docker inspect -f '{{.NetworkSettings.Networks.demo-net.IPAddress}}' app-a假设得到 172.20.0.2,然后从 app-b 里 ping app-a:
docker exec app-b ping -c 2 172.20.0.2结果直接就是通的。注意 app-b 并没有-p端口映射,app-a 也没有。它们能互通,靠的是共处同一个 bridge 网络,而不是端口映射。
2.2 数据链路拆解:报文到底是怎么走的
从 app-b ping app-a,数据包的完整路径是这样的:
- app-b 的 netns 里,ping 进程生成 ICMP 请求包,源地址为 app-b 的容器 IP,目标地址为 app-a 的容器 IP。
- 包从 app-b 的 eth0 发出,顺着 veth pair 到达宿主机端那个 vethxxxxxx 网卡。
- 宿主机端 veth 网卡插在 demo-net 对应的 Linux bridge 上,包进入 bridge。
- bridge 查自己的 MAC 地址表,发现 app-a 的 MAC 地址在另一个 veth 端口上,于是把包从那个端口发出去。
- 包再从另一端的 veth pair 进入 app-a 的 eth0,最终被 app-a 的网络栈收下。
整个过程跟两台物理机插在同一个交换机上通信几乎一样。这里最容易被忽略的一点是:两个容器虽然 netns 独立,但它们通过一个共同的 bridge 设备共享了二层网络。在二层眼里,它们就是同一网段上的两台主机。
你可以用 tcpdump 在宿主机端验证。找到 app-a 或 app-b 对应的 veth 网卡名后,在宿主机上抓包:
tcpdump -i any -n icmp执行 ping 的同时抓包,能看到 ICMP 请求和回应都会经过宿主机上的 veth 设备,这能直观验证上面的链路。
2.3 常见的三个误区
第一,以为容器之间必须用映射端口访问。实际上同一个 bridge 下容器间用容器 IP 直连,比走宿主机映射端口更高效,少一次 DNAT 转换。这也解释了为什么很多 Docker Compose 项目里,服务间连接都写http://service-name:port,而不是http://localhost:映射端口。
第二,以为容器 IP 是固定的。Docker 默认按网络顺序分配 IP,容器重启后可能变。所以容器间互访在生产环境最好用 Docker 内置 DNS(自定义 bridge 网络下,容器名就是主机名,可以被解析),而不是写死 IP。
第三,以为 bridge 模式下容器和宿主机互通理所当然。确实默认可以互通,而且要经过宿主机 IP 栈转发。但如果你在宿主机上看到 FORWARD 链默认策略是 DROP,那容器之间、容器到宿主机之间的流量就会出问题。Docker 安装时会自动把 FORWARD 链改成 ACCEPT 或插入放行规则,但有其他软件(比如某些防火墙管理工具)重置过 iptables 后,这个规则会丢失,容器间就突然不通了。这类问题我遇到不止一次,后面排错清单里会细说。
3. 案例二:跨 bridge 网络的不同命名空间容器,默认不通怎么办
3.1 场景还原:两个容器分别挂在 net1 和 net2
这是文章开头那个 A、B、C 场景的升级版。假设服务 A 在 net1,服务 B 在 net2:
docker network create net1 docker network create net2 docker run -itd --name app-a --network net1 nginx:alpine docker run -itd --name app-b --network net2 alpine sleep 3600从 app-b ping app-a:
docker exec app-b ping -c 2 <app-a-IP>默认结果就是不通。这里 Docker 没有报错,包发出去就石沉大海。
3.2 定位问题:被隔离规则挡在哪一层
搞清这个问题,要看两层。
第一层是二层隔离。net1 和 net2 各自对应一个 Linux bridge,它们是两个不同的二层广播域。app-b 发出的 ARP 请求只在 net2 的 bridge 上广播,根本到不了 net1 那边的 veth 端口,所以 app-b 连 app-a 的 MAC 地址都拿不到。
第二层是路由和 iptables。即使宿主机开着 IP 转发,能从路由表里找到去 net1 网段的路径,Docker 还会在 FORWARD 链里插入 DOCKER-ISOLATION 链,专门阻断不同 bridge 网络之间的转发流量。这条隔离规则在 Docker 里的优先级很高,是为了防止用户无意间把两个不相关的网络通过宿主机路由“打通”。
在宿主机上执行:
iptables -L FORWARD -n -v iptables -L DOCKER-ISOLATION -n -v就能看到相关规则。这个设计初衷是好的,但确实坑了不少人:有人说“我在宿主机上加了路由为什么还是不互通”,多半就是忘了这条 FORWARD 链上的隔离规则。
3.3 方案一:用 docker network connect 把容器接入另一个网络
最推荐的打通方式,不是改 iptables,而是让一个容器同时接入两个 bridge 网络:
docker network connect net2 app-a执行后,app-a 的 netns 里会多出一块网卡 eth1,IP 属于 net2 网段。这时 app-b 就能直接访问 app-a 在 net2 上的 IP 了。这个方案的本质是给同一台“主机”(容器)又插了一根网线到另一个交换机上,两个交换机之间的通信不需要经过任何转发,因为通信双方已经处在了同一个二三层可达范围内。
还有一种连法是把 app-b 也 connect 到 net1,效果一样。我一般看哪个容器的网络配置更灵活,尽量少动核心服务。这个方案最大的好处是:不需要改宿主机任何路由或 iptables 规则,Docker 完全托管,重启容器后依然有效。
3.4 方案二:手动配路由和 iptables,理解原理但别用于生产
理论上可以手动让两个 bridge 互相路由。前提是:
- 内核开启 IPv4 转发:
sysctl -w net.ipv4.ip_forward=1。 - 在宿主机路由表里添加去往对方网络的路由,nexthop 指向对应 bridge 的 IP。
- 在 FORWARD 链里放行两个网段之间的流量,包括去掉或绕过 DOCKER-ISOLATION 那条 DROP。
但你很快会发现,Docker 自己的 iptables 管理逻辑会跟你“打架”:Docker 服务重启、网络重建、容器重启都可能重置规则。我试过手工加规则,后来 docker network 一重建,规则全没了。所以这个方案我只建议用来做实验验证原理,生产环境绝对别这么干。
3.5 方案三:通过宿主机端口访问,作为临时过渡
如果两个服务只是临时需要互相访问,且不想改动网络拓扑,可以在启动容器时做端口映射。比如 app-a 启动时加-p 8080:80,app-b 里访问宿主IP:8080。这个方案在跨主机场景下很常见,但在同宿主机内部用,性能上多了一次 DNAT 转换,而且容器 IP 变化不影响,只要宿主机 IP 和端口不变就行。
三个方案的取舍我整理成了下表:
| 方案 | 优点 | 缺点 | 适用场景 |
|---|---|---|---|
| docker network connect | 约定俗成、容易维护、重启保留 | 容器多一块网卡,地址规划要留意 | 生产环境优先选择 |
| 手动路由 + iptables | 不增加容器网卡,原理直观 | Docker 规则冲突、重启失效 | 实验验证、临时调试 |
| 宿主机端口映射 | 最通用,跨宿主机也适用 | 多一次 NAT、端口可能冲突 | 临时互访、外部访问 |
3.6 实验验证
我实际做这个实验时,一开始只用了 route 和 iptables,ping 通了,但 HTTP 请求偶尔被 DROP,最后定位到 FORWARD 链上 Docker 规则顺序的问题,花了不少时间。后来改回docker network connect,一分钟解决。所以我现在遇到跨网络通信需求,第一反应永远是 connect,而不是去碰宿主机防火墙。
4. 案例三:bridge 容器与 host 网络容器的通信,路径有何不同
4.1 host 网络容器没有独立 netns
有些容器启动时会用--network host,比如:
docker run -itd --name app-host --network host nginx:alpine这种模式下,容器不创建新的 netns,直接复用宿主机网络栈。它没有自己的 eth0,也没有自己的容器 IP,你在容器里看到的 IP 就是宿主机的 IP。可以理解为容器进程直接跑在宿主机网络上,就像普通进程一样监听端口。
4.2 bridge 容器访问 host 容器:其实是在访问宿主机
从 bridge 容器访问 host 容器的服务,本质上就是访问宿主机 IP 的某个端口。比如 app-host 里的 nginx 监听 0.0.0.0:80,那么从一个 bridge 容器里:
curl http://<宿主机IP>:80就能访问到。因为 host 容器与宿主机共享 IP 地址和端口空间。
这里有个最常见的坑:如果服务监听的是 127.0.0.1,比如nginx配置里写listen 127.0.0.1:80,那只有宿主机自己和 host 网络模式的容器能访问到,bridge 容器从自己的 netns 发起请求,数据包到达宿主机回环接口时,目标地址是 127.0.0.1,宿主机接受没问题,但 bridge 容器发出去的时候目标 IP 必须是宿主机的物理 IP,而不是 127.0.0.1。
为什么不能在那个 bridge 容器里直接访问 127.0.0.1?因为 127.0.0.1 在每个 netns 里都指向自己。bridge 容器里的 127.0.0.1 是它自己,不是宿主机,更不是 app-host。这个知识点很重要,排查“容器里访问 localhost 失败”这类问题时非常有用。
4.3 host 容器访问 bridge 容器:两条路
反向访问就更有意思了。app-host 本身拥有宿主机的完整路由表,所以它访问一个 bridge 容器 IP 是直接可达的。比如 app-b 在 demo-net 上的 IP 是 172.20.0.3,那 app-host 里直接:
curl http://172.20.0.3:80就能访问,走的是宿主机路由加 bridge 转发,完全没问题。
如果 bridge 容器做了端口映射,比如-p 8081:80,那 app-host 也可以访问127.0.0.1:8081。两条路都能通,区别在于:直连容器 IP 少一次 NAT,更干净;走映射端口则受 Docker 的 DNAT 规则管理。
我在实际环境中遇到过一种组合问题:一个 host 网络的服务想调用另一个 bridge 网络里的服务,一开始走映射端口,后来又加了docker network connect把 host 容器也连进 bridge 网络,结果发现 host 网络模式根本不能和普通容器一样再被docker network connect(因为 host 模式没有自己的网络设备可插入)。所以如果服务注定要同时跑在 host 模式和 bridge 模式,跨网络通信就别用 connect 方案,老老实实用宿主机 IP 加映射端口,或者干脆把 host 模式改掉。
5. 手搓 netns:不用 Docker,从内核层面复现容器间通信
5.1 两个 netns 用 veth 直连,先让一个点对点互访
前面讲了那么多 Docker 场景,但如果你想彻底吃透“同宿主机下不同命名空间通信”,我建议自己动手在纯 Linux 环境里造两个 netns,不依赖 Docker。这会让 Docker 在你眼里变成一个“调用内核网络设施的壳”,而不是黑盒。
先做最基础的点对点连通。创建 ns1 和 ns2,用一根 veth 虚拟网线直连它们:
# 创建两个网络命名空间 ip netns add ns1 ip netns add ns2 # 创建 veth pair,两端分别叫 veth-a 和 veth-b ip link add veth-a type veth peer name veth-b # 把两端分别放进 ns1 和 ns2 ip link set veth-a netns ns1 ip link set veth-b netns ns2 # 在 ns1 里配置 IP 并启用网卡 ip netns exec ns1 ip addr add 10.200.0.1/24 dev veth-a ip netns exec ns1 ip link set veth-a up ip netns exec ns1 ip link set lo up # 在 ns2 里同样操作 ip netns exec ns2 ip addr add 10.200.0.2/24 dev veth-b ip netns exec ns2 ip link set veth-b up ip netns exec ns2 ip link set lo up # 互ping验证 ip netns exec ns1 ping 10.200.0.2这个实验能让你感受到 veth pair 的力量:两个完全隔离的网络栈,只靠一根“虚拟网线”连起来,就能三层互通。没有 bridge,没有交换机,因为 veth pair 本身就是一条点对点链路。数据包从 ns1 的 veth-a 发出,直接出现在 ns2 的 veth-b 上。
这里要注意,ip netns exec 后面跟的命令,是在指定 netns 里执行的。如果你在宿主机上直接执行ip addr,是看不到 ns1 里的 veth-a 的。只能通过ip netns exec ns1 ip addr查看。
5.2 用 Linux bridge 让多个 netns 互通
点对点太简单了,稍微复杂一点就加 bridge。模拟多个容器插到同一台虚拟交换机:
# 创建 bridge ip link add br-test type bridge ip link set br-test up # 创建两对 veth ip link add veth1 type veth peer name veth1-p ip link add veth2 type veth peer name veth2-p # 一端进 netns,一端挂 bridge ip link set veth1 netns ns1 ip link set veth2 netns ns2 ip link set veth1-p master br-test ip link set veth2-p master br-test # 启用宿主机侧 veth 端口 ip link set veth1-p up ip link set veth2-p up # 在 ns1 和 ns2 配置同网段 IP ip netns exec ns1 ip addr add 10.200.1.1/24 dev veth1 ip netns exec ns1 ip link set veth1 up ip netns exec ns2 ip addr add 10.200.1.2/24 dev veth2 ip netns exec ns2 ip link set veth2 up # 验证互通 ip netns exec ns1 ping 10.200.1.2这一次,两个 netns 之间不再是点对点,而是经过一个二层 bridge 互通。这和 Docker bridge 网络的行为完全一致。现在你再回头看 Docker:Docker 创建一个 bridge(docker0 或 br-xxxxxxxx),给容器创建 veth pair,一端放进容器 netns,另一端挂 bridge,然后通过内置的 IPAM 给容器分配 IP。它做的事情和上面这些命令几乎一模一样,只是外面包了一层 API 和数据库。
5.3 手工实验如何帮你排 Docker 的错
做过这个手工实验后,你再遇到 Docker 网络问题,思路会清晰很多。比如有人问:为什么同一个 bridge 下的两个容器能互通?你会知道它们其实是插在同一台“虚拟交换机”上的两台设备;为什么跨 bridge 就不通?因为两台设备插在不同的交换机上,且交换机之间没有级联。为什么 host 模式容器既没有 eth0 也没有容器 IP?因为它根本没有创建新的 netns,物理网卡就是它的“eth0”。
还有一个很实用的小技巧:在宿主机上看到一堆 veth 网卡,想搞清楚某个 veth 对应哪个容器,可以看 veth 的 peer 接口索引:
ethtool -S vethxxxxxx | grep peer_ifindex ip link | grep -A1 "peer_ifindex"把 peer_ifindex 和容器里的 eth0 索引对应上,就能精确配对。这个技巧在抓包排查时特别有用,能帮你快速锁定“这根线”插在哪个容器上。
6. 同宿主机容器互通的排错清单:我每次都会按这个顺序查
6.1 先搞清楚拓扑,再动手查包
遇到容器间网络不通,我第一步不是看防火墙,而是先回答这几个问题:
- 两个容器是不是同一个 bridge 网络?
- 有没有容器用了 host 网络模式?
- 目标容器的 IP 是多少?服务监听的是 0.0.0.0 还是 127.0.0.1?
- 容器里有没有默认路由?默认网关是不是 bridge 的 IP?
这些问题一回答,一半以上的问题已经定位了。剩下不确定的,再按下面的顺序查:
- 在源容器里 ping 目标容器 IP,确认三层通不通。
- ping 不通时,在宿主机上抓包:
tcpdump -i any -n icmp,看请求有没有到宿主机的 bridge。 - 如果请求到了 bridge 但没回应,查目标容器自身状态,比如容器内防火墙、服务监听地址。
- 如果请求根本没到 bridge,查源容器的路由表、网络配置,以及它和目标容器是否同 bridge。
- 如果三层通了但服务访问失败,查目标服务监听地址和端口,TCP 和 UDP 要区别对待。
6.2 排查命令速查表
下面是我整理的高频命令,按使用频率排序:
| 命令 | 用途 | 说明 |
|---|---|---|
docker inspect -f '{{.NetworkSettings.Networks}}' 容器名 | 查看容器网络信息和 IP | 最常用 |
nsenter -t <PID> -n ip addr | 进入容器 netns 查网卡 | 容器内没有 ip 命令时 |
docker exec 容器名 ip route | 查看容器默认路由 | 确认网关是否正常 |
tcpdump -i any -n host <目标IP> | 宿主机全接口抓包 | 快速定位丢包位置 |
iptables -L FORWARD -n -v | 查看转发链规则 | 排查 Docker 隔离规则 |
brctl show或bridge link | 查看 bridge 端口对应关系 | 确认 veth 挂在哪个 bridge 上 |
ip netns exec <ns> ... | 手动操作非 Docker 的 netns | 纯手工实验时必备 |
6.3 几条我踩过坑之后才总结出的经验
第一,ICMP 不通不代表 TCP 也不通。很多网络环境会屏蔽 ICMP 报文,比如云厂商安全组、内部防火墙策略,甚至容器镜像内配置了 sysctl 忽略 ping。我遇到过几次:ping 全丢,但 curl 完全正常。所以判断连通性时,最好同时测 TCP 端口,别只盯着 ping 的结果。
第二,容器里的默认网关通常是 bridge 的 IP,而不是宿主机的物理网卡 IP。举个例子,docker0 的 IP 是 172.17.0.1,那挂在 docker0 下的容器默认网关就是 172.17.0.1。有人进容器看到网关 172.17.0.1,拼命在宿主机上找这块网卡找不到,因为 docker0 本身就是一个虚拟网桥设备,不是物理网卡。
第三,自定义 bridge 的网段不要和宿主机所在局域网网段重叠。一次我图省事,把容器网段配成了 192.168.50.0/24,结果宿主机局域网正好也是 192.168.50.0/24,容器访问局域网内其他机器时路由全乱了,因为容器默认网关把目标流量当成了同网段广播。后来我把容器网段换到 172.20.0.0/24 这类私有网段,问题立刻消失。如果你希望 docker0 的默认网段固定下来,可以在/etc/docker/daemon.json里设置bip:
{ "bip": "172.26.0.1/24" }第四,Docker 服务重启后,手工加在 iptables 里的规则可能会被重置。这不是 Docker 有意清空,而是它启动时会重新生成自己的规则链。所以任何手工网络配置要想持久化,要么用docker network connect走 Docker 托管,要么写成开机自启脚本并放在 Docker 服务启动之后执行。
第五,容器内服务监听地址决定谁能访问。无论你怎么配置网络,只要服务在容器里只监听 127.0.0.1,那从另一个命名空间发起的请求就永远进不来。排查“其他容器访问不到我”时,第一步就该确认服务监听的是 0.0.0.0 还是具体 IP。
这套整理下来后,我最大的体会是:遇到容器网络问题别急着加规则,先想清楚数据包从哪个 netns 出发、经过哪些设备、到哪个 netns 结束,把链路路径画出来,问题一半就解决了。最后再分享一个我常用的土办法:在容器里 ping 另外一个容器,同时在宿主机上用tcpdump -i any -n icmp看包,如果能看到请求但看不到回应,基本锁定是目标容器侧的问题;如果连请求都看不到,多半是二层隔离或路由没配对。这套方法我后来用在几十次排障里,几乎都是几分钟内定位到问题。