容器在公司局域网里能应答,但公司 VPN 一连接,应答就停了。容器本身什么都没变:没有重启、没有更新镜像、没有改配置。变的是主机的路由表——隧道新增的一条路由,盖住了 Docker 分出去的那个地址。在改子网或申请 VPN 豁免之前,先把两套地址规划并排放好,因为不同修法的耗时差别很大。

改配置之前,先读懂路由

先从 Docker 真正建出来的网络看起。IPAM 块就是地址规划,而真正要紧的是 subnet 那一行:

docker network inspect project_default --format '{{json .IPAM.Config}}'
# [{"Subnet":"172.18.0.0/16","Gateway":"172.18.0.1"}]

Compose 默认给每个新项目分配一个 /16,两个服务可以挤在 65,536 个地址的范围内。正因为这么宽,撞车才常见:随机挑一个 /16,就足以把你在隧道另一头访问的站点整个吞掉,而隧道起来之前没人会发现。

然后比较同一个容器地址的路由选择:一次在隧道关闭时,一次在隧道开启时:

ip route get 172.18.0.7
# 172.18.0.7 dev br-3f2a9c src 172.18.0.1

两种结果会指向完全不同的方向。如果输出里出现 tun0、wg0 或别的隧道设备,说明 VPN 前缀至少和网桥路由一样具体,数据包根本到不了容器。如果仍然指向网桥,说明主机能到达容器,问题出在过滤、DNS,或只在一个方向上失败的返回路径。

为什么 /16 撞车这么容易

Docker 从私有地址池里切网桥网段,默认池取自 172.16.0.0/12 块,按 /16 切片。企业网也常用同一段私有空间,而远程接入 VPN 会把这些前缀推给每个连上来的客户端。当双方都认为 172.18.0.0/16 是自己的,一张路由表里就出现了两条互相矛盾的主张。

赢的是前缀长度,不是路由出现的前后顺序。推 10.0.0.0/8 的隧道赢不了 172.18.0.0/16 的网桥路由,因为 /8 不够具体。推 172.18.0.0/16、或推其中的 172.18.5.0/24 的隧道就能赢。所以同一台笔记本在公司网里正常、在酒店 Wi-Fi 上却失败,VPN 还是那个 VPN:客户端收到的前缀不一样。

同一张表也管反方向。需要访问 10.8.4.20 上内网主机的容器,把包发给自己的网桥网关,主机再把它路由进隧道。这条路通常通,因为 NAT 由主机来做;但公司侧只接受笔记本的 VPN 地址时,或者反向路径过滤把不对称的回包丢掉时,它就会断。

三种修法对比

出路只有三条,区别在于需要谁批准,以及执行期间会弄坏什么。

RepairWho owns itWhat it costs
Renumber the container networkYouRecreates the network, restarts services, invalidates literal addresses in configs and scripts
Exclude your range from the tunnelNetwork teamChanges what every laptop sees, needs a change request and a client profile update
Reach the service through a published portYou and the clientOne port only, no direct container-to-container routing across the tunnel

重新编号是唯一不用开工单就能做完的修法,所以大多数文章写到这里就停了。新项目上它很便宜,一旦有迁移工具或某个保存的连接串写死了地址,它就变得很贵。改 YAML 之前先数清这些引用,别等改完再数。

当重叠落在公司真正拥有的网段里,向隧道申请豁免是最干净的答案。但它最慢,而且对改不了客户端配置的外包人员毫无用处。发布端口或用一个小反向代理可以绕开争论:客户端连的是隧道本来就能正确路由的地址,只需要那一个端口可达。

有意识地固定网段

单个项目,就在 compose 文件里声明 subnet,让它不再从共享池里取:

networks:
  default:
    ipam:
      config:
        - subnet: 10.201.7.0/24

这个网段要来自团队维护的登记表,而不是搜索时随手抄来的示例。要拿它跟 VPN 下发的所有前缀、同事家里用的局域网、以及同一台主机上的其他容器网段逐一对过。然后安排好重建,因为已经存在的网络会保留旧的 subnet:先 docker compose down,再 up -d。docker network rm 在有端点挂着时会拒绝,文件里任何 ipv4_address 条目都必须落在新网段里。

Docker 自己创建的网段,则在 /etc/docker/daemon.json 里设置守护进程地址池:

{
  "default-address-pools": [
    { "base": "10.201.0.0/16", "size": 24 },
    { "base": "10.202.0.0/16", "size": 24 }
  ]
}

这个池只对守护进程重启之后新建的网络生效,已经存在的网桥仍保留原地址。这一点方便也容易被忘掉,所以在有人重建旧项目的网络之前,都当它还在重叠。Docker 的引擎网络文档描述了分配规则,方便你在评审时为这个池辩护。

Linux 网桥和 Docker Desktop 的失败方式不同

在 Linux 主机上,容器坐在主机命名空间的网桥上。一张表决定一切,所以上面的对比就是全部:读网桥路由、读隧道路由,看真实容器地址到底归谁。

Docker Desktop 把容器放在一台自带 NAT 的虚拟机后面。主机对容器地址根本没有路由;它是通过发布端口访问服务的,端口通常绑在 127.0.0.1 上。所以 VPN 客户端不必有任何子网重叠就能弄坏服务:要么给虚拟机的内部网段装了一条路由,要么在隧道连接期间拦了 loopback 流量。如果主机上 ip route get 显示容器地址没有路由,那是预期行为,不是 bug。

实际后果就是先看哪里。在 Linux 上,从路由表查起。在 Desktop 上,从发布端口和 VPN 客户端的防火墙规则查起,然后再看虚拟机的内部子网是否和隧道推下来的东西撞在一起。

在隧道开启的状态下验证

别急着说修好了,要把 VPN 连、断两次,因为那种连接时推路由、断开时撤回的配置,第一次尝试之后看起来也可能是对的。然后跑客户端真正会做的检查:

  1. 在主机上用容器的真实地址执行 ip route get。
  2. 从另一个容器发一个请求,证明服务本身是健康的。
  3. 隧道开着时从客户端执行 curl -sv http://127.0.0.1:8080/,再从局域网里的机器对容器地址执行 traceroute -n。
  4. 如果请求能到主机但回包消失,执行 sysctl net.ipv4.conf.all.rp_filter。

最后,把选定的网段连同日期和归属项目写回你挑它时用的那张登记表。下一次重叠一定来自某个没人记录的项目,唯一的防线就是这段地址是被有意预留的,而不是随手抽的。

相关文章