CNAME 与 A 记录顺序引发的全网 DNS 解析故障分析
一、事件概述
1. 事件背景
2026 年 1 月 8 日,Cloudflare 公共 DNS 服务 1.1.1.1 的一次例行更新意外引发了互联网范围内的 DNS 解析失败。此次故障的根本原因并非攻击或停机,而是 DNS 响应中记录顺序的微妙变化。
2. 影响范围
A. 影响用户
使用 1.1.1.1 作为 DNS 解析器的全球用户
B. 影响时长
约 1 小时 36 分钟(18:19 - 19:55 UTC)
C. 影响系统
- Linux 系统中使用 glibc getaddrinfo 的应用
- 三款 Cisco 以太网交换机的 DNSC 进程
3. 严重程度
P1 级故障(核心解析功能受损)
二、事件时间线
| 时间 | 事件描述 |
|---|---|
| 2025-12-02 | 记录重排序代码引入 1.1.1.1 代码库 |
| 2025-12-10 | 变更发布到测试环境 |
| 2026-01-07 23:48 | 包含该变更的全球发布开始 |
| 2026-01-08 17:40 | 发布覆盖 90% 的服务器 |
| 2026-01-08 18:19 | 宣布故障 |
| 2026-01-08 18:27 | 开始回滚发布 |
| 2026-01-08 19:55 | 回滚完成,故障结束 |
gantt
title DNS CNAME 顺序故障时间线
dateFormat YYYY-MM-DD HH:mm
axisFormat %m-%d
section 开发阶段
代码变更引入 :done, 2025-12-02, 1d
发布到测试环境 :done, 2025-12-10, 1d
section 生产部署
全球发布开始 :2026-01-07 23:48, 18h
90%部署完成 :2026-01-08 17:40, 1h
section 故障处理
宣布故障 :crit, 2026-01-08 18:19, 8m
开始回滚 :crit, 2026-01-08 18:27, 88m三、问题分析
1. 直接原因
1.1.1.1 在优化缓存实现的内存使用时,改变了 CNAME 记录在 DNS 响应中的顺序,将 CNAME 记录从响应开头移到了末尾。
2. 根本原因
A. DNS CNAME 链的工作原理
当查询如 www.example.com 的域名时,可能会得到 CNAME(规范名称)记录,表示一个名称是另一个名称的别名。公共解析器如 1.1.1.1 需要跟随这条别名链,直到获得最终响应:
www.example.com → cdn.example.com → server.cdn-provider.com → 198.51.100.1CNAME 链中的每个记录都有自己的 TTL(生存时间),指示可以缓存多久。CNAME 链中的所有 TTL 不必相同:
www.example.com → cdn.example.com (TTL: 3600 秒) # 仍在缓存
cdn.example.com → 198.51.100.1 (TTL: 300 秒) # 已过期当 CNAME 链中的一个或多个记录过期时,称为部分过期。由于链的部分仍在缓存中,无需重新解析整个 CNAME 链,只需重新解析已过期的部分。
B. 代码逻辑变更
合并这两条链的代码是变更发生的地方。之前的代码会创建一个新列表,插入现有的 CNAME 链,然后追加新记录:
impl PartialChain {
pub fn fill_cache(&self, entry: &mut CacheEntry) {
let mut answer_rrs = Vec::with_capacity(entry.answer.len() + self.records.len());
answer_rrs.extend_from_slice(&self.records); // CNAME 在前
answer_rrs.extend_from_slice(&entry.answer); // 然后 A/AAAA 记录
entry.answer = answer_rrs;
}
}为了节省一些内存分配和复制,代码被改为将 CNAME 追加到现有答案列表:
impl PartialChain {
pub fn fill_cache(&self, entry: &mut CacheEntry) {
entry.answer.extend(self.records); // CNAME 在后
}
}结果是 1.1.1.1 返回的响应中,CNAME 记录有时会出现在底部,位于最终解析的答案之后。
C. 为什么会导致解析失败
某些 DNS 客户端实现通过在顺序遍历时跟踪记录的预期名称来处理 CNAME 链。当遇到 CNAME 时,预期名称会更新:
正确的顺序:
;; ANSWER SECTION:
www.example.com. 3600 IN CNAME cdn.example.com.
cdn.example.com. 300 IN A 198.51.100.1- 查找 www.example.com 的记录
- 遇到 www.example.com. CNAME cdn.example.com
- 查找 cdn.example.com 的记录
- 遇到 cdn.example.com. A 198.51.100.1
错误的顺序(CNAME 在后):
;; ANSWER SECTION:
cdn.example.com. 300 IN A 198.51.100.1
www.example.com. 3600 IN CNAME cdn.example.com.- 查找 www.example.com 的记录
- 忽略 cdn.example.com. A 198.51.100.1(不匹配预期名称)
- 遇到 www.example.com. CNAME cdn.example.com
- 查找 cdn.example.com 的记录
- 没有更多记录,响应被视为空
3. 受影响的实现
A. glibc getaddrinfo
Linux 上常用的 DNS 解析函数 getaddrinfo 在 glibc 中的实现确实期望在答案之前找到 CNAME 记录:
for (; ancount > 0; --ancount)
{
if (rr.rtype == T_CNAME)
{
/* 记录 CNAME 目标作为新的预期名称 */
expected_name = name_buffer; // 更新查找目标
}
else if (rr.rtype == qtype
&& __ns_samebinaryname(rr.rname, expected_name)) // 必须匹配!
{
/* 地址记录匹配 - 存储它 */
ptrlist_add(list:addresses, item:...);
}
}B. Cisco 交换机 DNSC 进程
三款 Cisco 以太网交换机型号中的 DNSC 进程也受到影响。当交换机配置使用 1.1.1.1 时,在收到包含重排序 CNAME 的响应后会经历自发重启循环。Cisco 已发布描述此问题的服务文档。
C. 未受影响的实现
大多数 DNS 客户端没有这个问题。例如,systemd-resolved 首先将记录解析为有序集合,因此可以在整个答案集中搜索,即使 CNAME 记录不出现在顶部。
graph LR
subgraph 受影响实现
A1[glibc getaddrinfo]
A2[Cisco DNSC]
end
subgraph 未受影响实现
B1[systemd-resolved]
B2[大多数现代解析器]
end
C[1.1.1.1 响应] -->|CNAME 在后| A1
C -->|CNAME 在后| A2
C -->|CNAME 在后| B1
C -->|CNAME 在后| B2
A1 -->|解析失败| X[空响应]
A2 -->|重启循环| Y[系统崩溃]
B1 -->|正常解析| Z[正确响应]
B2 -->|正常解析| Z四、DNS 标准的歧义性
1. RFC 1034 的规范
RFC 1034 发布于 1987 年,定义了 DNS 协议的大部分行为。第 4.3.1 节包含以下文本:
如果请求递归服务且递归服务可用,对查询的递归响应将是以下之一:
- 查询的答案,可能以一个或多个 CNAME RR 为前缀,这些 RR 指定在通往答案过程中遇到的别名
虽然"可能以...为前缀"可以解释为要求 CNAME 记录出现在其他所有记录之前,但它没有使用现代 RFC 用来表达要求的标准关键词(如 MUST 和 SHOULD)。这不是 RFC 1034 的缺陷,而是由于其年代久远。RFC 2119 标准化这些关键词是在 1997 年发布的,比 RFC 1034 晚了 10 年。
2. RRsets 与 RRs 的微妙区别
RFC 1034 第 3.6 节将资源记录集定义为具有相同名称、类型和类别的记录集合。对于 RRsets,规范关于顺序的说明很明确:
集合中 RR 的顺序不重要,名称服务器、解析器或 DNS 的其他部分不需要保留它。
然而,RFC 1034 没有明确说明消息部分如何与 RRsets 相关。虽然现代 DNS 规范表明消息部分确实可以包含多个 RRsets(考虑带有签名的 DNSSEC 响应),但 RFC 1034 没有以这些术语描述消息部分。相反,它将消息部分视为包含单独的资源记录。
RFC 主要在 RRsets 的上下文中讨论排序,但没有指定消息部分内不同 RRsets 相对于彼此的排序。这就是歧义存在的地方。
RFC 1034 第 6.2.1 节包含一个进一步展示这种歧义的例子。它提到资源记录的顺序也不重要:
答案部分中 RR 的顺序差异不重要。
然而,这个例子只显示同一 RRset 中同一名称的两个 A 记录。它没有说明这是否适用于不同的记录类型,如 CNAME 和 A 记录。
3. CNAME 链的顺序问题
这个问题不仅限于将 CNAME 记录放在其他记录类型之前。即使 CNAME 出现在其他记录之前,如果 CNAME 链本身乱序,顺序解析仍然可能失败。考虑以下响应:
;; ANSWER SECTION:
cdn.example.com. 3600 IN CNAME server.cdn-provider.com.
www.example.com. 3600 IN CNAME cdn.example.com.
server.cdn-provider.com. 300 IN A 198.51.100.1每个 CNAME 属于不同的 RRset,因为它们有不同的所有者,所以关于 RRset 顺序不重要的声明在这里不适用。
然而,RFC 1034 没有指定 CNAME 链必须按任何特定顺序出现。没有要求 www.example.com. CNAME cdn.example.com. 必须出现在 cdn.example.com. CNAME server.cdn-provider.com. 之前。
五、解决方案
1. 临时方案
A. 实施措施
回滚包含 CNAME 重排序的代码发布
B. 效果评估
快速恢复服务,解决兼容性问题
2. 永久方案
A. 改进措施
- 恢复 CNAME 记录的原始顺序
- 不打算在未来改变顺序
- 添加测试以确保行为保持一致
B. IETF 提案
Cloudflare 已撰写了一个互联网草案形式的提案,将在 IETF 进行讨论。如果对澄清的行为达成共识,这将成为一个明确定义如何正确处理 DNS 响应中 CNAME 的 RFC,帮助 Cloudflare 和更广泛的 DNS 社区导航协议。提案可在 https://datatracker.ietf.org/doc/draft-jabley-dnsop-ordered-answer-section 查看。
3. 预防措施
- 在协议实现中考虑遗留实现的怪异行为
- 为涉及顺序和兼容性的行为编写明确的测试
- 参与标准化组织,帮助澄清协议歧义
六、经验总结
1. 协议兼容性的重要性
尽管 RFC 解释为不要求 CNAME 按任何特定顺序出现,但显然至少有一些广泛部署的 DNS 客户端依赖它。由于使用这些客户端的某些系统可能很少更新或从未更新,最佳做法是要求 CNAME 记录按顺序出现在任何其他记录之前。
2. 代码优化的风险
看似无害的内存优化(从创建新列表改为扩展现有列表)可能引发意想不到的兼容性问题。在处理有近 40 年历史的协议时,必须谨慎对待任何可能改变行为的变更。
3. 规范的歧义性
RFC 1034 中的歧义导致了解释和实现上的差异。这提醒我们,在处理遗留协议时,不仅要看规范的字面意思,还要考虑实际部署中的实现行为。
4. 测试覆盖的必要性
Cloudflare 最初按照规范实现,使 CNAME 首先出现,但由于 RFC 中语言歧义,没有任何测试断言行为保持一致。这突显了为关键行为编写明确测试的重要性。
graph TB
A[问题根源] --> B1[RFC 1034 语言歧义]
A --> B2[实现多样性]
A --> B3[测试覆盖不足]
C[代码变更] -->|内存优化| D[顺序改变]
D --> E[部分客户端故障]
B1 --> E
B2 --> E
B3 --> E
F[解决方案] --> G1[恢复原始顺序]
F --> G2[添加明确测试]
F --> G3[IETF 标准化提案]
E --> G1
E --> G2
E --> G3
style A fill:#f9f,stroke:#333,stroke-width:2px
style F fill:#9f9,stroke:#333,stroke-width:2px
style E fill:#f99,stroke:#333,stroke-width:2px七、技术深入
1. 递归解析器与存根解析器的区别
RFC 1034 第 5 节描述了解析器行为。第 5.2.2 节特别解决了解析器应如何处理别名(CNAME):
在大多数情况下,解析器在遇到 CNAME 时简单地以新名称重新开始查询。
这表明解析器应该在响应中找到 CNAME 时重新开始查询,无论它出现在响应的哪个位置。然而,重要的是区分不同类型的解析器:
- 递归解析器:如 1.1.1.1,是通过查询权威名称服务器执行递归解析的完整 DNS 解析器
- 存根解析器:如 glibc 的 getaddrinfo,是简化的本地接口,将查询转发给递归解析器并处理响应
RFC 中关于解析器行为的章节主要是在考虑完整解析器,而不是大多数应用程序实际使用的简化存根解析器。某些存根解析器显然没有实现规范的某些部分,如 RFC 中描述的 CNAME 重新开始逻辑。
2. DNSSEC 规范的对比
后来的 DNS 规范展示了定义记录顺序的不同方法。RFC 4035 定义 DNSSEC 的协议修改,使用了更明确的语言:
当将签名的 RRset 放入答案部分时,名称服务器必须也将 RRSIG RR 放入答案部分。RRSIG RR 的包含优先级高于可能必须包含的任何其他 RRsets。
规范使用 MUST 并明确定义 RRSIG 记录的"更高优先级"。然而,"更高的包含优先级"指的是是否应将 RRSIG 包含在响应中,而不是它们应该出现在哪里。这为实施者提供了关于 DNSSEC 上下文中记录包含的明确指导,同时不强加关于记录排序的任何特定行为。
对于无签名区域,RFC 1034 的歧义仍然存在。"前缀"一词指导了近 40 年的实现行为,但从未正式指定为要求。