V8 isolate 与 Layer-2 沙箱的双层隔离模型

isolate 是什么、能跑多少、能跑多久

把 Workers 想象成 Cloudflare 在每台边缘机器上长期运行一个多租户的 JavaScript 引擎实例,比"一堆容器"更接近事实。这个引擎就是 V8——Chromium 和 Node.js 用的同一个 JavaScript 引擎——但 Workers 用到的不是 Node.js 的整个对象生态,而是 V8 自己提供的一个核心抽象:isolate。V8 文档对 isolate 的定义是"a VM instance with its own JavaScript heap, its own microtask queue, and its own set of built-in objects"——一句话,isolate 之间彼此内存不共享,堆是隔离的。

Workers 文档把这件事翻译得直白:"V8 orchestrates isolates: lightweight contexts that provide your code with variables it can access and a safe environment to be executed within. You could even consider an isolate a sandbox for your function to run in." 在同一台机器上的同一个 Workers 运行时进程里,可以塞下"hundreds or thousands of isolates";当一个 HTTP 请求命中你的 Worker 时,Workers 运行时把请求路由到一个能容纳它的 isolate(或必要时新建一个),跑完你的 handler 之后这个 isolate 不会立刻被销毁,它留在内存里等待下一个命中——这就是为什么平均响应时间可以接近 2.2 毫秒:handler 代码不需要每次都冷启动。

这个密度是有代价的,但代价不在 CPU 也不在内存,而在生命周期。Workers 文档明确写道:"An isolate may be spun down and evicted for a number of reasons: Resource limitations on the machine; A suspicious script; Individual resource limits."——机器资源压力、可疑脚本特征、命中 limits 中的任一条都可能让一个 isolate 被静默回收。它不是 VM,没有"我付了钱,这台机器属于我"的承诺;它只是一段随时可以被回收的内存里的一段随时可以被回收的 V8 上下文。文档接下来一句直接给出了工程建议:"it is generally advised that you not store mutable state in your global scope unless you have accounted for this contingency."——你写到 globalThismodule 顶层、let 在 handler 外的任何值,下次请求来时可能还在,也可能已经被回收干净。

把"启动快"和"不可靠"合起来,读者会立刻得到三条具体的工作约束:(1) 不要在 module 顶层缓存基于 env 的客户端实例——isolate 复用时这些实例可能基于旧的 env 值;(2) 不要把 I/O 对象(RequestResponseReadableStream)放全局——它们和当次 invocation 的 event loop 绑死,下次进来就是已死句柄;(3) 不要相信 Date.now() 推进——这件事这一节后面会展开。

isolate 之外,Workers 还加了什么

读者很容易把"Workers 用了 V8 isolate"理解成"Workers 的隔离就靠 V8 isolate"。这种理解是错的——V8 isolate 只是 Worker 进程内部的内存边界,Workers 在它外面还套了两层。

第一层是 cordon。 Cloudflare 在同一台物理机上不只跑一个 Workers 运行时进程;它会跑多个。Workers Security model 页面把这种"多实例"命名为 "cordons",并解释:"Cloudflare runs multiple instances of the whole runtime on each machine, which is called cordons. Workers are distributed among cordons by assigning each Worker a level of trust and separating low-trusted Workers from those trusted more highly." 更具体地:"a customer who signs up for the Free plan will not be scheduled in the same process as an Enterprise customer."——同一台机器上的不同 cordon 可以承载不同信任等级的客户。cordon 是 Workers 把"在同一进程内多租户"和"不同租户绝不能因 V8 漏洞互相越界"调和起来的方式:哪怕 V8 有零日漏洞,漏洞的影响也被限制在某一个 cordon 内,而 cordon 已经按信任等级做了分隔。

第二层是 Layer-2 沙箱。 V8 isolate 解决的是"进程内"内存隔离,Layer-2 沙箱解决的是"Worker 进程能向 OS 做什么"。Workers 文档写得很清楚:"The layer 2 sandbox uses Linux namespaces and seccomp to prohibit all access to the filesystem and network. Namespaces and seccomp are commonly used to implement containers. However, Cloudflare's use of these technologies is much stricter than what is usually possible in container engines, because Cloudflare configures namespaces and seccomp after the process has started but before any isolates have been loaded."——这一句是 Workers 与普通容器最大的区别。普通容器引擎(Docker、containerd)必须保留 exec() 之类的系统调用,否则你无法从镜像启动容器;Workers 是在 Workers 运行时进程已经启动之后才装上 seccomp 过滤器,因此它可以"用 totally empty filesystem (mount namespace) and uses seccomp to block absolutely all filesystem-related system calls"——所有文件系统 syscall 全部屏蔽,不需要保留 exec()

后果是 Worker 进程自身不能读写磁盘、不能直接连网络。它能做的只有一件事:和本机其它进程通信,而那些通信只能走 UNIX domain socket。Workers 文档点出了三个关键对端:supervisor、inbound 代理、outbound 代理。"The sandbox talks to the supervisor using Cap'n Proto RPC"——supervisor 负责把 Worker 代码与配置从磁盘/配置中心送进沙箱(并且只能送"这个沙箱应该跑"的那些 Worker 的代码和密钥,不能越界),Cap'n Proto 是 Cloudflare 团队维护的开源 RPC 协议。"All outbound HTTP requests are sent over a UNIX domain socket to a local proxy service"——出站请求先到 outbound 代理,代理校验目标(公网或本 zone origin)并"adds a header to every request identifying the Worker from which it originates"——你发出的每个出站 HTTP 请求都带一个标识 Worker 来源的请求头,这正是 Cloudflare 能在日志里做请求归属审计的基础。inbound 代理则是 TLS 终结与"按 URL 选定 Worker"——它根本不让 Workers 运行时进程接触 TLS 私钥。

把三层画到一起:

flowchart LR subgraph 物理机[一台物理机] direction TB subgraph CordonA[cordon A: 信任等级高] L2A[Layer-2 沙箱: namespaces + seccomp, 屏蔽 FS/网络 syscall] subgraph 进程A[Workers 运行时进程 A] IA1[isolate 1] IA2[isolate 2] IA3[isolate 3] end end subgraph CordonB[cordon B: 信任等级低] L2B[Layer-2 沙箱: namespaces + seccomp, 屏蔽 FS/网络 syscall] subgraph 进程B[Workers 运行时进程 B] IB1[isolate 4] IB2[isolate 5] end end 进程A -- UNIX domain socket, Cap'n Proto --> SUP[supervisor: 发送代码与配置, 按密钥授权] 进程A -- UNIX domain socket --> IN[inbound 代理: TLS 终结, 按 URL 选 Worker] 进程A -- UNIX domain socket --> OUT[outbound 代理: 校验目标, 注入来源头] end 进程B -- UNIX domain socket, Cap'n Proto --> SUP 进程B -- UNIX domain socket --> IN 进程B -- UNIX domain socket --> OUT

读者可以从这张图里取走三个边界事实:(1) 一个 isolate 的越界访问被同进程内其它 isolate 隔离——这层是 V8 isolate 自己负责;(2) 一个 cordon 的越界访问被同物理机内其它 cordon 隔离——这层是 cordon + Layer-2 沙箱共同负责;(3) 一个 Worker 进程对 OS 的所有 syscall 都被 seccomp 屏蔽——这层是 Layer-2 沙箱独自负责。

计时与多线程被刻意关掉

读到 V8 isolate 这层,读者会有一个自然的问题:JS 引擎是单线程的,但既然 Workers 把所有租户塞进同一进程,攻击者能不能用精确计时构造侧信道?答案是 Workers 在 2017 年中——远早于 Spectre 公开——就把这条路堵了。Workers Security model 页面明确写道:

Workers is designed to make it impossible for code to measure its own execution time locally. For example, the value returned by Date.now() is locked in place while code is executing. No other timers are provided. Moreover, Cloudflare provides no access to concurrency (for example, multi-threading), as it could allow attackers to construct ad hoc timers.

所以一段 Worker 代码里 Date.now() 拿到的不是"现在的墙钟时间",而是"上一次 I/O 事件完成时的墙钟时间"——在你的代码执行期间它不会推进。文档原话更直白:"Date.now() returns the time of the last I/O. It does not advance during code execution. For example, if an attacker writes... let start = Date.now(); for (...) doSpectreAttack(); let end = Date.now(); the values of start and end will always be exactly the same."——startend 必然相等,无法在本地测量自己的执行时间。

同样地,Workers 不暴露多线程与共享内存:"multi-threading and shared memory are not permitted in Workers. Everything related to the processing of one event happens on the same thread."——单个事件的所有处理都在同一线程内完成;多个 Worker 处理同一请求时也"run in the same thread"(比如 zone 自身装的 App 也是一个 Worker,请求会先被 zone Worker 处理再被你的 Worker 处理,两者都跑在同一线程的不同阶段)。

这两条删掉之后还剩什么?只剩"通过 HTTP 远程测量自己的执行时间"——但这条信道噪音极大(必须穿过 Internet),需要在攻击端做大量统计平均才能压出信号。Workers 文档承认这条理论上仍然存在("Such noise can be overcome, in theory, by executing the attack many times and taking an average"),但 Cloudflare 与 TU Graz 联合测试至今未在生产中复现成功。即便如此,Workers 也不靠"攻击不会发生"赌安全,而是在更上层继续叠加防御。

纵深防御:动态进程隔离、内存重排、补丁 gap

Layer-2 沙箱与计时禁用是"静态"防御——它们决定 Worker 代码一开始就不可能做什么。Workers 还有三道"动态"防御,决定 Worker 代码一旦表现出异常行为就会被进一步隔离或被更快地修复。

第一道是动态进程隔离。 Workers Security model 写道:"The runtime chooses to reschedule any Worker with suspicious performance metrics into its own process. As described above, the runtime cannot do this with every Worker because the overhead would be too high. However, it is acceptable to isolate a few Worker processes as a defense mechanism. If the Worker is legitimate, it will keep operating, with a little more overhead. Fortunately, Cloudflare can relocate a Worker into its own process at basically any time."——一旦运行时通过 CPU 性能计数器识别出某个 Worker 在做"反常的"事(长时间 CPU 密集、cache 行为异常等),就把它迁到一个独立进程里运行;这等于把它升级成"享受独立进程的所有 Spectre 缓解"的待遇,且开销对少数 Worker 来说可以接受。文档后面还补了一句:对于 CPU 密集型 Worker,本就"switch context less often",把它们移出共享进程的代价相对低。

第二道是周期性内存重排。 文档原话:"it is within reason to restart the entire Workers runtime on a daily basis. This will reset the locations of everything in memory, forcing attacks to restart the process of discovering the locations of secrets. Cloudflare can also reschedule Workers across physical machines or cordons, so that the window to attack any particular neighbor is limited."——整台机器上的 Workers 运行时进程每天都会重启一次,所有 isolate 内存中 secret 的位置全部重置;同时把 Worker 在不同 cordon、不同物理机之间重排,缩小"攻击特定邻居"的时间窗。

第三道是补丁 gap 收敛。 V8 自身会出安全补丁。文档原话:"V8 is open source, so fixes for security bugs are developed in the open and released to everyone at the same time. It is important that any patch be rolled out to production as fast as possible, before malicious actors can develop an exploit." Cloudflare 给出的数据是:"the Workers patch gap is now under 24 hours"——V8 补丁公开后,Cloudflare 在 24 小时内把对应的 Workers 运行时版本推到生产;"A patch published by V8's team in Munich during their work day will usually be in production before the end of the US work day." 这与 Chrome 等其它"沙箱 + 高强度 fuzzing"平台的 patch gap 大致持平。

把这三道与之前的"cordon + Layer-2 沙箱 + 计时禁用"叠起来,读者得到的是一张"Worker 代码能做什么 / 不能做什么"的多层地图:V8 isolate 决定它不能越界访问同进程内其它租户的内存;cordon 决定它不能与不同信任等级的客户在同进程内被一同 V8 漏洞利用;Layer-2 沙箱决定它不能读文件、不能直接发网络包;计时/线程禁用决定它不能在本地构造精确侧信道;动态进程隔离 + 内存重排 + 短补丁 gap 决定它即便"被怀疑"也会被快速隔离、即便利用了某个未知漏洞攻击窗口也极短。这套防御既不是"靠 V8 的可信度"赌安全,也不是"靠 Linux 内核的 seccomp 严格"赌安全,而是把每一层单独都不够可靠的防御,叠加到让任何单点失败都不足以破坏整体隔离。

一句话总结这一章:Workers 的隔离不是"V8 自带的",而是 Cloudflare 在 V8 之上又盖了 cordon、Layer-2 沙箱、计时禁用、动态进程隔离、内存重排、短补丁 gap 共六层,每一层都针对不同的失败模式。

到这里读者已经知道 Workers 的隔离长什么样,但它还不是可对照检查的数字——CPU、内存、连接数、启动时间这些硬限额具体是多少?compatibility date 又把"运行时升级"这件事怎么控制?下一章会把隔离机制落到 limits 表上,让读者能在自己项目里逐项核对自己的设计。

References