「网站明明支持 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" 报文。流程是:
- 服务器发出一个 1500 字节的包,路径上某一段 MTU 只有 1280(比如 PPPoE、VPN 隧道、某些隧道代理)。
- 那个路由器丢弃这个包,并回一个 ICMPv6 Type 2 "Packet Too Big",告知 MTU=1280。
- 服务器收到后,把 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 类型:
| 类型 | 名称 | 封掉的后果 |
|---|---|---|
| 1 | Destination Unreachable | 连接失败时不回错误,客户端挂死到超时 |
| 2 | Packet Too Big | PMTU 黑洞,大包全部丢弃 |
| 3 | Time Exceeded | traceroute 失效,故障无法定位 |
| 4 | Parameter Problem | 扩展头处理失败无法报告 |
| 133-134 | Router Solicitation / Advertisement | SLAAC 地址自动配置失效 |
| 135-136 | Neighbor 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:80Linux 默认是 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 黑洞仍会持续一小时。
推荐的发布顺序:
- 服务器侧 IPv6 完全就绪,从至少三个外部网络(不同运营商、不同 AS)用
curl -6验证通过。 - AAAA 记录的 TTL 先设 60,观察 24~48 小时。
- 确认无误后再把 TTL 提到 600 或 3600。
- 如需下线 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 从多个网络验证自己的域名。
一份可执行的排查流程
把上面所有内容压成一个顺序流程,出问题时按这个顺序走,不要跳步:
- 确认本地有 IPv6:
ip -6 addr有公网地址、ip -6 route有默认路由。 - 确认能出网:
ping6到公网任一地址。 - 确认 TCP 可握手:
curl -6 -v https://你的域名,看是否卡在Trying ...而不报错。卡住 = 黑洞;立即 RST = 端口没开或被拒。 - 确认 MTU:二分法 ping6 找丢包边界,异常则钳 MSS 到 1240。
- 确认 ICMPv6 放行:防火墙里 Type 1/2/3/4/133-136 必须 accept。
- 确认 DNS:多公共 DNS 查询 AAAA 是否一致,TTL 是否合理。
- 确认日志格式:v4-mapped 地址是否影响分析与封禁。
这个流程里最值得记住的一条结论是:「IPv6 ping 得通」几乎证明不了任何事。ICMPv6 与 TCP 走的是不同的过滤策略,而 MTU 问题只在特定包长下暴露。真正能作为证据的只有一件事——从外部网络用一个超过 1280 字节的 HTTPS 响应体,完整地取回来。
# 最终验证:取一个确定大于 1280 字节的响应,并校验内容完整
curl -6 -sS https://www.example.com/ | wc -c
# 数值合理(几 KB 以上)说明大包路径正常
# 若 curl 挂住或返回 0,回到第 4 步查 MTUIPv6 的坑基本都集中在「沉默失败」这四个字上:它不报错,只是不工作,而且往往只在特定网络、特定包长、特定客户端上不工作。对付沉默失败唯一有效的办法就是把每一层的证据都显式取出来,而不是凭一个 ping 的结果推断全局。