容器的数据包永远到不了 tun0,给容器增加额外的能力也改变不了这一点。客户机与隧道之间有两次路由查找,一次在客户机的网络命名空间内,一次在帧落到网桥之后的宿主机上,第二次查询的是另一张表和另一套匹配规则。我的参考布局是一台 Linux 宿主机,其中运行着 OpenVPN 客户端,它持有 10.8.0.10,位于 tun0,还有 172.18.0.0/16 上的 Docker 网桥,以及一个只有隧道一侧才能到达的私有地址。目标依然很窄:这个客户机子网从隧道出去,而宿主机保留普通的上行链路。

在容器内部运行 VPN 客户端是另一种设计,而不是捷径。那时容器拥有自己的隧道和凭据,工作会转移到 Dockerfile 里,而不是留在 iptables 中。

转发只按目的地址选路,然后 NAT 改写源地址

设置 net.ipv4.ip_forward=1 后,被转发的数据包在主表中只按目的地址选路。源地址在这一决定中不起作用。只有选定路由之后,内核才会执行 POSTROUTING,而 Docker 自己的规则就放在这里:-s 172.18.0.0/16 ! -o br-appnet -j MASQUERADE。离开网桥去往其他接口的一切都会被转换,转换成的地址取决于路由把它送到了哪个接口。

这条规则解释了我被问到的大多数症状。如果主表中没有通往私有目的地址的路由,数据包就会带着宿主机的物理地址从上行链路出去。你的运营商丢弃该目的地址,或者远端站点丢弃一个它从未预料到的 RFC 1918 源地址,于是隧道仍然在线且健康,容器却超时了。tun0 上的抓包什么也看不到,这就是线索。如果路由确实指向 tun0,MASQUERADE 会把源地址改写成 10.8.0.10,同一个容器就无需你做任何改动即可工作。

请查询给出客户机源地址的那次查找。没有 from 和 iif,宿主机只针对自己的套接字作答,并报告一条被转发的数据包永远不会走的路:

ip route get 192.168.11.222 from 172.18.0.5 iif br-appnet

如果这个答复点明的是上行链路,那么隧道就不在考虑范围内,隧道一侧的任何设置都帮不上忙。

把客户机转换到隧道自己的地址上

最小的可行改动是在出口处显式改写源地址。要插入,而不是追加:Docker 已经坐在 POSTROUTING 的最前面,因此用 -A 追加的规则会在 Docker 的规则之后被评估,永远不会生效。

iptables -t nat -I POSTROUTING 1 -s 172.18.0.0/16 -o tun0 -j SNAT --to-source 10.8.0.10

有两个条件决定这样做能否成立。地址必须是客户端真正持有的地址,所以用每个客户端的条目把它固定住,其中包含 ifconfig-push 10.8.0.10 255.255.255.0;一条点名从未分配过的地址的规则只会造成静默丢弃,而不会报错。并且远端必须接受这个源地址并把回应路由回来,所以那边的允许列表写的是隧道地址,而不是客户机子网。当客户端地址是动态的时,-j MASQUERADE 配合 -o tun0 会跟随接口当前持有的地址,代价是你无法针对固定值编写远端策略。

过滤是另一半。Docker 把 FORWARD 策略设为 drop,并在每次守护进程重启时清空它自己的链,所以把本地策略放进 DOCKER-USER,它可以存活下来:

iptables -I DOCKER-USER -i br-appnet -o tun0 -j ACCEPT
iptables -I DOCKER-USER -i tun0 -o br-appnet -m conntrack --ctstate ESTABLISHED,RELATED -j ACCEPT

返回规则很重要:tun0 上到达的回应,对于经由网桥打开的连接,不会仅仅因为出方向已被放行就被接受。

用源地址规则让宿主机保持直连

当整个网桥都该走隧道、而宿主机上其他东西都不该走时,跳过 NAT,给这个源地址单独一张表。这样客户机地址端到端都保持可见。

echo "200 vpnbox" >> /etc/iproute2/rt_tables
ip rule add from 172.18.0.0/16 lookup vpnbox pref 1000
ip route add default dev tun0 table vpnbox

规则的查询先于主表,而 from 会匹配源地址。隧道自己的外层数据包携带的是宿主机的物理源地址,因此它们永远不会匹配 from 172.18.0.0/16,隧道也就无法把自己路由回自己。正是这种自我排除让这套布局保持稳定。

返回路径现在需要帮助。远端看到的是一个未经转换的 172.18.0.x,也没有理由知道它在哪里。在服务器上,每个客户端的文件需要 iroute 172.18.0.0 255.255.0.0,让服务器的内部表把这个前缀交给这个客户端;还需要推送 route 172.18.0.0 255.255.0.0,让其他客户端知道它的存在。

信任它之前做两项检查。运行 ip route show default:使用 redirect-gateway def1 时,表里有 0.0.0.0/1 和 128.0.0.0/1,经由 tun0,而宿主机根本不是直连的,因为这些半默认路由会在不替换 default 的情况下胜出。用 ip rule add 添加的规则在重启后也会消失,所以要把它们写进 up 脚本。

数据包大小是你遇到的第二个故障

1500 字节的网桥接口在加上外层 UDP 和 IP 首部后,交给隧道的东西超过了它能承载的量。小的交换能通过,所以 DNS 能解析、TLS 握手能完成,随后一次较大的上传就在任何地方都没有报错的情况下挂住。发送方一直重试同样的分段大小,因为那条本该告诉它缩小的 ICMP 需要分片消息永远到不了。

在接口上、在 MSS 上,或者两者都改。钳制不需要客户机配合:

ip link set dev tun0 mtu 1400
iptables -t mangle -A FORWARD -o tun0 -p tcp --tcp-flags SYN,RST SYN -j TCPMSS --clamp-mss-to-pmtu

钳制只改写 TCP。UDP(包括 QUIC)则需要改为降低客户机接口的 MTU,用 --opt com.docker.network.driver.mtu=1400 在创建网络时指定,或者用对应的 compose 键。

DNS 与值得重复的检查

域名解析走同一条路径。Docker 在 127.0.0.11 的内嵌解析器会把请求转发给宿主机配置的服务器,因此容器继承宿主机所用的一切。当私有区域只能由通过隧道才能到达的解析器回答时,把客户机指向那个地址,并用 getent hosts 在容器内部测试,而不是在宿主机上。

  • 在客户机内部执行失败的那个请求;在宿主机一侧测试会使用不同的源地址,从而掩盖问题。
  • 读取你添加的 NAT 和过滤规则上的计数器。一条没有任何匹配的规则是路由错误,而不是策略错误。
  • 确认你 SNAT 规则里的隧道地址是客户端真正持有的那个。
  • 在把停滞归咎于服务器之前,先降低客户机的 MTU 或钳制 MSS。

顺着这份清单往下走,在第一个故障处停下。要修的就是那一层;在更下面添加任何东西,只会掩盖正在断裂的边界。

相关文章