Cloudflare 2025-11-18 宕机事件分析与 “白嫖玩家” 的灾备方案设计

1. 故障分析(北京时间时间线)

Cloudflare 官方报告里所有时间都是 UTC,这里先统一换算成北京时间(UTC+8),再结合“普通用户视角”做一遍还原。

  • 报告时间换算:
    • 11:05 UTC → 19:05 北京时间
    • 11:20 UTC → 19:20 北京时间
    • 11:28 UTC → 19:28 北京时间
    • 13:05 UTC → 21:05 北京时间
    • 13:37 UTC → 21:37 北京时间
    • 14:24 UTC → 22:24 北京时间
    • 14:30 UTC → 22:30 北京时间
    • 17:06 UTC → 次日 01:06 北京时间

1.1 事件还原:从“误以为被打”到确认是自己搞崩了

先看一眼官方给出的关键时间线(已换算为北京时间):

  • 19:05 正常
    Cloudflare 在 ClickHouse 数据库中发布了一次权限控制改动,目标是让分布式查询的权限更细粒度、更安全。

  • 19:20 左右 隐患种下
    由于权限改变,用于生成 Bot Management 特征配置文件的 SQL 查询开始返回重复数据,导致特征文件体积翻倍。
    这一刻起,Cloudflare 的节点就开始周期性地“吃到坏配置”。

  • 19:28 故障开始
    部分节点加载到“超规格”的特征文件,Bot 模块在加载时触发内存上限检查,直接 panic。
    对用户来说,就是访问挂在 Cloudflare 后面的站点开始大量返回 HTTP 5xx。

  • 19:31 – 19:35 监控告警 & 内部拉群

    • 19:31 第一条自动化测试报警触发。
    • 19:32 人工排查启动,初始怀疑是 Workers KV 掉链子。
    • 19:35 官方 incident call 建立,大家开始线上“救火”。
  • 19:32 – 21:05 第一阶段:误判方向,先怀疑 Workers KV 和 DDoS
    这一段时间,Cloudflare 的工程师们主要在看:

    • Workers KV 错误率飙升;
    • 下游依赖 KV 的服务(包括 Access 等)跟着爆炸;
    • 加上 Cloudflare 状态页居然也“巧合”挂了,直接把大家往“是不是又被超大规模 DDoS 打了”的方向带。

    所以他们做的事情大概是:

    • 尝试对 Workers KV 做流量限制、账号限流;
    • 调整流量分配,试图先让 KV 这个“看起来最明显的病灶”活过来。

    对用户的体验就是:

    • 有时候能打开页面,有时候直接 5xx;
    • 各种“抽风”,很像被人恶意打挂,而不是稳定的内部错误。
  • 21:05 Workers KV / Access 绕过,影响减轻
    Cloudflare 内部有个“后门”:可以让 Workers KV 和 Access 绕过当前版本的核心代理(FL2),回退到旧版 FL。

    • 不幸的是,旧版 FL 同样依赖这份 Bot 特征文件,所以问题并没有从根上消失;
    • 幸运的是,旧版本在错误处理上的行为稍微“温和”一点,整体错误率比 FL2 好看一些。
  • 21:37 锁定元凶:Bot Management 配置文件
    经过一圈排查之后,他们终于意识到:

    • 真正触发 panic 的,是 Bot Management 模块;
    • 真正的问题,是那份“长歪了”的特征配置文件。

    接下来多条工作流并行:

    • 一边想办法彻底停止坏文件的生成和分发;
    • 一边准备回滚到最后一个“已知良好版本”的特征文件。
  • 22:24 停止坏文件的生成与分发
    这一步很关键,也是这次事故真正“踩住刹车”的时间点:

    • 停止 ClickHouse 那条会产生重复列的查询;
    • 阻断新的坏配置在全网继续传播。
  • 22:24 – 22:30 验证旧文件可用性
    工程团队在小范围节点上验证:

    • 替换为旧版特征文件后,核心代理是否能正常启动;
    • 错误率是否回落,延迟是否恢复正常。

    验证通过之后,开始全网铺开。

  • 22:30 主影响解除
    正常版本的 Bot 配置文件在全网重新下发:

    • 大部分 HTTP 5xx 开始消失;
    • Cloudflare 网络“躺平”了几个小时之后,流量瞬间涌回,局部出现拥挤和抖动。
  • 22:30 – 23:30 第二阶段:流量回流 + 控制面“被人挤爆”
    流量回归后的典型现象:

    • 用户端大部分访问恢复,但偶尔仍感觉卡顿;
    • Dashboard(登录、操作)也被回流的登录请求和重试“打到脸变形”,availability 掉了一段时间。
  • 次日 01:06 全面恢复
    Cloudflare 表示所有下游服务重启完成,错误率恢复到正常水平,这次事故正式画上句号。

1.2 根因复盘:一条 SQL + 一个硬编码上限,搞垮半个互联网

从技术视角看,这次事故非常“经典”:

  1. ClickHouse 权限变更
    之前:

    • 用户只在 default 数据库看到分布式表的元数据;
    • 底层真实数据存放在 r0 等库里,通过 Distributed 引擎访问,但用户不直接感知。

    这次变更:

    • 出于“安全 + 可靠性”的考虑,让用户也能显式看到自己有权限访问的底层表元数据。
  2. 历史遗留 SQL 假定“只有 default”
    有一条用于生成 Bot 特征配置文件的 SQL 长这样(简化):

    SELECT
      name,
      type
    FROM system.columns
    WHERE
      table = 'http_requests_features'
    ORDER BY name;
    

    注意:这里没有限制 database 字段。

    在权限变更之前:

    • 这条查询只会返回 default 里的那张表;
    • 行数等于特征数 ≈ 60,多年稳定运行,没人觉得有问题。

    在权限变更之后:

    • 查询开始同时返回 default + r0 中同名表的列信息;
    • 从“60 行”变成“1xx 行甚至 2xx 行”,特征配置文件被无辜放大一倍多。
  3. Bot 模块的硬编码上限
    为了避免无限内存占用,Bot 模块在加载特征文件时做了一个预分配:

    • 上限设为 200 个特征;
    • 实际日常只用了 ~60 个,看起来非常安全;
    • 但这次被权限改动 + SQL 逻辑一起阴了一把,超过 200 直接触发错误。
  4. 防护缺失点

    • 内部配置文件的“安全性假设”过高,没有像用户配置那样严谨校验;
    • 模块级别没有熔断/降级机制(例如超过特征上限时禁用模块而不是炸整个代理);
    • 错误采集系统在高并发 panic 场景下反而“吃掉”大量 CPU,加重延迟。

一句话总结:
一个看起来“只是做权限显式化”的小改动,踩中了“历史 SQL + 硬编码上限 + 缺乏防呆”的组合陷阱,进而把核心代理一锅端。

1.3 那段 Rust 代码到底在什么位置背了锅?

Cloudflare 在官方复盘的 “Memory preallocation” 小节里,把直接触发 panic 的 FL2 代码片段也贴出来了,大意如下(去掉了一些无关细节,格式按截图还原):

/// Fetch edge features based on `input` struct into [`Features`] buffer.
pub fn fetch_features(
    &mut self,
    input: &dyn BotsInput,
    features: &mut Features,
) -> Result<(), (ErrorFlags, i32)> {
    // update features checksum (lower 32 bits) and copy edge feature names
    features.checksum &= 0xFFFF_FFFF_0000_0000;
    features.checksum |= u64::from(self.config.checksum);
    let (feature_values, _) = features
        .append_with_names(&self.config.feature_names)
        .unwrap();
    // ...
}

官方描述是:

  • Bot Management 为“特征数量”设了一个硬编码上限 200;
  • 平时只用了 ~60 个特征;
  • 坏配置文件导致特征数量超过上限时,append_with_names 返回 Err
  • 上面这段代码直接 .unwrap(),于是整个 FL2 worker 线程 panic。

对应的 panic 日志也在官方文中给出:

thread fl2_worker_thread panicked: called Result::unwrap() on an Err value

从事故链路来看,这段 Rust 代码处在“最后一跳”的位置:

  1. 上游的 ClickHouse 权限变更 + 历史 SQL 假设,导致特征文件变大;
  2. Bot 模块在加载超规格配置时触发特征数量上限;
  3. fetch_features 里对结果无脑 unwrap(),让单个 worker 直接崩溃;
  4. 大量请求打到挂掉的代理上,最终变成对外可见的大面积 5xx。

所以,网上那种“Cloudflare 被一行 Rust unwrap 弄挂了”的说法,情绪价值比技术价值高:

  • 真正的锅在于整体防线缺失:上游 SQL 对行为变化缺乏防护、中游配置没有安全网、下游模块内部错误直接 panic,没有任何降级;
  • Rust 这行 unwrap() 更像是“扣扳机的人”,但枪是大家一起造的。

站在普通用户的角度,我们既没法帮 Cloudflare 把整条链路重写一遍,也没法保证它以后不再犯类似错误,所以更现实的做法,是在它出问题时,我们自己的系统还能有路可走


2. 在没办法推进 CF 提升可用性的情况下,怎么做灾备?

从这次事故可以看出一个非常现实的问题:

  • Cloudflare 的王牌服务(反向代理/CDN/WAF)要求你把域名 NS 托管给它;
  • 一旦 CF 自身控制面也受影响(Dashboard 登不上、API 不可用),你就没法在 CF 里改解析;
  • 换句话说:事故发生时,你手里没有那根“紧急断电开关”。

先对比一下两种常见使用方式:

  • 托管 NS + CF 代理(大部分免费 / Pro 用户)

    • 平时体验极好,一站式搞定 DNS + 代理 + WAF + 性能。
    • 故障时,如果 Dashboard / API 也受影响:你连“把流量挪回源站”都做不到。
  • 传统 CNAME 接入(部分 SaaS/CDN 场景)

    • 权威 DNS 在你或者第三方手里,可以随时把 CNAME 改成 A 直连源站。
    • 灾备时要批量修改大量 CNAME 记录,非常麻烦,一旦漏改某条记录,对应业务就直接黑掉。

Cloudflare 会不会因为这次事故开放更多“只 CNAME、不托管 NS”的接入方式?
从它自己的产品形态和历史来看,短期内不太乐观,尤其对免费玩家而言。

那在这个前提下,我们能做什么?

答案是:

  • 保持现有“NS 托管给 CF”的体验;
  • 同时,在第三方 DNS 里维护一份完全同步的解析数据;
  • 事故发生时,不去碰 Cloudflare,而是在域名注册商那里把 NS 改回备用 DNS,绕过 CF 直连源站。

这就要求一个硬前提:

域名注册商不能是 Cloudflare
不然你的 NS 改来改去还是在 Cloudflare 自己的体系里,无法做到“彻底脱离 CF”。

2.1 思路概述

核心设计:

  • 平时:

    • NS → Cloudflare;
    • 所有解析变更通过一个“统一脚本”,同时写入 CF 和备用 DNS
    • 再加一个定时同步脚本做兜底,保证两边解析记录尽量一致。
  • 灾备:

    • 不动 Cloudflare 的任何东西;
    • 只在域名注册商后台(注意,不是 CF 注册商)调用 API,把 NS 从 CF 改为备用 DNS;
    • 因为备用 DNS 已经实时同步了记录,所以切换后解析能立即工作(考虑 DNS 缓存会有几分钟的尾巴)。
  • 恢复:

    • Cloudflare 恢复可用且确认稳定之后,再通过脚本把 NS 改回 CF;
    • 解析记录本身两边一直同步,切回不会有记录错乱的问题。

对比 CNAME 玩家:

  • CNAME 灾备时要把所有 CNAME 批量改成 A,工作量和出错概率都挺可观;
  • 这个方案里,灾备时只改 NS,一次完成,子域多少完全不重要。

2.2 正常运行架构

先画一个“正常运行时”的结构:

flowchart LR subgraph User["用户浏览器 / 客户端"] end subgraph Registrar["域名注册商(非 Cloudflare)"] NS["NS = Cloudflare NS"] end subgraph CF["Cloudflare"] CF_DNS["Cloudflare DNS"] CF_Proxy["Cloudflare 代理 / CDN / WAF / Bot 等"] end subgraph BackupDNS["备用 DNS(如 DNSPod)"] B_DNS["DNS 记录(实时同步)"] end subgraph Origin["源站 / 后端服务"] APP["应用服务器 / 集群"] end Script["解析变更脚本\n(同时写 CF + 备用 DNS)"] Sync["定时同步脚本\n(以 CF 为源,CF → 备用 DNS)"] User -->|解析域名| CF_DNS CF_DNS --> CF_Proxy CF_Proxy --> APP Script --> CF_DNS Script --> B_DNS Sync --> CF_DNS Sync --> B_DNS NS -. 指向 .-> CF_DNS B_DNS -. 平时不对外 .-> Origin

关键点:

  • 用户正常只会问 CF 的 DNS;
  • 备用 DNS 平时“躺平”,只是跟着同步记录,不对外真正生效;
  • 所有解析变更都通过脚本走一遍 CF + 备用 DNS。

2.3 灾备时的 NS 切换

再看“灾备切换”的视图:

flowchart LR subgraph User["用户浏览器 / 客户端"] end subgraph Registrar["域名注册商(非 Cloudflare)"] NS_CF["NS = Cloudflare NS\n(正常)"] NS_BK["NS = 备用 DNS NS\n(灾备)"] end subgraph CF["Cloudflare(部分或全部故障)"] CF_DNS["Cloudflare DNS"] CF_Proxy["Cloudflare 代理 / CDN / WAF"] end subgraph BackupDNS["备用 DNS(如 DNSPod)"] B_DNS["已同步的 DNS 记录"] end subgraph Origin["源站 / 后端服务"] APP["应用服务器 / 集群"] end Switch["切换脚本\n(调用注册商 API 改 NS)"] User -->|正常解析| CF_DNS CF_DNS --> CF_Proxy --> APP User -->|灾备解析| B_DNS --> APP Switch --> NS_CF Switch --> NS_BK CF_DNS -. 正常时生效 .-> NS_CF B_DNS -. 灾备时生效 .-> NS_BK

要点:

  • 切换动作发生在非 Cloudflare 的域名注册商那里,Cloudflare 自己只是一个普通 NS 服务;
  • 一旦注册商那边改成“NS = 备用 DNS”,Cloudflare 整套 DNS + 代理对用户就“消失”了。

3. 具体方案实现

下面按“能落地”为目标来设计,默认你:

  • 域名注册商不是 Cloudflare;
  • 有至少一个在 Cloudflare 托管 DNS 的域名;
  • 有一个备用 DNS 服务(比如 DNSPod、Route53 等),支持 API 操作;
  • 有一个支持定时任务的环境(Linux + crontab 之类即可)。

3.1 前提与假设

  1. 业务接入方式

    • 现状:NS 已指向 Cloudflare,域名通过 CF 代理公开对外;
    • 目标:不改变日常使用方式,只额外增加灾备能力。
  2. 域名注册商支持 API

    • 例如腾讯云、阿里云、Namecheap 等;
    • 用于脚本化修改 NS(切到 CF / 切回备用 DNS)。
  3. 备用 DNS 支持 API 管理记录

    • DNSPod、Route53、Cloudflare 以外的任何一家都可以;
    • 要求:
      • 支持添加/修改/删除 A、AAAA、CNAME、TXT 等记录;
      • 支持设置 TTL(建议 60~120 秒)。
  4. 源站公网可直连

    • 灾备切换后,流量会直接打到源站或其他 CDN,而不再经过 CF;
    • 源站需要至少能承受一段时间内的流量(可以配合其它 CDN 做前层)。
  5. 域名注册商不能是 Cloudflare

    • 否则你的“改 NS”仍然是在 Cloudflare 自己的平台里兜圈子;
    • 真正的“紧急断电开关”必须掌握在第三方注册商手里。

3.2 解析层“双活”:变更脚本 + 定时同步

我们把解析层分成两部分逻辑:

  • “平时改解析”的统一入口;
  • “兜底定时同步”。

3.2.1 统一解析变更脚本

原则:

  • 不允许直接在 CF 控制台或备用 DNS 控制台手改解析;
  • 所有变更统一走一个脚本,比如:
./dns-update \
  --record "www.example.com" \
  --type "A" \
  --value "1.2.3.4" \
  --ttl 120

脚本内部做两件事:

  1. 调 Cloudflare API

    • 更新 zone 内对应的记录;
    • 保持 proxied = true(需要走代理的记录)。
  2. 调备用 DNS(如 DNSPod)API

    • 为同名记录写入 A 记录,指向源站真实 IP 或上游 CDN;
    • TTL 设置与 CF 尽量一致或者略小。

极简伪代码示例:

function update_record(name, type, value, ttl):
    # 1. Update Cloudflare
    cf_api.update_dns_record(
        zone_id = CF_ZONE_ID,
        name = name,
        type = type,
        content = value,
        ttl = ttl,
        proxied = true  # 走代理的照旧走代理
    )

    # 2. Update Backup DNS (DNSPod)
    backup_api.upsert_record(
        domain = ROOT_DOMAIN,
        subdomain = extract_subdomain(name, ROOT_DOMAIN),
        type = type,
        value = value,
        ttl = ttl
    )

    log("DNS record updated on both CF and Backup DNS")

这样可以保证:

  • 从此刻开始,CF DNS 和备用 DNS 里永远是同一套解析数据;
  • 灾备时切 NS 时,不用再临时调整记录。

3.2.2 定时同步脚本(以 CF 为源)

考虑到现实情况:

  • 有人难免会手贱直接在 CF 面板改一次;
  • 或者团队里有人绕过脚本直接操作;

可以每 5 分钟跑一次定时任务,做兜底同步:

  1. 从 Cloudflare 拉一遍当前 zone 的所有记录;
  2. 按规则过滤掉不需要同步的记录(比如 ACME 临时验证记录等);
  3. 对备用 DNS 做“幂等更新”。

伪代码示例:

function sync_from_cf_to_backup():
    cf_records = cf_api.list_dns_records(zone_id = CF_ZONE_ID)

    for r in cf_records:
        if should_ignore(r):
            continue

        backup_api.upsert_record(
            domain = ROOT_DOMAIN,
            subdomain = r.name_without_root,
            type = r.type,
            value = r.content,
            ttl = max(r.ttl, 60)
        )

    log("Sync from CF to backup DNS completed")

这样一来,即便偶尔有人违规“直改 CF”,最多也就 5~10 分钟后被同步脚本拉回一致。

3.3 灾备切换脚本(改 NS)

真正出事的时候,核心动作只有一个:

  • 在域名注册商 API 上,把 NS 从 CF NS 改为备用 DNS 的 NS。

伪代码示例:

function switch_to_backup_ns():
    registrar_api.update_ns(
        domain = ROOT_DOMAIN,
        nameservers = BACKUP_DNS_NS_LIST
    )
    log("NS switched to backup DNS")

function switch_back_to_cf_ns():
    registrar_api.update_ns(
        domain = ROOT_DOMAIN,
        nameservers = CLOUDFLARE_NS_LIST
    )
    log("NS switched back to Cloudflare")

操作模式可以分两种:

  • 手动触发:

    • 当你确认 Cloudflare 整体不可用,且短时间看不到恢复希望时;
    • 人为执行 switch_to_backup_ns
  • 半自动触发:

    • 写一个监控程序,每 N 分钟检测 Cloudflare 的可用性;
    • 当连续多次检测失败时,发告警通知 + 提供一键切换命令;
    • 最终是否切换,仍由人来决定,避免误伤。

3.4 故障检测逻辑示例

检测什么?

  • 若干“关键业务域名”的 HTTP 状态;
  • 直接访问 Cloudflare 的边缘节点(比如 trace 或公共 API);
  • 尝试调用 Cloudflare API,判断控制面是否还活着。

伪代码示例:

function health_check():
    errors = 0

    for url in CRITICAL_URLS:
        status = http_get(url, timeout=3)
        if status >= 500:
            errors += 1

    cf_api_ok = cf_api.ping()

    if errors >= ERROR_THRESHOLD or not cf_api_ok:
        return "bad"
    else:
        return "good"

守护进程示例:

state = "good"
bad_count = 0

while true:
    result = health_check()

    if result == "bad":
        bad_count += 1
    else:
        bad_count = 0

    if bad_count >= MAX_BAD_ROUNDS and state == "good":
        alert("Cloudflare may be down. Consider switching to backup NS.")
        state = "alerted"

    sleep(CHECK_INTERVAL)

这里刻意不自动切 NS,而是改为“告警 + 人工决定”,避免因为网络抖动导致“脚本自己把 NS 切来切去”。

3.5 实际效果与局限

这个方案能解决什么?

  • 当 Cloudflare 控制面 / 代理整体出问题时,你依然有能力在十几分钟内“脱离 CF”;
  • 灾备时的操作非常简单:只改 NS,不用逐条改解析记录;
  • 对“免费玩家”和小团队来说,成本非常低:写几个脚本 + 做一次演练即可。

解决不了什么?

  • 在 CF 恢复之前,你享受不到它的加速和安全能力;
  • NS 切换仍然会受 DNS 缓存影响,极端情况下全网完全一致需要几十分钟;
  • 如果源站本身抗压能力有限,直接暴露到公网同样有被打挂的风险,需要配合其他 CDN 或 WAF。

但在“我们没办法推动 Cloudflare 把可用性做到绝对完美”的前提下,这个方案至少做到了:

  • 控制权回到自己手里;
  • 切换和回切都可以脚本化、可演练;
  • 日常不改变你作为 Cloudflare 用户的使用体验,事故发生时又有一条退路。

posted @ 2025-11-20 16:07  玮仔Wayne  阅读(62)  评论(0)    收藏  举报