网站 IPv6 可达性排查实战:AAAA 记录、PMTU 黑洞、ICMPv6 放行与 Happy Eyeballs 降级设计

「网站明明支持 IPv6,为什么一部分用户打不开?」这个问题比它听起来更常见。运营一个站点,如果服务器加了 AAAA 记录、Nginx 也监听了 [::]:443,看起来 IPv6 就绪了——但对一部分访问者来说,站点直接从可用变成超时。而在你的服务器日志里,这些用户根本不存在,因为他们连第一个数据包都没送达到你。

这是 IPv6 部署里最反直觉的一类故障:故障发生在客户端网络,症状表现为你的站点「随机地慢」或「随机地打不开」,而你作为网站方看不到任何错误。本文按「先建立可达性证据 → 再分层定位 → 最后设计降级策略」的顺序走一遍,重点讲清 Happy Eyeballs、PMTU 黑洞、ICMPv6 被防火墙吞掉这三个真正卡人的机制。

第一步:先确认 IPv6 到底通了没有

不要凭「ping6 能通」就下结论。ping 走的是 ICMPv6,而 HTTP 走 TCP,两者在防火墙策略上经常被区别对待。按下面四层逐步验证:

# 第 1 层:有没有 IPv6 地址
ip -6 addr show scope global
# 期望看到 inet6 2400:xxxx:xxxx::1/64 之类的公网地址
# 如果只有 fe80:: 开头的链路本地地址,说明运营商没给公网 v6

# 第 2 层:默认路由存在吗
ip -6 route show default
# 期望 default via fe80::1 dev eth0 metric 1024

# 第 3 层:能不能出网(DNS 走 UDP,能验证基础连通性)
dig +short AAAA www.zz1984.com @2606:4700:4700::1111

# 第 4 层:TCP 能不能握手(这才是 HTTP 的真实前提)
curl -6 -sS -o /dev/null -w "%{http_code} %{time_connect}\n" https://www.zz1984.com/
# 输出 200 0.08 才算真正通

这四层里任何一层断了,问题就定位在那一层。最常见的断层出现在第 2 层和第 4 层之间:地址有了、路由有了、ping 也通,但 TCP 连不上。原因通常是上游防火墙或云安全组没有放行 IPv6 的 443 端口——很多云厂商的「安全组」规则是 IPv4 和 IPv6 分开配的,加了 IPv4 的 80/443 规则不代表 IPv6 也放行了。

Happy Eyeballs:为什么「部分用户打不开」

现代浏览器同时拿到 A 和 AAAA 记录时,不会傻等 IPv6。它执行的是 RFC 8305 定义的 Happy Eyeballs 算法:先尝试 IPv6,如果在约 250 毫秒内没有建立连接,就同时发起 IPv4 连接,谁先连上就用谁。

这个机制解释了两个看起来矛盾的现象:

  • 为什么大多数用户没感觉:IPv6 断了,250 毫秒后 IPv4 接管,用户只是觉得「好像慢了半拍」。
  • 为什么少数用户完全打不开:如果客户端的 IPv6 是「黑洞」状态——数据包能发出去但永远收不到回应,连接不会立刻失败,而是一直等到 TCP 超时(Linux 默认 SYN 重试约 130 秒)。这时 Happy Eyeballs 的 250ms 回退逻辑在部分老客户端、部分 App 内置 WebView、以及大量非浏览器客户端里并不存在,它们会一直等下去。

所以「部分用户打不开」这个症状,指向的几乎一定是IPv6 黑洞,而不是 IPv6 完全不通。完全不通会立刻 RST,客户端马上回退;黑洞才会挂死。

PMTU 黑洞:IPv6 部署里最隐蔽的杀手

这是最值得单独讲的一节,因为它几乎必然出现在 IPv6 运维里,而且症状极具误导性:小请求正常,大请求卡死。

IPv6 规范取消了路由器分片(RFC 8200 规定中间节点不得分片包),路径上的 MTU 发现完全依赖 ICMPv6 的 "Packet Too Big" 报文。流程是:

  1. 服务器发出一个 1500 字节的包,路径上某一段 MTU 只有 1280(比如 PPPoE、VPN 隧道、某些隧道代理)。
  2. 那个路由器丢弃这个包,并回一个 ICMPv6 Type 2 "Packet Too Big",告知 MTU=1280。
  3. 服务器收到后,把 MSS 降到 1240,重新发送。

问题就出在第 2 步:如果 ICMPv6 被防火墙拦掉,第 3 步永远不会发生。服务器不知道要变小,于是持续重传 1500 字节的包,一次次被丢弃。表现就是:

  • 首页能打开(HTML 小)
  • 图片加载到一半卡住(超过 1280 字节的部分全丢)
  • HTTPS 握手有时也失败(ServerHello 证书链超过一个 MTU)
  • curl -6 能通,浏览器里页面残缺

这类故障的迷惑性在于它「时好时坏」——取决于请求包的大小和路径 MTU,用户在不同网络间切换时结果不同。

如何验证 PMTU 黑洞

# 1) 查本机和路径 MTU
ip -6 route get 2400:cb00::1 | grep -o 'mtu [0-9]*'
# 没有输出说明用的是默认 1500

# 2) 用 ping6 做二分法:逐级加大包体,找到丢包边界
for sz in 1200 1280 1400 1472 1500; do
  if ping6 -c1 -W2 -s $sz 2400:cb00::1 >/dev/null 2>&1; then
    echo "size $sz OK"
  else
    echo "size $sz FAIL  <-- 黑洞边界在这附近"
  fi
done
# 若 1280 OK 而 1400 FAIL,且路径实际 MTU 是 1500,
# 说明中间设备在丢弃大包但不回 ICMPv6

修法:在服务器侧主动钳制 MSS

既然依赖 ICMPv6 不可靠,干脆在服务器出方向就把 MSS 钳到最保守值。对于隧道/VPN 场景,1280-40=1240 是安全值:

# 对出方向所有 IPv6 TCP 包,把 MSS 改为 1240
ip6tables -t mangle -A OUTPUT -p tcp --tcp-flags SYN,RST SYN \
  -j TCPMSS --set-mss 1240

# 持久化(Debian/Ubuntu)
apt-get install -y iptables-persistent
netfilter-persistent save

# 若已迁移到 nftables:
nft add rule inet filter output tcp flags syn tcp option maxseg size set 1240

钳 MSS 的代价是每个包少装一点数据,吞吐略有下降,但换来的是「不会挂死」。对于走隧道或不确定路径质量的服务器,这个交换非常划算。

ICMPv6 不能全封:这是 IPv6 与 IPv4 的重要差别

在 IPv4 时代,「禁 ping 防扫描」是个流传很广的习惯,很多人把这条经验直接搬到 IPv6,结果把整个 ICMPv6 都 drop 了。这是严重错误,因为 IPv6 有大量协议必需的 ICMPv6 类型:

类型名称封掉的后果
1Destination Unreachable连接失败时不回错误,客户端挂死到超时
2Packet Too BigPMTU 黑洞,大包全部丢弃
3Time Exceededtraceroute 失效,故障无法定位
4Parameter Problem扩展头处理失败无法报告
133-134Router Solicitation / AdvertisementSLAAC 地址自动配置失效
135-136Neighbor Solicitation / Advertisement邻居发现失败,同链路无法通信

正确的最小放行策略(nftables 写法):

table inet filter {
  chain input {
    type filter hook input priority 0; policy drop;

    ct state established,related accept
    iif lo accept

    # 协议必需:除 echo-request 外全部放行
    icmpv6 type { destination-unreachable, packet-too-big,
                  time-exceeded, parameter-problem,
                  nd-router-advert, nd-neighbor-solicit,
                  nd-neighbor-advert } accept

    # 可选:允许 ping 但限速(防扫描)
    icmpv6 type echo-request limit rate 5/second accept

    tcp dport { 22, 80, 443 } accept
  }
}

注意 nd-router-advert 和邻居发现相关的类型必须放行,否则连 IPv6 地址都拿不到。这类问题在云服务器上尤其常见:一台机器重启后 IPv6 地址消失,就是因为防火墙规则在开机时先于邻居发现生效了。

服务端要监听 [::],还要处理 v4-mapped

Nginx 监听 IPv6 的正确写法:

server {
    listen [::]:80 default_server;
    listen [::]:443 ssl http2 default_server;
    # ...
}

这里有个 sysctl 层面的细节决定行为:

sysctl net.ipv6.bindv6only
# 0 = IPv6 socket 同时接受 IPv4(以 ::ffff:a.b.c.d 映射形式)
# 1 = 只接受 IPv6,需要单独 listen 0.0.0.0:80

Linux 默认是 0,这意味着只写 listen [::]:80 就能同时服务 IPv4 和 IPv6。但很多配置模板会同时写 listen 0.0.0.0:80 和 listen [::]:80,在 bindv6only=0 的情况下第二个会报 Address already in use,Nginx 启动失败。看到这个报错别急着删监听行,先确认 bindv6only 的值。

另一个坑是日志。如果只监听 IPv6 而 IPv4 通过映射进来的,$remote_addr 会记录成 ::ffff:1.2.3.4 这种形式。做日志分析、grep 某个 IP、或者用 fail2ban 做封禁时,这个前缀会让所有规则失效。解决办法是在日志格式里做归一化:

log_format main '$remote_addr - $remote_user [$time_local] '
                '"$request" $status $body_bytes_sent '
                '"$http_referer" "$http_user_agent"';

# 如果必须归一化 v4-mapped,用 map 提前处理
map $remote_addr $client_ip {
    ~^::ffff:(?<ipv4>\d+\.\d+\.\d+\.\d+)$  $ipv4;
    default                                $remote_addr;
}

这样在 log_format 里用 $client_ip 代替 $remote_addr,IPv4 客户端就恢复成普通格式,fail2ban 的正则和日志分析脚本都不用改。

DNS 层:AAAA 记录的发布节奏

如果服务器的 IPv6 还在调试,不要提前发布 AAAA 记录,或者把 AAAA 的 TTL 设得很短(60 秒)。原因是 DNS 缓存会把错误放大:一旦 AAAA 被下游递归服务器缓存 3600 秒,即使你五分钟后就修好,那一批用户的 IPv6 黑洞仍会持续一小时。

推荐的发布顺序:

  1. 服务器侧 IPv6 完全就绪,从至少三个外部网络(不同运营商、不同 AS)用 curl -6 验证通过。
  2. AAAA 记录的 TTL 先设 60,观察 24~48 小时。
  3. 确认无误后再把 TTL 提到 600 或 3600。
  4. 如需下线 IPv6,先删 AAAA 并把 TTL 降到 60,等一个 TTL 周期后再关服务器监听。

验证 AAAA 是否正确发布,别只看自己的机器:

# 从多个公共 DNS 查,确认全球生效情况
for r in 8.8.8.8 1.1.1.1 208.67.222.222 9.9.9.9; do
  echo -n "$r: "; dig +short AAAA www.example.com @$r | head -1
done

降级:给 IPv6 加一个「断路器」

最务实的方案是承认 IPv6 可能在某些网络上坏掉,并为此设计降级。具体做法有两种。

方案一:监控驱动的 AAAA 撤除

写一个定时任务,从独立的 IPv6-only 探针(比如一台只有 IPv6 出口的 VPS,或用在线检测服务)检查站点可达性。连续 N 次失败则调用 DNS API 撤掉 AAAA 记录,恢复后再加回来:

#!/bin/bash
# /usr/local/bin/v6watch.sh —— 每 5 分钟跑一次
STATE=/var/run/v6_fail_count
PROBE_URL="https://www.example.com/healthz"
THRESHOLD=3

if curl -6 -sS --max-time 10 -o /dev/null "$PROBE_URL"; then
    echo 0 > "$STATE"
    # 可选:若此前已撤除 AAAA,此处恢复(需幂等处理)
else
    n=$(( $(cat "$STATE" 2>/dev/null || echo 0) + 1 ))
    echo "$n" > "$STATE"
    if [ "$n" -ge "$THRESHOLD" ]; then
        logger -t v6watch "IPv6 unreachable x$n, withdrawing AAAA"
        # 调用 DNS 服务商 API 删除 AAAA 记录,例如:
        # curl -s -X DELETE "https://api.cloudflare.com/client/v4/zones/$ZONE/dns_records/$ID" ...
    fi
fi

这个方案的价值在于把「用户先发现」变成「监控先发现」。没有它的话,IPv6 故障平均要等几个小时才会被反馈。

方案二:CDN 代理,把 v6 问题交出去

对个人站长来说,更省事的选择是让 CDN 同时提供 v4 和 v6 入口。此时用户连的是 CDN 的边缘节点,IPv6 链路的健壮性由 CDN 负责,回源走的是 CDN 到你自己服务器的链路(通常可以选 v4-only)。这等于把「客户端最后一公里」的 IPv6 风险外包了,代价是要接受 CDN 的缓存行为和潜在成本。

但要注意:用了 CDN 不等于 IPv6 问题自动消失。CDN 边缘节点一样会遭遇 PMTU 黑洞,只不过它处理这类问题的经验比你丰富,且通常会做正确的 MSS 钳制。你仍然应该定期用 curl -6 从多个网络验证自己的域名。

一份可执行的排查流程

把上面所有内容压成一个顺序流程,出问题时按这个顺序走,不要跳步:

  1. 确认本地有 IPv6:ip -6 addr 有公网地址、ip -6 route 有默认路由。
  2. 确认能出网:ping6 到公网任一地址。
  3. 确认 TCP 可握手:curl -6 -v https://你的域名,看是否卡在 Trying ... 而不报错。卡住 = 黑洞;立即 RST = 端口没开或被拒。
  4. 确认 MTU:二分法 ping6 找丢包边界,异常则钳 MSS 到 1240。
  5. 确认 ICMPv6 放行:防火墙里 Type 1/2/3/4/133-136 必须 accept。
  6. 确认 DNS:多公共 DNS 查询 AAAA 是否一致,TTL 是否合理。
  7. 确认日志格式:v4-mapped 地址是否影响分析与封禁。

这个流程里最值得记住的一条结论是:「IPv6 ping 得通」几乎证明不了任何事。ICMPv6 与 TCP 走的是不同的过滤策略,而 MTU 问题只在特定包长下暴露。真正能作为证据的只有一件事——从外部网络用一个超过 1280 字节的 HTTPS 响应体,完整地取回来。

# 最终验证:取一个确定大于 1280 字节的响应,并校验内容完整
curl -6 -sS https://www.example.com/ | wc -c
# 数值合理(几 KB 以上)说明大包路径正常
# 若 curl 挂住或返回 0,回到第 4 步查 MTU

IPv6 的坑基本都集中在「沉默失败」这四个字上:它不报错,只是不工作,而且往往只在特定网络、特定包长、特定客户端上不工作。对付沉默失败唯一有效的办法就是把每一层的证据都显式取出来,而不是凭一个 ping 的结果推断全局。

Last modification:October 17th, 2026 at 09:25 pm

Leave a Comment