SSD Nodes Learn Hosting plans →
指南 Matt Connor作者: Matt Connor

Docker Gluetun 容器网络配置:如何访问宿主机与其它容器

当容器使用 network_mode: service:gluetun 时,无法在应用容器中定义端口。你必须在 Gluetun 服务中发布端口,并使用环境变量配置子网路由,以解决容器间通信及访问宿主机的问题。

容器加入 gluetun 的网络后会发生什么

设置 network_mode: service:gluetun 的容器没有自己的网络接口。它会加入 gluetun 的网络命名空间,因此,端口发布和防火墙规则不再属于该容器,而是属于 gluetun 服务。下面的所有结论都源于这一事实。

网络命名空间是内核中网络协议栈的私有副本:包含自己的接口、路由表、防火墙规则和监听套接字。Docker 默认会为每个容器创建一个网络命名空间。使用 network_mode: service:gluetun 时,Docker 会跳过这一步,并将新容器放入 gluetun 已经拥有的命名空间中。该容器仍保留自己的文件系统,但没有自己的 /etc/hosts 或 /etc/resolv.conf:Docker 会将 gluetun 使用的同一组文件交给它,这一点稍后很重要。

您可以直接查看。

docker inspect -f '{{.HostConfig.NetworkMode}}' qbittorrent

该命令会输出 container:,后面跟着 gluetun 容器 ID;如果是普通容器,则会输出 bridge。本指南接续使用 gluetun 通过 VPN 路由 Docker 流量中的内容:隧道已正常工作,但现在没有任何连接可以访问该容器。

在 gluetun 上发布端口,而不是在应用上发布

如果在设置 network_mode 的服务中保留 ports: 块,Docker 将拒绝创建容器:

Error response from daemon: conflicting options: port publishing and the container type network mode

原因很直接。发布端口意味着添加一条 NAT(网络地址转换)规则,将主机端口转发到容器自己的网络命名空间中,但该容器没有独立的网络命名空间。将端口映射移到 gluetun 服务中。端口号不变,因为应用仍在共享的命名空间中监听该端口。

services:
  gluetun:
    ports:
      - "8080:8080/tcp"   # qBittorrent web UI

  qbittorrent:
    network_mode: "service:gluetun"
    # no ports: block here

在依赖服务中使用 expose: 块也会以同样方式失败,并显示 conflicting options: port exposing and the container type network mode;其中的 networks: 块同样会直接导致停止:Compose 报告该服务声明了互斥的 network_mode 和 networks,并拒绝加载整个文件。

另一个问题会在之后出现。命名空间中的所有容器共享同一个端口空间,因此两个默认使用 8080 的应用会发生冲突,后启动的应用会因地址已被占用而启动失败。在其中一个应用自己的配置中修改端口,例如修改 LinuxServer qBittorrent 镜像中的 WEBUI_PORT 变量,然后在 gluetun 上发布新的端口号。

gluetun 后的容器如何相互访问?

在该命名空间内,它们已经共享 loopback 接口。位于 gluetun 后的容器无需经过 Docker 网络,即可通过 127.0.0.1:<port> 访问同级容器。

从命名空间外部看,该容器没有名称。Docker 内置 DNS 会将服务名称解析为用户自定义网络上该服务的地址,而此容器在任何网络上都没有地址。因此,Sonarr 等普通容器无法通过 http://qbittorrent:8080 访问 torrent 客户端。它应通过 http://gluetun:8080 访问,因为该套接字在 gluetun 的命名空间中监听,使用的是 gluetun 的地址。熟悉 Docker Compose 网络和服务名称工作方式 的用户通常会对此感到意外,因为他们预期常规命名方式仍然适用。此方式也无需向主机发布任何端口,因为两个容器位于同一个 Compose 网络中。

在排查其他问题前,先检查 DNS。Gluetun 会在命名空间内运行自己的 DNS 服务器。Docker 会为 gluetun 后的每个容器提供与 gluetun 相同的 /etc/resolv.conf 文件,因此应用的查询会通过 gluetun 的解析器,而不是主机的解析器。默认情况下,该解析器还会拒绝指向私有地址的响应。这就是某个名称能在主机上解析,却可能在容器内解析失败的原因。gluetun 文档说明,必须将此类主机名列入 DNS_REBINDING_PROTECTION_EXEMPT_HOSTNAMES。

docker exec qbittorrent cat /etc/resolv.conf

如何访问运行在 Docker 主机上的服务?

使用 host.docker.internal。它需要配置两个设置,而且都要配置在 gluetun 服务上,因为这里有两个不同的问题。

先配置名称。gluetun 后面的容器会读取 gluetun 的 /etc/hosts;对于设置了 network_mode 的服务,Docker 会拒绝其中的 extra_hosts 条目,并显示 conflicting options: custom host-to-IP mapping and the network mode。因此,该条目应配置在 gluetun 上,同一网络命名空间中的所有容器都能看到它。

  gluetun:
    extra_hosts:
      - "host.docker.internal:host-gateway"

host-gateway 是一个特殊值,Docker 会将其替换为主机自身的内部地址。在普通的 Linux Docker 安装中,该地址属于 docker0 网桥,通常是 172.17.0.1。在 VPS 上使用 ip -4 addr show docker0 确认实际地址。Docker Desktop 会自行解析此名称,因此面向笔记本编写的指南会省略 extra_hosts 行;同一个文件随后在服务器上就会失败。

然后配置路由。仅添加名称只能告诉容器使用哪个地址。数据包仍会通过 gluetun 的默认路由离开,而默认路由是隧道;gluetun 的防火墙会丢弃该数据包。其表现是连接等待一段时间后超时,而不是立即被拒绝。拒绝表示数据包已到达,并且某个服务返回了拒绝响应。超时表示数据包根本没有到达。

  gluetun:
    environment:
      - FIREWALL_OUTBOUND_SUBNETS=172.17.0.1/32

然后确认主机服务确实在该地址上监听。仅绑定到 127.0.0.1 的 PostgreSQL 服务器无法从任何拥有独立网络命名空间的容器访问,包括 gluetun 后面的所有容器,无论是否使用隧道。这是因为命名空间内的 127.0.0.1 是该命名空间自身的回环地址。只有使用主机网络的容器才会与主机共享回环地址,而 gluetun 后面的容器已经将其 network_mode 用于 gluetun。改为将 PostgreSQL 绑定到 172.17.0.1:这样容器可以连接,同时服务不会暴露在公网接口上。使用主机上的 ss -lntp | grep 5432 验证。

FIREWALL_OUTBOUND_SUBNETS 实际会改变什么

Gluetun 文档将其描述为一个逗号分隔的子网列表,表示允许 gluetun 及共享其网络栈的容器访问的子网,并说明该设置涉及防火墙和路由变更。这两部分都很重要。Gluetun 会为每个列出的子网添加一条经由 Docker bridge 网关的路由,使发往这些地址的数据包通过 eth0 离开,而不是进入隧道。它还会为这些子网放行防火墙流量,因为在其他情况下,gluetun 会丢弃所有不是发往 VPN 服务器的出站流量。

逗号后不要添加空格。

      - FIREWALL_OUTBOUND_SUBNETS=172.17.0.1/32,192.168.1.0/24,100.64.7.9/32

有两个属性很容易忽略。该设置作用于网络命名空间级别,因此会应用于 gluetun 后面的每个容器,而不仅是你指定的那个容器。它仅控制出站流量,即容器主动发起的连接。到达已发布端口的连接会经过不同的路径,无需在此处添加对应网段。

从 Tailscale 节点访问 Web UI

Tailscale 会为每台机器分配一个 100.64.0.0/10 地址,该地址段保留给运营商级 NAT 使用。在个人 tailnet 上,这些功能均不收费;但当其他人需要访问相同的 UI 时,免费计划最多支持 6 个用户,这会决定是否仍可免费使用。所有计划对用户设备的数量均不设限制。服务器和其他带标签的设备单独计数:Tailscale 定价页面显示,计划包含 50 个带标签资源,超出部分每个每月 $1。由于其他计费通常按用户而不是机器计算,付费 tailnet 的实际费用主要取决于你邀请多少人,而不是向他们公开多少个容器。入站和出站流量需要分别配置。

入站配置比较简单。在 gluetun 上发布 8080:8080 会将该端口绑定到主机的所有地址,其中包括主机的 tailscale0 接口,因此节点访问 http://<machine-name>:8080 即可到达容器。此路径不经过 gluetun,因为 Docker 的 NAT 规则位于主机上,在该网络命名空间之外。

若要让 UI 只能通过 tailnet 访问,请将发布的端口绑定到主机的 Tailscale 地址,而不是绑定到所有地址。

    ports:
      - "100.101.102.103:8080:8080/tcp"

在主机上使用 tailscale ip -4 查找该地址。在这里,绑定比防火墙规则更可靠,因为该端口根本不会在公网接口上开放。如果你希望通过 HTTPS 名称而不是主机和端口访问 UI,可以使用 tailscale serve 代理该已发布端口;不过,serve 和 funnel 对最终可访问对象的范围不同,并且只有其中一个会将 UI 保留在 tailnet 内。这样也可以避开 Docker 绕过 ufw 直接发布端口的问题。用于 Compose 的 Tailscale sidecar 模式可以进一步完全移除主机端口,但它的工作方式是将应用放入 sidecar 的网络命名空间,而位于 gluetun 后面的容器已经将自己的一个 network_mode 用于 gluetun。

出站连接则需要处理 FIREWALL_OUTBOUND_SUBNETS。如果容器必须连接某个节点,请添加该节点的地址,并优先为每个节点配置一个 /32,而不是配置整个 /10。如果要连接的机器位于某个私有网络中,而该网络通过向 tailnet 宣告该子网的 VPS可达,请列出已宣告的网段,而不是路由器自身的 100.x 地址,并确认主机本身已接受这些路由。MagicDNS 名称默认无法在容器内解析,因为容器使用 gluetun 的 DNS 服务器,而不是主机的解析器;因此请使用数字形式的 100.x 地址,或在 gluetun 服务中添加 extra_hosts 行来固定该名称。Gluetun 的 DNS_UPSTREAM_PLAIN_ADDRESSES 选项可以改为将查询转发到其他解析器,但所有查询都会以明文发送。gluetun 文档不建议这样做,因为发往私有地址的查询会泄露到 VPN 隧道之外。使用自建的 Tailscale 控制服务器 Headscale时也一样。

常见架构的完整 compose 文件

一个位于 VPN 后的下载客户端,两个仅在 tailnet 上响应的 Web UI,以及一个读取宿主机 PostgreSQL 数据库的容器。

services:
  gluetun:
    image: qmcgaw/gluetun:latest
    container_name: gluetun
    cap_add:
      - NET_ADMIN
    devices:
      - /dev/net/tun:/dev/net/tun
    ports:
      - "127.0.0.1:8000:8000/tcp"        # gluetun control server, host only
      - "100.101.102.103:8080:8080/tcp"  # qBittorrent UI, tailnet only
      - "100.101.102.103:9696:9696/tcp"  # Prowlarr UI, tailnet only
    extra_hosts:
      - "host.docker.internal:host-gateway"
    volumes:
      - ./gluetun:/gluetun
    environment:
      - VPN_SERVICE_PROVIDER=mullvad
      - VPN_TYPE=wireguard
      - WIREGUARD_PRIVATE_KEY=${WIREGUARD_PRIVATE_KEY}
      - WIREGUARD_ADDRESSES=${WIREGUARD_ADDRESSES}
      - SERVER_CITIES=Amsterdam
      - FIREWALL_OUTBOUND_SUBNETS=172.17.0.1/32,100.64.7.9/32
      - TZ=Europe/Amsterdam
    restart: unless-stopped

  qbittorrent:
    image: lscr.io/linuxserver/qbittorrent:latest
    network_mode: "service:gluetun"
    environment:
      - PUID=1000
      - PGID=1000
      - TZ=Europe/Amsterdam
      - WEBUI_PORT=8080
    volumes:
      - ./qbittorrent:/config
      - /srv/downloads:/downloads
    depends_on:
      gluetun:
        condition: service_healthy
    restart: unless-stopped

  prowlarr:
    image: lscr.io/linuxserver/prowlarr:latest
    network_mode: "service:gluetun"
    environment:
      - PUID=1000
      - PGID=1000
      - TZ=Europe/Amsterdam
      - PROWLARR__POSTGRES__HOST=host.docker.internal
      - PROWLARR__POSTGRES__PORT=5432
      - PROWLARR__POSTGRES__USER=prowlarr
      - PROWLARR__POSTGRES__PASSWORD=${PROWLARR_DB_PASSWORD}
      - PROWLARR__POSTGRES__MAINDB=prowlarr-main
      - PROWLARR__POSTGRES__LOGDB=prowlarr-log
    volumes:
      - ./prowlarr:/config
    depends_on:
      gluetun:
        condition: service_healthy
    restart: unless-stopped

请关注该文件的模式而非具体产品名称。两个 UI 均发布在 gluetun 上,并绑定到宿主机的 tailnet 地址,因此它们仅在 Tailscale 上响应,不会在其他地方暴露。extra_hosts 行也位于 gluetun 上,因为 Prowlarr 在解析 host.docker.internal 时会读取 gluetun 的 /etc/hosts,而 Docker 会拒绝在 Prowlarr 自身上使用该行。FIREWALL_OUTBOUND_SUBNETS 指定了两个单一地址:一个是宿主机的 Docker 网桥地址(以便 Prowlarr 可以建立数据库连接),另一个是 tailnet 对端地址。

PostgreSQL 服务器特意未包含在该文件中。它作为普通的系统服务运行在 VPS 上,监听 172.17.0.1:5432。这与 Docker Compose 上的 arr 栈 中的分层方式相同,只是将数据库移到了 Docker 之外。

请勿将 WireGuard 私钥放入 compose 文件中。${WIREGUARD_PRIVATE_KEY} 会读取其旁边的 .env 文件,该模式已在 Docker Compose 的 env 文件与密钥 中介绍。condition: service_healthy 子句使用了 gluetun 镜像自带的健康检查,因此在隧道报告就绪之前,没有任何服务会启动。Compose 健康检查 解释了其通用形式。

在所有地址而非仅 tailnet 上发布

删除地址前缀,这会导致端口绑定在 0.0.0.0 上,从而包含 VPS 的公网 IP。仅在您可控的防火墙后执行此操作,并请先阅读上方的 ufw 注意事项。

    ports:
      - "8080:8080/tcp"

确认隧道仍在传输流量

分别在命名空间内和主机上运行两次相同的请求,然后进行比较。

docker run --rm --network=container:gluetun curlimages/curl:latest -s https://api.ipify.org
curl -s https://api.ipify.org

第一次应输出 VPN 提供商的出口地址。第二次应输出 VPS 地址。如果两者相同,说明容器流量没有经过隧道。在纠正此问题之前,本指南中的其他修复都无关紧要。

路由规则和路由表可以显示哪些流量经过隧道,哪些没有经过。

docker run --rm --network=container:gluetun alpine:3.22 sh -c 'ip rule show; ip route show table 51820; ip route show table 199'

在本指南的 WireGuard 配置中,gluetun 将隧道接口命名为 wg0。名称 tun0 仅是其 OpenVPN 默认名称。gluetun 还会将隧道的默认路由保留在主路由表之外。ip rule show 应包含一条规则,将所有未由 WireGuard 自身标记的流量发送到表 51820;该表应在 wg0 上包含默认路由。表 199 为 FIREWALL_OUTBOUND_SUBNETS 中的每个条目保存一条路由,每条路由都指向 eth0 上的 Docker 网桥网关;其规则的检查顺序紧接在隧道规则之前。直接执行 ip route show 仍会列出 Docker 自身在 eth0 上的默认路由。这是预期行为,不属于泄漏,因为 gluetun 的两条规则都会在主表之前检查。表 199 中任何未列出的路由,都表示相应流量绕过了 VPN。

gluetun 的控制服务器可以在端口 8000 的 /v1/publicip/ip 处报告相同的公网 IP,但默认情况下,必须先配置身份验证。gluetun 文档说明,控制服务器的所有路由现在默认都是私有的,因此直接请求该路径会失败。使用 basic 或 apikey 方法,将身份验证文件绑定挂载到 /gluetun/auth/config.toml,然后重启 gluetun。上面的 compose 文件已将 ./gluetun 挂载到 /gluetun,因此主机上的文件路径为 ./gluetun/auth/config.toml。HTTP_CONTROL_SERVER_AUTH_DEFAULT_ROLE 变量的配置更快捷。将其设置为 {"auth":"none"} 会完全关闭身份验证,文档明确不建议这样做。在 v3.39.1 之前的版本中,服务器不会以这种方式限制访问;文档警告应信任所有能够访问该服务器的来源。

一次错误的子网配置造成的泄露

FIREWALL_OUTBOUND_SUBNETS 是您主动在防火墙上打出的孔,因此孔的大小就是风险范围。以下是将范围配置得过大的4种方式:

  • 0.0.0.0/0 会将所有流量发送到隧道外。上面的两项 IP 检查会在首次运行时发现这一点,因为它们会返回相同的地址。
  • 范围大于目标范围。为访问 10.0.1.7 上的一台主机而开放 10.0.0.0/8,还会开放该范围内 torrent 对等端可能公布的所有地址。请填写 10.0.1.7/32。
  • 范围与隧道自身的地址重叠。gluetun 文档警告称,这会导致 gluetun 将 VPN 流量改为经由网桥发送,从而破坏端口转发。在开放任何私有地址范围前,先检查您的 WIREGUARD_ADDRESSES 值。
  • Tailscale 使用 100.64.0.0/10。这大约会开放400万个地址,只为访问一个对等端。请将所需的对等端列为 /32 条目。

请记住,该设置会覆盖整个命名空间。开放一个子网供索引器访问主机服务,也会为共享该命名空间的 torrent 客户端开放同一子网。每次修改此变量后,都要重新执行公网 IP 检查,因为只有这项测试能显示修改是否达到了预期效果。

重启 gluetun 时会发生什么

Gluetun 负责管理该网络命名空间,因此 gluetun 的生命周期就是该命名空间的生命周期。gluetun 停止时,立即启动依赖它的容器会失败:

Error response from daemon: cannot join network namespace of a non running container: container <id> is exited

原地重启 gluetun 会导致更隐蔽的故障。依赖容器会继续运行,但它们所连接的网络命名空间已在后台重建,因此 docker ps 报告一切正常,却没有任何服务响应。修改 gluetun 服务后,应重建整个容器组,而不是只重启其中一个服务。

docker compose up -d --force-recreate

镜像更新也一样。拉取新的 gluetun 镜像并只重建该服务后,其他容器仍会指向一个已经不存在的网络命名空间。

FAQ

为什么 Docker 会提示“端口发布与容器类型网络模式”?

因为某个服务仍保留 ports: 块,同时还设置了 network_mode: service:gluetun。发布端口会添加 NAT 规则,将主机端口转发到容器自己的网络命名空间;而使用此模式的容器没有独立的网络命名空间。请从该服务中删除 ports: 块,并将完全相同的映射添加到 gluetun 服务中。端口号保持不变,因为应用仍在共享的命名空间中监听该端口。

gluetun 后面的其他容器如何访问该服务?

同一命名空间中的容器通过 127.0.0.1 互相访问。命名空间外的容器使用 gluetun 服务名,因此 http://gluetun:8080 可以工作,而 http://qbittorrent:8080 不可以。应用容器在任何 Docker 网络上都没有地址,因此内置 DNS 服务器无法解析其名称。只要两个容器共享一个 Compose 网络,就不需要为此发布端口。

FIREWALL_OUTBOUND_SUBNETS 应该填写什么?

只填写 gluetun 后面的容器必须主动建立连接的地址,并尽可能使用最窄的范围。单台主机应写成 /32。最常见的两个条目是 Docker 主机的 172.17.0.1/32,以及每个需要访问的 Tailscale 对等节点对应的一个 /32。不要添加 0.0.0.0/0,也不要添加与 VPN 自身隧道地址重叠的地址范围。访问已发布端口的入站连接不需要在此处添加条目。

为什么容器无法解析我的 Tailscale MagicDNS 名称?

MagicDNS 通过将主机的解析器指向 Tailscale 的 DNS 服务器来工作,但容器不使用主机的解析器。容器使用与 gluetun 共享的 /etc/resolv.conf,该文件将请求指向 gluetun 自己的 DNS 服务器。使用 docker exec <container> cat /etc/resolv.conf 确认。请使用对等节点的数字 100.x 地址,或在 gluetun 服务中通过 extra_hosts 条目固定该名称。Gluetun 可以通过 DNS_UPSTREAM_PLAIN_ADDRESSES 将查询转发到其他解析器,但这会以明文发送查询,其文档不建议这样做。

如何确认流量仍通过 VPN 传输?

在该命名空间内发起一次请求,再从主机发起相同请求,然后比较返回结果。docker run --rm --network=container:gluetun curlimages/curl:latest -s https://api.ipify.org 应返回 VPN 提供商的出口地址,而 VPS 上的 curl -s https://api.ipify.org 应返回 VPS 地址。如果两次返回结果相同,说明隧道没有承载容器的流量。每次修改 FIREWALL_OUTBOUND_SUBNETS 后,都要重新执行此检查。

这篇文章对您有帮助吗?