在某些场景中,我们可能需要搭建一个链式 VPN 结构,即先让内网设备连接到自建的 WireGuard 服务器(如个人 VPS),再通过该服务器将所有流量转发到另一个 VPN(公司、学校或商业 VPN 服务)后才进入互联网。搭建这种「VPN 套 VPN」的网络架构,有以下明显优势:

  • 强大的隐私保护:内网设备的数据流量首先通过自己搭建的 WireGuard 加密隧道传输至个人 VPS,再由 VPS 二次通过商业或公司 VPN 进行转发,最终从该 VPN 的 IP 出口访问互联网。这种方式彻底避免了使用 VPS 自身 IP 地址直接访问目标网络,大大降低了个人 IP 被跟踪或泄露的风险。

  • 集中化便捷管理:所有客户端设备统一连接到单个 WireGuard 服务,免去逐一配置每台设备的麻烦。更重要的是,它还能巧妙绕过一些商业 VPN 服务商设定的账号设备数量限制,一次付费,所有设备共享。

  • 灵活便捷,轻松切换:由于商业或公司 VPN 配置仅在自建 WireGuard 服务器端修改一次即可生效,下游客户端(手机、电脑等)无需单独调整。这使得更换或重新配置上游 VPN 时更加迅速和便捷,大大提高日常维护的效率。

  • 打造专属虚拟局域网,实现跨设备互通:一旦所有设备连接到自建的 WireGuard VPN 服务器后,将自动获得统一的 VPN 虚拟局域网 IP 地址(例如:10.8.0.0/16)。处于该网络中的设备能够直接互相通信,无需额外设置。

这种便捷性意味着无论你身处何处,都能轻松实现:

  • 远程 SSH 登录:随时访问家中或办公室的服务器。
  • 私有 NAS 媒体访问:在外轻松访问家中 NAS 存储的媒体文件。
  • 远程办公:随时随地安全访问公司内部网络资源。

方案

经过本人的探索和实践,我逐步摸索出了自己的一套链式 VPN 方案。总体来看,我的方案完全基于 Docker 部署,所用到的核心组件有:

  1. gluetun:一个 Docker 镜像,内置 OpenVPN/ WireGuard 客户端,可连接到各大 VPN 服务提供商。本例中我们通过配置在 Docker 网络中提供 VPN 网关功能。
  2. wg-easy:一个简化的 WireGuard Docker 镜像,内置 Web 界面,便于创建/管理 WireGuard 配置文件。下游设备通过 WireGuard 客户端连接到该服务,所有流量将被转发到 gluetun 容器,由 gluetun 再发给上游 VPN。

该方案的优势在于完全容器化部署,避免污染服务器/VPS 的环境。然后就是比较稳定,个人日常使用未发现明显延迟、卡顿、断流问题,而且性能良好(在个人家用带宽下测试)。另外,该方案现在能够支持 IPv6,即使设备身处 IPv4 的网络环境,也能通过链路实现对 IPv6 资源的代理访问。

在网络拓扑上,整个系统仅需要一个 Docker 网桥(bridge):

  • vpn:用于 gluetun 与 wg-easy 之间的“VPN 网络”, gluetun 在其中作为网关,wg-easy 发出的流量都将进入该网络并通过 gluetun 转发到上游 VPN。
[客户端设备]
    ↓ WireGuard(连入)
[wg-easy 容器] ——→ Docker 网络 vpn ——→ [gluetun 容器] ——→ 上游 VPN 提供商

其中,wg-easy 监听 UDP 51820,客户端(自己的手机电脑)连接后,wg-easy (在容器中)会将流量通过 Docker 网络 vpn 发往 gluetun 容器,gluetun 再使用 WireGuard/OpenVPN 连接上游 VPN。所有下游流量最终从 gluetun 的 tun0 接口出去,达到链式 VPN 的效果。

环境与前置条件

  • 一台支持 Docker 与 Docker Compose 的 Linux 服务器/VPS(内核支持 WireGuard 和 iptables/NFT)。建议内核版本 ≥ 5.10。
  • 已安装 Docker 和 Docker Compose v2+。
  • 拥有一个支持 OpenVPN/WireGuard 的商业 VPN 服务商账号。
  • 确保宿主机允许 CAP_NET_ADMIN 与 SYS_MODULE 权限,能加载 WireGuard 内核模块(可通过 lsmod | grep wireguard 验证)。

创建自定义网络

在正式部署前,先在宿主机上创建网桥

docker network create \
  --driver bridge \
  --subnet 172.32.0.0/16 \
  --subnet fd01:beee:beee::/48 \
  vpn

说明:如果已有网络与子网冲突,请根据实际情况调整子网段。本示例假设 Docker 网桥支持 IPv6(用于示例 IPv6 转发)。若不使用 IPv6,可省略相关配置或将 disable_ipv6=1。

Docker Compose 配置详解

version: "3"

services:
  gluetun:
    image: qmcgaw/gluetun
    cap_add:
      - NET_ADMIN
      - SYS_MODULE
    devices:
      - /dev/net/tun:/dev/net/tun
    environment:
      - VPN_SERVICE_PROVIDER=${VPN_SERVICE_PROVIDER}
      - VPN_TYPE=${VPN_TYPE}
      - WIREGUARD_PRIVATE_KEY=${WIREGUARD_PRIVATE_KEY}
      - WIREGUARD_PRESHARED_KEY=${WIREGUARD_PRESHARED_KEY}
      - WIREGUARD_ADDRESSES=${WIREGUARD_ADDRESSES}
      - SERVER_REGIONS=${SERVER_REGIONS}
    configs:
      - source: post-rules.txt
        target: /iptables/post-rules.txt
    volumes:
      - /lib/modules:/lib/modules:ro
      - /data/gluetun-ws:/gluetun
    sysctls:
      - net.ipv4.ip_forward=1
      - net.ipv4.conf.all.src_valid_mark=1
      - net.ipv6.conf.all.disable_ipv6=0
      - net.ipv6.conf.all.forwarding=1
      - net.ipv6.conf.default.forwarding=1
    networks:
      vpn:
        ipv4_address: 172.32.0.4
        ipv6_address: "fd01:beee:beee::4"
    restart: unless-stopped

  wg-easy:
    image: ghcr.io/wg-easy/wg-easy:15
    ports:
      - "51820:51820/udp"
    networks:
      vpn:
        ipv4_address: 172.32.0.8
        ipv6_address: "fd01:beee:beee::8"
    cap_add:
      - NET_ADMIN
      - SYS_MODULE
    sysctls:
      - net.ipv4.ip_forward=1
      - net.ipv4.conf.all.src_valid_mark=1
      - net.ipv6.conf.all.disable_ipv6=0
      - net.ipv6.conf.all.forwarding=1
      - net.ipv6.conf.default.forwarding=1
    volumes:
      - /lib/modules:/lib/modules:ro
      - /data/wg-easy:/etc/wireguard
    restart: unless-stopped

networks:
  vpn:
    external: true

configs:
  post-rules.txt:
    content: |
      iptables -A FORWARD -i eth0 -o tun0 -j ACCEPT
      iptables -A FORWARD -i tun0 -o eth0 -m state --state RELATED,ESTABLISHED -j ACCEPT
      iptables -t nat -A POSTROUTING -o tun0 -j MASQUERADE
      ip6tables -I INPUT -p icmpv6 -j ACCEPT
      ip6tables -I OUTPUT -p icmpv6 -j ACCEPT
      ip6tables -A FORWARD -i eth0 -o tun0 -j ACCEPT
      ip6tables -A FORWARD -i tun0 -o eth0 -m state --state RELATED,ESTABLISHED -j ACCEPT
      ip6tables -t nat -A POSTROUTING -o tun0 -j MASQUERADE

接下来,逐项拆解说明:

  • image: qmcgaw/gluetun:Lightweight swiss-army-knife-like VPN client to multiple VPN service providers,能够使用在容器环境提供 VPN 服务,给其他有需要的容器提供 VPN 连接。官方维护的 gluetun 镜像,支持多种 VPN 协议与服务商。
  • cap_add: [NET_ADMIN, SYS_MODULE]:需要 NET_ADMIN、SYS_MODULE 权限,以及开启 IP 转发等设置(wg-easy 与 gluetun 一致),赋予容器网络管理与加载内核模块的权限,以便创建 TUN 设备及配置路由,且保证 WireGuard 隧道与路由功能正常。
  • devices: ["/dev/net/tun:/dev/net/tun"]:挂载宿主机的 TUN 设备到容器,使 gluetun 能创建虚拟网卡。
  • image: ghcr.io/wg-easy/wg-easy:15:wg-easy 的官方镜像(版本号可根据需要调整)。
  • "51820:51820/udp":将宿主机 UDP 51820 端口映射到容器,以便下游设备通过宿主机或直接通过 Docker 网络来连接 WireGuard。如果你需要对外暴露 WG-Easy 的 Web 管理界面,那你需要额外加入"51821:51821/tcp"

环境变量中的具体参数可参考 gluetun 官方文档

需要注意的是上面提到的这两个配置,用于固定容器在 Docker 网桥中的 IP 地址,后续配置 WG-Easy 的路由表和防火墙规则的时候会频繁引用。如果需要替换,那么需要注意修改后续配置中的相应值。

networks:
  vpn:
    ipv4_address: 172.32.0.4
    ipv6_address: "fd01:beee:beee::4"
networks:
  vpn:
    ipv4_address: 172.32.0.8
    ipv6_address: "fd01:beee:beee::8"

gluetun 额外启动脚本post-rules.txt加入的防火墙规则的解释如下:

# 1. IPv4 转发规则 (允许 eth0 <-> tun0 双向转发)
iptables -A FORWARD -i eth0 -o tun0 -j ACCEPT
iptables -A FORWARD -i tun0 -o eth0 -m state --state RELATED,ESTABLISHED -j ACCEPT

# 2. IPv4 NAT 伪装(所有走 tun0 的包都做 SNAT)
iptables -t nat -A POSTROUTING -o tun0 -j MASQUERADE

# 3. IPv6 ICMP 放行(保证 Neighbor Discovery Protocol 等协议正常)
ip6tables -I INPUT -p icmpv6 -j ACCEPT
ip6tables -I OUTPUT -p icmpv6 -j ACCEPT

# 4. IPv6 转发规则 (允许 eth0 <-> tun0 双向转发)
ip6tables -A FORWARD -i eth0 -o tun0 -j ACCEPT
ip6tables -A FORWARD -i tun0 -o eth0 -m state --state RELATED,ESTABLISHED -j ACCEPT

# 5. IPv6 NAT 伪装(所有走 tun0 的 IPv6 包都做 SNAT)
ip6tables -t nat -A POSTROUTING -o tun0 -j MASQUERADE

通过以上规则,下游(来自 wg-easy 的流量)在 gluetun 容器内部可以顺利地被转发到 tun0 接口,进入上游 VPN 通道;上游返回的数据包同样能回到 Docker 网络,并最终到达 wg-easy 再返回客户端。这就实现了一个 VPN 网关的功能。

WG-Easy “Post Up” 脚本说明

除了 gluetun 的网络规则,还需在 wg-easy 里设置“Post Up”和“Post Down”脚本,用于在 WireGuard 隧道建立后动态添加路由与 ip rule,让下游客户端的流量都走 gluetun。本例针对 wg-easy v15 版本,因为在 v15 版本后 wg-easy 才支持 IPv6。如果想知道 v14 版本的配置,可以看我在GitHub 发布的帖子

image-20250602184632727

在目前 v15.0.0 版本的实际的操作中,需要把我给出的示例转换为单行(用分号分隔每一条命令)填写到截图所示的输入框中。或者,你可以把脚本写成文件,添加到 wg-easy 中的目录中,然后引用。然后点击Save,耐心等待 wg-easy 弹出配置成功的提示框。必要时,可以在配置后重启 wg-easy 容器。

配置示例

以下为完整示例以及解释(注意需在 wg-easy 的 WEB 管理页面配置):

# WG-Easy Post Up 脚本(示例)
VPN=$(ifconfig | grep -B1 172.32.0.8 | grep -o "^\w*")

# 先默认拒绝所有转发流量
iptables -P FORWARD DROP
ip6tables -P FORWARD DROP

# 开放 WireGuard UDP 端口({{port}} 会被替换为 WireGuard 端口号,一般为 51820)
iptables -A INPUT -p udp -m udp --dport {{port}} -j ACCEPT
ip6tables -A INPUT -p udp -m udp --dport {{port}} -j ACCEPT

# 允许本地与 WireGuard 隧道之间的转发
iptables -A FORWARD -i wg0 -j ACCEPT
iptables -A FORWARD -o wg0 -m state --state RELATED,ESTABLISHED -j ACCEPT
iptables -A FORWARD -s {{ipv4Cidr}} -d {{ipv4Cidr}} -i wg0 -o wg0 -j ACCEPT

ip6tables -A FORWARD -i wg0 -j ACCEPT
ip6tables -A FORWARD -o wg0 -m state --state RELATED,ESTABLISHED -j ACCEPT
ip6tables -A FORWARD -s {{ipv6Cidr}} -d {{ipv6Cidr}} -i wg0 -o wg0 -j ACCEPT

# 配置 IPv4 路由表(表 200 用作下游流量专用表)
iptables -t nat -A POSTROUTING -s {{ipv4Cidr}} -o $VPN -j MASQUERADE

# 添加 ip rule,让源自 {{ipv4Cidr}} 的流量走表 200
ip rule add from {{ipv4Cidr}} table 200
ip route add default via 172.32.0.4 dev $VPN table 200
ip route add 172.32.0.0/16 via 172.32.0.1 dev $VPN table 200
ip route add {{ipv4Cidr}} dev wg0 table 200

# 配置 IPv6 路由表(同理,使用表 200)
ip -6 rule add from {{ipv6Cidr}} table 200
ip -6 route add default via fd01:beee:beee::4 dev $VPN table 200
ip -6 route add fd01:beee:beee::/48 via fd01:beee:beee::1 dev $VPN table 200
ip -6 route add {{ipv6Cidr}} dev wg0 table 200

下面三个变量是 wg-easy 的模板变量,会由 wg-easy 自动填充,但用户要确保参数有效:

  • {{port}}:WireGuard 监听的端口,一般与 Docker 映射的端口(51820)一致。
  • {{ipv4Cidr}}:下游客户端网段,比如 10.8.0.0/24。由 wg-easy 分配给 Peer。
  • {{ipv6Cidr}}:下游客户端的 IPv6 段,比如 fdcc:ad94:bacf:61a4::cafe::/64

这里提供用于 PostDown 的脚本(简单将-A替换为-Dadd变成del):

VPN=$(ifconfig | grep -B1 172.32.0.8 | grep -o "^\w*")

iptables -P FORWARD DROP
ip6tables -P FORWARD DROP

iptables -D INPUT -p udp -m udp --dport {{port}} -j ACCEPT
ip6tables -D INPUT -p udp -m udp --dport {{port}} -j ACCEPT

iptables -D FORWARD -i wg0 -j ACCEPT
iptables -D FORWARD -o wg0 -m state --state RELATED,ESTABLISHED -j ACCEPT
iptables -D FORWARD -s {{ipv4Cidr}} -d {{ipv4Cidr}} -i wg0 -o wg0 -j ACCEPT

ip6tables -D FORWARD -i wg0 -j ACCEPT
ip6tables -D FORWARD -o wg0 -m state --state RELATED,ESTABLISHED -j ACCEPT
ip6tables -D FORWARD -s {{ipv6Cidr}} -d {{ipv6Cidr}} -i wg0 -o wg0 -j ACCEPT

iptables -t nat -D POSTROUTING -s {{ipv4Cidr}} -o $VPN -j MASQUERADE

ip rule del from {{ipv4Cidr}} table 200
ip route del default via 172.32.0.4 dev $VPN table 200
ip route del 172.32.0.0/16 via 172.32.0.1 dev $VPN table 200
ip route del {{ipv4Cidr}} dev wg0 table 200

ip -6 rule del from {{ipv6Cidr}} table 200
ip -6 route del default via fd01:beee:beee::4 dev $VPN table 200
ip -6 route del fd01:beee:beee::/48 via fd01:beee:beee::1 dev $VPN table 200
ip -6 route del {{ipv6Cidr}} dev wg0 table 200

使用多张Docker网桥

在我的环境中,wg-easy 会连接到多张Docker网桥,也就是除了 vpn 之外,还有其他服务专用的网桥。如果你需要为 wg-easy 再接入新的桥接网络,建议保留上面配置中 $VPN 变量而不是直接wg-easy提供的 {{device}}模板变量。因为通过 $VPN 变量可以自动绑定对应网段对应的网卡,确保路由和防火墙规则只作用在指定的接口上,而不会误伤其他网络服务。对于第二个Docker网桥,你可以在PostUp中添加如下命令:

VPN_IF=$(ifconfig | grep -B1 172.32.0.8 | grep -o "^\w*")

需要注意将172.32.0.8替换为 对应Docker网桥上的 wg-easy 容器的 IP,这个需要你固定 wg-easy在该Docker 网桥中的IP。或者,你可以调整一下这个正则匹配,让grep筛选网段而非IP地址。这样,$VPN_IF 将解析为对应的网段正在使用的网络接口。然后,请同时更新路由表和防火墙规则,保证 各个服务网络之间的流量隔离与正常转发:

iptables -t nat -A POSTROUTING -s <NETWORK_CIDR> -o <NETWORK_INTERFACE> -j MASQUERADE
ip6tables -t nat -A POSTROUTING -s <NETWORK_CIDR> -o <NETWORK_INTERFACE> -j MASQUERADE  # 如果此桥有 IPv6
ip route add <NETWORK_CIDR> via <NETWORK_GATEWAY> dev <NETWORK_INTERFACE> table 200
ip -6 route add <NETWORK_CIDR> via <NETWORK_GATEWAY> dev <NETWORK_INTERFACE> table 200  # 如果此桥有 IPv6

对于需要给多个容器提供VPN服务的但又不希望直接给wg-easy添加多个Docker网桥的读者。这里提供一种更精细可控的做法:将那些需要走 VPN 的其他服务分别绑定到独立的 WireGuard 客户端容器(如 linuxserver/wireguard 或类似 Gluetun 的镜像)中,然后通过wg-easy(或类似管理界面)为每个服务创建单独的 WireGuard 账户。最后将对应的配置导入到 服务对应的WireGuard 客户端容器中。这样做的好处在于:

  • 每个服务拥有独立的 WireGuard 配置文件,流量隔离更彻底;
  • 可以在 wg-easy 中更直观地监控、限制单个服务的网络访问;
  • 不再需要编写诸如 iptables -A FORWARD -i eth+ -o tun0 这类对某个网络接口一概放行的规则,而是仅对特定容器进行信任授权。

这种方式能够为每个容器提供更细粒度的权限控制,例如实时流量监控、随时撤销某个服务的 VPN 权限等等。不过,如果运行多个 WireGuard 客户端容器会带来额外的 CPU 和内存消耗。

部署

创建并加载环境变量

在与 docker-compose.yml 同目录下,创建一个 .env 文件,写入(参考前面给出的 gluetun 文档):

VPN_SERVICE_PROVIDER=XXX
VPN_TYPE=XXXX
WIREGUARD_PRIVATE_KEY=YOUR_PRIVATE_KEY
WIREGUARD_PRESHARED_KEY=YOUR_PSK
WIREGUARD_ADDRESSES=XXX
SERVER_REGIONS=XXX

确认 Docker 网络存在

如前文所述,确保 名为 vpn 的自定义网络已创建。

启动容器

执行下面的命令,观察 wg-easy 与 gluetun-ws 容器启动日志,确认 WireGuard 客户端与服务端都正常建立。

$ docker-compose up -d

添加 WireGuard 客户端

  • 通过浏览器访问 http://<WG-Easy容器IP>:51821(取决于 wg-easy 配置),进入 wg-easy 的 Web 界面。或者你可以在 Docker Compose 中加入对于 51821 端口的 TCP 映射,然后用 http://<本机IP>:51821 访问 Web 管理界面。或者用 Nginx 进行反向代理也行。
  • 在 Admin Panel 的 Hooks 栏,填写上面给出的的 Post Up 和 Post Down 配置,并保存。这一步特别关键。
  • 创建一个新的 Peer(填写 Peer 名称),wg-easy 会自动生成对应的配置文件,带有 AllowedIPs = 0.0.0.0/0, ::/0,并下载 .conf 文件。
  • 在本地设备(Windows/Mac/Linux/手机)上导入该配置,启动 WireGuard,进行测试。

验证 VPN2VPN 通道

  • 在客户端连上 WireGuard 后,打开 https://ipleak.nethttps://ifconfig.co,查看出口 IP。
  • 若显示的是上游商业 VPN 的 IP,则说明链式 VPN 通道正常。
  • 可在 gluetun 容器中执行 curl ifconfig.co,应该能看到商业 VPN 给的公网 IP;在 wg-easy 容器中执行同样操作,也能看到相同的出口 IP(走上游 VPN)。

检查流量路由

  • 在 wg-easy 容器中,可执行 ip rule show / ip route show table 200,确认路由表 200 已生效,默认路由指向 172.32.0.4(gluetun)。
  • 在 gluetun 容器中,执行 iptables -t nat -L -nv,可看到对应的 MASQUERADE 规则。
  • 如需排查,可在两容器内分别使用 tcpdump -i 对应接口进行抓包,确认流量状态。

注意事项

DNS 泄露

本例如果不做额外的 DNS 安全措施,就存在 DNS 泄露的风险:当容器或客户端直接使用宿主机或公共 DNS 时,DNS 查询可能绕过 VPN 通道而直连上游,暴露真实 IP 和访问的网站记录;其实gluetun在自己内部会创建一个DNS服务器,然后强制所有的DNS流量走这个服务,来避免使用本地DNS造成DNS泄露。但 gluetun 默认只在容器内部监听 DNS 访问,外部容器或主机无法访问。这也让wg-easy无法直接使用gluetun内部的DNS服务,所以我们不能简单把wg-easy中的DNS流量直接转发到gluetun。

为了避免这些问题,通常需要部署一个独立的 DNS 服务(如 AdGuard Home 或 Pi-hole),并将所有 DNS 查询都通过该服务处理,通过 VPN 出口进行上游解析,这样既能保证 DNS 查询也走 VPN,又能实现广告拦截和恶意域名过滤。

部署 AdGuard Home 或类似的 DNS 服务

我们可以在同一个 Docker 网络(例如 vpn 网络)中运行一个独立的 AdGuard Home 容器。AdGuard Home 会同时监听 UDP/TCP 53 端口,并且具有更强的拦截广告、恶意域名过滤等功能。举例来说,你可以在 docker-compose.yml 中加入类似以下服务(仅示范):

adguard:
  image: adguard/adguardhome
  restart: unless-stopped
  networks:
    vpn:
      ipv4_address: 172.32.0.16
      ipv6_address: "fd01:beee:beee::16"
  ports:
    - "53:53/udp"
    - "53:53/tcp"
    - "3000:3000/tcp"  # Web 界面端口
  volumes:
    - /data/adguard/workdir:/opt/adguardhome/work
    - /data/adguard/conf:/opt/adguardhome/conf

这样,AdGuard Home 会暴露在宿主机的 53 端口,同时在 Docker vpn 网络内分配一个 IP(比如 172.32.0.5)。后续我们就能够从 wg-easy、客户机甚至其他容器直接访问这个 AdGuard 容器。你也可以选择不暴露53端口,仅仅通过vpn这个网桥提供DNS服务。

访问 AdGuard 的 Web 界面(通常在 http://<Host-IP>:3000),确认上游 DNS 设置正确,广告拦截等策略正常。然后你需要进行测试,最好在WG-Easy容器中执行nslookup hostname 172.32.0.16,测试 wg-easy到AdGuard Home的DNS链路是否是通的。

修改DNS配置

在Adgaurd部署、配置并测试成功后,我们需要调整在 wg-easy 生成的客户端DNS配置,因为 wg-easy默认会把 DNS 服务器设置为1.1.1.12606:4700:4700::111。这一步需要在 wg-easy的Admin Panel把 DNS 改为 AdGuard 容器的 IP:172.32.0.16fd01:beee:beee::16

image-20250603173359127

需要注意:已经添加的设备的DNS配置并不会跟随Admin Panel中的DNS配置自动进行修改,需要你手动更新设备中WireGuard里的DNS配置,或者在WG-Easy中对这个设备单独进行调整然后在重新下载并应用配置文件到设备。

最后,为了严格限制WG-Easy对外的DNS访问,杜绝DNS泄露。我们可以在PostUp Hook脚本中补充如下指令:

# 允许向 adgaurd 的 DNS 端口发包
iptables -A OUTPUT -o $VPN -d 172.32.0.16 -p udp --dport 53 -j ACCEPT
iptables -A OUTPUT -o $VPN -d 172.32.0.16 -p tcp --dport 53 -j ACCEPT
ip6tables -A OUTPUT -o $VPN -d fd01:beee:beee::16 -p udp --dport 53 -j ACCEPT
ip6tables -A OUTPUT -o $VPN -d fd01:beee:beee::16 -p tcp --dport 53 -j ACCEPT

# 拒绝其它网络栈的 DNS 查询,避免漏出到宿主机
iptables -A OUTPUT -p tcp --dport 53 -j REJECT
iptables -A OUTPUT -p udp --dport 53 -j REJECT
ip6tables -A OUTPUT -p tcp --dport 53 -j REJECT
ip6tables -A OUTPUT -p udp --dport 53 -j REJECT

其对应的PostDown脚本如下:

iptables -D OUTPUT -o $VPN -d 172.32.0.16 -p udp --dport 53 -j ACCEPT
iptables -D OUTPUT -o $VPN -d 172.32.0.16 -p tcp --dport 53 -j ACCEPT
ip6tables -D OUTPUT -o $VPN -d fd01:beee:beee::16 -p udp --dport 53 -j ACCEPT
ip6tables -D OUTPUT -o $VPN -d fd01:beee:beee::16 -p tcp --dport 53 -j ACCEPT

iptables -D OUTPUT -p tcp --dport 53 -j REJECT
iptables -D OUTPUT -p udp --dport 53 -j REJECT
ip6tables -D OUTPUT -p tcp --dport 53 -j REJECT
ip6tables -D OUTPUT -p udp --dport 53 -j REJECT

IPv6 支持

  • 如不使用 IPv6,可在 docker-compose.yml 中将 ipv6_address、ip6tables 规则等相关配置移除,并在创建网络时不启用 IPv6。
  • 如果使用 IPv6,需要确保宿主机与 VPN 服务商支持 IPv6 隧道。

性能与资源

WireGuard 本身对 CPU 要求较高,特别在满速传输时,需要较好的硬件支持。

Multi-Hop(多跳)

本架构即为双跳(下游 → wg-easy → gluetun → 上游 VPN → 互联网)。如果想再进一步配置多跳,可在上游 VPN 选择不同区域,或再叠加一个 VPN 容器。但需注意性能下降与维护复杂度上升。

防火墙与安全

本例中,wg-easy 容器默认仅开放 UDP 51820 端口,如需在公网访问,请在宿主机防火墙(iptables/nftables)开放对应端口,同时只允许可信 IP 访问,以减少暴露面。

PostDown 脚本

为避免卸载后遗留规则,务必为 wg-easy 的 PostDown 添加相应的 iptables -D、ip rule del、ip route del 等操作。否则重启时可能产生重复路由或冲突。

网络排查示例

当发现客户端无法访问 LAN 或 Internet 时,我在这里提供一些常用步骤快速定位问题的方案。这个方案也是我平时常用的。我相信通过这种由粗到细的思路,就能有效找出大多数网络连接故障的根源。

下面给出测试方案的具体的步骤,但这里仅仅展示在IPv4下的排查指令,对于IPv6,读者可以很容易通过对指令进行微调来排查。

分阶段 Ping 测试(本地)

  • ping 10.8.0.1:确认 VPN 隧道是否建立。
  • ping 172.32.0.8:检查客户端是否能到达 wg-easy 容器(Docker 桥接网络)。
  • ping 172.32.0.4:验证桥接网络到 gluetun 容器的连通性。
  • ping 8.8.8.8:测试 gluetun 是否能正常访问外网。

如果某步失败,就有针对性地检查对应的路由或防火墙规则。

DNS 解析验证(本地)

通过上面的测试后,说明IP层面链路没有问题,接下来我们就继续排查DNS的问题。

  • nslookup example.com :通过 默认DNS服务器 。
  • nslookup example.com 172.32.0.16 :通过 adgaurd 。
  • nslookup example.com 8.8.8.8 :测试公共 DNS 是否可用。

若无法得到正确响应,说明 DNS 请求没有到达预期的服务器。

进入容器内部排查

如果DNS也没有问题,那可能是一些更深层次的路由表或者防火墙规则的冲突问题。我们可以分别进入wg-easy或者gluetun容器进行调试:

docker exec -it <container-name/id-of-wg-easy> /bin/bash
docker exec -it <container-name/id-of-gluetun> /bin/sh

在对应容器内执行:

ip rule show             # 查看路由规则是否正确
ip route show table 200  # 验证路由表 200
ping -c 2 8.8.8.8        # 测试容器出网
ping -c 2 example.com    # 测试容器内 DNS
iptables-saves           # 检查防火墙规则
ip route                 # 验证默认路由表

更高阶方法:tcpdump、路由表与防火墙检查

如果上述简单手段仍无法定位,那就说明存在一些更深的路由表或者防火墙配置冲突问题,这个时候可使用 tcpdump -i <接口> 捕获数据包,观察 DNS(UDP/TCP 53)或其他流量是否到达容器或被丢弃。然后,请仔细检查宿主机和容器内的路由表(ip rule show、ip route show)是否存在重叠或冲突项;确认 ip rule 的优先级和表表项是否生效。再次审视 iptables/ip6tables 规则,尤其是 FORWARD、OUTPUT、nat 表,确认是否有误拒绝或重复的规则导致流量被拦截。

更新造成的问题

需要注意,你遇到的问题也很有可能是某一个容器的镜像更新后造成的问题,这个时候需要检查是否最近有自动更新,然后尝试通过版本降级该解决。此时如果遇到的问题消失,那么接下来,在问题最终解决之前,可以先固定旧的镜像版本确保服务可用。