从进程到容器,从 QEMU 到 Firecracker,从 VM 到 V8 Isolate——多租户隔离没有银弹,只有取舍:安全边界越硬,启动开销越大;资源密度越高,逃逸风险越高。这篇文章拆解 AWS Lambda、Cloudflare Workers、Vercel Functions 各自的隔离方案,梳理它们在安全性、冷启动、资源密度之间的不同选择。
这篇文章从一个具体的工程问题出发:如果要运行大量用户上传的任意代码,安全、快、便宜——三个目标互相对立,该如何设计?
这是 AWS Lambda(Chen 注:AWS 的 FaaS(函数即服务)平台,2014 年发布,是 Serverless 计算的事实标准。底层使用 Firecracker microVM 隔离,按实际执行时间计费,不需要管理服务器。)、Cloudflare Workers(Chen 注:Cloudflare 的边缘计算平台,代码运行在全球 300+ 个 PoP 节点上,基于 V8 Isolate 隔离,冷启动 < 1ms。不是传统 Serverless,没有「区域」概念,请求直接在离用户最近的节点执行。)、Vercel Functions(Chen 注:Vercel 的计算平台,底层基于 Firecracker microVM,支持完整 Node.js 运行时。与 Next.js 深度集成,是本站 API 路由和 RSC 渲染的执行环境。) 每天面对的问题。他们给出了不同的答案,而这些答案背后的取舍逻辑,在任何需要多租户隔离的系统里都会反复出现——包括专线 SaaS 的私有化部署场景。
在同一批机器上运行多租户代码,有三个互相对立的约束:
这三者构成一个三角,每种隔离方案都是在不同顶点上做权衡。
没有方案能同时最优化三个维度。理解这个前提,才能理解为什么 AWS 和 Cloudflare 做出了截然不同的选择。
最直觉的隔离单元是操作系统进程。Linux 进程天然拥有独立的虚拟地址空间,内核通过内存管理单元(MMU)保证进程间内存不可见。fork() + exec() 启动一个新进程,操作系统负责隔离。
但进程隔离的问题在于共享内核。所有进程共用同一个 Linux 内核,内核漏洞可以被任意进程利用来逃逸。对于公有云的多租户场景,「共享内核」意味着一个租户的恶意代码可以通过内核漏洞影响其他租户甚至宿主机。
此外,启动一个能跑 Node.js 的进程需要几百毫秒,内存开销在几十到几百 MB,在 per-request 的 FaaS 模型下代价太高。
容器并不是一项新技术,它是 Linux 内核若干特性的组合。
Linux 提供了六类命名空间(Namespace),每一类隔离一种资源视图:
| 命名空间 | 隔离内容 | 内核引入版本 |
|---|---|---|
| PID | 进程 ID 视图 | 3.8(2013) |
| Network | 网络接口、路由表、端口 | 2.6.24(2008) |
| Mount | 文件系统挂载点 | 2.4.19(2002) |
| UTS | 主机名、域名 | 2.6.19(2006) |
| IPC | System V IPC、POSIX 消息队列 | 2.6.19(2006) |
| User | 用户 ID、组 ID 映射 | 3.8(2013) |
容器 = 进程 + 命名空间约束 + cgroups 资源限制。Docker 不过是把这些内核特性包装成了友好的 CLI。
容器比进程轻,因为镜像层共享,同一宿主机上多个容器可以复用相同的只读层。但根本问题没有变:共享内核。Dirty COW(CVE-2016-5195)、Runc 逃逸(CVE-2019-5736)表明容器逃逸漏洞的根源都在于内核被共享。
对于运行用户提交任意代码的场景,容器的隔离强度不够。
内核是共享的,也是最大的攻击面。
要从根本上消除共享内核的风险,需要让每个租户运行独立的内核。传统虚拟机(VM)做到了这一点,但启动时间以分钟计,资源开销以 GB 计,用来处理 HTTP 请求完全不现实。
AWS 的答案是 Firecracker(Chen 注:AWS 2018 年开源的极简 VMM,用 Rust 编写,约 5 万行代码。专为 serverless 多租户设计,最小化设备模型(只有 virtio-net、virtio-block 等 5 种设备),冷启动约 125ms,每个 microVM 内存开销约 5MB。)——一个为 serverless(Chen 注:无服务器计算模式:开发者只部署函数代码,平台负责服务器的供给、扩缩容和运维。按实际调用次数和执行时间计费,没有请求时不产生计算成本。本质上是把「弹性伸缩」和「容量规划」的复杂度转移给平台。) 设计的极简 VMM(Virtual Machine Monitor)。
QEMU 是 Linux 世界最通用的 VMM,代码量约 140 万行 C,支持模拟 PCI 总线、USB 控制器、声卡、显卡……这些模拟设备的代码构成了巨大的攻击面。一个 guest VM 可以通过构造恶意的设备 I/O 请求触发 QEMU 中的漏洞,从而逃逸到宿主机。
Firecracker 的设计原则是最小化设备模型:只实现运行服务端工作负载所必需的设备。
| 设备 | 说明 |
|---|---|
| virtio-net | 网络 |
| virtio-block | 块存储 |
| virtio-vsock | host-guest 通信 |
| 串口(UART) | 控制台输出 |
| i8042 键盘控制器 | 仅用于发送电源事件(关机信号) |
没有 PCI 总线模拟,没有 USB,没有 BIOS/UEFI。Firecracker 实现了一套自定义的 microVM 引导协议,直接加载 Linux 内核,跳过 BIOS 初始化阶段。整个代码库约 5 万行 Rust,相比 QEMU 的约 140 万行,代码规模缩小了约 28 倍。(Chen 注:数据来源:Brooker et al., Firecracker: Lightweight Virtualization for Serverless Applications, NSDI 2020, USENIX。)
jailer 在启动 VMM 之前完成三件事:
即使 Firecracker VMM 本身存在漏洞,攻击者在 VMM 进程内能调用的系统调用也被 seccomp 严格限制,极大地缩减了提权路径。
Firecracker microVM 的 ~125ms 冷启动构成如下:
AWS Lambda 在这个基础上做了预热池(warm slot)优化:预先维护一批已初始化的 microVM 实例,请求到来时直接注入函数代码执行,用户感知到的「冷启动」只剩代码加载和运行时初始化。
Cloudflare Workers 走了一条完全不同的路。
V8 是 Chrome 和 Node.js 使用的 JavaScript 引擎。V8 的 C++ API 提供了 v8::Isolate 类,代表一个完全独立的 JS 引擎实例——这里的 Isolate(Chen 注:V8 引擎中的隔离执行单元。每个 Isolate 拥有独立的堆内存、垃圾回收器和 JIT 编译器,与其他 Isolate 之间内存完全不可达。单个 OS 进程可以同时运行数千个 Isolate,每个约 3MB 开销。这是 CF Workers 能在一台服务器上运行数万个 Worker 的基础。) 是隔离的核心单元:
单个进程里可以跑数千个 Isolate,每个约 3MB 内存开销,是 microVM(5MB + 完整内核)的若干分之一。
V8 Isolate 冷启动能达到微秒级的关键是 Snapshot 机制:将一个已初始化的 Isolate 堆内存序列化成二进制 blob,之后用这个 blob 恢复新 Isolate——本质上是把「初始化后的内存状态」存成快照,实例化时直接 memcpy。
V8 Isolate 的隔离有一个根本性的安全限制:多个 Isolate 共享同一个 OS 进程,共享 CPU 缓存。
Spectre(CVE-2017-5753)利用 CPU 的预测执行和缓存时序差异,允许攻击者通过测量内存访问时间来推断同一进程内其他区域的内容——即使 V8 的 JS 层面禁止了直接内存访问。
实施这类攻击需要:
SharedArrayBuffer 可作为计时器基础Cloudflare 的缓解措施:
| 措施 | 目的 |
|---|---|
禁用 SharedArrayBuffer | 消除最常用的高精度计时来源 |
降低 performance.now() 精度 | 加入随机抖动,使时序推断不可靠 |
| 进程级隔离(高付费层) | 高价值 Worker 运行在独立 OS 进程,获得硬件级隔离 |
这是 V8 Isolate 模型无法彻底消除的风险。
四种隔离方案的综合对比:
| 方案 | 隔离强度 | 冷启时间 | 内存开销 | 典型用途 |
|---|---|---|---|---|
| 进程 | 中(共享内核) | ~200ms | 几十 MB | 传统 web 服务 |
| 容器 | 中(共享内核) | ~100ms | 几十 MB + 镜像 | 微服务、CI |
| Firecracker microVM | 高(独立内核) | ~125ms | ~5MB + 内核 | AWS Lambda |
| V8 Isolate | 中高(语言层面) | < 1ms | ~3MB | CF Workers |
选择逻辑:
如果零设计一个 FaaS 平台,需要在隔离方案之上回答几个关键问题。
无论选哪种隔离方案,都需要设计预热池来消除感知冷启动。设计要素:
预热池大小是一个成本与延迟的权衡:池子太小,峰值时冷启增多;太大,空闲实例浪费资源。通常基于历史请求分布做预测性扩缩容,而不是被动响应。
每个隔离方案在不同层次实施资源限制:
| 限制维度 | Firecracker 方案 | V8 Isolate 方案 |
|---|---|---|
| CPU | cgroups 配额 | 独立计时线程 + TerminateExecution |
| 内存 | cgroups 限制 | V8 HeapStatistics 监控 |
| 网络带宽 | virtio 设备限速 | 出站请求过滤(无原始 TCP) |
| 进程 / 文件 | Guest 内核参数 | 不适用(无进程、无文件系统) |
V8 的 CPU 限制实现尤其有趣:CF 在独立线程里用 wall clock 计时,超过配额时调用 isolate->TerminateExecution(),这会在下一个 V8 安全点(safepoint)注入一个终止异常。JS 代码无法捕获这个异常,isolate 被强制终止。
计算隔离做好之后,还需要网络层面的隔离。每个租户的函数实例应当只能访问其被授权的出站地址,不能扫描内网、访问其他租户的服务。
Firecracker 方案:每个 microVM 有独立的 tap 设备,宿主机用 iptables/nftables 规则控制出站,结合 VPC/安全组实现网络隔离。
V8 Isolate 方案:CF 的 Worker 发起的 fetch() 请求经过 CF 自己的网络层过滤,私有地址段(RFC 1918)的请求被拒绝,出站流量可审计。
本站的动态请求(评论 API、浏览量统计、内容审核)跑在 Vercel Functions 上,底层是 Firecracker microVM(Vercel 的 Fluid Compute)。
对这个场景来说,V8 Isolate 方案在技术上也够用——API 逻辑简单,不依赖 Node.js 特有的底层 API。真正决定选择 Vercel 而不是 CF Workers 的原因,不是隔离模型,而是 Next.js 的深度集成:Server Components、Server Actions、Middleware 的完整支持,在 CF Workers 的 V8 Isolate 约束下至今有功能缺口。
隔离方案的选择,最终被框架兼容性决定,而不是被安全需求决定——这是个值得记住的教训。
最后将这套隔离思路映射到专线 SaaS场景。对于一批自有机器,通过专线向多个金融机构客户提供服务,不同客户的数据和计算必须严格隔离。
隔离方案的选择逻辑与公有云完全一致:
容器方案适用于:客户代码是我自己写的(受信任),客户之间的隔离主要是逻辑隔离(数据不互通),对等保合规的解读允许共享内核。成本低,运维简单。
microVM 方案适用于:需要通过等保三级或更高级别的安全评审,合规要求明确要求内核级隔离;或客户是监管机构,审计时需要展示硬件级隔离证明。成本相对高,但 Firecracker 的 ~5MB 开销使得同一台机器上运行数百个隔离实例是现实可行的。
关键差异:公有云的多租户问题是「互不相识的陌生人共用机器」,而专线 SaaS 的问题是「已知的 B 端客户共用机器」。后者的威胁模型不同——攻击者通常不是客户本身,而是针对你基础设施的外部入侵。因此隔离的首要目的从「防止客户间互攻」变成了「满足监管合规要求 + 防止单点被攻破后横向扩散」。
这个差异直接影响方案选择:在专线 SaaS 场景下,容器 + 严格的网络隔离 + 数据加密,往往比 microVM + 松散的网络策略更安全、更实用。