如何让宿主机流量经由 Docker VPN 容器转发
VPN 容器是网络命名空间,不是代理。选择共享 netns、真正的 SOCKS 监听器或带路由的网关,然后验证出口路径。
你的隧道容器工作正常,docker compose exec vpn curl -s https://ifconfig.me 返回的是服务商地址,但桌面上的浏览器仍然走你的 ISP 出去。这个落差是正常的。运行 VPN 客户端的容器只持有网络命名空间、路由和隧道接口,没有任何讲 HTTP 或 SOCKS 的东西;在它上面发布端口,并不能造出从未存在过的监听器。
客户端在哪里运行,决定了设计方案。兄弟容器可以进入隧道的命名空间,桌面应用不行,而路由器需要的是路由,不是代理字段。映射搞错,症状都一样:连接被拒绝,或者流量悄悄走回老路。
按客户端运行的位置选择方案
在改 Compose 之前先问一个问题:客户端能否在 VPN 容器的网络命名空间里启动?任何已经以 Linux 容器方式运行的东西都可以。桌面上的浏览器、IDE 或命令行则不行,因为它的代理设置期待的是协议服务,而不是命名空间。
| Client | Arrangement | What must already exist |
|---|---|---|
| Another Compose service | Shares the vpn namespace via network_mode: service:vpn | A working tunnel and correct start order |
| Host browser, IDE or CLI | Proxy listener inside the namespace, published to host loopback | A configured SOCKS or HTTP proxy and its access rules |
| Router, VM or phone | The container as a layer-3 gateway | Forwarding, NAT and a return route |
这三种方案不能混用。把 HTTP 代理字段指向容器网桥地址,除非那里真有代理在监听,否则什么也到不了;而代理监听器也不会为路由器转发流量。先决定方案,再照对应小节做。
与兄弟容器共享隧道命名空间
这是最省事的方案,因为完全不涉及代理协议。应用加入 vpn 服务的命名空间,继承它的接口、路由和端口空间:
services:
vpn:
image: your-vpn-client
cap_add:
- NET_ADMIN
healthcheck:
test: ["CMD-SHELL", "ip link show tun0 >/dev/null"]
interval: 10s
retries: 5
app:
image: your-app
network_mode: service:vpn
depends_on:
vpn:
condition: service_healthy由于命名空间属于另一个服务,会产生三条限制。应用拿不到自己的 networks 键,因为它没有可挂靠的命名空间。发布端口属于 vpn,因为命名空间在那里。而重建 vpn 会销毁应用所加入的命名空间,所以 docker compose up -d --force-recreate vpn 会要求你把 app 也一起重建;单纯重启并不总是够。 Compose 服务参考 讲的就是这种方案及其端口发布限制。
不要以为解析器会跟着走。/etc/resolv.conf 是每个容器各自的文件,不属于网络命名空间,所以即使数据包从隧道出去,应用仍可能去问宿主机的解析器。要在容器里读它,并在私有域名必须经隧道解析时给应用设置 dns:。
docker compose exec app ip route
docker compose exec app cat /etc/resolv.conf
docker compose exec app curl -s https://ifconfig.me如果路由表里有隧道接口,且地址是服务商的,应用就在隧道里。如果接口缺失,说明 app 在 vpn 建立隧道之前就启动了,等 vpn 报告健康后重建 app。
为宿主机客户端在命名空间里放一个真正的代理监听器
桌面浏览器进不了命名空间,所以隧道必须以服务形式暴露出来。在 vpn 命名空间里运行真正的 SOCKS 或 HTTP 代理,只发布它的端口:
services:
vpn:
ports:
- "127.0.0.1:1080:1080"
proxy:
image: your-proxy
network_mode: service:vpn
command: ["--socks", "0.0.0.0:1080"]宿主机侧的地址才是这个映射的关键一半。左边的 127.0.0.1:1080 让监听器不暴露到你的局域网;裸写 "1080:1080" 则等于把一个开放代理发布给任何能连到你机器的设备。
容器侧还有它自己的陷阱。发布端口投递到容器在网桥上的地址,而不是它的 loopback,所以在命名空间里用 --socks 127.0.0.1:1080 启动的代理会接受兄弟容器的连接,却拒绝所有经发布端口进来的连接。把它绑到 0.0.0.0,隔离则依赖宿主机侧的 loopback 绑定。同一条规则也适用于代理配置文件里的监听地址。
接着决定域名在哪里解析。在 curl 里,--socks5 在本地解析主机名,--socks5-hostname 把名字交给代理;浏览器用另一个标签提供同样的开关。即使页面经隧道加载,本地解析仍会泄露这次查询。
只有在有回程路由时,才把容器当作网关
路由器、虚拟机和手机不会用 HTTP 代理字段,有些应用干脆完全忽略代理变量。转发它们的流量意味着第 3 层:容器转发数据包,并在隧道接口上做地址转换。
sysctl -w net.ipv4.ip_forward=1
iptables -t nat -A POSTROUTING -o tun0 -j MASQUERADE有两点很容易漏掉。客户端需要一条把默认网关指向这台宿主机的路由,而回包必须找得到回来的路:要么在上游路由器上加静态路由,要么在面向客户端的接口上做 NAT。两者缺一,隧道把请求送出去,回包就消失了。
它的影响面也最大。一台充当整个局域网网关的宿主机,会把每个配置错误的客户端都送进你的 VPN 账户;隧道一断,要么让局域网黑掉,要么按 kill switch 退回 ISP。
先验证出口路径,再按顺序回滚
按这个顺序检查,遇到第一个失败就停下:命名空间内的出口地址、从宿主机经代理的出口地址,然后是私有主机的域名解析。
docker compose exec vpn curl -s https://ifconfig.me
curl -s -x socks5h://127.0.0.1:1080 https://ifconfig.me
curl -s -x socks5h://127.0.0.1:1080 http://internal.example/health前两个地址应当与服务商一致,第三个应当经代理解析,而不是以域名错误失败。第一行一致、第二行失败,说明错的是监听器或发布的端口,不是隧道。
回滚就是安装的逆过程。从 shell 配置和任何 systemd 单元里删掉代理环境变量,然后停掉整套服务,让共享命名空间随它的属主一起消失。要先删发布的端口再删代理服务,否则宿主机上会留下一个没人读取的 loopback 监听器。
继续之前,确认四件事:你在意的客户端确实能到达隧道,只有命名空间属主发布端口,私有域名在你想要的地方解析,以及没有任何东西监听在非 loopback 的宿主机地址上。这四点在线上都会静默失败,而每一点都是上面的一行检查。