Loading... # 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 | 回滚完成,故障结束 | ```mermaid 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.1 ``` CNAME 链中的每个记录都有自己的 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 ``` 1. 查找 www.example.com 的记录 2. 遇到 www.example.com. CNAME cdn.example.com 3. 查找 cdn.example.com 的记录 4. 遇到 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. ``` 1. 查找 www.example.com 的记录 2. 忽略 cdn.example.com. A 198.51.100.1(不匹配预期名称) 3. 遇到 www.example.com. CNAME cdn.example.com 4. 查找 cdn.example.com 的记录 5. 没有更多记录,响应被视为空 ## 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 记录不出现在顶部。 ```mermaid 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 中语言歧义,没有任何测试断言行为保持一致。这突显了为关键行为编写明确测试的重要性。 ```mermaid 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 年的实现行为,但从未正式指定为要求。 # 八、参考资料 1. [What came first: the CNAME or the A record? - Cloudflare Blog](https://blog.cloudflare.com/cname-a-record-order-dns-standards/) 2. [RFC 1034 - Domain Names - Concepts and Facilities](https://www.rfc-editor.org/rfc/rfc1034) 3. [RFC 2119 - Key words for use in RFCs to Indicate Requirement Levels](https://www.rfc-editor.org/rfc/rfc2119) 4. [RFC 4035 - Protocol Modifications for the DNS Security Extensions](https://www.rfc-editor.org/rfc/rfc4035) 5. [IETF Draft: Ordered Answer Section](https://datatracker.ietf.org/doc/draft-jabley-dnsop-ordered-answer-section) 最后修改:2026 年 01 月 20 日 © 允许规范转载 赞 如果觉得我的文章对你有用,请随意赞赏