大多数分布式系统的架构图会画出”层”:接入层、逻辑层、存储层,每一层由不同的机器、不同的软件构成。Cloudflare 的边缘网络刻意反其道而行:同一批服务器、同一段 IP 地址、同一套二进制程序,部署在全球每一个数据中心,处理 DNS、HTTP、TLS、DDoS 过滤和用户上传的代码。这不是偷懒,而是一套经过反复验证的取舍——用统一性换运维复杂度,用软件定义的隔离换容器和虚拟机的资源开销,用可抢占、可重建的边缘机器换传统数据中心里”稳定驻留”的假设。
这套取舍在多数场景下工作得很好,但并非没有代价。当 Discord 试图把有状态、长连接的语音通话迁移到 Cloudflare 边缘时,双方都撞上了这套哲学没有预留的角落。
本文从四个层面拆解 Cloudflare 的架构:网络层的 Anycast 如何把”就近接入”和”抗 DDoS”变成同一件事;操作系统层的 BPF 如何把边缘服务器的数据面几乎整个搬进内核;计算层的 V8 Isolate 如何在没有容器和虚拟机的情况下运行数百万租户的代码;以及安全层如何在无法”修复”Spectre 的前提下把攻击拖慢到没有意义。最后回到这套架构本身的边界——它的经济学假设在哪些工作负载下会失效。
一、把全球网络当成一台机器
Cloudflare 的核心叙事是”网络即计算机”(The Network is the Computer):客户端不需要知道请求最终由哪个数据中心的哪台机器处理,只需要访问一个固定的 IP 或域名,网络本身负责把请求送到最合适的位置。这个想法并不新——CDN 行业几十年来都在做”就近接入”——但 Cloudflare 把它推向了一个更彻底的方向:不仅内容分发就近,连计算(Workers)、DNS 解析、DDoS 过滤、TLS 终止都在同一批边缘机器上完成,而且这批机器数量巨大、分布极广。
这种规模本身也在持续变化。不同年份的官方与第三方资料给出的数据点各不相同,统计口径(“数据中心”与”PoP”未必是同一概念)也可能不一致,下表按发布时间罗列,不做外推:
| 时间 | 规模描述 | 来源 |
|---|---|---|
| 2011 | 12 个数据中心 | Cloudflare Blog,“A Brief Primer on Anycast” |
| 2019 | Edge 服务器覆盖全球 180 个地点 | Cloudflare Blog,“Cloudflare architecture and how BPF eats the world” |
| 2020 | 200 个 Cloudflare 数据中心,且还在增长 | Cloudflare Blog,“Mitigating Spectre…” |
| 2021 | 200 多个数据中心 | Cloudflare Blog,“Containers at the edge” |
| 2026 | 300 多个城市的 PoP(第三方视角) | Discord Blog,“How We Moved Discord Voice to the Edge” |
这张表本身就是一个提醒:架构案例文章最容易犯的错误,是把某个时间点的宣传数字当成永远成立的事实。本文后续引用的具体数字(延迟、内存占用、启动时间)都标注了来源和发布时间,读者在复现或引用时应对照当前官方文档。
要让”网络即计算机”这个叙事成立,至少需要解决三个独立的工程问题:
- 怎么把请求路由到最近的机器——这是网络层的问题,答案是 Anycast。
- 怎么让每台边缘机器都能处理任意一种流量——这是操作系统和软件栈组织的问题,答案是同构软件栈加 BPF/XDP。
- 怎么在同一台机器上安全地运行数十万互不信任的租户代码——这是计算隔离的问题,答案是 V8 Isolate 而不是容器或虚拟机。
后面三节依次拆解这三个答案,第七、八节讨论这套架构的经济学假设和它在真实外部负载下暴露的边界。整篇文章的行文结构可以概括为下表:
| 章节 | 核心问题 | 主要依据 |
|---|---|---|
| 二、Anycast | 怎么把请求路由到最近的机器,以及这个假设在哪里失效 | Cloudflare Blog(2011);RFC 1546 / 4786 / 7094 |
| 三、同构边缘软件栈 | 怎么让每台机器都能处理任意流量 | Cloudflare Blog(2019) |
| 四、BPF 处理流水线 | 内核里具体怎么实现 DoS 过滤、负载均衡、socket 分发 | Cloudflare Blog(2019) |
| 五、Workers 与 V8 Isolate | 怎么在一台机器上安全地运行数十万租户的代码 | Cloudflare Blog(2018);Cloudflare Workers Docs |
| 六、安全模型与 Spectre | 隔离层级如何设计,Spectre 这类硬件级漏洞如何应对 | Cloudflare Blog(2020、2021) |
| 七、设计哲学 | 这套架构的经济学假设是什么,对什么工作负载成立 | Cloudflare Blog(2018、2021) |
| 八、张力 | 假设在真实外部负载下如何失效 | Discord Blog(2026) |
| 九、开放问题 | 哪些问题目前没有通用答案 | 综合前述来源的推理 |
二、Anycast:让”最近”成为网络层的默认行为
2.1 从 Unicast 到 Anycast
互联网上绝大多数服务使用 Unicast:一个 IP 地址对应一台机器(或一组通过负载均衡器聚合成单一出口的机器),路由器维护到每个 IP 的”最短路径”,层层转发直到包到达目的地。
不管从世界哪个角落发起请求,Unicast 场景下的流量最终都会汇聚到同一个物理位置。
Anycast 则让多台地理上分散的机器共享同一个 IP 地址段。当多个数据中心同时通过 BGP(Border Gateway Protocol)宣告同一个 IP 前缀时,互联网骨干路由器会按照各自的路径选择算法,把发往这个 IP 的流量转发到(从该路由器的视角看)拓扑最近的一处宣告点。Cloudflare 从创立初期就采用了这种方案:早在 2011 年的一篇说明性文章里,Cloudflare 创始人 Matthew Prince 就用 traceroute 案例展示了同一个域名解析出的 IP,从北加州访问会落在圣何塞的数据中心,因为当时圣何塞是网络拓扑上离该用户最近的宣告点[1]。
2.2 Anycast 带来的三个收益
那篇 2011 年的文章总结了 Anycast 的三个核心价值,这三点至今仍是 Cloudflare 架构叙事的基石[1]:
速度:由于流量被路由到拓扑最近的数据中心,末端 RTT(Round-Trip Time)大幅缩短。文章给出的例子里,到最近数据中心的最后一跳延迟约为 9.5 毫秒,而对比同一用户到源站(Unicast,达拉斯)的直接延迟约 50 毫秒。
韧性:因为流量会”自动寻找”下一条可用路径,运维人员可以直接下线整个数据中心的 BGP 宣告,流量在路由收敛后会自动转移到下一个最近的数据中心,不需要额外的应用层容灾切换逻辑。
抗攻击能力:DDoS 攻击通常由分布在全球各地、彼此不通信的”僵尸”主机(构成 botnet)发起。Anycast 网络的结构决定了这些攻击流量会按照攻击源的地理分布被自然拆分到多个数据中心,每个数据中心只承受总攻击流量的一部分,而不是全部流量涌向单一 Unicast 出口。这不是一种”检测并拦截”的主动防御,而是网络拓扑本身自带的被动分散效应——随着 Cloudflare 部署更多数据中心,对任意单一客户发起有效 DDoS 所需要的攻击带宽也随之上升[1]。
“韧性”这一点值得再展开一层:它依赖的是 BGP 路由收敛这个通用机制,而不是任何 Cloudflare 专有的故障转移代码。
stateDiagram-v2
[*] --> Announcing: PoP starts announcing prefix via BGP
Announcing --> Serving: routers converge, traffic flows in
Serving --> Draining: operator withdraws BGP announcement
Draining --> Withdrawn: upstream routers recalculate paths
Withdrawn --> [*]: traffic now flows to next-nearest PoP
Serving --> Serving: normal operation, no application-level failover needed
这张状态图对应第 2.2 节里”下线整个数据中心,流量自动转移”的描述:从运维视角看,这只是一次 BGP 宣告的撤回(withdraw),不需要在应用层写任何”检测故障并切换”的代码——收敛过程完全由互联网路由系统本身完成。这也解释了为什么 Anycast 常被视为比传统的健康检查加 DNS 切换更快的容灾手段:它把”发现故障”和”切换流量”两件事,都下放给了本来就要持续运行的路由协议。
这三点里,第三点最容易被误解成”Anycast 就是抗 DDoS 的护城河”。更准确的说法是:Anycast 把攻击流量从”集中打一个点”变成”分散打很多点”,把峰值压力问题变成了每个数据中心各自的入站过载问题——而入站过载具体怎么处理(限流、连接数控制、丢包优先级),是另一层需要独立解决的工程问题。站内《限流与过载保护》一文详细讨论了单个服务在入站流量超出处理能力时的应对手段[9];Anycast 解决的是”让每个节点分到的份额足够小”,而不是”让节点在承受这份流量时不崩溃”,两者是互补而非替代关系。
2.3 Anycast 请求路径
graph TB
IP["Shared Anycast IP<br/>e.g. 1.1.1.1"]
IP -.BGP announce.-> PoP1["PoP: Tokyo"]
IP -.BGP announce.-> PoP2["PoP: Frankfurt"]
IP -.BGP announce.-> PoP3["PoP: San Jose"]
U1["User in Osaka"] -->|shortest AS path| PoP1
U2["User in Munich"] -->|shortest AS path| PoP2
U3["User in Fremont"] -->|shortest AS path| PoP3
A1["Botnet node A"] -.attack traffic.-> PoP1
A2["Botnet node B"] -.attack traffic.-> PoP2
A3["Botnet node C"] -.attack traffic.-> PoP3
PoP1 -->|cache hit, no origin round trip| U1
PoP2 -->|cache hit, no origin round trip| U2
PoP3 -->|cache hit, no origin round trip| U3
图中三个数据中心宣告同一个 IP,正常用户请求按 BGP 最短路径就近接入;同一时刻的攻击流量因为发起方分散在不同网络位置,也被同一套路由机制自然拆散到三个数据中心,而不是全部涌向一处。DNS 路由与 Anycast 路由的详细取舍(调度精度、故障切换速度、部署复杂度)已经在《CDN 架构》一文的第二节做过系统对比[8],这里不再重复。
只强调一点:Cloudflare 选择以 Anycast 而非 DNS 调度为主,是后面两节要讲的”同构软件栈”和”BPF 处理流水线”能够成立的前提——因为 Anycast 保证了同一个 IP 背后可能是任意一台边缘机器,所以每台边缘机器都必须具备处理任意流量的能力,不能像传统 DNS 调度那样把不同职责的机器分池管理。
2.4 Anycast 不是”打开开关”就能获得的能力
2011 年那篇文章的结尾特意提醒读者:真正搭建一张可用的 Anycast 网络并不简单,它要求运营方拥有自己的硬件、和上游运营商建立直接的对接关系(direct relationships with upstream carriers),并持续调优网络路由,防止流量在多个数据中心之间来回”抖动”(flap)[1]。
这句 2011 年的提醒和第 8.2 节鹿特丹案例里 Discord 与 Orange、Telia 之间的转接路径饱和问题遥相呼应——十五年前那句”要建立直接对接关系”的告诫,在真实的大规模流量迁移中具体表现为”哪怕拓扑距离更近,中间转接商的容量跟不上,效果照样会变差”,说明 Anycast 真正依赖的不只是宣告同一个 IP 这个动作本身,还包括宣告方在物理层面和对等网络之间建立起来的对接关系质量。
文章同时提到了两个和延迟收益直接相关的细节,解释了为什么”最后一跳只有 9.5 毫秒”这个数字背后不只是”离用户更近”这一个原因:Cloudflare 当时能够直接从边缘缓存回答大约 75% 的请求,不需要每次都回源;而对于确实需要回源的请求,Cloudflare 会优先选择原本网络路径就会途经的核心 IBX 数据中心作为宣告点,使得”去边缘节点”这一段路径基本不会给”最终仍要回源”的请求带来额外的绕路开销[1]。
换句话说,Anycast 带来的速度收益,一部分来自路由层面的就近接入,另一部分来自缓存命中率和数据中心选址策略的协同——三者叠加,共同解释了那 9.5 毫秒对比 50 毫秒的差距,而不能把全部收益都归因于 Anycast 这一项机制。
2.5 标准里早就写明的一个警告:Anycast 对有状态传输不友好
Anycast 的学术谱系比 Cloudflare 的工程实践早了将近二十年。最早的正式定义来自 Partridge、Mendez 与 Milliken 在 1993 年发表的 RFC 1546《Host Anycasting Service》:这份文档把 Anycast 定义成一种无状态(stateless)的尽力而为(best-effort)投递服务——网络只承诺把一个数据报送到”至少一个、最好只有一个”响应该地址的服务器,不承诺同一个会话的后续包一定落在同一台服务器上[11]。
这个定义本身就已经暗示了问题所在:一旦上层协议是有状态的,Anycast 的路由自由度就变成了风险。
十三年后,Abley 与 Lindqvist 在 IETF GROW 工作组发布的 RFC 4786(BCP 126,《Operation of Anycast Services》)把这个警告落到了具体协议层面:当路由系统存在多条等价路径(Equal-Cost Multi-Path,也就是第四节要讲的 ECMP)并且这些路径通向不同的 Anycast 节点时,属于同一个 TCP 事务的不同数据包有可能被分别送到不同的节点——TCP 建连本身就需要三次握手的多个包来回,任何一次握手中途的路径切换都可能导致连接建立失败或被重置[12]。
同时,若路由路径本身对报文做了逐包负载均衡(Per-Packet Load Balancing),还会造成持续的乱序,进一步拖累 TCP 的性能表现,文档甚至将这类持续乱序的网络路径称为”病态”(pathological)[12]。
2014 年的 RFC 7094 在回顾这份操作指南时补充了一句更直白的判断:运营者应当意识到,尤其对于长时间运行的流(long running flows),使用 Anycast 存在比 Unicast 下”目标不可达”更复杂的潜在失败模式[13]。
这条从 1993 年到 2014 年的标准演进线索,恰好预告了本文第八节要讲的真实故事:Anycast 和第三、四节的同构软件栈能够很好地服务短生命周期、无状态或弱状态的请求(一次 HTTP 请求、一次 DNS 查询),但对于建立时间长、要求路径保持稳定的会话——比如一通语音通话——Anycast”就近路由”这个假设本身就需要额外的应用层设计去弥补,而不能指望网络层白白解决。
三、同构边缘软件栈:每台机器都是万能节点
3.1 “每台服务器运行所有服务”——但先要说清楚是哪批服务器
在讲”同构”之前,有一个容易被忽略的前提需要先澄清:Cloudflare 的服务器并不是铁板一块的一个整体。Majkowski 在演讲开场就说明,Cloudflare 运营两类数据中心——“Core”(核心)数据中心负责处理日志、分析攻击、计算统计报表;“Edge”(边缘)服务器集群负责向终端用户交付内容,分布在(演讲发布时)全球 180 个地点[2]。
本文第二到第六节讨论的 Anycast、同构软件栈、BPF 流水线、Workers,全部指的是 Edge 这一类服务器,而不是 Core——“同构”这个设计选择是边缘服务器特有的取舍,不代表 Cloudflare 内部所有机器都运行同一套软件。
厘清这个前提后再看”同构”:多数大规模系统会按职责拆分机器角色:一批机器跑 DNS,一批跑 HTTP 代理,一批跑安全过滤。Cloudflare 的边缘服务器反过来:Majkowski 明确写道,边缘服务器的软件配置是统一的(uniform),“所有软件片段都运行在所有服务器上”,包括完整的 HTTP 栈、Cloudflare Workers、权威 DNS 与递归 DNS 两套 DNS 服务,以及 Spectrum、WARP 等面向公网的其他应用[2]。原则上,任何一台边缘机器都能处理任何一类任务。
这种同构性直接服务于第二节的 Anycast 设计:如果流量落到哪台机器完全由 BGP 路径决定,运维方就没有办法预先保证”DNS 流量只会落在 DNS 机器上”,唯一可行的做法就是让每台机器都具备处理全部流量类型的能力。反过来,同构性也降低了扩容和运维的复杂度——新增一个数据中心只需要复制同一套标准化配置,不需要按流量类型分别规划各类机器的容量比例。
3.2 一次请求跨越多台机器
同构不等于”一次请求只碰一台机器”。Majkowski 的演讲特别指出,即便每台服务器安装了全部软件,一次 HTTP 请求在处理过程中通常仍会跨越多台不同的物理机——他给出的例子是请求处理的 5 个阶段可能分别由不同机器完成[2]。这是因为”同构”解决的是软件部署一致性问题,而请求内部仍然需要经过路由聚合、DDoS 过滤、负载均衡重定向、协议终止等多个独立阶段,每个阶段可以被分派到当前负载最轻的机器上执行,而不是绑死在第一台接收到包的机器上。第四节详细展开这 5 个阶段具体做了什么。
3.3 同构性的第二重红利:跨时区的统计复用
同构软件栈还带来一个不那么直观的好处:闲置容量可以被任意用途复用,而不需要按用途预留独立的机器池。Cloudflare 在 2021 年宣布把容器引擎引入边缘时提到了一个具体场景:任何一个地理位置的流量都存在昼夜周期,白天高峰时段的算力紧张,深夜时段则大量闲置[6]。
因为边缘机器不是按”这台专门跑 A 业务、那台专门跑 B 业务”预先划分的,运维方可以把对延迟不敏感的批处理任务(例如 CI 构建)调度到当前处于本地深夜、流量低谷的数据中心去执行,用全球时区错位换取计算资源的统计复用;Cloudflare 自己面向定时任务的调度器(Cron Triggers)就运行在这套内部容器服务上,因为定时任务不需要等一个真实用户在原地等待响应,可以被安排到任意一个当前空闲的地点执行[6]。
这个设计选择再次印证了第一节的判断:同构不是为了”整齐”,而是为了让”闲置容量”和”紧急需求”可以在同一批物理资源上自由调配,不必为每一种工作负载单独规划容量水位线。
3.4 标准视角下的另一种解法:RFC 4786 的”多服务节点”问题
第 2.5 节引用过 RFC 4786 对有状态传输的警告,这份文档还单独讨论过一个和本节主题几乎完全对应的问题:当一组 Anycast 节点需要同时承载多种服务时,该如何向路由系统通告可用性,才不至于让请求落到没有部署该服务的节点上?文档给出了三种做法,并各自指出了代价[12]:
- 多个独立覆盖前缀(Multiple Covering Prefixes)——每个服务用独立的 IP 前缀单独宣告,宣告与撤回只和这一个服务的可用性挂钩。做法最直接,但对全局唯一地址空间的利用率很差,文档明确指出这种方式”无法规模化”,只适合根 DNS 这类极少数关键基础设施服务。
- 悲观撤回(Pessimistic Withdrawal)——多个服务共享同一个覆盖前缀,只要其中任意一个服务不可用,就撤回整个前缀的宣告。问题在于一个服务故障会把同一节点上其他工作正常的服务也一并拖下线,文档认为这对很多应用场景是不可接受的。
- 节点内部互联(Intra-Node Interior Connectivity)——节点之间用隧道等方式建立内部连通,某个节点本地的服务不可用时,把请求通过内部网络转发到其他节点处理;代价是节点之间的自治性降低,负责内部互联的 IGP(内部网关协议)本身变成了一个新的单点故障来源。
这三种做法的共同前提是”节点上只部署了部分服务,所以需要一套机制协调宣告与实际可用性之间的一致性”。把这个前提和第 3.1 节对照就能看出,Cloudflare 的同构软件栈实际上绕开了整套问题:因为每台边缘机器都装有全部服务的完整软件栈,“某个服务在某个节点不可用”这种情况不再是”这台机器没装这个服务”的部署缺口,而单纯是单机故障或容量问题——不需要为每个服务单独维护一套精细的宣告耦合逻辑,也不需要在”服务级隔离”和”节点自治”之间做 RFC 4786 列出的这类取舍。
这不是说 Cloudflare 发明了一种标准之外的全新范式,而是说”把所有服务都装到所有节点上”提前消除了促使这三种方案被写进操作规范的那个前提条件本身。
四、BPF 吃掉软件栈:内核里的边缘处理流水线
4.1 从路由器到应用的五个阶段
Majkowski 在演讲中把入站数据包在边缘服务器内部的处理路径拆成五步[2]:
- 数据包先到达 Cloudflare 的路由器,路由器执行 ECMP(Equal-Cost Multi-Path),把发往同一个目标 IP 的流量粗粒度地分摊到至少 16 台机器上,作为一种初级负载均衡手段。
- 数据包被摊到具体的 Linux 服务器后,首先由 XDP(eXpress Data Path)eBPF 程序摄入。这一阶段先执行体积型 DoS 缓解:识别并丢弃属于大流量 L3 攻击的数据包,越早丢弃,消耗的 CPU 和内存越少。
- 仍然在 XDP 阶段,接下来执行 L4 负载均衡:把非攻击流量重新定向到其他机器。这一步的意义是绕开 ECMP 的固有缺陷——ECMP 是无状态的哈希分摊,一旦某台机器加入或退出集群,哈希结果会重新洗牌,导致已经建立的连接被分配到错误的机器上;XDP 里的这层重定向提供了更细粒度的负载均衡,也让运维可以优雅地把某台机器从服务中摘除而不打断已有连接。
- 完成重定向后,数据包才真正进入 Linux 常规网络栈,经过 iptables 防火墙规则,最后被分发到对应的网络 socket。
- 应用层收到数据包。以 HTTP 流量为例,接手的是一个”协议服务器”(protocol server),负责 TLS 加解密以及 HTTP/1.1、HTTP/2、QUIC 协议的处理。
sequenceDiagram
participant Router as Border Router (ECMP)
participant XDP1 as XDP eBPF: volumetric DoS filter
participant XDP2 as XDP eBPF: L4 load balancer
participant Kernel as Linux TCP/IP stack + iptables
participant App as Protocol server (TLS/HTTP/DNS/Workers)
Router->>XDP1: packet, hashed onto one of 16+ machines
XDP1->>XDP1: drop packets matching volumetric attack signatures
XDP1->>XDP2: surviving packets
XDP2->>XDP2: redirect across machines (bypass ECMP reshuffle)
XDP2->>Kernel: packet reaches designated machine
Kernel->>Kernel: iptables xt_bpf filtering, socket dispatch
Kernel->>App: dispatched to correct socket
App-->>App: TLS termination, HTTP/1.1/2/QUIC parsing
4.2 为什么 DoS、负载均衡、Socket 分发都下沉到内核
这条流水线里最值得注意的地方,是几乎每一步都用 BPF(Berkeley Packet Filter)及其现代变体 eBPF/XDP 实现,而不是在用户态应用里处理。Majkowski 把这些用法归纳成三类:DoS 处理、负载均衡、Socket 分发[2]。
DoS 处理:早期的体积型攻击缓解用的是传统
BPF 和 iptables 风格规则,后来迁移到 XDP eBPF
上执行,过程中暴露了 eBPF
早期版本缺乏并发原语的问题——实现无竞争的令牌桶(token
bucket)非常困难,直到 bpf_spin_lock
帮助函数出现才被解决[2]。
应用层(L7)的攻击缓解仍然依赖 iptables 的
connlimit、hashlimit、ipset 等高层防火墙特性,并通过
xt_bpf 模块在 iptables 里运行经典 BPF
来匹配报文载荷特征[2]。
团队在把 UDP socket 限速迁到 eBPF 的过程中还踩到过一个内核本身的坑:eBPF 验证器(verifier,负责静态检查每个提交的 eBPF 程序是否安全)在挂载到 UDP socket 这个用法上暴露了一个此前没人触发过的 bug,Majkowski 半开玩笑地评论说”大概很少有人把 eBPF 挂在 UDP socket 上”[2]——这个细节说明”把数据面搬进内核”这件事本身也在推动内核基础设施的成熟,Cloudflare 不只是 eBPF 的使用者,也是它早期不成熟阶段的踩坑者和贡献者。
L4 负载均衡的一个具体难点:在 XDP
阶段做基于 5
元组(源地址、目的地址、源端口、目的端口、协议)的 socket
查找时,团队使用了内核提供的 bpf_sk_lookup
帮助函数,但遇到一个边界情况——当服务端开启 SYN
cookie(一种在连接队列被打满时仍能完成三次握手的防护机制)时,无法直接判断收到的一个
ACK 包是否是一次合法三次握手的一部分[2]。
这类问题恰好说明第三、四节反复强调的”下沉到内核”并不是一次性铺好就一劳永逸——像 SYN cookie 这样几十年前就存在的机制,一旦和新的 eBPF 处理路径结合,仍然会暴露出需要专门工程投入才能补齐的边界情况。
Socket 分发在超大规模下的特殊解法:Cloudflare 在 2019 年运行着超过 3 万个 DNS IP 地址[2]。对每个 IP 分别开一个 UDP socket 曾经是隔离故障域的手段——一次未被拦截的 UDP 洪泛只会影响这一个 IP 的流量。但在 3 万个 IP 的规模下,维护 3 万个 socket 已不现实[2]。
现在的方案是用单个 UDP socket 搭配一段复杂的 eBPF socket
filter(通过 SO_ATTACH_BPF 挂载),在 eBPF map
里维护每个源 IP
的包计数状态,对洪泛流量做限速,同时保证一个被打爆的 IP
不会拖累同一 socket 上其他 IP 的流量[2]。
为兼容超大规模而定制内核:在数万个 IP 地址的规模下,即便是”让 Linux 正确路由这些 IP 的数据包”这件事本身也不是开箱即用的——团队用一个被称为”AnyIP”的技巧解决了这一步,把大范围的 IP 前缀当作本地地址处理,而不需要为每一个具体 IP 单独配置网络接口[2]。
但路由通了之后还有第二个问题:标准 Linux 的
bind() 语义只能绑定到具体 IP 或全部
IP(0.0.0.0),不支持”绑定到一个 IP
前缀,把这个前缀里所有地址的流量分给同一个应用,但保留其他前缀给另一个应用”。Cloudflare
为此开发了自定义内核补丁,新增 SO_BINDTOPREFIX
socket 选项,解决多个应用共享 80、53 等常用端口、但按不同 IP
段路由的问题[2]。
对于需要监听全部 65535 个端口的 Spectrum 产品,团队没有选择开 6.5 万个监听 socket,而是复用了 iptables 里一个相对冷门的模块 TPROXY 来实现[2]。
应用层性能优化:BPF 的用途不止于丢包和转发决策,还延伸到已经建立的 TCP 连接内部。Majkowski 提到团队使用 SOCKMAP 这个 eBPF 机制在内核态直接把数据从一个 socket 转发到另一个 socket(socket splicing),不需要把数据先拷贝到用户态再拷回内核态,用于代理场景下降低不必要的数据拷贝开销[2]。
同时用 BPF_SOCK_OPS(TCP-BPF)附加在 TCP
状态机的各个事件点上,读取拥塞控制算法维护的
RTT、重传次数等内部状态,这些信息原本只有内核自己看得到,现在可以被导出成细粒度的可观测性数据[2]。
这一类用法和前面几类(丢包、负载均衡、socket 分发)的区别在于:它不改变数据包的转发路径,只是把原本封闭在内核 TCP 栈内部的状态”开一个后门”读出来,服务于第 4.3 节要讨论的可观测性需求。
为了让第一层”体积型 DoS 过滤”这个抽象概念更具体,下面是一段概念示意代码——用来说明 XDP eBPF 程序在内核网络栈最早期拦截数据包的基本形状,不是 Cloudflare 的实际生产代码(Cloudflare 没有公开这部分源码),字段和判断逻辑做了大幅简化:
/* 概念示意:一个最简化的 XDP 程序骨架,
* 说明"在包进入常规内核路径之前就地丢弃"这件事的基本结构。
* 真实的 Cloudflare 生产实现更复杂,涉及 eBPF map 里维护的
* 攻击特征库和限速状态,此处仅作教学用途。 */
SEC("xdp")
int xdp_volumetric_filter(struct xdp_md *ctx) {
void *data_end = (void *)(long)ctx->data_end;
void *data = (void *)(long)ctx->data;
struct ethhdr *eth = data;
if ((void *)(eth + 1) > data_end)
return XDP_PASS; /* 包太短,交给常规栈处理 */
struct iphdr *ip = (void *)(eth + 1);
if ((void *)(ip + 1) > data_end)
return XDP_PASS;
/* 查一个 eBPF map,判断源 IP 是否已被标记为攻击流量 */
__u32 key = ip->saddr;
__u64 *pkt_count = bpf_map_lookup_elem(&attack_src_map, &key);
if (pkt_count && *pkt_count > VOLUMETRIC_THRESHOLD)
return XDP_DROP; /* 就地丢弃,不进入 Linux TCP/IP 协议栈 */
return XDP_PASS; /* 交给下一阶段:L4 负载均衡重定向 */
}这段代码要传达的核心机制是XDP_DROP这个返回值——它让内核在还没有为这个数据包分配
sk_buff、还没有走完整套 TCP/IP
协议栈之前就把包丢弃,丢弃成本远低于让包走到应用层再拒绝。这正是”体积型
DoS 缓解要放在 XDP 阶段”这个设计选择背后的根本原因。
4.3 可观测性也要下沉:当标准内核指标不够用
BPF 在 Cloudflare
边缘的用途不止拦截和转发,还包括把可观测性数据从内核里挖出来。Majkowski
提到一个具体的痛点:Linux 内置的网络指标(比如
TcpExtListenDrops、TcpExtListenOverflows)质量很好,但粒度不够——它们是按整机汇总的全局计数器,而
Cloudflare
需要的是按应用、按端口区分的指标,才能定位到底是哪个服务在丢包[2]。
团队的解法是自己写 eBPF
探针直接从内核里按需抽取更细粒度的数字,同事 Ivan Babrou
为此开发了一个叫 ebpf_exporter 的 Prometheus
指标导出工具,Majkowski 形容它”帮了大忙不止一次”[2]。
这个细节值得记一笔:它说明”用 BPF 重写数据面”这件事一旦做到一定深度,标准的操作系统可观测性工具也会跟着不够用,团队必须为自己造的每一层新机制配一套新的观测手段,可观测性和数据面改造是同步演进的,不是事后补上去的。
演讲末尾,Majkowski 把当时边缘服务器上运行的 BPF 用法归纳成六类,这也是理解这套架构”BPF 吃掉软件栈”这个说法具体所指的最好总结[2]:
| 类别 | 挂载点 | 解决的问题 |
|---|---|---|
| 体积型 DoS 缓解 | XDP eBPF | 在数据包进入常规内核路径之前丢弃大流量 L3/L4 攻击 |
| 应用层攻击过滤 | iptables xt_bpf(经典 BPF) |
按报文载荷特征匹配 L7 攻击模式 |
| UDP socket 限速 | SO_ATTACH_BPF |
在单个 UDP socket 上对海量 IP 做无状态干扰下的限速 |
| L4 负载均衡 | XDP eBPF | 绕开 ECMP 重新洗牌问题,做细粒度的机器间重定向 |
| 应用层性能优化 | SOCKMAP、TCP-BPF(BPF_SOCK_OPS) |
TCP 流量的内核态转发(socket splicing)与 TCP 流性能观测 |
| 细粒度可观测性 | ebpf_exporter |
从内核结构体直接导出按应用/端口区分的指标 |
五、Workers 与 V8 Isolate:抛开容器和虚拟机的多租户计算
5.1 问题:在 200 个地点便宜地运行陌生人的代码
第三、四节讨论的都是 Cloudflare 自己写的软件如何组织。Workers 面对的是另一个问题:如何让第三方在这些边缘机器上安全地运行任意代码。Zack Bloom 在 2018 年的一篇文章里回顾了这个决策的起点:Cloudflare 此前用 Lua 提供有限的可编程能力,但 Lua 脚本不在沙箱里运行,客户不能在没有 Cloudflare 工程师介入的情况下自行编写代码[4]。
而如果沿用容器或 Kubernetes 之类的传统虚拟化技术,在当时一百多个数据中心(文章发布时是 155 个)里各自运行成千上万个租户的 Pod,无论是资源开销还是跨数据中心的调度复杂度都会非常高昂[4]。
Cloudflare 最终选择了 V8 引擎(Google Chrome 团队为浏览器 JavaScript 引擎开发)里的 Isolate 技术。Isolate 是一种轻量级的执行上下文,把变量和能修改这些变量的代码绑在一起;关键特性是一个操作系统进程可以同时运行成百上千个 Isolate,并在它们之间无缝切换,同时保证一个 Isolate 无法访问同一进程内另一个 Isolate 的内存[4]。
这不是容器,也不是虚拟机——JavaScript 运行时的启动开销只需要支付一次,之后可以近乎零边际成本地运行海量脚本。
5.2 冷启动:数量级差异
Bloom 的文章给出了具体的对比数字,这些数字来自 Cloudflare 2018 年发布的一篇产品向文章,反映的是当时的测量结果和计费口径,不代表当前实时价格,读者引用时应以官方最新文档为准[4]:
| 指标 | 传统容器化 Serverless(如 Lambda) | Cloudflare Workers(V8 Isolate) |
|---|---|---|
| 冷启动耗时 | 500 毫秒到 10 秒 | 5 毫秒 |
| 启动方式 | 为每次扩容拉起一个新的容器化进程 | 在已运行的 JS 运行时里创建一个新 Isolate |
| 单实例并发处理 | 通常一个实例同时只处理一个请求 | 单进程可承载成百上千个并发 Isolate |
| 常驻内存(无实际业务代码时) | 约 35 MB(Node 进程) | 约 3 MB(共享运行时后的边际开销) |
| 相对 Node 进程的启动速度 | 基线 | 约快 100 倍 |
冷启动数字背后的机制差异是:传统 Serverless 平台在没有空闲容器可复用时,必须重新拉起一个完整的容器化进程,这个过程等价于在你自己的机器上启动一次 Node 进程;如果一个 Lambda 实例同一时刻只能处理一个请求,那么任何额外的并发请求都要重复这个冷启动过程。Workers 因为不需要启动进程,只需要在已经运行的 JS 引擎里创建一个新的执行上下文,所以启动耗时可以压到 5 毫秒这个量级[4]。
Cloudflare Workers 文档在 2026 年的更新中仍然复述了同一个结论——“任意一个 Isolate 的启动速度大约是容器或虚拟机上 Node 进程的一百倍”,并将其列为 Workers 与传统架构最大的三点区别之一[3]。
5.3 上下文切换与内存:为什么共享进程更便宜
除了启动延迟,Bloom 的文章还指出了两个容易被忽视的成本:
上下文切换:操作系统在多个进程之间切换时,需要把一个进程占用的内存换出、把下一个进程需要的内存换入,这个过程可能耗费上百微秒。当一台服务器上同时运行大量 Node、Python 或 Go 进程时,这部分开销会显著挤占本该用于执行业务代码的 CPU 时间。基于 Isolate 的系统把所有代码运行在单一进程内,用自己的机制保证内存访问安全,因此不存在这种进程级上下文切换[4]。
内存:Node、Python 这类运行时最初是为”一个人在自己的服务器上独占运行”设计的,不是为”和上千个陌生租户的代码共享同一台机器、且有严格内存约束”设计的。一个不跑任何业务代码的 Node Lambda 基线内存占用约 35 MB;而在 Isolate 模型下,因为整个 JS 运行时可以被所有租户共享,边际内存开销可以降到约 3 MB[4]。
这两点合起来解释了 Workers 定价模型的另一面:因为 Isolate 内存足够小,Cloudflare 可以只在代码实际执行期间计费,而不必像某些容器化 Serverless 平台那样,把等待外部请求返回的时间也一并计入计费窗口[4]。
Bloom 的文章给了两个具体的计费机制差异:其一,当时的 Lambda 计费按 100 毫秒为单位向上取整,这意味着用户平均每次调用要为多出来的约 50 毫秒埋单;其二,Lambda 的计费窗口覆盖整个运行期间,即使这段时间里代码只是在等待一次外部请求返回也照算,而外部请求可能耗时几百到几千毫秒,规模化之后会被放大成相当可观的费用[4]。
文章据此给出的价格对照表:
| 项目 | AWS Lambda(2018 年数据) | Cloudflare Workers(2018 年数据) |
|---|---|---|
| 计费粒度 | 按 100 毫秒向上取整 | 按实际执行时间计费 |
| 计费窗口 | 覆盖整个运行期间,含等待外部请求的时间 | 仅代码实际执行期间 |
| 50 毫秒 CPU 时间的价格(每百万次请求) | 1.84 美元 | 0.50 美元 |
| 按 CPU 周期折算的相对成本 | 基线 | 约为 Lambda 的三分之一 |
需要再次提醒的是,这组价格是 2018 年的历史快照,两家公司的定价模型此后都可能发生变化,这里引用它不是为了说明”现在便宜多少”,而是为了说明计算隔离单元的重量差异,如何具体折算成了计费模型设计空间上的差异——按执行时间计费而不是按运行时长计费,是轻量隔离单元才能负担得起的一种定价自由度[4]。
5.4 代价:不能运行任意编译产物
Bloom 也坦率列出了 Isolate 模型的限制:它不能运行任意的已编译二进制程序。容器和虚拟机提供进程级隔离,理论上可以启动任何可执行文件;Isolate 模型要求代码必须是 JavaScript,或者是能编译到 WebAssembly 的语言(如 Go、Rust)[4]。这意味着无法重新编译的遗留应用无法直接搬进 Isolate,这个限制在第七、八节讨论架构边界时会再次出现——它不是一个可以靠优化消除的实现细节,而是这套隔离模型本身对”计算形状”的假设。
5.5 一个直接的对照:Lambda@Edge
Bloom 的文章里还提到了一个可以直接类比的竞品:AWS 彼时的 Lambda@Edge,部署在 AWS 的 CDN 数据中心里。文章给出的三点差异是:Lambda@Edge 的计费比传统 Lambda 贵三倍;初次部署需要长达 30 分钟才能全球生效;并且不允许处理任意请求,用途被限制在”CDN 风格”的场景里[4]。
作者据此推断的判断是:与其说 Amazon 的边缘计费更贵是因为边缘部署本身成本更高,不如说容器/虚拟机模型天生就不适合”以极低边际成本铺到几百个地点”这件事——因为每个地点都要独立承担容器化进程的启动和维护开销,规模一旦铺开,容器模型的固定成本会被放大。这段对照没有给出 Cloudflare 这边同一时期的对应价格作为直接反证,读者应当把它当作 Cloudflare 一方的产品叙事,而不是独立第三方的基准测试[4]。
但它清楚说明了这篇文章的核心论点:决定一个 Serverless 平台能不能便宜地铺到几百个地理位置的,不是这家公司多有钱,而是它的计算隔离单元本身有多重。
5.6 一个最小的 Workers 处理函数
把前面几段的抽象机制落到看得见的代码上:一个 Workers
脚本的最小形态就是实现一个 fetch
处理函数,接收请求对象,返回响应对象,这是 Cloudflare
Workers 文档里给出的标准示例结构[3]:
export default {
async fetch(request, env, ctx) {
// 这段代码运行在某个数据中心新建或复用的 Isolate 里,
// 具体是哪个数据中心、哪个 Isolate 实例,由 Anycast 路由
// 和运行时调度决定,开发者不需要也不能指定。
const url = new URL(request.url);
if (url.pathname === "/health") {
return new Response("ok", { status: 200 });
}
// 官方文档明确建议:不要在全局作用域保存可变状态,
// 因为 Isolate 可能因资源紧张、可疑脚本行为等原因被随时回收,
// 两次请求也不保证落在同一个 Isolate 实例上。
return fetch(request);
},
};这段代码本身很平淡,平淡正是重点:Workers
的编程模型刻意让开发者感知不到底层复杂的隔离和调度机制——第六节要讲的四层隔离、cordon
分组、supervisor 能力中介,全部隐藏在这一个
fetch
函数之下。官方文档里”不要在全局作用域保存可变状态”这条建议,正是第六节要讲的
Isolate
可被随时驱逐、请求也不保证路由到同一实例这套隔离经济学,在应用层暴露出来的唯一痕迹[3]。
开发者部署这段代码时看到的配置文件同样简单,路由规则由声明式配置驱动,具体调度到哪台机器、哪个 Isolate 完全不暴露给开发者:
# 概念示意:Workers 项目配置文件的典型结构(字段名以官方文档惯例为准,
# 具体可用字段以当前版本的 Cloudflare Workers Docs 为准)
name = "edge-health-check"
main = "src/index.js"
compatibility_date = "2026-01-01"
# 声明这个 Worker 绑定到哪些路由;同一个域名下的不同路径
# 可以绑定到不同的 Worker,由入站代理服务(第六节 API 设计
# 提到的 inbound proxy)负责匹配路由并转发给对应的沙箱进程
[[routes]]
pattern = "example.com/health*"
zone_name = "example.com"这份配置文件里唯一需要开发者关心的”位置”信息是路由规则(这段代码在哪个域名、哪个路径下生效),而不是”运行在哪个数据中心”——后者完全由 Anycast 路由和 Isolate 调度决定,这正是第一节”网络即计算机”这句话在开发者体验层面的具体落地:开发者写的是一段处理逻辑,而不是一份部署到某个具体地点的程序。
5.7 官方文档的三点定式:Isolate、按请求计算、分布式执行
Cloudflare Workers 文档在解释”Workers 和普通 JavaScript 有什么不同”时,明确把差异归纳成三点:Isolate、按请求计算(Compute per Request)、分布式执行(Distributed Execution)[3]。前几节已经展开了 Isolate 本身的机制,这里补上文档强调的另外两点。
graph LR
subgraph Traditional["传统容器 / 虚拟机架构"]
P1["Process overhead"] --- C1["User code"]
P2["Process overhead"] --- C2["User code"]
P3["Process overhead"] --- C3["User code"]
end
subgraph WorkersModel["Workers V8 isolates"]
RT["共享 JS 运行时<br/>启动开销只支付一次"]
RT --- I1["User code (isolate 1)"]
RT --- I2["User code (isolate 2)"]
RT --- I3["User code (isolate 3)"]
end
这张图对应文档原文用来解释两种架构差异的示意:传统模型下每一份用户代码都要背负一份独立的进程启动开销;Isolate 模型下这份开销只由共享运行时支付一次,新增租户只是在已经运行的运行时里新建一个隔离上下文[3]。
按请求计算:绝大多数 Workers
脚本的结构就是第 5.6 节那样一个 fetch
处理函数——请求到达任意一个 Cloudflare
数据中心时,由该数据中心当前可用的某个 Isolate
调用这个函数处理,处理完毕即返回。这和”常驻服务进程持续监听端口、自行管理生命周期”的传统服务器编程模型不同:Worker
代码本身不需要、也没有能力管理自己的启动和关闭[3]。
分布式执行:文档特别强调了两个容易被忽视的推论。其一,Isolate 在一次请求处理期间是稳定可用的,但仍然可能因为三类原因之一被提前回收:机器资源紧张、个体资源限额触顶、脚本行为被判定为可疑(比如尝试逃逸沙箱);回收会等到当前事件处理完毕之后才发生,而不是粗暴地中断正在执行的请求[3]。
其二,和所有单线程事件循环的 JavaScript 运行时一样,一个
Workers 实例可能在等待某个 await(例如一次
fetch
调用)时去处理另一个并发到达的请求;但两次不同的用户请求不保证被路由到同一个(或不同的)Worker
实例,这正是文档反复强调”不要依赖或修改全局状态”这条建议的直接原因[3]——这不是一条泛泛的最佳实践,而是分布式执行模型下的必然要求:全局变量在下一次调用时到底是”上次修改后的状态”还是”全新
Isolate 里的初始状态”,本身是未定义的。
六、安全模型:分层隔离与”无法修复”的 Spectre
Workers 团队的架构师 Kenton Varda 在 2020 年的一篇文章里开门见山地写道:Workers 的核心就是一个安全项目,“运行第三方编写的代码永远是一件让人紧张的事情”[5]。这一节拆解这套安全模型的两个组成部分——隔离与 API 设计——以及团队如何应对一个业界公认”无法真正修复”的硬件级漏洞类别:Spectre。
6.1 隔离:四层防御
Workers 的隔离不是单一机制,而是层层叠加的纵深防御[5]:
第一层,V8 Isolate:这是最基础的隔离单元,阻止代码访问同一进程内其他 Isolate 的内存。这一层是让”一台机器承载成千上万个租户”成为可能的关键——如果每个租户都要求独立进程,边缘计算就只能服务少数愿意为专属资源付费的大客户。
第二层,按信任等级分组的”围栏”(cordon):即使共享同一进程运行的 Isolate 之间理论上互不可见,Cloudflare 仍然在每台机器上运行多个独立的运行时实例,按 Worker 的信任等级把它们分配到不同的 cordon 里——例如免费套餐用户的代码不会和企业客户的代码调度进同一个进程。这是应对”万一 V8 出现零日漏洞”场景的纵深防御手段,而不是假设 Isolate 隔离本身会失效[5]。
此外,使用调试器检查 Worker 的场景会被单独调度到独立进程,因为调试协议历史上主要面向浏览器的可信操作者,没有经过和 V8 主体同等强度的安全审查。
第三层,进程级”Layer 2”沙箱:使用 Linux 命名空间(namespace)和 seccomp,禁止整个进程访问文件系统和网络。这一层的实现比常见容器引擎更严格。
因为 Cloudflare 是在进程启动之后、Isolate
加载之前才配置命名空间和
seccomp,可以直接使用完全空的挂载命名空间,并用 seccomp
屏蔽所有文件系统相关的系统调用;普通容器引擎做不到这么彻底,因为它们通常需要用
exec() 从磁盘启动客户程序,而 Workers
的”客户程序”不是原生二进制,运行时本身在屏蔽文件系统访问之前就已经加载完毕[5]。
网络访问被完全禁止,沙箱进程只能通过本机 Unix domain socket 与同一系统上的其他进程通信。
第四层,Supervisor 进程做能力中介:一个称为”supervisor”的独立进程负责从磁盘或内部服务读取 Worker 代码和配置。沙箱进程无法枚举有哪些 Worker 存在,也无法请求它不需要的配置——比如它拿不到用于 HTTPS 流量的 TLS 私钥[5]。
沙箱与 supervisor 之间用 Cap’n Proto(一种能力安全的 RPC 协议,Cloudflare Workers 团队目前维护该开源项目)通信,这让基于能力(capability-based security)的 API 设计变得容易实现:给 Worker 一个代表”它自己目录”的对象,这个对象只能创建和打开子文件/子目录,无法向上遍历到父目录[5]。
把这四层防御放在一张表里,能看出它们各自针对的失效场景并不重叠,是纵深防御而不是重复劳动:
| 层级 | 隔离机制 | 主要防御的失效场景 |
|---|---|---|
| 第一层:V8 Isolate | 单进程内的内存隔离 | 常规情况下租户之间互相窥探或篡改内存 |
| 第二层:Cordon 信任分组 | 按信任等级分配到不同操作系统进程 | V8 本身出现尚未修复的零日漏洞时的爆炸半径 |
| 第三层:Layer 2 进程沙箱 | Linux 命名空间 + seccomp | 恶意代码试图绕过 V8、直接从进程层面访问文件系统或网络 |
| 第四层:Supervisor 能力中介 | Cap’n Proto RPC + 能力对象 | 沙箱进程即使被攻破,也无法枚举或访问它不该拥有的配置与密钥 |
6.2 API 设计即安全边界
Varda 强调了一个容易被忽视的原则:没有 API 就没有访问。今天的 Workers 不提供文件系统 API,所以不存在文件系统访问的攻击面,不是因为额外加了一层检查,而是因为这个能力从未被暴露过[5]。
网络访问同理:Workers 只能通过 HTTP 收发流量,出站请求会经过本地代理服务校验目标地址(必须是公网服务或该 Worker 所属 zone 的源站,不能访问同一台机器上可能存在的内部服务),并且会给每个出站请求打上标识发起 Worker 的 header,以便追踪滥用行为;入站请求则先由独立的代理服务完成 TLS 终止(Workers 运行时本身永远看不到 TLS 私钥)和路由匹配,再通过 Unix domain socket 转发给沙箱进程[5]。
Varda 用一个假设性的例子解释了这套能力安全(capability-based security)设计的思路:假如未来 Workers 要支持文件系统访问,也不会把整个文件系统暴露出去,而是给每个 Worker 一个只代表”它自己目录”的对象,这个对象本身就是能力的边界——它只暴露”在这个目录下创建/打开文件或子目录”的操作,不提供任何”访问父目录”的方法,因此从类型系统层面就杜绝了路径穿越(path traversal)风格的攻击[5]。
用接口语言描述这个思路大致是这样的(下面是概念示意,不是 Cloudflare 的实际代码):
// 概念示意:一个只暴露"当前目录以内"操作的能力对象,
// 没有任何方法能拿到父目录或绝对路径。
interface Directory {
openFile(name: string): Promise<File>;
createFile(name: string): Promise<File>;
openSubdirectory(name: string): Promise<Directory>;
// 故意不存在 getParent()、listAbsolutePath() 这类方法:
// 安全边界由"这个类型能做什么"决定,而不是靠运行时权限检查。
}这种设计的价值在于:安全边界不是写在一堆分散的权限检查代码里(容易被遗漏),而是直接编码进对象的类型签名——一个只拿到
Directory
对象的调用方,无论写多少行代码,在类型层面就不存在”跳出这个目录”的路径。Cloudflare
与 supervisor 之间用 Cap’n Proto RPC
实现的正是这一类能力安全协议,用于中介所有跨进程的配置和资源访问[5]。
6.3 V8 漏洞与”补丁差”
V8 是一个极其复杂的软件,复杂度本身意味着比虚拟机更大的攻击面;但反过来,V8 作为全世界使用最广泛的沙箱技术之一,也吸引了海量的安全测试投入——Google 会为发现 V8 沙箱逃逸的研究者支付五位数悬赏,并运行自动化模糊测试基础设施[5]。
真正的风险窗口在于”补丁差”(patch gap):V8 是开源项目,安全修复在公开仓库里开发,一旦发布,好人和坏人同时能看到。Varda 提到当时 Chrome 团队把自己的补丁差从 33 天压缩到 15 天;Cloudflare 因为直接掌控自己的生产机器,把整条构建发布流程几乎全自动化,一旦 V8 上游发布补丁,系统会自动构建新版本 Workers 运行时,经过人工一键签核后自动推送到生产环境,把补丁差压缩到 24 小时以内[5]。
6.4 Spectre:一个业界共识”修不好”的漏洞类别
Spectre 是一类利用 CPU 推测执行(speculative execution)泄露数据的攻击:程序诱导 CPU”投机”执行本不该访问的数据计算,CPU 事后会撤销这次计算的可见结果,但计算过程留下的缓存等副作用仍然可能被测出来,从而间接还原出被访问数据的部分内容[5]。
Google V8 团队公开表示 V8 自身无法防御 Spectre,这对依赖 V8 做隔离的 Workers 而言是一个绕不开的问题——多租户共享同一硬件核心时,Spectre 类攻击理论上都可能生效,而 Workers 使用 Isolate 而非进程或虚拟机隔离租户,意味着不能简单依赖操作系统或虚拟化层的补丁来”解决”问题,必须自己想办法[5]。
Varda 也解释了为什么不直接切换成进程级隔离:Workers 的目标是让边缘计算对所有人可用,而不仅是能为专属资源付费的大客户,这意味着单台机器需要承载数千个活跃租户,且很多租户流量很低;每个租户占用的内存不能超过几 MB;上下文切换必须极其廉价——单核每秒在数千个租户之间切换是常态。如果每个租户各自独立进程,这些开销会被放大到共享进程模式下的一个数量级:文中给出的估算是,严格按进程隔离 Workers,CPU 成本很容易达到共享进程模式的 10 倍[5]。
没有”修复”,只有拖慢。Varda 直言业界目前处理 Spectre 的主流方式是”打地鼠”:研究者每隔几个月发现新的变种,CPU 厂商发微码更新,操作系统厂商发内核补丁,所有人跟着升级——但没有人真正”修好”了 Spectre,即便是使用重量级虚拟机隔离的平台同样存在暴露面[5]。Cloudflare 选择的策略是:既然所有 Spectre 变种本质上都依赖某种边信道(side channel)去探测硬件的隐藏状态,而绝大多数边信道最终都要靠计时来提取信号,那么就从”让攻击者拿不到精确本地计时”入手,把攻击拖慢到不再有意义:
- 冻结本地计时:Workers 里
Date.now()返回的不是真实的挂钟时间,而是触发这次执行的网络消息到达时的时间,代码执行期间这个值保持不变,攻击者无法用它测出自己代码的执行耗时。这个措施早在 2017 年就已经实现,比 Spectre 公开披露还早——团队当时是出于对时序边信道的一般性担忧才加上的,不是针对 Spectre 专门设计[5]。 - 禁止多线程和共享内存:同一个事件的处理全部发生在单一线程上,避免通过”赛跑”两个线程间接构造出一个隐式计时器;即便同一个请求依次经过多个 Worker(例如站点自身的 Worker 和挂载的 Cloudflare App 都基于 Workers 实现),它们也会在同一线程里顺序执行[5]。
- 局部计时被冻结之后,攻击者理论上仍可以用远程计时:让触发 Worker 执行的 HTTP 客户端自己测量响应耗时。这个信号会因为要穿越公网而非常嘈杂,理论上可以靠重复攻击、统计平均来滤掉噪声。在与 Spectre 领域专家合作的对抗性测试中,团队没能在生产环境里实际做出一个可行的攻击,但团队同时强调”没做出攻击”不等于”可以停止防御”[5]。
- 动态进程隔离(Dynamic Process Isolation):Spectre 攻击往往需要制造出”不正常”的性能特征(例如故意让代码进入病态的性能场景以放大微架构副作用),这类特征通常能在 CPU 性能计数器上体现出来。与其试图靠这类指标直接判定”这是攻击”(容易误报——有些正常程序表现也很差),Cloudflare 选择把任何表现出可疑性能指标的 Worker,动态重新调度进它自己的独立进程,这样就能借助操作系统层面的 Spectre 防御。这是与格拉茨技术大学(TU Graz,Spectre 的共同发现者所在机构)合作研发的机制。
- 2020 年文章发布时这套机制仍在测试,2021 年 10 月发布的后续研究论文确认该机制已在生产环境全面部署,测试样本里对已知 PoC 攻击的检出率良好,误报率也控制在可接受范围(论文提到在成千上万个正在运行的 Worker 里,大约只有 20 个被误判为攻击)[5][6]。CPU 密集型的 Worker 即便没有触发可疑指标,也会因为切换频率较低、隔离开销相对更小,被顺带纳入进程隔离范围。
- 周期性整体内存洗牌:如果前面几层防御已经堵住了所有已知攻击,剩下的只是”未知攻击可能需要多久”这个假设性问题——团队认为经过层层拖慢,未知攻击大概率需要几天甚至更久。基于这个假设,整套 Workers 运行时会按日重启,重置内存里所有对象的物理位置,迫使潜在攻击从头开始摸索目标数据的位置;同时 Worker 会被在物理机器和 cordon 之间重新调度,缩小攻击者能够针对同一个”邻居”持续观察的时间窗口[5]。
Varda 的原话概括了这套思路的本质:没有人能一次性”修好”Spectre,但测量学告诉我们,把一次攻击拖慢到需要几个月甚至几年,和让它变得完全不可行,在工程上是等价的——前提是防御方愿意不断叠加新的拖慢手段,而不是指望某一层防御一劳永逸[5]。
把这套”层层拖慢”策略按引入时间排列,能看出它不是一次性设计出来的,而是随着 Spectre 研究进展逐步叠加的:
| 缓解手段 | 引入 / 部署时间 | 针对的问题 | 来源 |
|---|---|---|---|
冻结 Date.now(),锁定为事件到达时刻 |
2017 年中(早于 Spectre 公开披露) | 本地高精度计时器泄露执行耗时 | [5] |
| 禁止多线程与共享内存,同请求内多个 Worker 顺序执行 | 与上一条同期,Workers 平台设计初期 | 通过并发”赛跑”间接构造隐式计时器 | [5] |
| 拒绝原生二进制,只接受 JS / Wasm,经 V8 转译为本地代码 | Workers 平台设计初期 | 剥夺攻击者对 CLFLUSH、RDTSC 等边信道友好指令的直接控制权 | [5] |
| 动态进程隔离(按 CPU 性能计数器异常自动迁移进程) | 2020 年测试中,2021 年生产全量部署 | 尚未被计时防御挡住的、依赖异常性能特征的攻击尝试 | [5][6] |
| 按日重启运行时、跨机器 / cordon 重新调度 | 与动态进程隔离同期 | 假设性的、需要数天甚至更久才能完成的未知攻击 | [5] |
这张表也回应了第九节要展开的第一个开放问题:防御是分批叠加的,而不是一次性设计完备的,这意味着”Workers 现在安全吗”这个问题的答案永远只是”相对于已知攻击手法,在当前这一批缓解措施下是安全的”,而不是一个可以一劳永逸盖章的结论。
动态进程隔离这一层的触发逻辑可以用一张时序图表示,它把”检测”和”处置”两个动作解耦,避免因为误报而误伤正常业务:
sequenceDiagram
participant Iso as Isolate (tenant Worker)
participant PerfMon as Performance counter monitor
participant Scheduler as Runtime scheduler
participant NewProc as Dedicated process
loop every event processed
Iso->>PerfMon: CPU performance counters (branch mispredicts, cache misses)
end
PerfMon->>PerfMon: compare against suspicious-pattern heuristics
alt metrics look pathological
PerfMon->>Scheduler: flag isolate as suspicious
Scheduler->>NewProc: spin up / reuse a dedicated OS process
Scheduler->>Iso: reschedule isolate into NewProc
Note over NewProc: OS-level Spectre mitigations now apply<br/>(process boundary, no shared-process neighbors)
else metrics look normal
PerfMon->>Scheduler: no action
Note over Iso: isolate keeps running in shared-process runtime
end
这张图里最关键的设计是”错误的代价不对称”:如果一个合法的高性能 Worker 被误判,后果只是它多付出一点进程切换开销,业务本身不受影响;但如果放过一个真正的 Spectre 攻击,代价可能是攻击者拿到不该拿到的数据。这种不对称,是团队愿意接受”每千个 Worker 里大约 20 个被误判”这个误报率的原因——宁可多花一点资源隔离,也不要在检测阈值上冒险收紧[6]。
6.5 Isolate 调度与信任分组示意
graph TB
subgraph Machine["Edge machine"]
subgraph CordonFree["Cordon: low-trust / free tier"]
RuntimeA["Workers Runtime process A<br/>shared V8 engine"]
IsoA1["Isolate: tenant 1"]
IsoA2["Isolate: tenant 2"]
IsoA3["Isolate: tenant 3 (suspicious CPU pattern)"]
RuntimeA --> IsoA1
RuntimeA --> IsoA2
end
subgraph CordonEnterprise["Cordon: high-trust / enterprise tier"]
RuntimeB["Workers Runtime process B<br/>shared V8 engine"]
IsoB1["Isolate: enterprise tenant X"]
IsoB2["Isolate: enterprise tenant Y"]
RuntimeB --> IsoB1
RuntimeB --> IsoB2
end
ProcIso["Dedicated process<br/>(Dynamic Process Isolation)"]
IsoA3 -.rescheduled on suspicious metrics.-> ProcIso
Sup["Supervisor process<br/>capability-mediated config & network"]
RuntimeA -->|Cap'n Proto RPC, Unix socket| Sup
RuntimeB -->|Cap'n Proto RPC, Unix socket| Sup
ProcIso -->|Cap'n Proto RPC, Unix socket| Sup
end
Sup -->|filtered outbound HTTP only| Internet["Public Internet / origin"]
这张图把前面两节的隔离层级压缩成一张示意:同一进程内的多个 Isolate 共享一个 V8 引擎实例;不同信任等级的租户被分进不同的 cordon(对应不同的操作系统进程);出现可疑 CPU 特征的 Isolate 会被动态迁移进独立进程;所有对外的文件、配置、网络访问都必须经过 supervisor 做能力校验,Workers Runtime 进程本身被 seccomp 和命名空间限制,无法直接触碰文件系统或网络。
七、设计哲学:隔离经济学与”把算力推到边缘”
前面三节分别拆解了网络、内核、计算三个层面的具体机制,这一节把它们收拢成一句话:Cloudflare 边缘架构的核心哲学,是用软件定义的、进程内的隔离,换取把计算单元做到足够小、足够便宜,从而能够铺满几百个地理位置。
这个哲学在三个维度上做了明确取舍:
颗粒度换分布范围。容器和虚拟机的隔离强度更高,但也更重——正是因为重,传统云平台必须把流量集中到少数几个大区域才能摊薄成本。Isolate 把隔离颗粒度做到几 MB 级别,代价是接受一种较弱、需要额外工程投入去加固的隔离边界(第六节的整套 Spectre 缓解,本质上就是在为这个选择打的”保险”);换来的收益,是运行一个 Isolate 处理一次 HTTP 请求的成本足够低,可以选择在离用户最近的任意一个数据中心执行,而不必为了摊薄成本把流量汇聚到少数中心化区域[6]。
计费与实际消耗对齐。因为 Isolate 内存和启动开销都被压到很低,Cloudflare 可以只对代码实际执行的时间计费,而不是像容器化平台那样,把等待外部 I/O 返回的时间也计入账单——第五节引用的 2018 年数据里,这一差异被换算成了具体的价格对比(尽管那组价格数字已经是历史快照,不代表当前定价)[4]。
并非所有工作负载都适合被”拆小”。Cloudflare 自己在 2021 年宣布把容器引擎引入边缘时,坦率承认了 Isolate 模型的适用边界:Isolate 最适合”可以被拆分、可以分布式处理”的工作负载,比如 Web 应用服务器、可以按 MapReduce 思路拆分的批处理任务[6]。
但有一类应用天生不适合被拆分——文章举的例子是单机 3D 游戏:所有计算最终要压缩成一路按严格时序输出的视频帧,与其说这是”大数据”问题,不如说它是一个必须在单点完成、无法从分布式处理中获益的问题;Isolate 生态当时也还没有成熟到能承接遗留代码库(例如需要访问 GPU 的场景)[6]。
Cloudflare 给出的反例更直接:他们自己的 Browser Isolation 产品需要在边缘运行完整的 Chromium 浏览器并把画面流回客户端,而 Chromium 这样体量的既有代码库无法运行在 Isolate 里(讽刺的是,V8 本身就是 Chromium 的一部分,“自己套自己”在技术上目前也不可行),团队因此专门为这个产品部署了一套独立的容器引擎[6]。
Cloudflare 那篇文章给出的取舍表格值得原样引用,因为它是这家公司对自己”Isolate 优先”叙事最直白的一次自我修正[6]:
| 更适合 Isolate | 更适合容器 |
|---|---|
| 分布式/全球化系统 | 遗留系统、单用户应用 |
| Web 应用服务器 | 3D 渲染 |
| 大数据(如 MapReduce 风格的批处理) | CI 构建 |
文章还补充了一个容易被忽略的推论:把一个本来就是容器化的应用直接”搬到边缘”运行,未必能获得延迟收益。一个跑在 Node.js 或 Django 上的传统 Web 应用,即使部署到边缘的容器里,冷启动依然是几百毫秒到几秒的量级,这部分开销会抵消掉网络延迟上省下的几十毫秒;与此同时,把一套经典的服务端技术栈铺到两百多个数据中心去管理状态,认知负担和运维复杂度也和管理一两个中心化数据中心完全不是一个量级[6]。
换句话说,“够不够便宜、够不够轻,可以被铺向几百个地点”这件事,本身就是判断一个工作负载是否适合”上边缘”的第一道筛选,而不是加上边缘部署之后自动获得的收益。
这段自我修正很重要,因为它说明”Isolate 优于容器”从来不是一个无条件成立的架构真理,而是一个对特定工作负载形状(无状态、可分布、生命周期短、不需要访问已编译的遗留二进制)成立的工程判断。下一节用一个真实的外部案例,展示当负载形状偏离这个假设时会发生什么。
八、张力:有状态、长连接负载遇上可抢占的边缘平台
前面所有讨论都隐含着一个假设:边缘上运行的工作单元生命周期很短、随时可以被换掉、状态可以留在客户端或另外的存储服务里。这个假设对 HTTP 请求处理成立,但对实时语音这类负载并不成立。Discord 在 2026 年 6 月发布的一篇工程博客,完整记录了把语音/视频流量迁移到 Cloudflare 边缘过程中撞上的一系列问题,是审视这套架构边界的一个难得的外部视角[10]。
促成这次迁移的最初动机是覆盖范围,而不是性能优化。Discord 博客开篇就交代了背景:在此之前,Discord 能够提供的”最近语音服务器”局限在全球约 30 个城市——这些城市恰好是各大云厂商设有数据中心的地方;对于旧金山湾区、法兰克福这类云厂商重仓的城市,体验没问题,但对雷克雅未克、奥克兰这类主流云厂商覆盖稀疏的地区,用户能连到的”最近”服务器可能远在数百公里之外[10]。
文中给出的一个直接对比是:其他大型云厂商大致运营 30 到 40 个区域,留下不少国家完全没有覆盖,而 Cloudflare 的网络覆盖超过 300 个城市的 PoP[10]。这个覆盖范围上的数量级差异,才是 Discord 愿意为一类原本没有被验证过的负载去做这次高成本迁移的根本原因——延迟和抗抖动收益是后来才逐步验证和优化出来的,不是最初的核心诉求。
8.1 为什么语音负载和边缘架构的默认假设不合
Discord 的语音架构把一整通通话绑定到一个 SFU(Selective Forwarding Unit,选择性转发单元)实例上,通话期间所有参与者都连接到这同一个宿主。这个宿主同时承载两类状态:一个有状态的控制面(谁在通话里、谁被静音、媒体流该转发给谁)和一个无状态的媒体面(承载音视频的 UDP 包)。UDP 传输在通话全程保持打开,中途回收这台宿主机会直接掉线[10]。
Discord 的工程团队直接点出了这和 Cloudflare 平台默认假设的冲突:
“Cloudflare’s deployment platform was optimized for short-lived V8 Isolates, and not originally intended for voice traffic.”(Cloudflare 的部署平台是为短生命周期的 V8 Isolate 优化的,最初并不是为语音流量设计的。)[10]
需要说明一个容易混淆的地方:Discord 的语音服务本身运行在 Cloudflare 的容器平台上,而不是直接跑在 V8 Isolate 里——这与第七节提到的 Cloudflare Browser Isolation 走的是同一条路径(容器承载 Isolate 模型无法处理的负载形状)。
但 Discord 观察到的调度行为,仍然带着整个边缘平台”以短生命周期、可抢占单元为默认设计中心”的烙印:Cloudflare 的调度器会按自己的节奏拉起和回收机器,用于负载迁移或维护,容器可能在没有预先通知的情况下被回收,Discord 的容器额外每月至少经历一次强制重启维护[10]。这和传统云平台”主机拿到一个稳定的名字,退役节奏由使用方自己决定”的假设完全相反——Discord 原话是”大多数云假设主机会一直待在原地”[10]。
8.2 两次意外:冰岛与鹿特丹的教训
在投入大规模工程改造之前,Discord 先做了两次区域性试点,用来验证”就近部署”这个前提是否真的成立。这两次试点都没有得到预期的结果,而且失败的原因恰好分别对应第二节末尾提到的两条 RFC 警告——多方会话下”最近节点”未必是”最优节点”,以及路由路径本身可能成为瓶颈[10]。
冰岛(2025 年 2 月):此前的语音服务商在冰岛没有节点,冰岛用户的通话统一绕道鹿特丹中转。Cloudflare 在雷克雅未克有 PoP,Discord 在那里部署了语音服务器并观察效果。纯冰岛本地用户之间的通话确实变好了:延迟下降 9%,丢包下降 11%[10]。
但问题出现在混合地区的通话上——如果一通电话里既有冰岛用户又有其他地区用户(例如德国),由于 Discord 的架构是把整通电话绑定到一个 SFU 实例上(第 8.1 节已经解释过这个设计),电话很可能被分配到雷克雅未克这台”看起来最近”的服务器,而对德国用户而言,数据包反而要多绕一趟去冰岛再绕回来:这些非本地用户的延迟不是持平,而是上升了 2.7 倍,丢包上升 9%[10]。
graph LR
IS["Icelandic user"] -->|"local, low latency"| SFU["SFU instance<br/>hosted in Reykjavik PoP"]
DE["German user"] -->|"long detour to Iceland"| SFU
SFU -->|"packets routed back"| DE
style SFU fill:#f9f,stroke:#333
这张示意图对应的正是问题的机制:一通电话只有一个 SFU 宿主,“离谁最近”是一个单点判断,但通话里往往有多个地理位置各异的参与者,把宿主放在其中一人附近,对其余参与者反而可能是一次绕远路。团队据此调整了策略:新增一个 PoP 只有在”这个 PoP 承载的通话本身就是本地通话”时才有收益,对于多方混合通话,服务器摆放的位置比”覆盖了多少地理空白”更重要——这正是 RFC 1546 定义 Anycast 语义时就已经暗示、RFC 7094 又重申的问题:单点”最近”的判断,在涉及多个参与方的有状态会话里并不天然成立。
鹿特丹(2025 年 4 月):第二阶段把鹿特丹的流量迁移到 Cloudflare 的阿姆斯特丹 PoP。大多数运营商的用户指标都正常,但法国最大运营商之一 Orange 的用户在几个小时内出现高峰时段延迟超过 1 秒的情况,语音质量的”冻结比率”(freeze ratio)恶化了 30%[10]。
排查发现,其他运营商和其他 Cloudflare PoP 都健康,问题出在 Orange 到阿姆斯特丹这条具体路径上:没有一家运营商和互联网上所有其他网络都直连,中间总要经过转接运营商(transit provider)搭桥,而 Orange 到 Cloudflare 边缘这段路径恰好要经过 Telia 的转接骨干网,Telia 与 Orange 的对接点在高峰时段已经接近饱和,Discord 每往 Cloudflare 迁移一部分流量,都会让这条本已拥挤的路径更堵[10]。
团队大约十天后回滚了这次迁移,一边靠原服务商的容量吸收 Orange 方向的流量,一边由 Cloudflare 出面和 Orange 谈直连对等(direct peering),并建议 Discord 把语音服务器也部署到巴黎和伦敦的 PoP,为 Orange 用户提供更短的路径。这些新 PoP 在随后几个月陆续上线后,问题才彻底解决[10]。
这两次试点直接改变了 Discord 后续的迁移方法论:从”按容量就绪程度排期”改成”先做对等关系分析,确认 Cloudflare 与目标区域主要运营商之间有冗余带宽,再决定这个区域是否上线”[10]。这也是一个值得记住的教训:Anycast 和边缘部署解决的是”物理距离”问题,但物理距离之外还有”运营商对等关系是否健康”这另一重独立的约束,第二节讨论的路由层理论说明了”最近”在有状态会话里可能不成立,而这两次试点则说明”最近”在链路容量层面也可能不成立——两种失效模式都不是靠增加 PoP 数量能自动解决的。
8.3 具体撞上的几类问题
Discord 的博客按时间线记录了几类典型问题,都直接指向”短生命周期单元优先”的设计取向和实时媒体负载之间的摩擦[10]:
服务发现方向反了。Discord 原有的服务发现模型是”先拉起主机,再向发现服务注册”,这在传统云上没问题;但 Cloudflare 的调度器让机器随时凭自己的节奏冒出来,没有预先通知,因此 Discord 不得不重新设计发现服务:主机启动后主动”打电话回来”注册,运行期间定期续期,注册信息存放在 Valkey(一种内存数据存储)里并带 10 分钟 TTL,停止续期即自动过期下线[10]。
这次重构还顺带解决了一个原本就存在的隐患:Discord 原有的服务发现建立在 etcd 之上,是多年前为规模小得多的机群设计的,在语音主机规模增长到 2.5 万台时已经开始出现发现延迟升高的迹象,只差一次大规模上线就可能被压垮;把 Cloudflare 侧新增的主机改走 Valkey,相当于顺势把增量负载从已经吃紧的 etcd 上移开,团队后续又把原有服务商的 SFU 记录也迁移过去、经过一段双写过渡期后正式退役了 etcd[10]。
“重建”而非”重启”带来的容量瞬时归零风险。在 Cloudflare 上,任何代码变更都是用新镜像重新创建容器,而不是原地重启进程。一次跨 PoP 的协调式重建如果没有节流,可能让某个 PoP 的实例数量短暂归零,Discord 为此专门写了一个部署器,让容器的 supervisor 捕获到关闭信号后延迟 5 分钟才真正退出,给替换实例留出上线窗口,让通话重连落在同一区域的新主机上而不是被弹到别处。
共享硬件上的资源隔离粒度。早期的语音/Worker 混部容器沿用了”每台主机 4 个 worker”的密度设定,结果在美国东部出现持续 1.5%–2% 的丢包(此前基线低于 0.5%)。团队排查后发现根因是 NIC 发送队列争用:裸机部署下每个 worker 独占一条网卡发送队列,而当时 Cloudflare 的容器运行时还没有把多队列网卡暴露给容器,四个 worker 共享同一条发送队列,高峰期内核在发送缓冲层丢包[10]。
团队先把密度降回 2 个 worker/主机做临时止血,Cloudflare 在随后几周修复了底层的 UDP 缓冲问题后,密度先恢复到 4 个 worker/主机在阿姆斯特丹试点,随后在全球范围逐步提高到 8 个 worker/主机的默认配置[10]。
8.4 欧洲的延迟尖峰:一次横跨两层技术栈的联合调试
2025 年末,Discord 把布加勒斯特和斯德哥尔摩两个区域迁移到基于 Rust 重写的新版 SFU 上运行在 Cloudflare 边缘后,这两个区域的指标开始持续走低:高峰时段的延迟逐渐升高,语音质量在随后几周内持续恶化,到 2026 年 2 月,客户端观测到的延迟尖峰已经超过 500 毫秒,用户支持渠道和社交媒体上开始出现”声音发闷像机器人”的反馈[10]。
团队先把部分欧洲流量切回原服务商兜底,再投入排查——这是整个迁移过程中耗时最长、涉及双方专业知识最深的一次联合调试,路由异常、对等点问题、DDoS 攻击这些”常规嫌疑对象”逐一被排除,都不成立[10]。
真正的突破来自 Cloudflare 性能团队自己写的一个无侵入探针:Discord 的语音健康检查包在报文里带有固定的可识别特征,不需要解析连接状态就能在网卡层面识别出来。Cloudflare 写了一段 eBPF 程序挂载在宿主机网络栈上,给每一个流经的 Discord 健康检查包打时间戳,通过比较网卡收包和发包的时刻,就能在不触碰 Discord 自身流量的前提下,测出每台宿主机上这些包的”裸机处理时延”[10]。
这个探针进一步做了分段排查,确认物理网卡到虚拟网卡这一段是干净的,虚拟网卡再回到物理网卡这一段也是干净的——时间消耗全部发生在虚拟机内部,也就是说问题出在 Discord 自己的应用进程里[10]。
Cloudflare 团队随后拿到了受影响区域某个容器的直接访问权限,几分钟内就观测到进程内部出现了长达 860 毫秒的 futex(一种 Linux 线程同步原语)阻塞;接下来几天的逐线程 CPU 采样显示,每次延迟尖峰期间总有一个线程被钉死在单核上跑满 100%,同一时间 socket 接收缓冲区也在持续增长;几天后,双方拿到了一份完整还原故障链条的火焰图(flame graph)[10]。
排查结果显示,根因分为独立的两部分,分别落在 Discord 和 Cloudflare 各自的技术栈里:
根因一:应用层的写饥饿。Discord 的 SFU
每个 worker 线程各自跑一个单线程 Tokio 运行时和
select! 事件循环,循环里有两个关键分支——一个
recv future 负责从 socket
收包,一个发送定时器负责把待发的包刷出去。只要接收缓冲区非空,recv
future 就始终处于”就绪”状态;而一个永远就绪的 future
会被反复轮询,导致事件循环死死缠在收包分支上,发送定时器长期得不到调度机会[10]。
Tokio 本身设有 128
次轮询的让出预算,用来防止这种情况,但在流量高峰期,128
次轮询可以在几微秒内跑完,让出预算耗尽时 recv
依然就绪,线程于是持续满载,media_batch.latency_microseconds(数据包在发送队列里等待被刷出的时长)这项指标从平时约
1 毫秒的基线飙升到几百毫秒[10]。
修复方式是给发送路径加一个 9
毫秒预算:如果距离上一次成功发送已经过去 9 毫秒,本轮
select! 就临时禁用 recv
分支,强制把机会让给发送定时器。这个修复在 3 月上线后,p99
延迟立刻下降,但延迟尖峰在高峰时段仍然存在,说明还有第二个独立的根因没有解决[10]。
根因二:虚拟化层的软中断噪声邻居。剩下的抖动来自 Cloudflare 一侧:当时的虚拟机平台每个 VM 只暴露单队列 virtio-net,意味着一个 VM 收到的全部接收软中断(softirq)都被压在同一个 vCPU 上处理。如果 Discord 的 worker 线程恰好被调度到这个正在处理软中断的 vCPU 上,就会被中断处理打断,陷入停顿[10]。
两项修复上线后拍摄的火焰图里,net_rx_action
和 local_bh_enable
这两个和软中断处理相关的调用帧,与残余的延迟尖峰完美对齐,证实了这条根因链路[10]。
Cloudflare 确认问题后,Discord 在容器启动脚本里加了一段
CPU 亲和性配置:用 taskset 把 worker 线程从被
Cloudflare 钉住处理接收中断的那个 vCPU 上迁开,同时启用
RPS(Receive Packet
Steering)把软中断处理进一步摊到其他核心上;两项修复一起上线后的第一个流量高峰,延迟尖峰完全消失,欧洲流量随即迁回
Cloudflare,此前用于兜底的备用容量也随之退役[10]。
Cloudflare 自己也在推进把容器平台迁移到支持多队列 virtio-net 的新一代 hypervisor 上,届时 Discord 这边的 CPU 亲和性变通方案就可以彻底移除[10]。
这次排查之所以格外具有代表性,是因为它精确地印证了 Discord 博客自己的判断:在共享硬件上运行实时媒体,会暴露出 HTTP 或静态内容这类工作负载完全不会触发的调度和队列争用行为——这不是哪一方代码写得不好,而是两层独立的技术栈(应用层的协程调度、虚拟化层的中断路由)在高频、低延迟要求的负载下,各自的隐藏假设同时被戳破[10]。
8.5 二十五秒的诡异卡顿:共享硬件上的噪声邻居
除了上面几类”设计假设不合”的问题,Discord 还遇到过一类更难缠的故障:部分 PoP 的语音实例会毫无征兆地卡住长达 25 秒,日志里什么线索都没有留下,洛杉矶 PoP 出现的频率明显更高[10]。这类问题不是靠 Discord 单方面的应用层排查能定位的,因为故障根源在共享硬件的内核层面。
Cloudflare 恰好从另一个客户那里收到过类似的信号,其内核团队已经独立定位到这是虚拟机层面的 I/O 停滞问题:新一代磁盘上的页缓存(page cache)堆积过多脏页后,内核会一次性把几个 GB 的数据刷回磁盘,这次刷盘期间,同一块设备上所有其他的写入者都会被阻塞,直到刷盘完成。运行更新硬件的 PoP 承载了更多 Discord 的负载,因此卡顿也出现得更频繁[10]。
这是一个典型的”噪声邻居”(noisy neighbor)故障:Discord 的语音容器本身没有任何问题,卡顿完全是同一台物理机上其他租户(或者说,共享的磁盘子系统本身)的行为造成的,这也是第三、四节讨论的”同构、共享硬件”架构在获得资源利用率红利的同时,必须承担的代价——故障域不再是单个租户的容器,而是整块物理设备。
问题定位后,Cloudflare 从两个方向修复:先是调整了脏页刷新频率的内核参数,让刷盘更频繁、每次刷的数据量更小;之后又对其 VMM(虚拟机监控器)的同步 I/O 路径做了更彻底的改造。Discord 一方的贡献仅限于提供足够详细的遥测数据,帮助 Cloudflare 的团队把症状和宿主机指标关联起来——修复本身完全发生在 Cloudflare 一侧,且这个修复惠及了 Cloudflare 的全部客户,不只是 Discord[10]。
这个细节反过来印证了同构架构的另一面价值:因为所有客户共享同一套底层平台,一次诊断可以让所有租户受益,但前提是客户愿意投入精力做跨公司的联合排查,而不是把问题完全甩给平台方。
8.6 一年之后:迁移的净效果
经过前面几轮问题排查,Discord 在博客发布时(2026 年年中)给出了迁移一年后的整体效果:超过 80% 的 Discord 语音与视频流量已经运行在 Cloudflare 边缘上,其中 70% 以上已迁移区域的质量指标同比(year-over-year)有净提升[10]。
法兰克福是提升最明显的区域之一,平均延迟下降 34%,丢包下降 42%;欧洲和拉丁美洲的多个区域丢包下降 20% 到 60% 不等;圣地亚哥的”扩展比率”(expand ratio,衡量为掩盖丢包而拉伸播放的音频帧比例)下降 40%[10]。冰岛、奥克兰这类当初促成这次迁移的边缘案例,博客发布时仍未完全解决,连同”通话该放在哪个位置”的调度逻辑一起被列入下一阶段的工作。
这组数字值得和第 8.2、8.3、8.4、8.5 节的问题清单放在一起看:净效果是正的,但正的净效果建立在整整一年的联合调试之上,涉及服务发现重构、部署节奏调整、网卡多队列支持、内核 I/O 调度改动、跨公司的 eBPF 联合探测——这不是”把负载搬到边缘就自动变快”的故事,而是”两个工程团队共同为一类边缘平台原本没有充分支持的负载,把配套设施逐块搭起来”的故事。把整条时间线放在一张表里更容易看出问题的性质在逐步变化——从”架构假设不合”到”容量参数没调对”,再到”共享硬件的内核级噪声邻居”:
| 时间 | 事件 | 问题性质 | 解决方式 |
|---|---|---|---|
| 2025-02 | 冰岛试点上线 | 多方会话下”最近节点”假设失效 | 调整迁移策略:先看通话是否本地化,再决定是否新增 PoP |
| 2025-04 | 鹿特丹流量迁往阿姆斯特丹 PoP | Orange-Telia 转接路径饱和 | 回滚流量,Cloudflare 与 Orange 谈直连对等,后续新增巴黎/伦敦 PoP |
| 2025-05 前后 | 美国东部丢包升至 1.5%–2% | 4 worker 共享单队列网卡发送队列 | 先降密度至 2 worker/主机止血,Cloudflare 修复后逐步恢复到 8 worker/主机 |
| 2025 年内 | 部分 PoP 出现 25 秒无征兆卡顿 | 新硬盘页缓存刷新引发的整机 I/O 停滞 | Cloudflare 调整脏页刷新参数并改造 VMM 同步 I/O 路径 |
| 2025 年末–2026 年初 | 布加勒斯特、斯德哥尔摩延迟尖峰 | Discord 应用层 recv 饥饿 + Cloudflare 单队列 virtio-net 软中断噪声邻居 | 双方联合 eBPF 探测定位,各自修复应用层调度与网卡虚拟化 |
| 2026-06(发布时) | 迁移满一年 | — | 80% 以上流量已迁移,70% 以上区域同比质量提升 |
这条时间线也说明了一个容易被忽视的事实:越往后期,问题定位所需要的专业知识越深——从”业务架构假设”到”网络对等关系”,再到”内核调度和虚拟化细节”,每一层都需要 Discord 和 Cloudflare 各自投入对方难以独立触达的领域知识,这也是为什么这类跨公司联合调试往往需要以年为单位的时间尺度,而不是一次迁移评审就能规划完的。
8.7 这个案例说明了什么
把这些细节抽象一层看,Discord 案例暴露的不是”Cloudflare 的平台有 bug”,而是一种更根本的假设错配:边缘平台的调度器、网卡虚拟化、部署流程,都是围绕”单元生命周期短、可以随时被替换、状态可以留在别处”这个假设优化的;而实时语音的核心约束恰恰相反——单个会话必须长时间绑定在同一台机器上,网络层的抖动会直接变成用户能听到的卡顿。
这不是说 Isolate/容器化的边缘模型”错了”,而是说它对”计算形状”做了一个明确的假设,一旦负载不满足这个假设,使用方就需要投入大量工程(自建服务发现、协调重建节奏、跨团队联合排查内核调度问题)去弥合这道缝隙。第七节提到 Cloudflare 自己为 Browser Isolation 单独铺了一套容器引擎,某种意义上正是提前承认了这道缝隙的存在;Discord 的经历则是这道缝隙在真实生产流量下具体长什么样子,也说明这道缝隙不是靠单方面选型能填平的,需要平台方和负载方共同投入。
九、开放问题
Spectre 缓解是一场没有终局的军备竞赛,而不是一次性修复。第六节引用的原文已经说得很直白:没有人能真正”修好”Spectre,Cloudflare 的策略是不断叠加拖慢手段并持续投入研究(与 TU Graz 的合作论文本身也标注了”这是 2019–2020 年的研究,防御措施仍在演进”[6])。这意味着任何时间点上对 Workers 安全模型的评估都只是一张快照,读者如果要复用类似的隔离思路(例如自建多租户 Serverless 平台),需要接受这是一项需要长期投入、没有”完成”状态的工程,而不是集成一个库就能拿到的属性。
“进程内隔离 + 能力受限 API”这套模型的边界还没有清晰的判定标准。第六节的四层防御里,只有最后一层(动态进程隔离)依赖运行时的行为特征去判定风险,前三层都是静态设计。目前公开资料给出的是”检测到可疑 CPU 特征后重新调度”这种事后响应式的做法,而不是能提前证明”某类 Worker 一定安全”的形式化方法。多租户 Serverless 平台的安全边界目前更多依赖持续的红队测试和真实攻击数据反馈,而非可验证的隔离证明,这是一个仍然开放、也很难在单篇工程博客里说清楚的问题。
边缘计算的”计算形状假设”该由谁来适配:平台还是负载?第八节的 Discord 案例代表了一类会持续出现的张力——越来越多原本运行在传统数据中心的有状态、长连接负载(实时音视频、协作编辑、游戏服务器)想要搬到边缘以降低延迟,但边缘平台的调度模型(可抢占、周期性重建、面向短生命周期单元优化的网络虚拟化)大多是为 Isolate 式负载优化的。
Cloudflare 用 Durable Objects 提供了一种”强一致、全局唯一实例”的托管抽象来承接部分有状态场景,用容器产品承接需要遗留代码库或持续占用资源的场景,但这些都是针对特定子问题的专门方案,而不是一套通用回答。Discord 这样自带成熟基础设施团队的公司选择了”自己适配平台的调度怪癖”,这条路径未必对中小团队可行——这也是判断一个工作负载是否真正适合”上边缘”时,除了延迟数字之外必须认真核算的工程成本。
多方有状态会话下,“就近”路由本身需要一层应用层调度来纠偏。RFC 4786 和 RFC 7094 在十几年前就指出,Anycast 对长时间运行的流缺乏简单的失败模式保证[12][13],第 8.2 节冰岛的例子把这条理论级警告变成了具体数字:单点视角下的”最近服务器”,换到多方参会的场景下可能让大多数参与者变得更远。
这提出了一个至今没有通用解法的问题——像 Discord 这样的多方实时系统,到底应该由网络层(更聪明的 Anycast 或者 Overlay 路由)、应用层(更聪明的会话放置算法),还是两者协同来解决”给一组分散各地的参与者选一个整体最优的宿主”这个问题?目前公开资料显示的答案是”应用层自己摸索”——Discord 把这个问题列进了后续要做的”通话放置逻辑”里,但尚未发表通用结论,这也是边缘计算领域一个仍然开放、值得持续关注的方向。
同构、共享硬件放大了单点故障的波及半径。第 8.5 节的”25 秒卡顿”案例里,故障根源不是 Discord 或者 Cloudflare 任何一方的应用代码,而是共享磁盘子系统的页缓存刷新行为——这类问题只有在多个互不知情的租户共享同一物理设备时才会出现,而这恰恰是第三节”每台机器都是万能节点”这套同构设计换来资源利用率的代价。目前没有公开资料说明 Cloudflare 是否有系统性的方法提前预测这类跨租户的硬件资源争用,还是主要依赖类似这次一样的事后遥测关联,这是同构边缘架构在故障排查方法论上一个尚未有定论的方向。
把这四个开放问题按”问题、为何重要、可读入口”整理成一张表,方便按图索骥:
| 开放问题 | 为何重要 | 可读入口 |
|---|---|---|
| Spectre 缓解是拖慢而非修复,安全评估只是快照 | 任何基于共享硬件的多租户平台都无法回避这个问题,不止 Cloudflare | Cloudflare/TU Graz 联合研究论文(2021-10)[6] |
| 隔离模型的安全边界缺少形式化证明 | 决定了多租户 Serverless 能否被用于更高敏感度的场景 | Cloudflare Workers Security Model 文档[7] |
| 多方有状态会话下”最近”路由需要应用层纠偏 | 影响所有想把实时通信迁上边缘网络的团队 | RFC 4786、RFC 7094;Discord 迁移博客[10][12][13] |
| 同构共享硬件放大跨租户故障半径 | 决定了同构架构在可靠性工程上的真实成本 | Discord 迁移博客中”25 秒卡顿”案例[10] |
十、小结
Cloudflare 的边缘架构不是单一技术选型的结果,而是三层决策叠加:网络层用 Anycast 把”就近接入”和”分散攻击流量”合并成同一个机制;操作系统层用同构软件栈加 BPF/XDP,把 DoS 过滤、负载均衡、协议分发几乎整体搬进内核可编程的路径里;计算层用 V8 Isolate 取代容器和虚拟机,把多租户隔离的颗粒度做到几毫秒启动、几 MB 内存的量级,并为此投入了一整套持续演进的 Spectre 缓解体系。这三层决策共同服务于同一个目标——让”随时随地都有一台离用户足够近的机器”这件事,在经济上和运维上都可持续。
这套哲学的边界也同样清楚:它建立在”计算单元短生命周期、可分布、可随时重建”的假设之上。Cloudflare 自己承认这个假设不适用于单机游戏、遗留浏览器这类工作负载,并为此专门铺设了容器产品线;Discord 把有状态语音负载搬上同一张边缘网络的经历,则从外部视角具体展示了当负载偏离这个假设时,需要投入多少工程去弥合调度模型和负载形状之间的错位。理解一套架构哲学,既要理解它解决了什么问题,也要理解它默认排除了哪些问题——这大概是所有”把网络当成一台机器”叙事最终都要正视的一课。
术语表
正文里出现的部分术语第一次出现时给出了中英对照,这里按出现顺序集中整理一份,方便查阅:
| 术语 | 英文 | 简述 |
|---|---|---|
| 任播 | Anycast | 多台地理分散的机器共享同一 IP 地址段,路由系统按拓扑距离把流量导向最近的一处宣告点 |
| 边界网关协议 | BGP(Border Gateway Protocol) | 互联网自治系统之间交换路由信息的协议,Anycast 依赖 BGP 宣告实现”就近路由” |
| 等价多路径 | ECMP(Equal-Cost Multi-Path) | 路由器把去往同一目标的流量按哈希摊分到多条代价相同的路径/机器上的一种负载均衡手段 |
| 伯克利包过滤器 / 扩展版 | BPF / eBPF | 一种可以在内核里安全运行沙箱化程序的机制,用于包过滤、追踪、可观测性等场景 |
| 高速数据路径 | XDP(eXpress Data Path) | 一种 eBPF 程序的挂载点,允许在网卡驱动层、数据包进入常规内核网络栈之前就处理或丢弃它 |
| 沙箱执行上下文 | V8 Isolate | V8 引擎提供的轻量级隔离单元,多个 Isolate 可以共享同一个操作系统进程,各自内存互不可见 |
| 冷启动 | Cold Start | 一个计算单元从”未运行”到”能处理第一个请求”所需要的初始化耗时 |
| 隔离分组 | Cordon | Cloudflare Workers 运行时按租户信任等级把 Isolate 分配到不同操作系统进程的机制 |
| 能力安全 | Capability-based Security | 一种安全模型:调用方能做什么完全由它持有的对象类型决定,而不是运行时权限检查 |
| 幽灵漏洞 | Spectre | 一类利用 CPU 推测执行的边信道,通过缓存等副作用间接还原被访问数据的攻击手法 |
| 动态进程隔离 | Dynamic Process Isolation | Cloudflare 与 TU Graz 合作研发的机制,把出现可疑性能特征的 Isolate 动态迁移到独立进程 |
| 选择性转发单元 | SFU(Selective Forwarding Unit) | 实时音视频架构中负责接收各参与方媒体流并按需转发给其他参与方的服务端组件 |
| 噪声邻居 | Noisy Neighbor | 多租户共享物理资源时,一个租户的行为影响到同一硬件上其他租户表现的现象 |
参考资料
- Matthew Prince. “A Brief Primer on Anycast.” Cloudflare Blog, 2011-10-21. https://blog.cloudflare.com/a-brief-anycast-primer/
- Marek Majkowski. “Cloudflare architecture and how BPF eats the world.” Cloudflare Blog(Netdev 0x13 演讲整理稿,布拉格,2019 年 3 月). https://blog.cloudflare.com/cloudflare-architecture-and-how-bpf-eats-the-world/
- Cloudflare. “How Workers works.” Cloudflare Workers Docs,最后更新 2026-04-23. https://developers.cloudflare.com/workers/reference/how-workers-works/
- Zack Bloom. “Cloud Computing without Containers.” Cloudflare Blog, 2018-11-09. https://blog.cloudflare.com/cloud-computing-without-containers/
- Kenton Varda. “Mitigating Spectre and Other Security Threats: The Cloudflare Workers Security Model.” Cloudflare Blog, 2020-07-29. https://blog.cloudflare.com/mitigating-spectre-and-other-security-threats-the-cloudflare-workers-security-model/
- Cloudflare. “Dynamic Process Isolation: Research by Cloudflare and TU Graz.” Cloudflare Blog, 2021-10-12. https://blog.cloudflare.com/spectre-research-with-tu-graz/;另见 Kenton Varda / Rita Kozlov. “Containers at the edge: it’s not what you think, or maybe it is.” Cloudflare Blog, 2021-04-17. https://blog.cloudflare.com/containers-on-the-edge/
- Cloudflare. “Security model.” Cloudflare Workers Docs. https://developers.cloudflare.com/workers/reference/security-model/
- 站内《CDN 架构:全球加速的设计原理》,第二节 DNS 路由与 Anycast 路由对比,第七节 Cloudflare 架构解析。
- 站内《限流与过载保护:保命的最后一道防线》,入站过载与出站故障的区分、令牌桶算法与部署位置。
- David Chen. “How We Moved Discord Voice to the Edge.” Discord Blog, 2026-06-09. https://discord.com/blog/how-we-moved-discord-voice-to-the-edge
- Partridge, C., Mendez, T., and Milliken, W. “Host Anycasting Service.” RFC 1546, 1993-11.
- Abley, J. and Lindqvist, K. “Operation of Anycast Services.” RFC 4786 / BCP 126, 2006-12.
- McPherson, D., Oran, D., Thaler, D., and Osterweil, E. “Architectural Considerations of IP Anycast.” RFC 7094, 2014-01.
上一篇:Discord 架构
同主题继续阅读
把当前热点继续串成多页阅读,而不是停在单篇消费。
【系统架构设计】边缘计算架构:算力下沉的设计挑战
把计算推到离用户更近的 PoP 上,CDN 的缓存假设不再够用:边缘节点如何在最终一致与强一致之间取舍,控制面如何把配置安全送达数千节点,回源与写路径如何不拖垮源站,本地状态与会话亲和又在 Anycast 下如何失效。本文以 ETSI MEC 定义与 Dynamo 谱系为锚,对照 Cloudflare/Discord 公开案例,梳理边缘架构的四条硬约束与仍未闭合的开放问题。
【系统架构设计】CDN 架构:全球加速的设计原理
互联网应用的用户遍布全球,从北京到纽约、从东京到伦敦,一次 HTTP 请求如果需要跨越半个地球才能到达源站服务器,延迟可能高达数百毫秒。内容分发网络(Content Delivery Network,简称 CDN)通过在全球各地部署边缘节点,将内容推送到离用户最近的位置,从根本上缩短了用户与内容之间的物理距离。本文将从…
【系统架构设计】WebAssembly 架构:浏览器之外的运行时革命
从 WebAssembly 核心规范的沙箱语义出发,拆解 WASI 0.2 组件模型与 WIT 接口、能力安全的 preopen 边界,对照 Cloudflare Workers 的 V8 Isolate 路径,并以 Wasmtime 为锚点讨论边缘插件、SaaS 插件沙箱与多语言组件组合的服务端架构取舍与未决问题。
持续性能分析(Continuous Profiling):Parca、Pyroscope、Grafana Beyla
深入剖析持续性能分析(Continuous Profiling)的原理、架构与落地实践,覆盖 Parca、Pyroscope、Grafana Beyla 三大主流方案,包含 eBPF 采样、符号解析、火焰图、差异分析以及字节跳动、美团的生产案例与工程坑点。