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

mermaid

三、问题分析

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 记录不出现在顶部。

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

mermaid

四、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

mermaid

七、技术深入

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
  2. RFC 1034 - Domain Names - Concepts and Facilities
  3. RFC 2119 - Key words for use in RFCs to Indicate Requirement Levels
  4. RFC 4035 - Protocol Modifications for the DNS Security Extensions
  5. IETF Draft: Ordered Answer Section
最后修改:2026 年 01 月 20 日
如果觉得我的文章对你有用,请随意赞赏