VPN 下 Docker 容器无法访问?先把两套子网摆在一起比
Docker 分到的子网与 VPN 下发的地址段撞在一起。先比对两套地址规划,再选一种能在重连后依然成立的修法。
容器在公司局域网里能应答,但公司 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 地址时,或者反向路径过滤把不对称的回包丢掉时,它就会断。
三种修法对比
出路只有三条,区别在于需要谁批准,以及执行期间会弄坏什么。
| Repair | Who owns it | What it costs |
|---|---|---|
| Renumber the container network | You | Recreates the network, restarts services, invalidates literal addresses in configs and scripts |
| Exclude your range from the tunnel | Network team | Changes what every laptop sees, needs a change request and a client profile update |
| Reach the service through a published port | You and the client | One 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 连、断两次,因为那种连接时推路由、断开时撤回的配置,第一次尝试之后看起来也可能是对的。然后跑客户端真正会做的检查:
- 在主机上用容器的真实地址执行
ip route get。 - 从另一个容器发一个请求,证明服务本身是健康的。
- 隧道开着时从客户端执行
curl -sv http://127.0.0.1:8080/,再从局域网里的机器对容器地址执行traceroute -n。 - 如果请求能到主机但回包消失,执行
sysctl net.ipv4.conf.all.rp_filter。
最后,把选定的网段连同日期和归属项目写回你挑它时用的那张登记表。下一次重叠一定来自某个没人记录的项目,唯一的防线就是这段地址是被有意预留的,而不是随手抽的。