跳到内容

第 4 卷 / 第 13 章 / 第 3 课

13.3 双栈、Happy Eyeballs 与 IPv6-only 过渡 ​

一台主机有 IPv6 地址,不代表应用一定会用 IPv6;域名同时有 A 和 AAAA,也不代表客户端会机械地“先 IPv6,失败三秒后 IPv4”。真实连接建立还涉及系统解析器、地址排序、并发尝试、代理和网络质量。

DNS 返回记录,不替客户端选择路径 ​

  • A record 保存 IPv4 地址;
  • AAAA record 保存 IPv6 地址;
  • PTR 反向记录中的 IPv6 nibble 使用 ip6.arpa;
  • 普通权威 DNS 通常根据查询类型回答,不会因为客户端“支持 IPv6”就自动只返回 AAAA。
bash
dig example.com A
dig example.com AAAA
getent ahosts example.com

dig 观察 DNS 协议答案;getent 更接近使用系统名称服务配置的普通应用。二者不同可能来自 /etc/hosts、NSS、缓存、split DNS、搜索域或本地 resolver。DNS 查询经 IPv4 发送仍可获得 AAAA,经 IPv6 发送也可获得 A;传输 DNS 的地址族不决定答案类型。

Python 中使用 getaddrinfo(),不要自己拼接 DNS 答案:

python
import socket

def stream_candidates(host: str, port: int):
    seen = set()
    for family, socktype, proto, _, sockaddr in socket.getaddrinfo(
        host,
        port,
        family=socket.AF_UNSPEC,
        type=socket.SOCK_STREAM,
        proto=socket.IPPROTO_TCP,
    ):
        key = (family, sockaddr)
        if key not in seen:
            seen.add(key)
            yield family, socktype, proto, sockaddr

返回顺序受到 RFC 6724 地址选择规则、平台策略和网络配置影响。应用仍须处理多个候选地址。这里仅枚举候选,getaddrinfo() 返回前可能阻塞;它本身不执行 Happy Eyeballs 的连接竞速或异步 DNS 调度。

Happy Eyeballs 不是串行 fallback ​

最简单的客户端会依次尝试地址。如果首个 IPv6 路径是黑洞,一个数秒的 connect timeout 会让用户误以为整个服务很慢。

Happy Eyeballs v2 的核心是:对 DNS 结果排序,同时以小的时间间隔安排不同地址族的连接尝试;首个成功的连接获胜,其余尝试取消。它还包含 DNS 查询、连接尝试顺序和历史偏好的细节。

text
t=0 ms     start first preferred-family attempt
t=250 ms   if no winner, start next suitable attempt
...        continue paced attempts
winner     use connection; cancel/close others

这里以首个候选可用时为相对起点,不含全部 DNS 耗时。250 ms 只是 RFC 中的建议性默认量级,不应硬编码为所有网络的最佳值。并发过大也会浪费 socket、端口和服务端资源。

Python 标准库的 socket.create_connection() 会尝试多个地址,但不要假设所有版本与平台都实现了完整 RFC 8305 竞速。逐地址 connect timeout 也不一定等于整次调用的总预算。生产客户端优先使用已经明确支持 Happy Eyeballs 的成熟 HTTP/网络库,并测试 IPv6 黑洞、慢 AAAA 和单栈环境。

分别验证 IPv4 与 IPv6 ​

bash
curl -4 --verbose --connect-timeout 3 https://example.com/
curl -6 --verbose --connect-timeout 3 https://example.com/

ip -6 route get 2001:db8::10
ping -6 -c 3 2001:db8::10
traceroute -6 -n 2001:db8::10

2001:db8::10 是文档占位地址,须换成测试目标;示例不会凭空把它变成可达主机。若环境配置了代理,curl -4/-6 可能只限制到代理的连接地址族,应先确认实际路径。curl -4/-6 是诊断对照,不表示生产应用应长期强制某个地址族。若 IPv6 失败而 IPv4 成功,继续检查 AAAA 地址、源地址选择、默认路由、NDP、ICMPv6、MTU、防火墙和回程路由。

服务端如何监听双栈 ​

IPv6 wildcard :: 是否同时接受 IPv4-mapped connections 取决于平台和 IPV6_V6ONLY。不要把一台 Linux 主机上的默认行为当成可移植契约。

Python 在支持的平台上可以显式请求 dual-stack socket。下面只接收一条 TCP 连接、发送一行文本后退出,并非 HTTP server;在测试环境运行,8080 需可用:

python
import socket

if not socket.has_dualstack_ipv6():
    raise RuntimeError("this platform does not expose a dual-stack IPv6 socket")

with socket.create_server(
    ("::", 8080),
    family=socket.AF_INET6,
    dualstack_ipv6=True,
) as server:
    server.settimeout(10)
    connection, peer = server.accept()
    with connection:
        connection.settimeout(3)
        print(peer)
        connection.sendall(b"connected\n")

IPv4 客户端在这个 IPv6 socket 上通常呈现为 ::ffff:IPv4 的 mapped address;日志和地址访问规则应统一解析,避免把同一 IPv4 对端当成两个身份。真实服务还需要请求解析、并发、deadline、上限、日志和优雅关闭。另一种更明确的方式是分别创建 IPv4 与 IPv6 listener,由服务框架管理。

三类过渡部署 ​

双栈 ​

主机和网络同时运行 IPv4/IPv6,应用通过地址选择与 Happy Eyeballs 选择可用路径。优点是两种协议都原生;代价是需要同时运营两套路由、安全策略、监控和容量。

双栈本身不需要 NAT64。只有当 IPv6-only 客户端需要访问 IPv4-only 服务时,NAT64 才进入路径。

IPv6-in-IPv4 隧道 ​

隧道把 IPv6 包封装进 IPv4,以穿过 IPv4-only 区域。它仍用于受控网络和特定产品,但 RFC 7526 废弃的是 anycast 6to4 relay 机制及相关配置,并要求 6to4 默认关闭;它明确没有废弃基本 unicast 6to4 和 2002::/16。不要把历史自动隧道当作原生 IPv6 的默认替代。

隧道会引入额外首部、MTU、可观测性和安全边界。必须明确 tunnel endpoint、有效 MTU、路由与过滤策略。

IPv6-only + DNS64/NAT64 ​

当客户端只有 IPv6,而目标只有 IPv4 时:

  1. DNS64 在通常没有可用原生 AAAA 答案时,根据 A record 合成带 NAT64 prefix 的 AAAA;
  2. 客户端向合成地址发送 IPv6;
  3. NAT64 translator 提取 IPv4 目标并维护必要的转换状态;
  4. IPv4 服务看到的是 translator 一侧的 IPv4 流量。
text
IPv6-only client
  -> synthesized AAAA under Pref64
  -> NAT64 translator
  -> IPv4-only server

IETF well-known prefix 是 64:ff9b::/96,运营者也可以使用 network-specific prefix。RFC 6052 还定义了其他允许的前缀长度,不能对任意字符串简单拼上 32 位 IPv4。

下面只演示 /96 的位级嵌入,不证明地址可路由或可用于生产。RFC 6052 的 well-known prefix 不用于 RFC 1918 等非全球 IPv4 地址,私网转换要按规范选择前缀和策略:

python
import ipaddress

def embed_ipv4_in_96(prefix: str, ipv4: str) -> ipaddress.IPv6Address:
    network = ipaddress.IPv6Network(prefix)
    if network.prefixlen != 96:
        raise ValueError("this example only implements RFC 6052 /96 embedding")
    value = int(network.network_address) | int(ipaddress.IPv4Address(ipv4))
    return ipaddress.IPv6Address(value)

assert str(embed_ipv4_in_96("64:ff9b::/96", "192.0.2.33")) == \
    "64:ff9b::c000:221"

NAT64 不是对所有应用透明:IPv4 literal、在 payload 中嵌入地址、某些非 TCP/UDP 协议、地址族相关 API 和 DNSSEC 验证边界都可能需要额外处理。464XLAT 再加入客户端侧的无状态 CLAT,把 IPv4-only 应用发出的 IPv4 转成 IPv6,运营者侧 PLAT 再执行有状态 NAT64。这样某些 IPv4 literal/API 场景也可工作,无须假定应用先获得 DNS64 答案。

DNSSEC 与 DNS64 的信任边界 ​

DNS64 合成的 AAAA 没有该权威区对它的原始签名。可由受信任的验证解析器先校验原始 DNS 数据再合成,客户端依赖该验证边界;客户端若要独立验证,就需要获得原始数据并在验证后自行执行兼容的合成流程。不能把合成 AAAA 冒充权威签名记录,也不能把关闭验证当作修复。原生 AAAA 若存在但对应 IPv6 路径坏了,普通 DNS64 也不会自动改用 A 来解决连接故障。

IPv6 不需要 NAT,也不等于不需要防火墙 ​

global unicast address 只是可路由地址类型。是否真正从公网可达仍由路由、stateful firewall、ACL、主机监听地址、云安全组和应用认证共同决定。

安全基线应至少覆盖:

  • IPv4 与 IPv6 等价的入站/出站策略;
  • 必要 ICMPv6,而不是全部屏蔽;
  • link-local、ULA、global 等不同作用域;
  • rogue RA/NDP 风险;
  • 临时地址对日志、资产与访问控制的影响;
  • IPv6 隧道与未受管地址是否绕过既有监控。

庞大的 /64 使顺序穷举低效,但攻击者可以从 DNS、证书透明度日志、服务扫描命中、固定 IID 和应用泄漏中发现活跃地址。地址空间大不是访问控制。

上线前验证矩阵 ​

场景必测项目
IPv4-only clientA record、IPv4 listener、TLS/HTTP、回程
IPv6-only clientAAAA 或 DNS64、IPv6 route、NDP、PMTUD、TLS/HTTP
dual-stack client地址排序、Happy Eyeballs、单族故障时的回退延迟
IPv6-only → IPv4-onlyPref64、DNS64 synthesis、NAT64 session、literal 与 DNSSEC 边界
load balancer/CDNA/AAAA 后端一致性、健康检查协议族、日志中的真实地址

故障演练至少包括:坏 AAAA、IPv6 静默丢包、ICMPv6 Packet Too Big 被误拦、IPv4 正常而 IPv6 后端未发布,以及 NAT64 容量耗尽。

本课小结 ​

双栈的难点不是多一类地址,而是两条可能独立失败的路径。Happy Eyeballs 用节奏受控的竞速降低单一路径故障的用户等待;IPv6-only 网络则可通过 DNS64/NAT64 访问 IPv4-only 服务,但翻译边界必须被明确测试和监控。

规范入口 ​

Built with VitePress | Software Systems Atlas