Docker 容器如何通过 VPN 路由及发布端口
使用 Gluetun sidecar 后端口为何消失?了解共享网络命名空间的原因、端口发布位置,以及可用的 Compose 配置。
通过 VPN 路由 Docker 容器时端口为何会消失
要通过 VPN 路由 Docker 容器,需要先让一个容器建立隧道,再使用 network_mode: "service:gluetun" 将其他容器连接到它的网络命名空间。后一个连接步骤最容易引起疑问。连接后的容器不再拥有独立网络,因此其发布端口和 Docker 服务名称也会随之消失。应改为在 VPN 容器上发布端口,其他容器则通过 VPN 容器的名称访问该应用。
如果在已连接的容器上保留 ports: 块,Docker 会直接拒绝创建该容器:
Error response from daemon: conflicting options: port publishing and the container type network mode这里使用的工具是 Gluetun。它可以通过 WireGuard 或 OpenVPN 连接商业 VPN(虚拟专用网络)提供商,并自带防火墙。截至 August 2026,当前版本为 v3.41.3。示例使用 WireGuard 连接 Mullvad,因此需要从提供商处获取账户和密钥。如果希望在自有硬件上终止隧道,可以通过 在 VPS 上运行自己的 WireGuard 服务器 构建隧道的另一端,也可以使用 Docker 中的 wg-easy 为其提供 Web 界面。
What network_mode: "service:gluetun" actually does
Every Docker container normally gets its own network namespace: its own interfaces, routing table, firewall rules and listening sockets. service: mode skips that step and starts the container inside gluetun's namespace. One namespace means one IP address, and that changes six things.
- The app has no address of its own. Its address is gluetun's address.
- The app is attached to no Docker network, so its service name is never registered and never resolves. Other containers must use
gluetun. - Containers inside the namespace reach each other over
localhost. - Two containers in one namespace cannot listen on the same port. The Gluetun documentation is blunt about this: there is no workaround.
- Capabilities belong to a container, not to a namespace. Gluetun holds
NET_ADMINand/dev/net/tunbecause it creates the tunnel interface. The attached container does not inherit them. - Compose rejects any file where one service sets both
network_modeandnetworks. Attach gluetun to your networks, and the app rides along.
Restarting gluetun disconnects everything attached to it. That is documented behaviour, and it is the reason gluetun restarts the VPN process inside the container instead of exiting when the connection fails. After you restart or recreate gluetun yourself, restart the containers attached to it.
可用的 Compose 文件
services:
gluetun:
image: qmcgaw/gluetun:v3
container_name: gluetun
cap_add:
- NET_ADMIN
devices:
- /dev/net/tun:/dev/net/tun
environment:
- VPN_SERVICE_PROVIDER=mullvad
- VPN_TYPE=wireguard
- SERVER_CITIES=Amsterdam
- TZ=Europe/Amsterdam
env_file:
- ./gluetun.env
volumes:
- ./gluetun:/gluetun
ports:
- 127.0.0.1:8080:8080/tcp
restart: unless-stopped
qbittorrent:
image: lscr.io/linuxserver/qbittorrent:latest
container_name: qbittorrent
network_mode: "service:gluetun"
environment:
- PUID=1000
- PGID=1000
- TZ=Europe/Amsterdam
- WEBUI_PORT=8080
volumes:
- ./qbittorrent:/config
- ./downloads:/downloads
depends_on:
gluetun:
condition: service_healthy
restart: unless-stopped:v3标签是 v3 系列最新的稳定版本。:latest标签指向 master 分支的最后一次提交,而该分支属于开发边缘版本。因此,在不希望某个工作日突然需要排查问题的机器上,应将 :v3 固定为指定版本。
WEBUI_PORT=8080必须与发布端口匹配,因为 qBittorrent 在 gluetun 的网络命名空间内绑定端口,发布规则会将主机流量转发到其中的 8080 端口。只修改其中一个数字,端口就不会响应。127.0.0.1:8080:8080会让 Web 界面仅监听主机的 loopback 地址。单独使用 8080:8080 会在所有网络接口上发布端口,并自动写入防火墙规则。这正是 Docker 发布的端口直接绕过 ufw 的原因。
启动服务,然后按以下顺序检查:
docker compose up -d
docker compose ps
docker compose logs gluetun | tail -30docker compose ps应显示 gluetun 为 healthy,qbittorrent 为 running。然后从该网络命名空间内部确认出口地址。这项检查的结果决定其他所有检查:
docker run --rm --network=container:gluetun alpine:3.22 sh -c "apk add wget && wget -qO- https://ipinfo.io"该 JSON 中的 ip字段应为 VPN 提供商的地址。如果显示的是服务器自身的地址,应用就没有进入隧道,后续内容都不会按本文所述工作。
不要将密钥写入 compose 文件
gluetun.env 存储凭据,并且不会纳入 git:
WIREGUARD_PRIVATE_KEY=wOEI9rqqbDwnN8/Bpp22sVz48T71vJ4fYmFWujulwUU=
WIREGUARD_ADDRESSES=10.64.222.21/32这两个值都来自您在服务提供商账户区域生成的 WireGuard 配置文件。将该文件的权限设置为 600。需要明确这一措施的作用:密钥不会出现在代码仓库中,但 docker inspect gluetun 仍会向任何能够访问 Docker socket 的人输出所有环境变量。Docker Compose 中的环境文件和密钥介绍了更强的保护选项。
容器如何与隧道外部的容器通信
两个方向都可用,但使用的名称不同。两个容器需要加入同一个 Docker 网络,也就是 gluetun 的网络,因为附加的容器没有自己的网络。Docker Compose 网络的连接方式介绍了默认配置。
从外部访问内部容器时,使用 gluetun 的名称和应用监听的端口。反向代理容器通过 gluetun:8080 访问 qBittorrent Web 界面。无需添加 ports: 配置,因为容器之间的流量会留在 Docker 网络中,不会经过主机端口。
从内部访问外部容器时,使用另一个容器的服务名称,例如 postgres:5432。从 v3.41 开始,Gluetun 可以在自己的网络命名空间中解析其他容器的名称。如果名称无法解析,请固定使用该版本或更高版本。
Gluetun 的防火墙决定哪些客户端可以向它发起连接。来自 gluetun 自身 Docker 网络的流量会被允许。来自其他子网的客户端、LAN 上的笔记本电脑或单独 bridge 网络中的容器,都会被丢弃,除非指定该子网:
FIREWALL_OUTBOUND_SUBNETS=192.168.1.0/24文档中的含义是确定的:以逗号分隔的子网列表,表示允许 Gluetun 以及共享其网络栈的容器访问的子网。
来自互联网的入站连接是另一个问题。torrent 客户端的对端从 VPN 侧连接,因此在主机上发布端口 6881 对这些连接不起作用。您需要从 VPN 提供商处获取转发端口,并将该端口填入 FIREWALL_VPN_INPUT_PORTS。该配置允许来自 VPN 服务器侧的端口访问。这是大多数使用 Docker Compose 构建的媒体栈未正确配置的部分。
隧道断开时会发生什么:紧急断网开关
这种方案的复杂性在故障时才体现出价值。附加的容器没有备用路由。它离开主机的唯一通道是所共享的网络命名空间,因此隧道断开后没有其他路径可用。Gluetun 从另一侧强制执行相同规则:出站流量只能通过隧道或发送到 VPN 服务器端点,其他流量全部丢弃。客户端重新连接期间,不会出现数据包通过普通网卡泄漏的时间窗口。
Gluetun 会监控自身的连接。它每分钟向 HEALTH_ICMP_TARGET_IPS 中的地址发送 ICMP 回显请求(ping),这些地址默认是 1.1.1.1,8.8.8.8。它每 5 分钟向 HEALTH_TARGET_ADDRESSES 建立完整的 TCP 和 TLS(传输层安全)连接,默认值为 cloudflare.com:443,github.com:443。这些检查失败时,它会在容器内重启 VPN,并记录日志:
WARN [vpn] restarting VPN because it failed to pass the healthcheck: periodic check: dialing: dial tcp4: lookup cloudflare.com: i/o timeout阅读附加容器的日志时,应按这一顺序理解。应用内部出现的 connection refused、operation not permitted 和 i/o timeout 等日志,是隧道已断开的结果,而不是原因。Gluetun 文档明确说明了这一点,因为用户经常只报告结果,却花数小时追查错误方向。
HEALTH_RESTART_VPN=on 是默认设置,应保持启用。只有在调试某个特定故障时才暂时关闭它,因为关闭后,已断开的隧道会一直保持断开。
启动栈的顺序:隧道建立后再启动应用
该镜像内置 Docker 健康检查:
HEALTHCHECK --interval=5s --timeout=5s --start-period=10s --retries=1 CMD /gluetun-entrypoint healthcheck该命令会启动 gluetun 的另一个短生命周期副本,并查询运行中实例在 http://127.0.0.1:9999/ 上提供的健康检查服务器。隧道正常时返回 200 OK。隧道故障时返回带错误字符串的 500 Internal server error;只要检查失败一次,容器就会被标记为不健康。
condition: service_healthy 会等待该条件。普通的 depends_on: [gluetun] 只等待容器启动,而容器启动通常早于握手完成几秒,因此应用会在网络不可用时启动,并且通常会在首次连接尝试时直接放弃。Docker Compose 中的健康检查介绍了相关语法和计时字段。
有一个限制容易被忽略。Compose 只在创建容器时评估一次该条件。如果 gluetun 之后变为不健康,Compose 不会停止或重启应用。此时由 gluetun 的内部自动修复机制处理,因此它重启的是 VPN 进程,而不是容器。
在信任配置前检查 DNS 泄漏
DNS(域名系统)是即使隧道配置正确也可能存在的泄漏。Gluetun 会在命名空间内运行自己的解析器,并默认通过 DoT(DNS over TLS)将查询转发到 Cloudflare:DNS_UPSTREAM_RESOLVER_TYPE=dot 和 DNS_UPSTREAM_RESOLVERS=cloudflare。不要修改这两项,DNS 查询就会加密,并通过隧道传输。
会导致泄漏的设置是 DNS_UPSTREAM_PLAIN_ADDRESSES。当某个名称无法解析时,有些人会启用它,让路由器或网络提供商的解析器处理查询。Gluetun 文档明确说明了代价:所有 DNS 流量都不会通过 VPN 隧道,而是从隧道外泄漏。网络流量仍然是私密的,但主机名列表不是。WireGuard 隧道中的同类错误见WireGuard 隧道中的 DNS 停止解析。
要进行测试,请在 gluetun 上设置 HTTPPROXY=on 并发布 8888:8888/tcp,然后让浏览器访问该代理并加载 DNS 泄漏测试页面。结果应显示您的网络提供商或 Cloudflare,不能显示家庭路由器。Gluetun 自己的文档提醒,某些泄漏测试可能会报告异常结果,因为命名空间内的解析器是本地缓存中间层,而不是最终返回答案的服务器。应将错误的国家/地区或您自己的 ISP 解析器视为真正的泄漏信号。
在 VPN sidecar 旁添加 Tailscale,以及两者的优先级
Tailscale 是基于 WireGuard 构建的覆盖网络,用于访问您自己的设备。许多人会将它与提供商 VPN 并行运行,以便保留进入整个服务栈的管理路径。两者很少发生冲突,原因值得了解。Tailscale 文档说明了默认行为:它作为覆盖网络运行,只在运行 Tailscale 的设备之间路由流量,不会处理您的公网流量。
因此,答案取决于一个设置。
- Tailscale 运行在独立容器中,使用默认配置:它完全看不到应用的出站流量。所有流量都由 Gluetun 承载。Tailscale 可通过
gluetun:8080访问应用,与其他外部容器的方式完全相同。 - Tailscale 加入 gluetun 的网络命名空间,并使用
network_mode: "service:gluetun":它仍需要自己的cap_add、net_admin和net_raw,因为加入网络命名空间不会自动获得相应 capability。在默认的 userspace networking 模式下,TS_USERSPACE已启用,tailscaled 不会创建任何网络接口,而是作为 SOCKS5 或 HTTP 代理运行,因此无法更改路由。所有流量仍由 Gluetun 承载。 - 使用相同配置,但启用
TS_USERSPACE=false:tailscaled 会创建隧道设备并安装路由,但仅覆盖 tailnet 网段100.64.0.0/10,以及您通过TS_ROUTES发布的任何子网路由。公网流量仍通过 gluetun 发出。 - 上述任一配置选择了出口节点
sudo tailscale set --exit-node=<exit-node-ip>:Tailscale 会接管默认路由并获得优先级。不要将此配置与 gluetun 组合使用。只能有一条默认路由,也只能有一个负责方。
如果您需要这些已发布的路由,并且希望访问该主机后面的整个私有网络,而不只是访问主机本身,请参阅在 VPS 上运行 Tailscale 子网路由器。其中介绍了路由批准、IP 转发,以及客户端侧需要设置的标志;仅使用 TS_ROUTES 不会自动完成这些配置。
如果使用 Tailscale 是为了提供管理 URL,而不是提供路由,请参阅tailscale serve 和 tailscale funnel。它们会在您的 tailnet 前为 gluetun:8080 提供 HTTPS,只有 funnel 会将其开放到公网。
Tailscale 在隧道内运行时,还会产生一个可观察的副作用。其对等节点看到的是 VPN 提供商的地址,因此更容易回退到中继。发生这种情况时,tailscale status 会在对等节点旁显示 relay "...",而不是 direct。连接仍可使用,但速度会变慢。如果您实际只需要覆盖网络,请从普通 WireGuard 与 Tailscale 的区别开始了解。
会出现哪些问题,以及你将看到的消息
Docker 拒绝创建应用容器。 Error response from daemon: conflicting options: port publishing and the container type network mode 表示附加到该服务的容器中仍有一个 ports: 块。将它移到 gluetun。
Compose 拒绝整个文件。 一个服务不能同时设置 network_mode 和 networks。将网络配置放在 gluetun 上。
其他容器无法解析应用。 curl: (6) Could not resolve host: qbittorrent 属于正常行为,因为附加的容器未加入任何网络,也未注册名称。使用 gluetun 和端口。
第二个附加容器无法启动。 同一命名空间中的两个进程不能绑定同一个端口。无法绑定的进程会报告地址已在使用。修改应用的内部端口,或运行第二个 gluetun。
修改 gluetun 后,应用没有网络。 重启或重新创建 gluetun 会断开所有附加容器的网络连接。重启这些容器。
小页面可以加载,大页面却一直挂起。 这是 MTU(最大传输单元)问题。隧道会增加额外开销,路径中的某个环节会丢弃超大的数据包,且不会返回错误。降低 WIREGUARD_MTU,尝试 1400,然后尝试 1320。
Gluetun 始终无法变为健康状态。 启动检查列出了最先应排查的对象:WARN [vpn] restarting VPN because it failed to pass the healthcheck: startup check: dialing: dial tcp4: lookup cloudflare.com: i/o timeout。先确认密钥是否已过期,再确认服务器列表是否已过时,最后确认主机防火墙是否阻止出站 UDP。
FAQ
为什么容器通过 Gluetun 发布的端口停止工作?
因为 network_mode: "service:gluetun" 会将容器放入 gluetun 的网络命名空间,而一个命名空间只有一个 IP 地址和一组监听端口。应用仍在监听,但端口发布规则必须配置在拥有该命名空间的容器上。将 ports: 列表移到 gluetun 服务中。如果该列表仍留在连接的服务上,Docker 甚至不会创建它:Error response from daemon: conflicting options: port publishing and the container type network mode。
如何从 VPN 隧道外部的容器访问隧道内的容器?
使用 gluetun 的服务名和应用监听的端口,例如 gluetun:8080。连接的容器不属于任何独立的 Docker 网络,因此无法解析自身名称。容器之间的流量无需发布端口。反过来,在 Gluetun v3.41 及更高版本中,命名空间内的容器可以通过服务名访问外部容器,例如 postgres:5432。如果客户端位于其他子网,例如局域网中的笔记本电脑,gluetun 的防火墙会丢弃其流量,直到将该子网添加到 FIREWALL_OUTBOUND_SUBNETS。
Gluetun 在 VPN 断开时能否充当终止开关?
可以,原因有两个。连接的容器除了共享命名空间中的路由外没有其他路由,因此隧道断开后无法离开本机。Gluetun 的防火墙也只允许通过隧道和 VPN 服务器端点发出流量。随后 Gluetun 会在内部重启 VPN,并记录 WARN [vpn] restarting VPN because it failed to pass the healthcheck,而不是退出,因为 gluetun 自身重启时,所有连接的容器都会失去网络。
Tailscale 和 Gluetun 位于同一堆栈时,哪个负责出站流量?
除一种配置外,始终由 Gluetun 负责。默认情况下,Tailscale 只在 tailnet 中的设备之间路由流量,不处理公网流量。在容器镜像的默认 userspace 模式下,它甚至不会创建网络接口,因此无法影响路由。使用 TS_USERSPACE=false 时,它只会为 100.64.0.0/10 和已公布的子网安装路由。例外是 exit node:sudo tailscale set --exit-node=<exit-node-ip> 会使 Tailscale 成为默认路由,此时由 Tailscale 接管。应选择一个产品负责默认路由,不要同时叠加两者。