Docker:让一个容器走 VPN,另一个保持直连
给隧道单独的容器,只让需要它的服务共享那个命名空间,然后考虑所有者带来的一切:端口、localhost、DNS。
一台主机、两个工作负载,以及一条把它们分开的要求:一个下载客户端必须经由商业 VPN 出网,而一个管理界面必须继续使用主机的连接。把 VPN 客户端装在主机上会立刻违背这条要求,因为两个工作负载从此共用一个出口。真正可行的模式是把隧道搬进它自己的容器,只让需要它的工作负载加入那个容器的网络命名空间。其余一切保持普通的 Docker 路径,接下来的细节都是「谁拥有命名空间」的后果。
为什么共享命名空间会改变规则
加入另一个容器命名空间的容器不会获得自己的网络栈。它看到的接口、路由表和 loopback 都与它加入的那个容器相同,这正是该模式有用的原因:工作负载照常解析和连接,下面的隧道在应用毫不知情的情况下捕获流量。代价是每一项网络设置从此只有一个所有者,而加入的那个容器并不是所有者。
这条归属规则在大多数配置问题被提出之前就给出了答案。如果某项设置活在网络栈里,它就属于边车容器。如果它描述的是进程,它就留在工作负载这边。
loopback 是最让人意外的部分。在加入的容器内部,localhost 会解析到边车容器的 loopback,而不是它自己的私有地址,因此一个绑定 127.0.0.1 并期待隔离的服务,如今与隧道容器共享这个地址。
在拥有命名空间的容器上发布端口
端口映射属于命名空间的所有者,即使监听进程跑在另一个容器里。把映射声明在边车容器上,让工作负载保持自己的进程配置不变。加在加入方服务上的映射不会生效,无论引擎是拒绝它,还是根据容器的启动方式悄悄忽略它,这也正是片刻前还能访问的界面在套用该模式后立刻消失的原因。
services:
gluetun:
image: qmcgaw/gluetun
cap_add: [NET_ADMIN]
ports: ["8080:8080"]
client-vpn:
image: example/client
network_mode: "service:gluetun"
client-direct:
image: example/client
ports: ["8081:8080"]把这个代码块读成三个决定,而不是三个服务。边车容器拥有隧道和已发布的端口;加入的客户端根本不声明任何网络;直连客户端在默认网络上保留自己的映射。NET_ADMIN 能力让边车容器得以创建接口并修改路由,这也是这个容器比它服务的工作负载需要更多权限的原因。
保持直连的服务保留自己的网络
不得穿过隧道的工作负载保留默认网桥或自己的用户自定义网络,连同自己的已发布端口和自己的出口。为了两个容器能按名称通信而把它挂到边车网络上很诱人,但这一步会悄悄把它的流量挪进隧道。如果它们必须互相到达,就另建一个用户自定义网络,把两者都接上去,并让边车容器成为唯一的向外通路。
把两个问题分开:哪些容器能互相到达,以及哪一个从哪里出去。把它们合并,正是这套设计重新变成整台主机级 VPN 最常见的方式。
第一个小时的故障矩阵
大多数第一次尝试会以四种方式之一失败,每一种的成因都不同。按这个顺序检查,因为前两种几乎解释了所有报告。
| Symptom | Likely cause |
|---|---|
| Web UI unreachable after the change | Port mapping declared on the joining container instead of the sidecar |
| Both containers show the provider's exit address | The direct service was attached to the sidecar network |
| Internal names stop resolving | Resolver settings live on the host, not in the shared namespace |
| Health check fails but the app works | The check probes localhost, which now means the sidecar |
第四行是人们最常误判为应用缺陷的那一行。针对工作负载自身 loopback 写的探针,如今测的是隧道容器,所以它要么失败,要么报告一个完全错误的服务。
当只有一个主机名需要走隧道时
有时整条隧道太过头:一个内部名称必须走企业 VPN,而本地套接字或显示连接还要继续工作。容器内的整条隧道会切断那条路径以及一切发往主机的东西,所以只路由需要它的前缀。客户端外面的分流路由包装器就能做到,把指定的主机送进隧道,其余一切保留原来的路由不动。
当你只需要到达一个服务、并不打算搬走容器的全部流量时,选这个。它与边车容器是不同的工具,回答的是另一个问题。
先验证,再让它经得起重启
问每个容器看起来是从哪个地址来的。走隧道的那一个应当打印提供商的地址,直连的那一个应当打印主机的出口。答案相同意味着直连服务继承了错误的命名空间,或者主机级 VPN 仍在捕获一切,而这两种修法并不相同。每次改完 compose 都重新跑一遍检查,因为复制一个服务块就足以让问题复发。
有三样东西往往会在之后坏掉。DNS 跟随命名空间的所有者,所以解析器和搜索域的设置属于边车容器,而不是主机。带端口转发的 VPN 会在重连时改变监听端口,所以提供商转发的端口和容器侧的监听端口必须写回客户端配置或端口映射,通常靠一个小更新器读取提供商的 API 来完成。而任何针对 localhost 写的探针,如今问的是隧道容器,而不是工作负载。
把模式写下来,包括哪个容器拥有命名空间、哪些端口属于它。下一个人会新增一个服务、复制错误的块,并复现本文的第一个故障。