Docker 端口发布与 VPN:回包究竟从哪里发出
已发布的容器在反向 NAT 之前以自身地址回包。两种设计都能把该回包留在公网路径上,代价各不相同。
全隧道 VPN 启动后主机仍能响应 SSH,而同一台机器上通过 Docker 发布的服务却没了声音。监听器本身没有问题:容器在运行,端口映射存在,数据包也确实到达。容器是健康的,请求真的进入了容器。差别在于 Linux 选择路由时回包从哪个地址发出,而这个差别决定了你需要下面两种设计中的哪一种。
顺着元组走一遍目的地址 NAT
设想一台主机位于 192.0.2.10,把 TCP 2222 发布到位于 172.30.240.5:22 的容器。来自 198.51.100.25 的请求在到达容器之前就被转换,而容器以自身身份回包。
WAN request: 198.51.100.25 -> 192.0.2.10:2222
after DNAT: 198.51.100.25 -> 172.30.240.5:22
container reply: 172.30.240.5:22 -> 198.51.100.25
在主机做路由决策的那一刻,回包仍然携带容器的源地址。反向转换要到之后才把已发布的元组还原,所以数据包恰恰在选路的那一刻看起来是错的。
为什么只针对主机的规则会漏掉它
只对来自 192.0.2.10 的数据包选择 WAN 表的规则,覆盖了主机自身的守护进程,却完全漏掉了这条被转发的回包。回包上的源地址是 172.30.240.5,而不是主机。
过度纠正的做法是让容器的一切都走 WAN,这同时会让容器那些无关的出站连接也脱离 VPN。那是与需求不同的策略,通常会在日后让某个人感到意外。
DNAT 之后的匹配是另一个钩子
Docker 关于 防火墙的文档 指出,普通用户态过滤看到的是 DNAT 之后的目的地址,因此在普通规则里匹配已发布的端口 2222 并不等于匹配容器的 22 端口。如果原始元组重要,请使用受支持的原始目的地址机制,而不是猜测哪个端口可见。
这种钩子混淆会产生看起来正确、却永远不匹配的规则。改写策略之前,先看规则自身的计数器。零命中的规则告诉你:它被评估的位置,数据包根本到不了。
设计 A:把决策当作连接状态携带
连接标记可以带着入口决策穿过地址转换,但必须在真正重要的那次路由查找之前写回数据包。步骤如下:
- 预留一个不与 Docker、VPN 或其他策略所有者冲突的标记字段和规则优先级。
- 只在连接跟踪可用的 DNAT 之前的位置,识别预期的新 WAN 连接。
- 把决策记录到连接标记上,而不强行让原始请求本身走 WAN 表。
- 在回包方向上,只在路由之前把自己那几位标记写回数据包。
- 足够早地选择一张完整的 WAN 表,让 VPN 规则无法抢先命中。
那张表需要真实的直连路由和网关。它还需要定义路由缺失时的行为。静默落入隧道是最糟的结果,因为配置看上去仍然在工作。应当失败关闭并记录日志。
设计 B:在主机上终止
在容器前面刻意配置一个主机侧前端,例如 TCP 代理或反向代理,就能让回包始终从主机地址发出。为主机编写的规则于是按预期生效,而容器仍留在自己的私有网络里。
代价是多了一个会动的部件,它有自己的配置、源地址处理和访问控制。这通常是更简单的受支持边界,而当路由策略只能由某个随时可能离开的人维护时,它往往就是正确答案。
验证变更,而不是意图
从公网侧新开一条已授权的连接,确认回包从预期接口发出。然后检查一条与容器无关、由容器发起的请求是否仍然走 VPN。
不要为了测试一条规则就清空整台机器的连接表:已有条目会保留先前的映射,而清空会破坏主机上其他一切。发布端口定义的是可达性,并不定义回包路径。把选定的设计连同表名写下来,让下一个人不必在事故中重新发现它。