为什么 Workers 不是另一个容器 serverless

flowchart LR subgraph 传统[传统 serverless: 容器 / VM] A1[租户 A] --> A2["OS 进程 + 容器/VM, Node 运行时"] A3[租户 B] --> A4["OS 进程 + 容器/VM, Node 运行时"] A5[租户 C] --> A6["OS 进程 + 容器/VM, Node 运行时"] end subgraph Workers[Cloudflare Workers: V8 isolate] W1[租户 A] --> W2["Workers 运行时进程, 内含多个 V8 isolate"] W3[租户 B] --> W2 W4[租户 C] --> W2 W5[租户 D] --> W2 W6[租户 E] --> W2 end 传统 ~~~ Workers

上面这张并排图,是几乎所有写 Cloudflare Workers 的文档都会先放、却很少正面解释的对比。左边一列一列的方框是"传统 serverless 给你的直觉":每个租户进来都被打包成独立 OS 进程或容器镜像,进程里再启动一个完整的语言运行时(多数情况下是 Node.js),代码在这个运行时里运行;右边这一团方框才是 Workers 的真实结构——一个 Workers 运行时进程,里面同时塞着成百上千个 V8 isolate,每个 isolate 对应一个租户的一段代码。两种模型都能对外提供"函数即服务"的接口,调用体感甚至都没差别。但模型不同,故障会从完全不同的地方漏出来

容器模型解释不了的现象

如果你带着"Workers = 另一种 FaaS"的直觉读官方 limits 页面,第一批看到的数字不会让你惊讶:CPU 时间、内存、subrequest 上限、并发连接数、单 Worker 大小、启动时间——这些都是任何一个 serverless 平台都有的硬约束。让你真正感到不对的是这些数字之间的比例

按 Cloudflare Workers 文档给出的官方数据,平均每个请求只消耗约 2.2 毫秒的 CPU 时间,CPU 时间上限在 Free 计划上只有 10 毫秒;一个 isolate 的内存上限是 128 MB;一次 invocation 同时打开的 outbound 连接最多 6 个。这些数字单独看都不算稀奇,但合起来透露一件容器模型解释不了的事——请求还没"启动"完就要被掐死。一台 8 核机器的 Node 进程冷启动几百毫秒是常态,10 ms 的 CPU 预算根本撑不住一次冷启动;128 MB 的进程内存预算里光是 V8 引擎基础就可能用去一大半,更别提 container runtime 的开销。这套数字只对一种执行单元说得通:它必须几乎免费地随用随建、用完即扔,进程级的开销根本不应该出现在它的成本账上。

第二个解释不了的,是跨请求不能共享某些对象。浏览器和 Node 都允许你把一个对象放模块顶层缓存,第二次进来直接拿。Workers 看起来也支持 module-top-level 缓存,但官方 errors 页面专门列了一个错误:Cannot perform I/O on behalf of a different request,触发场景就是把上一次请求拿到的 Response 缓存到全局,下一次请求再读。在容器模型里,这种"上下文对象"是天然属于进程局部的,没有"为另一个请求做 I/O"这种说法。Workers 抛这个错,是在告诉你:执行单元比进程更小,跨请求的"上下文"必须显式以数据形式传递,不能以 I/O 对象形式传递

第三个解释不了的,是隔离单元可以临时消失。Worker limits 文档明说:"an isolate may be spun down and evicted for a number of reasons: Resource limitations on the machine; A suspicious script; Individual resource limits." 一个进程是不会因为"机器资源紧张"被单独赶出去的——驱逐进程等于驱逐整个租户,这是平台级的硬动作;但 Workers 的 isolate 可以"安静地"被回收。这意味着你把可变状态写进 module top-level,相当于把它写进了一段随时可能被回收的内存。

这三个现象放在一起,就是这一章要交给读者的核心判断:Workers 的执行单元不是 OS 进程、不是容器、不是 VM,而是 V8 isolate——V8 引擎提供的一种语言级执行沙箱。理解这一点的瞬间,limits 数字、跨请求约束、可变状态不可靠三件事就同时变得可解释。

V8 isolate 是什么

V8 是 Chromium 和 Node.js 用的 JavaScript 引擎,它把 JavaScript / WebAssembly 编译成机器码执行。V8 内部有一个独立的抽象叫做 isolate——一段 JS 代码对应的完整执行环境,有自己的栈、自己的堆、自己的全局对象,与同进程内的其他 isolate 之间不可越界访问内存。Cloudflare 在 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."

隔离的好处在密度上立刻兑现:官方明确写道 "a single instance of the runtime can run hundreds or thousands of isolates",且 "any given isolate can start around a hundred times faster than a Node process on a container or virtual machine"——同一份运行时进程里把 isolate 切进切出,几乎不付"启动"成本,因为根本没有 OS 进程要被 fork、没有 container runtime 要做 layer 解析、没有 Node.js 模块系统要走一遍。on startup isolates consume an order of magnitude less memory 这条原话更直接:内存占用比 Node-on-VM 容器低一个数量级。

密度高、启动快、内存省,这三个性质叠在一起,就自然推出了前面那些数字:CPU 预算 10 ms 是合理的——isolate 的"启动"本来就是毫秒级;128 MB 内存上限也是合理的——isolate 没有 container runtime 的固定开销,128 MB 几乎全部是 V8 堆 + WebAssembly 内存。

但 isolate 不是长寿进程。它可被机器资源压力驱逐,可被运行时判定为"可疑脚本"驱逐,可被 Workers limits 触发驱逐。文档原话建议:"it is generally advised that you not store mutable state in your global scope unless you have accounted for this contingency." 这里的"账户"不是"账户"字面意思——是"你已经考虑过 isolate 消失之后这段全局状态怎么办"。最简单也最正确的"账户"方式:根本不存。

顺带说一件会让 JavaScript 老手困惑的事:Workers 不允许 Date.now() 在代码执行期间推进,文档原话是 "the value returned by Date.now() is locked in place while code is executing. No other timers are provided"。也没有多线程、共享内存。这是 Workers 在 2017 年中(远早于 Spectre 公开)就部署的 Spectre 防御的一部分——本地不能计时,就没办法构造精确的时序侧信道。这意味着你不能用 Date.now() 在本地衡量代码性能;性能数据只能从运行时统一通过 CPU time 指标向你报告。这一点在性能调试章节会再次出现。

跨请求 I/O 对象不能共享的约束同样可以从 isolate 的生命周期推出:每次 invocation 进入 handler 时,Workers 把请求包装成一个 Request 对象、附上 envctx;handler 返回 Response 时,相关的 I/O 资源就关闭了。ResponseRequest.bodyTransformStream 这些对象都和当次 invocation 的 event loop 绑死。把它们塞进全局,等于让下一次 invocation 拿着上一个 invocation 的"已死"句柄去操作。

这套选择如何改变你写代码的方式

抽象讲完,落地到三个具体的写法差异。

第一,不要把基于 env 的客户端实例放 module 顶层env.MY_BUCKETenv.MY_DB 这些绑定是"权限 + API"——它们把访问凭证藏在 Cloudflare 自己的运行时里,你的代码只能看到一个接口对象,看不到密钥。这件事值得立刻接受,因为官方 Bindings 页面把它写得很直白:"you can think of a binding as a permission and an API in one piece. With bindings, you never have to add secret keys or tokens to your Worker in order to access resources on your Cloudflare account." 但一个非显然的事实是:当只改 bindings 不改代码时,Cloudflare 可能复用现有 isolate——bindings 改了,你的代码不一定被重新加载;模块顶层那些基于旧 env 值初始化的客户端就会读到旧值。正确做法是每个请求 new 一个客户端,让 isolate 复用不会导致状态陈旧。

第二,version 与 deployment 是两个东西。Cloudflare 把"你这次提交了什么"(version)和"现在线上跑的是哪个"(deployment)显式分开。默认 wrangler deploy 把这两步合为一步,但你随时可以解耦:wrangler versions upload 只创建不可变快照,wrangler versions deploy 决定哪个快照接流量。这样做的工程意义是:你的发布流水线不再受"我到底现在改了多少"影响——每一次上传都是一个可回放的工件,每一次部署都是一个可审计的事件。在传统 CI/CD 里,你能做到的极限是"把镜像 tag 推到注册表并滚动更新";在 Workers 里你能做到"上传 5 个候选版本、按 5% / 20% / 50% / 100% 分四步切流、随时 rollback 到 100 个最近版本中的任意一个"。这套发布控制面是 Workers 把"隔离"概念延伸到"发布"维度的关键设计。

第三,可观测性是被集中提供的对外面。Workers 的可观测性不是"你自己接一个日志 SDK",而是平台提供四件套:Logs(Workers Logs / Real-time / Tail Workers / Logpush)、Traces(开 observability.traces.enabled = true 即可获得对 fetch / bindings / handler 的自动埋点,遵循 OpenTelemetry 标准)、Metrics(请求成功/错误、subrequests、wall time、CPU time、内存分位数、invocation statuses)、Errors(错误码体系如 1101=JS 异常、1102=超 CPU/内存、1027=Free 日上限、1019=循环上限、10021=启动期超 1 s 等等)。这件事读者此刻只需要记下"有这么一个对外面"——具体怎么用,本系列会在收束章节里给一条完整的可迁移工作流。

把这三点合起来,本章想给读者带走的判断是:Workers 的隔离、Workers 的能力面(绑定)、Workers 的发布控制面、Workers 的可观测性,是四个相互咬合的机制层。任何在 Workers 上构建可靠边缘服务的实践,最终都要回到这四层各自的边界与衔接。容器模型只能解释"为什么没有冷启动";只有把这四层一起画出来,你才看得见 Workers 真正"可靠"在哪里。

接下来我们要回答一个更具体的问题:V8 isolate 本身只是 V8 的语言级沙箱,Workers 凭什么说租户之间不可越界访问?它和 OS/进程级隔离的边界在哪里?这一问会把我们带进 Layer-2 沙箱、cordon 信任分级、计时与多线程禁用,以及那条"< 24 小时"的 V8 补丁 gap。

References