修好无视主机 VPN 的 Docker 与 WSL 流量
容器与 WSL 各自保留路由表、解析器和转发路径,因此一条正常工作的主机隧道仍会让它们留在过期 DNS 或没有出口的状态。逐层排查。
今年春天,我花了整整一个上午追查一个连一个内部域名都解析不了的容器,而旁边的浏览器却毫无怨言地打开了 wiki。隧道是通的,浏览器证明了这一点,而容器却待在 另一个网络命名空间里,带着另一张路由表和另一个解析器。下面是现在的排查顺序:先看路由表,再看域名解析,然后是转发,最后才是主机平台。停在第一个已经出错的环节,因为在栈的更下层加规则只会掩盖边界,而不会移动它。
改规则之前,先对比三张路由表
Docker 默认网桥上的容器只有一条指向 docker0 网关的默认路由,根本不知道 tun0 的存在。主机上有 tun0、VPN 推送的路由,还可能有策略规则把测试流量从你意想不到的接口送出去。WSL 又在自己的虚拟适配器后面加了第三张表。三者打印出来之后再动手改。
| Context | Command | What a wrong answer looks like |
|---|---|---|
| Host | ip route get 10.8.0.20 | Leaves a physical interface instead of tun0 |
| Container | ip route | Only 172.17.0.0/16 via 172.17.0.1 |
| Container DNS | cat /etc/resolv.conf | A public resolver, or an upstream the tunnel never pushed |
| WSL | ip addr; ip route | Only the WSL subnet, no Windows VPN adapter |
如果容器唯一的路由就是网桥,tun0 从未出现在它的命名空间里,下面这些都没用:改域名服务器不会造出路由,任何路由也不会造出对端肯接受的隧道身份。顺序重要,因为在 shell 里看,DNS 故障和路由故障长得一模一样。
把解析器指向隧道,而不是指向公共解析器
在主机上,resolvectl status 按链路列出 DNS 服务器,readlink -f /etc/resolv.conf 则告诉你这个文件是 systemd 的占位文件、NetworkManager 的文件,还是被厂商客户端改写过的东西。在用户自定义网络里的容器中,/etc/resolv.conf 指向 127.0.0.11,即 Docker 内置的解析器,它把请求转发给守护进程配置的上游。而默认网桥上的容器在创建时就拿到了一份主机文件的副本,并一直沿用。
正是这份副本,让容器启动之后才建立的隧道仍然被容器拿去问旧上游。要么用 docker run --dns 10.8.0.53 显式把 VPN 的解析器传进去,要么在 "dns": ["10.8.0.53"] 里设置 /etc/docker/daemon.json 并重启守护进程,要么等隧道起来后重建容器。重建通常比调试一小时前还正确的解析器更便宜。
换成公共解析器是保留症状的修法。它能解析互联网,却在每个私有区域里失败,还可能悄悄绕过隧道本应执行的过滤。如果你试着在运行中容器的 /etc/resolv.conf 里写东西,就要预备看到 Device or resource busy:Docker 以 bind-mount 挂载了那个文件,所以修改前要先卸载。在创建时就设好 --dns,才是能扛过重启的顺序。
容器借用隧道时,要转发也要转换
用默认网桥时,主机是 docker0 与 tun0 之间的路由器,而默认情况下它两件事都不做。打开转发并做源地址转换,好让对端看到的是主机的隧道地址,而不是一个没有回程路由的地址(把 tun0 换成你的客户端对接口的称呼):
sysctl -w net.ipv4.ip_forward=1
iptables -t nat -A POSTROUTING -o tun0 -j MASQUERADE
iptables -A FORWARD -i docker0 -o tun0 -j ACCEPT
iptables -A FORWARD -i tun0 -o docker0 -m conntrack --ctstate ESTABLISHED,RELATED -j ACCEPT当主机的转发策略是 DROP 时,FORWARD 规则才有分量。Docker 会插入自己的链,所以要用报文去测,而不是读 iptables -S:从容器里,ip route get 10.8.0.20 应当解析到网桥网关,nc -vz 10.8.0.20 443 应当连上隧道或超时,而不是打到解析器上。
没有 MASQUERADE,对端就需要一条回到 172.17.0.0/16 的路由,而大多数远程访问 VPN 不会推送它。策略型客户端也会在这里破坏计划:如果它只捕获特定 UID 或 cgroup 的流量,你的转发规则根本看不到隧道里的报文,修法在客户端的路由策略里,而不在 iptables 里。手动加的规则会在重启时消失,所以把它们放进 systemd 单元或 netfilter-persistent 表,并在重启后复测,因为一条迟到的隧道可能把它们冲掉。
目标是隔离时,把隧道放进容器里
如果某个容器必须经过某个节点出网,就在那个容器里跑客户端。它需要 --cap-add NET_ADMIN 和 --device /dev/net/tun,然后它拥有自己的 tun0、路由和防火墙。如果其他容器要经过它,它内部还需要转发和一条 MASQUERADE 规则,而那些对端需要一条指向它的显式路由或代理;Docker 的默认网桥不会替你加这条路由。
主机网络 docker run --network host 是便宜的那一档:容器加入主机的命名空间,直接看到隧道路由。代价是丢掉网络隔离、招来端口冲突,实践中它是 Linux 才有的选项。发布端口不是替代品,-p 8080:80 让服务能从主机访问,并不会让内部的 10.x 地址从容器内部可达,把两者混为一谈,正是路由问题变成暴露数据库端口的那条路。
把域名故障和重叠子网区分开
Docker 默认网桥是 172.17.0.0/16,而很多企业 VPN 地址池就落在 172.16.0.0/12 里。一旦隧道占住了那个范围,发给容器的流量就可能被路由进隧道。域名解析失败,报错看着像 DNS,真正的问题却在地址上。查目的地,而不是查解析器:ip route get 172.17.0.5 看主机,再拿网桥前缀去比 VPN 地址池以及 docker network inspect bridge --format '{{range .IPAM.Config}}{{.Subnet}}{{end}}'。
修法是挪开其中一个范围:要么在 daemon.json 里给 Docker 换一个默认地址池,要么在集中器上换一个池。在这里再加一个域名服务器毫无用处,ssh -b 也一样没用:把客户端绑到某个接口改的是源地址,而不是指向隧道的目的路由。快速分诊:在主机上能解析、在容器里不行的名字,是解析器问题;而在容器里挂住、在主机上却能通的直连 IP,是路由或转发链问题。
WSL 与 Docker Desktop 不守 Linux 的规矩
在默认的 NAT 模式下,WSL 坐在自己的虚拟适配器后面,拿不到 Windows VPN 的内部路由和 DNS 后缀,所以浏览器能通,而发行版里的 ping 不通。较新的 Windows 11 版本提供了镜像网络,改为共享主机接口,但要在 .wslconfig 里打开,并用 wsl --shutdown 重启发行版。应用修复前先用 wslinfo --networking-mode 确认当前模式,因为 NAT 的答案对镜像模式是错的,反之亦然。
Docker Desktop 又加了一层。Windows 上的 Linux 容器坐在一个历史上叫 vpnkit 的 NAT 实现后面,路径与原生 Linux 主机不同。Windows 容器通过 Hyper-V 虚拟交换机接入,企业客户端可能封掉该交换机的流量,于是同一台机器上的 Linux 容器能出网,Windows 容器却什么都不通。把外部交换机绑到物理适配器是常见的解法,它会以策略可能不允许的方式改变容器的网络身份。
在写下一堆规则之前,按顺序走完这四层,在哪一层出错就停在哪一层:
- 在容器里:
ip route是否到达私有前缀,/etc/resolv.conf是否指向能解析它的上游? - 在主机上:转发是否打开,NAT 是否绑定到隧道接口,转发链是否放行网桥到隧道?
- 在对端:它知道回程怎么走,还是你指望 NAT 把容器子网藏起来?
- 在平台上:WSL 处于 NAT 还是镜像模式,容器是 Linux 还是 Windows?
每一项测试都在全新容器或 shell 里重跑。带缓存应答或已建立连接的长寿进程,在配置改对之后仍会继续失败,而那种失败看起来恰恰就像一次没起作用的修复。