你的隧道容器工作正常,docker compose exec vpn curl -s https://ifconfig.me 返回的是服务商地址,但桌面上的浏览器仍然走你的 ISP 出去。这个落差是正常的。运行 VPN 客户端的容器只持有网络命名空间、路由和隧道接口,没有任何讲 HTTP 或 SOCKS 的东西;在它上面发布端口,并不能造出从未存在过的监听器。

客户端在哪里运行,决定了设计方案。兄弟容器可以进入隧道的命名空间,桌面应用不行,而路由器需要的是路由,不是代理字段。映射搞错,症状都一样:连接被拒绝,或者流量悄悄走回老路。

按客户端运行的位置选择方案

在改 Compose 之前先问一个问题:客户端能否在 VPN 容器的网络命名空间里启动?任何已经以 Linux 容器方式运行的东西都可以。桌面上的浏览器、IDE 或命令行则不行,因为它的代理设置期待的是协议服务,而不是命名空间。

ClientArrangementWhat must already exist
Another Compose serviceShares the vpn namespace via network_mode: service:vpnA working tunnel and correct start order
Host browser, IDE or CLIProxy listener inside the namespace, published to host loopbackA configured SOCKS or HTTP proxy and its access rules
Router, VM or phoneThe container as a layer-3 gatewayForwarding, 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 的宿主机地址上。这四点在线上都会静默失败,而每一点都是上面的一行检查。

相关文章