一条正则,82% 流量蒸发:Cloudflare 2019 回溯事故如何烧满全网每一颗 CPU
Published:
先看数字:最严重时,经过 Cloudflare 的流量下跌了 82%。不是某一个客户、某一个区域——而是横跨 180 多座城市的整张网络,持续 27 分钟,发生在 2019 年 7 月 2 日。访问 Cloudflare 前置站点的访客,看到的不是想打开的网站,而是一个 502 错误页。而 Cloudflare 此前已经六年没有发生过全球性宕机。
扳机是一条正则表达式,藏在一条新的 Web 应用防火墙(WAF)规则里。没有攻击、没有硬件故障、没有流量洪峰——Cloudflare 当天的帖子明确写了,这不是一次攻击。一条按当时流程正确完成的例行变更,在几秒内传遍全球,然后在每一台机器上同时吃掉了每一次请求都需要的那个资源:CPU。
这是 🔥 Incident Recap 系列第二篇。Cloudflare 十天后由 John Graham-Cumming 发布的详细 postmortem,难得地拒绝给出单一 root cause,而是列出了十一个相互叠加的漏洞点。这份清单才是真正的故事:正则解释了 CPU 是怎么被烧掉的,其余十项解释了为什么没有任何东西——没有护栏、没有测试、没有灰度阶段、没有快速回滚——挡在一条写坏的 pattern 和整张面向互联网的网络之间。
背景:一面必须快速反应的盾,一张为速度而建的网
Cloudflare 的 WAF Managed Rules 是一面共享的盾。一组工程师持续编写和更新跑在边缘客户流量上的规则:拦住这种利用模式、标出那种攻击形状、压低误报。在事故之前的 60 天里,这支团队处理了 476 个变更请求,平均约每三小时一个。速度在这里不是虚荣指标:一个严重漏洞被披露、利用代码出现之后,一条 WAF 规则的价值以小时计衰减。Postmortem 举了 2019 年 5 月的例子:一条规则被快速推送,为客户挡住了一个严重的 SharePoint 漏洞,赶在可见的利用尝试高峰之前。
让这种速度成为可能的是两套机器,两者都与这次事故有关。
第一是分发系统。WAF 规则、客户配置变更和 Cloudflare Workers 代码,都由 Quicksilver 分发——Cloudflare 自研的全球复制键值存储,复制到 180 多座城市。Postmortem 给的数字:Quicksilver 平均每秒分发约 350 个变更,一个变更到达全球每台机器的 p99 是 2.29 秒。从仪表盘上点一下按钮到全球生效,只需几秒。这是产品承诺——而在 7 月 2 日,它也同时是爆炸半径的保证:根本不存在一段传播延迟,让任何人有机会发现问题并叫停下一个区域。
第二是执行引擎。当时的 WAF 用 Lua 编写,pattern 匹配用 PCRE——一个使用回溯的正则库,并且用 Cloudflare 自己的话说,没有任何机制防止表达式失控。记住这两件事:pattern 在热路径上、对每一次相关请求执行、没有成本上限,而引擎的最坏成本不受输入长度约束。
7 月 2 日的那次变更,是一条针对跨站脚本(XSS)攻击、瞄准攻击中内联 JavaScript 的规则。它以 simulate 模式部署:真实客户流量穿过规则、匹配被记录,但不拦截任何东西,以便团队先测误报率和漏报率再正式启用。Simulate 模式听起来安全。但它不是免费的。Postmortem 写得很直白:即便在 simulate 模式下,规则也实际需要执行——而这条规则把一个极其昂贵的 pattern,对着真实流量,在全网每一个地方,同时执行了。
时间线(UTC,2019 年 7 月 2 日)
| 时间(UTC) | 事件 |
|---|---|
| 13:31 | 一名工程师在审批通过后合并了包含该规则变更的 Pull Request。 |
| 13:37 | TeamCity 构建规则,测试套件通过。 |
| 13:42 | 部署自动开始,规则全球上线。 |
| 约 13:45 | 第一条 PagerDuty 告警:一个合成测试发现 WAF 故障,随后迅速跟上端到端测试失败、全球流量下跌告警、大面积 502 错误和全球各 PoP 的 CPU 耗尽报告。 |
| 14:00 | WAF 被确认为元凶,排除攻击可能。实时 CPU 数据与 strace 均证实。 |
| 14:02 | 团队决定使用 global terminate 机制,在全球范围内禁用 WAF Managed Rulesets。 |
| 14:07 | 在克服访问障碍(见下文)之后,一名团队成员执行了全球 WAF 终止。 |
| 14:09 | 全球 CPU 与流量水平恢复到预期水平。 |
| 14:52 | 在一座城市做完否定与肯定两类测试、并把付费客户流量从该地点移走之后,WAF 在去掉坏规则后全球重新启用。 |
关于来源的两点说明。Cloudflare 的详细 postmortem 说终止在 14:07 执行、流量与 CPU 在 14:09 恢复正常;当天的帖子把这压缩成终止发生在 14:09。本文按详细 postmortem 写顺序,把 14:09 作为恢复时点——广为引用的 27 分钟(13:42 到 14:09)也是这样算出来的。当天帖子给出最严重时 82% 的流量跌幅;详细版则引用现场一位负责人的说法是丢了 80% 的流量。两者都是 Cloudflare 自己的数字,只是精度不同。
故障传播:一条 pattern,五个放大器
放大器 1:回溯把短字符串变成无界工作量——而且在热路径上、按请求收费
出事的表达式很长,但 postmortem 把它危险的内核约简为 .*.*=.*——准确地说,真实 pattern 的尾部是 .*(?:.*=.*)。逐字读:匹配任意多个字符,再匹配任意多个字符,然后是一个字面量 =,然后是任意内容。
机制在于切分问题。两个相邻的贪婪 .* 可以用很多种方式瓜分同一段输入:第一个全吃、第二个吃零个;第一个吐回一个字符;如此等等。回溯引擎试一种切分,往前走,后面的项匹配失败时,它退回去试下一种切分。它不记忆失败。它重新遍历它们。
Cloudflare 数了步数,而这些步数就是全部教训:
- 用
.*.*=.*匹配x=x需要 23 步——对一个只有三个字符的字符串。 - 匹配
x=后面跟 20 个x需要 555 步。 - 如果字符串只是 20 个
x、根本没有=,引擎需要 4,067 步才能得出不匹配的结论。 - 在 pattern 末尾加上一个分号变成
.*.*=.*;——如果你想匹配foo=bar;,这是很容易写出来的——对同样 22 个字符的字符串做一次失败匹配需要 5,353 步。
增长随输入长度超线性,而失败的情形才是最贵的,因为引擎必须穷尽每一种切分才能放弃。现在把这个成本放到 Cloudflare 放它的位置:一条 WAF 规则内部,对全网服务的每一次 HTTP 和 HTTPS 请求的真实数据执行,simulate 模式和拦截模式都一样。攻击者根本不需要刻意构造这种负载。普通流量里有大量足够长、又足够”不匹配”的字符串,让单次求值贵得离谱,而求值请求永远不会停止到达。每一个进入 pattern 的请求都把一颗 CPU 核心占用得远超应有时间;核心饱和逼近 100%;请求排在大部分是引擎重试已失败切分的工作后面。
这正是为什么可见症状是 502 而不是变慢。Postmortem 写得非常精确:502 错误是由前线 Cloudflare Web 服务器生成的,它们还有可用的 CPU 核心,但够不到处理 HTTP/HTTPS 流量的进程。服务进程没有崩溃。它们在忙——在数学意义上忙,做可证明无用的工作。一个系统不需要宕掉才会不可用;它只需要让共享瓶颈资源被不会快速终止的工作吃光。
那么什么真正修复这个机制、又修不了什么?线性时间引擎——Cloudflare 承诺切换到 RE2 或 Rust regex crate,两者都有运行时保证——打的是根源:不再顺序尝试切分,而是用 Thompson 式 NFA 模拟同时跟踪许多可能状态,匹配时间随输入长度线性增长(附录引用的正是 Ken Thompson 1968 年描述这一方法的论文)。这能消除这类引擎所支持 pattern 的灾难性回溯。它不能让每条规则都便宜:线性引擎仍然按字符按规则收费,几千条规则仍然会累加;而且有保证的引擎通常会放弃回溯独有的特性比如反向引用——有些 pattern 必须重写,而不只是重新编译。按规则 CPU 预算是第二个互补修复:它不让 pattern 变快,它让 pattern 的最坏情况变得可存活,在超限时切断它。任何一个修复单独存在,都留着另一个才能补的洞。
放大器 2:CPU 护栏本来存在——几周前在一次重构中被误删
十一项清单里埋着整个 postmortem 最安静也最致命的一条事实:一个本可帮助防止正则过度消耗 CPU 的保护,在几周前的一次 WAF 重构中被误删除——而那次重构的目的本身就是让 WAF 少用 CPU。
拆开这个机制。这个系统并不是在没有护栏的情况下设计的。它有过护栏,然后一次看起来无关的变更——一次以效率为目标的清理——把它删了,而且没有任何东西注意到。没有测试失败,因为没有测试在断言这个护栏的存在或效果。没有告警触发,因为护栏的缺席是沉默的——直到一条失控 pattern 到来的那一天。这种失败形态具体而常见:安全机制也是代码,它和代码一样会退化,但与功能退化不同,护栏退化在正常运行中没有任何用户可见症状。它恰恰在一切健康的时候隐形。
修复不是”不要重构”。修复是把护栏当作被测试的行为对待:测试套件里应当放入一个已知坏、已知贵的 pattern,并断言系统会卸掉或限制它,这样删掉护栏就会让构建变红。注意边界:这样的测试只保护你记得要测的那个护栏。它不会为还没有人想象到的失败模式发明护栏——这正是为什么它必须和成本上限、和下文的灰度部署搭配使用,而不是替代它们。
放大器 3:测试套件测的是正确性,而事故是一次成本失败
直到部署为止,流程都被正确执行了——postmortem 特别强调这一点。Pull Request 被提出并获批;CI 构建代码并测试;带有标准操作程序、部署计划和回滚计划的 Change Request 被生成。13:31,PR 合并。13:37,TeamCity 构建规则并运行测试,全绿。13:42,变更自动部署。
那么”全绿”是什么意思?WAF 测试套件测的是核心 WAF 功能是否工作:单个匹配函数的单元测试,然后一大组 HTTP 请求,用来检查攻击被拦住、合法请求被放行。它没有做的,用 Cloudflare 的话说,是测 WAF 的失控 CPU 消耗。更糟的是,回看之前 WAF 构建的日志,新规则没有让测试套件运行时间增加——测试语料里根本没有把这条 pattern 推进其昂贵区域的输入,于是连”套件是不是变慢了”这种非正式信号都保持平坦。
这是一次测量范围的失败,不是用心程度的失败。套件正确回答了它被设计来回答的问题——规则能不能抓住对的东西、会不会误拦。事故住在一个没人装仪表的问题里:这条规则在最坏情况下,每请求要花多少成本? 一条规则可以完全正确,同时仍然是一次宕机,因为正确性和成本是两个独立属性,生产会惩罚你没测的那一个。Cloudflare 的修复——给测试套件加全规则性能剖析——正好补上这个缺口,但有一个诚实的边界:性能剖析只能抓住你的测试输入实际触发的昂贵 pattern。没有任何测试输入长得像的对抗性与偶然最坏情况,仍然可能溜过去——这再次说明为什么需要运行时 CPU 上限:测试减少你需要上限的频率,上限在测试漏掉时约束损失。
放大器 4:一条例行变更,被允许走了紧急通道——全球、一步到位、几秒
Cloudflare 知道怎么做灰度部署。它常规的软件流程在 postmortem 里被详细描述,形式上堪称范本:构建测试、生成 Change Request、获批,然后部署到 animal PoPs——先是 DOG,一个只供员工使用的内部 PoP;再是 PIG,一个承载一小部分非付费客户流量的 PoP;然后是分布在全球的三个 Canary PoP,承载付费与非付费流量——最后才全球上线。整个序列可以花几小时到几天。
而 WAF 规则按设计不走这个流程,因为需要快速响应威胁。规则变更的 SOP 明确允许它全球推送。而 Quicksilver 让”全球”意味着几秒:p99 2.29 秒到达地球上每一台机器。
现在把三件事合起来。这次变更是非紧急的——一条 XSS 检测改进,以 simulate 模式部署来测量它——但它走的是紧急通道,带着紧急的速度和紧急的爆炸半径,因为这条通道是按变更类型(WAF 规则)而不是按变更紧迫程度定义的。没有一个 DOG 阶段让员工流量先被烧掉、在客户无感时把团队叫起来。没有一个金丝雀城市让 CPU 在一小部分流量上冲高、让部署得以叫停。灰度机器就在同一栋楼里、为同一张网络存在,而这一类变更被豁免于它。
Cloudflare 的修复把 tradeoff 明说出来,而不是假装它不存在:规则也做灰度部署,与其他软件同样的方式,同时保留针对活跃攻击的紧急全球部署能力。这个保留很重要。教训不是”永远不要快速全球上线”——在活跃利用浪潮里,慢也是一种失败。教训是快速通道必须由紧迫程度把关,而不是由类别把关,进入它应当是一个有意识、被记录的决定,因为它的爆炸半径是约两秒钟内的整张网络。
还有一个加速器。回滚计划要求把完整的 WAF 构建跑两遍,耗时太长——于是文档里写的那条回头路,并不是实际走的那条。真正恢复服务的不是回滚,而是 global terminate 开关,这就带到了最后一个放大器。
放大器 5:终止开关有效,但通往它的路穿过火场
14:00,WAF 被确认为元凶。14:02,团队提出 global terminate——Cloudflare 内置的一个在全球范围禁用单个组件的机制。它一旦执行,效果几乎即时:CPU 回落、流量在 14:09 恢复,其余的 Cloudflare 保护机制继续运行。
通往它的五分钟,是时间线不该藏起来的部分。Cloudflare 用自己的产品,而它的 Access 服务和其他一切一起倒下了,于是团队无法认证进入内部控制面板。部分团队成员在访问恢复后发现自己还是进不去,因为一个安全特性会禁用不够频繁使用的凭证。Jira 和构建系统等内部服务同样够不到,够到它们需要一个不常使用的 bypass 机制——postmortem 尖锐地补了一句,这是事后要再练的又一件事。客户从外面撞上同一堵墙:Cloudflare 仪表盘和 API 不可用,因为它们要穿过 Cloudflare 边缘——也就是倒下的那个东西。
这与本系列第一篇 Meta BGP 宕机是同一种恢复耦合形态,只是换了身衣服:控制路径与它所控制的数据路径同命运。认证、内部工具、客户控制面和 bypass 程序,全都直接或经由生疏而依赖于它们本应去拯救的那张网络。终止开关本身设计得很好——单个组件、全球禁用、其余保护继续运行。失败的是开关周围的一切:身份、访问与演练。
Cloudflare 列出的修复一一对应这个诊断:一个在紧急情况下把仪表盘和 API 从 Cloudflare 自家边缘上拿下来的应急能力,以及——藏在那句”要练”里——把 bypass 练到无聊为止。还要注意终止开关修不了什么:它以移除一层保护为代价恢复服务。从 14:07 到 14:52 WAF 被安全重新启用为止,网络是在没有 Managed Rulesets 的情况下运行的。终止开关用可用性买时间,价格是这个组件存在所要提供的那份保护本身;它是止血带,不是治疗,而重新启用前在一座城市里做 45 分钟测试,正是负责任的松止血带方式。
Cloudflare 改了什么——以及每项改动的边界
Postmortem 的修复清单,以及 2019 年 7 月 12 日发布时的状态:
- 重新引入被移除的过度 CPU 保护。 发布时已完成。它约束失控规则,但约束得有多好,取决于它的阈值、以及它自身对再次被重构掉的抵抗力。
- 人工检查全部 3,868 条规则(WAF Managed Rules),找其他回溯风险。发布时检查完成。必要,但本质是一次性的:它清理现有语料,不管下一条规则。
- 给测试套件加全规则性能剖析。 ETA 2019 年 7 月 19 日。把成本从不可测变成可测——针对套件实际跑到的输入。
- 切换到 RE2 或 Rust 正则引擎,两者都有运行时保证。ETA 2019 年 7 月 31 日。从结构上移除灾难性回溯,代价是重写依赖回溯独有特性的 pattern。
- 规则像其他软件一样灰度部署,同时保留针对活跃攻击的紧急全球部署。把默认爆炸半径从”几秒内无处不在”改成”先一个 PoP、再几个、然后全网”——同时刻意保留那条危险通道,由紧迫程度把关。
- 一条把仪表盘和 API 在紧急时拿下边缘的路径,以及自动更新状态页。前者把客户控制面从失效的数据面上解耦;后者针对十一项清单里两个更安静的条目——第一个全球流量下跌告警触发太慢,以及状态页更新不够快。
- 长期:把 WAF 从 Lua 实现移植到新的防火墙引擎,为了速度,也为了多一层保护。
作为一个整体来读,这份修复是正确做法的纵深防御:引擎保证约束 pattern,CPU 上限约束规则,性能剖析约束能上线什么,灰度约束它上线到哪里,解耦的控制面约束恢复要花多久。没有任何一项声称独自防止重演——而这恰恰是对的,因为这次宕机本来就不是单点失败。
值得偷走的教训
- 在热路径上,成本就是正确性的一部分。 一条给出正确答案但太慢的 pattern、查询或规则,在生产里就是错的。用对实现怀有敌意的输入去测最坏情况成本,而不只是用代表性流量测——否则生产会替你跑这项测试。
- 护栏会安静地退化;要测护栏本身,而不只是测系统。 如果一个 CPU 上限、限流器或熔断器重要,你的套件里需要一个在它被移除时变红的用例。那种”只是清理一下”的重构,恰恰是最可能删掉某个没人记得的护栏的变更。
- 部署速度就是爆炸半径。 一套能在 2.29 秒内把变更传到每台机器的系统,同时也决定了坏变更在 2.29 秒内无处不在。速度对你想要的变更是功能,对你不想要的变更是倍增器——所以即便传播本身不需要阶段,发布也需要,尤其在传播极快时。
- 快速通道按紧迫程度把关,不按类别把关。 “WAF 规则走全球”是一条类别规则,它让一条非紧急的、simulate 模式的测量变更走了紧急路径。保留紧急通道;让进入它成为一个署名的、自觉的决定。
- Simulate 模式仍然是生产执行。 任何拿真实代码跑真实流量的模式,都在花真实的 CPU、真实的内存和真实的尾延迟。”它不拦截任何东西”只限制一种伤害——错误决策——对资源伤害毫无限制。
- 终止开关的好坏,取决于通往它的路。 一个几秒生效的全球终止,仍然花了五分钟才够到,因为认证、内部工具和 bypass 要么与宕机同命运,要么因生疏而失效。把开关建好、把它的访问路径解耦、把 bypass 练到常规为止——没练过的 bypass 是传闻,不是程序。
- 给不受信任的 pattern 工作选有保证的引擎。 当 pattern 由许多人快速编写、面对任意输入时,回溯引擎让每一位作者离一次宕机只差一个
.*.*。线性时间引擎从结构上移除一整类失败,而不是依赖每一位作者永远不犯这个错。
Postmortem 没有说的
Cloudflare 的详细 postmortem 是这一体裁最强的公开范例之一——它公布了出事的正则、数了它的步数、列了十一个叠加成因、给修复标了日期。但它的边界还是要指出。它没有在流量跌幅和 502 窗口之外量化客户影响:没有受影响客户数或请求数,也没有收入或 SLA 赔付数字。它没有在”重构中误删”之外解释 CPU 保护是怎么被移除的,也没有解释评审和测试为什么没拦住这次移除——护栏消失的机制是断言,不是重建。它的修复 ETA(2019 年 7 月 19 日和 7 月 31 日)是 7 月 12 日发布时陈述的计划,帖子本身没有确认其完成情况。和本系列的 Meta postmortem 一样,要把机制证据的权重——在这里异常强——和修复承诺分开称。
Postmortem 开头还有一个近乎文学性的细节。它的作者 John Graham-Cumming,在 2010 年那次宕机时是 Cloudflare 的客户,曾写信给它的 CEO 追问自己的 DNS 去哪了,得到的——并为之称赞的——是一封冗长而透明的技术回复。九年后,写这封透明帖子的人成了他自己。其下的贯穿线正是所有机制讨论底下真正的产品教训:那些能让你学到这么多的 postmortem 背后的公司,是提前用文字定下了”我们就这样解释自己”的公司。那个决定,和灰度部署、和被测试的护栏一样,必须在需要它的那一天到来之前就做好。
来源
- John Graham-Cumming, “Details of the Cloudflare outage on July 2, 2019,” Cloudflare Blog, July 12, 2019. https://blog.cloudflare.com/details-of-the-cloudflare-outage-on-july-2-2019/ ——除非另有注明,本文一切内容的主要来源:从 13:42 部署到 14:07 终止、14:52 重新启用的完整时间线、出事的正则表达式与步数计数(
x=x23 步;x=加 20 个x555 步;不匹配的 20 字符字符串 4,067 步;分号失败变体 5,353 步)、Quicksilver 数字(180 多座城市、约每秒 350 个变更、p99 2.29 秒)、60 天 476 个变更请求、simulate 模式的执行细节、DOG / PIG / Canary 软件流程与全球 WAF SOP 的对比、包含重构中被移除的 CPU 保护在内的十一个叠加成因、Access 与凭证对全球终止造成的障碍、3,868 条规则检查,以及带日期的修复清单。 - Matthew Prince, “Cloudflare outage caused by bad software deploy (updated),” Cloudflare Blog, July 2, 2019. https://blog.cloudflare.com/cloudflare-outage/ ——当天的主要来源:宕机自 1342 UTC 开始、确认这不是攻击、最严重时流量下跌 82%、14:02 UTC 决定对 WAF Managed Rulesets 执行全球终止,以及在复查并测试回滚后于 1452 UTC 重新启用。当天帖子与详细 postmortem 对 14:07–14:09 终止窗口的压缩方式不同,本文按详细 postmortem 写并已在时间线说明中注明。
Share on
Twitter Facebook LinkedIn☕ Buy me a coffee! 💝
If you found this article helpful, consider buying me a coffee to support my work! 🚀
