建站(九): 为 Docker WordPress 站点启用 IPv6

29次阅读
没有评论

共计 9263 个字符,预计需要花费 24 分钟才能阅读完成。

本文记录在已有 Docker WordPress 架构(Nginx 反代 + WordPress + MariaDB + phpMyAdmin)基础上启用 IPv6 双栈监听的全过程:conf.d 中每个站点配置需要添加哪些指令、Docker 层需要做哪些配套改造、DNS 与防火墙如何放行,以及一路上会踩的坑。


变更地图:先看清要改哪里

文件 需要添加的内容
conf.d/default.conf 3 处:HTTP 默认 server 加 listen [::]:80 default_server;,HTTPS 默认 server 加 listen [::]:443 ssl default_server;
conf.d/abc.com.conf 3 个 server 块各加 v6 listen;主站额外加 listen [::]:443 quic reuseport;
conf.d/pma.abc.com.conf 2 个 server 块各加 v6 listen(不需要 quic 行)
nginx.conf 无需必改resolver ... ipv6=off 保留);可选加固见第五节
docker-compose.yml ports 增加 [::]: 绑定;web 网络启用 IPv6
Docker daemon daemon.json 启用 ipv6 + ip6tables
DNS / 防火墙 AAAA 记录;ip6tables / 云安全组放行 v6

核心结论一句话: conf.d 里要做的全部事情,就是给每个 listen 指令补一条对应的 listen [::]:...,并在主站补一条 v6 的 QUIC 监听;其余工作都在 Docker、DNS、防火墙层。


一、原理:双栈监听与 ipv6only

1.1 为什么加了 [::] 还要保留原来的 listen

Nginx 的 IPv6 监听 socket 默认带 ipv6only=on(Nginx 主动设置),即:

listen 443 ssl;          → 创建 IPv4 socket,绑定 0.0.0.0:443
listen [::]:443 ssl;     → 创建 IPv6 socket,仅绑定 [::]:443(不收 IPv4)

两者是两个独立的 socket,互不替代。只写 [::] 会丢掉 IPv4 用户;只写 IPv4 则 IPv6 用户连不上。所以是”补一行”,不是”改一行”。

1.2 QUIC 的 v6 监听与 reuseport

回顾讨论四的结论:reuseport同一个 address:port 只能声明一次。而 0.0.0.0:443/udp[::]:443/udp不同的 address,因此:

listen 443 quic reuseport;        # IPv4 UDP socket,声明一次 ✅
listen [::]:443 quic reuseport;   # IPv6 UDP socket,再声明一次 ✅(不冲突)

但和之前一样,这两行只能写在主站 abc.com 的 server 块里,子站 pma.abc.com 依然不写——v6 的 QUIC socket 同样按 SNI 路由到所有 server 块。

1.3 default_server 必须 v4/v6 各一个

这是最容易漏的一点:如果只给 IPv4 加了 default_server,那么未知 SNI 的 IPv6 请求会匹配到第一个 listen [::]:443 ssl 的 server 块(即你的主站),导致证书和站点内容通过非法域名泄露。v6 的兜底拒绝必须显式声明。


二、default.conf 完整配置(IPv6 版)

# ─── HTTP 默认 server:未知 Host 一律拒绝 ───
# 只允许 /nginx-health 和 ACME HTTP-01 challenge。
server {
    listen 80 default_server;
    listen [::]:80 default_server;        # ★ IPv6 兜底
    server_name _;

    # Docker 健康检查
    location = /nginx-health {
        access_log off;
        default_type text/plain;
        return 200 "ok\n";
    }

    # 未知 Host 直接拒绝,避免开放跳转。
    # IPv6 字面量 Host(如 [2408:4003::1])不匹配任何 server_name,
    # 同样落入这里返回 403。
    location / {
        return 403;
    }
}

# ─── HTTPS 默认 server:未知 SNI 直接拒绝握手 ───
server {
    listen 443 ssl default_server;
    listen [::]:443 ssl default_server;   # ★ IPv6 兜底(关键!)
    server_name _;

    # Nginx >= 1.19.4
    ssl_reject_handshake on;
}

# ─── 显式禁止通过公网 IP 访问 HTTP ───
server {
    listen 80;
    server_name 服务器公网IP;              # 替换为你的 IPv4
    location / {
        return 403;
    }
}
# 说明:IPv6 公网 IP 直连无需单独 server 块——
# Host 为 [2408:xxxx::1] 的请求不匹配任何 server_name,
# 自动落入上面的 default_server 返回 403;
# HTTPS 侧 SNI 为 IP 字面量时直接被 ssl_reject_handshake 拒绝。

三、abc.com.conf 完整配置(IPv6 版)

# ─── HTTP → HTTPS 跳转 ───
server {
    listen 80;
    listen [::]:80;                       # ★ IPv6
    server_name abc.com www.abc.com;

    location / {
        return 301 https://abc.com$request_uri;
    }
}

# ─── www → 主域跳转(HTTPS) ───
server {
    listen 443 ssl;
    listen [::]:443 ssl;                  # ★ IPv6
    http2 on;
    server_name www.abc.com;

    ssl_certificate     /etc/ssl/fullchain.cer;
    ssl_certificate_key /etc/ssl/abc.com.key;

    add_header Strict-Transport-Security "max-age=63072000; includeSubDomains; preload" always;
    add_header X-Content-Type-Options "nosniff" always;
    add_header Referrer-Policy "strict-origin-when-cross-origin" always;

    location / {
        return 301 https://abc.com$request_uri;
    }
}

# ─── 主站:abc.com ───
server {
    listen 443 ssl;
    listen [::]:443 ssl;                  # ★ IPv6 TCP

    # HTTP/3 / QUIC:v4 与 v6 各声明一次 reuseport(仅主站写)
    listen 443 quic reuseport;
    listen [::]:443 quic reuseport;       # ★ IPv6 UDP
    http2 on;
    server_name abc.com;

    ssl_certificate     /etc/ssl/fullchain.cer;
    ssl_certificate_key /etc/ssl/abc.com.key;

    # OCSP Stapling 保持注释(原因同前文:证书无 OCSP URL,
    # 且额外 resolver 会干扰 Docker 内置 DNS)
    # ssl_stapling on;
    # ssl_stapling_verify on;
    # ssl_trusted_certificate /etc/ssl/fullchain.cer;

    # 安全响应头
    add_header Strict-Transport-Security "max-age=63072000; includeSubDomains; preload" always;
    add_header X-Content-Type-Options "nosniff" always;
    add_header X-Frame-Options "SAMEORIGIN" always;
    add_header Referrer-Policy "strict-origin-when-cross-origin" always;
    add_header Permissions-Policy "geolocation=(), microphone=(), camera=()" always;
    add_header Alt-Svc 'h3=":443"; ma=86400' always;

    # 上传大小需与 PHP upload.ini 一致
    client_max_body_size 100m;

    # 健康检查
    location = /nginx-health {
        access_log off;
        default_type text/plain;
        return 200 "ok\n";
    }

    # 禁止 XML-RPC
    location = /xmlrpc.php {
        return 444;
    }

    # 禁止访问 WordPress 敏感文件
    location ~* /(wp-config\.php|readme\.html|license\.txt|wp-config-sample\.php)$ {
        return 404;
    }

    # 禁止访问隐藏文件和敏感后缀
    location ~* /\.(env|git|svn|htaccess|htpasswd|ini|log|sql|bak|old|save|swp)$ {
        return 444;
    }
    location ~ /\. {
        return 404;
    }

    # 禁止 uploads 目录执行 PHP
    location ~* /wp-content/uploads/.*\.php$ {
        return 403;
    }

    # WordPress 登录限流
    location = /wp-login.php {
        limit_req zone=login burst=5 nodelay;
        limit_conn addr 8;
        include /etc/nginx/snippets/proxy-wordpress.conf;
    }

    # admin-ajax 限流
    location = /wp-admin/admin-ajax.php {
        limit_req zone=general burst=50 nodelay;
        limit_conn addr 30;
        include /etc/nginx/snippets/proxy-wordpress.conf;
    }

    # 主站请求
    location / {
        limit_req zone=general burst=100 nodelay;
        limit_conn addr 100;
        include /etc/nginx/snippets/proxy-wordpress.conf;
    }
}

四、pma.abc.com.conf 完整配置(IPv6 版)

# ─── phpMyAdmin HTTP 跳转到 HTTPS ───
server {
    listen 80;
    listen [::]:80;                       # ★ IPv6
    server_name pma.abc.com;

    location / {
        return 301 https://pma.abc.com$request_uri;
    }
}

# ─── phpMyAdmin HTTPS ───
server {
    # 同讨论四:QUIC/reuseport 只需主站声明一次,
    # v6 的 QUIC socket 同样按 SNI 自动路由到本 server 块,此处不写。
    listen 443 ssl;
    listen [::]:443 ssl;                  # ★ IPv6
    http2 on;
    server_name pma.abc.com;

    ssl_certificate     /etc/ssl/fullchain.cer;
    ssl_certificate_key /etc/ssl/abc.com.key;

    # 管理后台安全头
    add_header X-Robots-Tag "noindex, nofollow" always;
    add_header X-Content-Type-Options "nosniff" always;
    add_header X-Frame-Options "DENY" always;
    add_header Referrer-Policy "no-referrer" always;
    add_header Strict-Transport-Security "max-age=63072000" always;

    # 访问控制(按需启用)。启用 IPv6 后白名单要同时写 v4 和 v6:
    # allow 127.0.0.1;
    # allow ::1;                          # ★ IPv6 本机
    # allow 203.0.113.10;                 # 你的固定 IPv4
    # allow 2408:4003:10ce::abcd;         # 你的固定 IPv6
    # deny all;

    # Basic Auth(与白名单叠加使用):
    # auth_basic "DB Admin";
    # auth_basic_user_file /etc/nginx/htpasswd/dbadmin;

    client_max_body_size 100m;

    location / {
        # 不要对 phpMyAdmin 启用 limit_req(静态资源会被拦截导致空白页)
        limit_conn addr 5;

        proxy_pass http://phpmyadmin_backend;
        proxy_http_version 1.1;

        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;
        proxy_set_header X-Forwarded-Host $host;
        proxy_set_header X-Forwarded-Port $server_port;

        proxy_set_header Upgrade $http_upgrade;
        proxy_set_header Connection $connection_upgrade;

        proxy_redirect off;

        proxy_connect_timeout 10s;
        proxy_send_timeout 60s;
        proxy_read_timeout 60s;
    }

    location ~ /\. {
        return 404;
    }
}

五、nginx.conf 需要改吗?

没有必改项,但有三处与 IPv6 相关的点需要理解:

5.1 resolver ... ipv6=off 保留不动

resolver 127.0.0.11 valid=10s ipv6=off;

这条只影响”运行时变量解析”(本方案未使用)。即使未来启用,ipv6=off 也是对的:容器间通信走 Docker 网络的 IPv4 地址最稳定,强制 AAAA 反而可能因 v6 路由未就绪而失败。对外双栈、对内纯 v4 是容器环境的最佳实践。

5.2 限流 zone 对 IPv6 天然兼容,但有一个语义陷阱

limit_req_zone $binary_remote_addr 对 IPv6 存 16 字节,zone 容量足够,无需修改。但要注意:

家庭/企业 IPv6 用户通常持有整个 /64 前缀,攻击者可以无限轮换地址绕过”按 /128 限流”。

可选加固:用 map 把 v6 地址截断到 /64 作为限流 key:

# nginx.conf http 块内(可选)
map $remote_addr $limit_key {
    default $binary_remote_addr;
    # 提取 IPv6 前四组(/64 前缀)作为 key
    "~^(?P<v6pfx>[0-9a-fA-F]{1,4}:[0-9a-fA-F]{1,4}:[0-9a-fA-F]{1,4}:[0-9a-fA-F]{1,4}):" $v6pfx;
}

limit_req_zone $limit_key zone=general:20m rate=50r/s;
limit_req_zone $limit_key zone=login:10m rate=10r/m;
limit_conn_zone $limit_key zone=addr:20m;

IPv4 地址不匹配正则,走 default 分支,行为不变。

5.3 若未来套 CDN,real_ip 要补 v6 段

# set_real_ip_from 173.245.48.0/20;
# set_real_ip_from 2400:cb00::/32;      # ★ Cloudflare IPv6 段示例
# set_real_ip_from 2606:4700::/32;
# set_real_ip_from 2803:f800::/32;
# real_ip_header CF-Connecting-IP;

六、让 IPv6 流量真正到达容器:Docker 层改造

只在 conf.d 加 listen 是不够的。 容器在独立网络命名空间里,IPv6 流量要经过 Docker 的发布与 NAT 链才能进来。

6.1 Docker daemon 启用 IPv6(Engine ≥ 26 推荐)

sudo mkdir -p /etc/docker
sudo tee /etc/docker/daemon.json <<'EOF'
{
  "ipv6": true,
  "fixed-cidr-v6": "fd00:d0c4:0001::/48",
  "ip6tables": true,
  "log-driver": "json-file",
  "log-opts": { "max-size": "10m", "max-file": "3" }
}
EOF

sudo systemctl restart docker

ip6tables: true 是关键:它让发布的端口走 ip6tables DNAT(保留客户端真实源 IPv6)。若不启用,IPv6 发布端口可能回退到 userland proxy,Nginx 日志和限流里看到的将全是网关地址。

6.2 docker-compose.yml 变更

services:
  nginx:
    # ...其余不变...
    ports:
      - "80:80/tcp"
      - "[::]:80:80/tcp"        # ★ IPv6 TCP
      - "443:443/tcp"
      - "[::]:443:443/tcp"      # ★ IPv6 TCP
      - "443:443/udp"
      - "[::]:443:443/udp"      # ★ IPv6 UDP(QUIC)

networks:
  web:
    enable_ipv6: true           # ★ 让 nginx/wordpress 容器获得 v6 地址
    ipam:
      config:
        - subnet: 172.28.0.0/16          # 显式固定 v4 子网(可选)
        - subnet: fd00:c11c:0001::/64    # ULA v6 子网(入站靠 DNAT,无需公网段)
  admin:
  backend:
    internal: true

说明:

  • 只有 web 网络需要 v6(nginx 入站所在网络);backend 保持 internal 纯 v4,数据库隔离策略不变。
  • ULA(fd00::/8)子网即可:入站流量在宿主机边界被 DNAT 到容器 ULA 地址,源地址保留为客户端公网 IPv6

6.3 重载

cd /web
docker compose down
docker compose up -d
docker exec nginx nginx -t && docker exec nginx nginx -s reload

七、DNS 与防火墙

7.1 DNS:添加 AAAA 记录

abc.com.        AAAA   2408:4003:10ce::1
www.abc.com.    AAAA   2408:4003:10ce::1
pma.abc.com.     AAAA   2408:4003:10ce::1

没有 AAAA 记录,客户端根本不会发起 IPv6 连接——Nginx 配得再对也无人访问。

7.2 防火墙放行 v6

# UFW 确认 /etc/default/ufw 里 IPV6=yes
sudo ufw allow 80
sudo ufw allow 443
sudo ufw allow 443/udp

# 或裸 ip6tables
sudo ip6tables -A INPUT -p tcp -m multiport --dports 80,443 -j ACCEPT
sudo ip6tables -A INPUT -p udp --dport 443 -j ACCEPT

# 云服务器:安全组里 IPv6 规则是独立的一套,务必单独添加!

八、验证

# 1. 容器内确认双栈监听
docker exec nginx ss -tln | grep ':443'
# 期望同时看到 *:443 和 [::]:443
docker exec nginx ss -uln | grep ':443'
# 期望同时看到 v4/v6 的 UDP 443

# 2. DNS
dig AAAA abc.com +short

# 3. 从 IPv6 网络测试(本机无 v6 时用在线工具)
curl -6 -I https://abc.com
curl -6 -I https://pma.abc.com
curl --http3 -6 -I https://abc.com

# 4. 确认日志中真实客户端 IPv6
tail -1 /web/nginx/log/access.log
# 期望 $remote_addr 为 2408:xxxx::xxxx 形式,而非网关地址

# 5. 兜底验证:v6 直连 IP 应被拒
curl -6 -I http://[2408:4003:10ce::1]/      # 期望 403

在线检测:https://test-ipv6.comhttps://ipv6-test.com(填入你的域名)。


九、踩坑清单

表现 解决
漏掉 v6 default_server 非法域名的 v6 请求命中主站,证书/内容泄露 default.conf 补 listen [::]:443 ssl default_server;
子站误加 quic reuseport nginx: [emerg] duplicate "reuseport" v4/v6 的 reuseport 都只写在主站
同一族地址重复 reuseport 同上 emerg 每个 address:port:族 只声明一次
Docker 未启用 IPv6 Nginx 正常但 curl -6 超时 daemon.json + compose enable_ipv6 + [::]: ports
未启用 ip6tables 日志/限流里源 IP 全是网关地址 "ip6tables": true 后重启 Docker
云安全组只开了 v4 本机 v6 通、外部 v6 不通 安全组 IPv6 规则单独添加
只有 A 记录 客户端从不走 v6 补 AAAA
容器禁用了 IPv6 nginx: [emerg] socket() [::]:443 failed (97: Address family not supported) 检查 sysctl net.ipv6.conf.all.disable_ipv6,或移除 v6 listen
phpMyAdmin 白名单漏 v6 启用白名单后 v6 管理端 403 allow ::1; 与你的 v6 地址
误以为要改后端 给 wordpress/upstream 加 v6 不需要:对外双栈、对内纯 v4

本文内容在完整版教程 Docker WordPress 建站指南:从零到生产级部署 基础上进行讨论

正文完
 0
评论(没有评论)