Firecracker 已经把安全、快照、COW 的硬活干完了,我们要补充的只是控制面:状态机 + 文件管理 + 进程编排。本文手把手拆解自建 Agent 沙箱集群的每个决策,并用 E2B 开源 runtime 的生产经验做校准。 姊妹篇:《Firecracker 详解:设计、机制与接口》讲了 FC 提供的机制;本文讲怎么把这些机制组装成你自己的系统。 场景:为多个用户提供 Node.js / JVM / Python / Linux 基础运行环境沙箱,生命周期 create / pause / resume / kill,从一台 64C/200G 物理机起步,演进到集群。 核心论点:FC 已经把"安全 + 快照 + COW"的硬活干完了,接下来只要补充"把 FC 进程、快照文件、netns 管起来的控制面"------本质是一个状态机 + 文件管理 + 进程编排。
0. 先建立一个画面
把系统想象成一家胶囊旅馆:
- Firecracker 是胶囊舱本身:隔音、带锁、标准化生产,安全是厂家的事
- 快照文件是冬眠舱:客人想省钱不退房,就连人带行李冻起来存放,回来原样唤醒
- netns 是房间门卡:每个客人一张,网络互不串门
- 你写的控制面是前台:不生产舱、不造门卡,只负责登记(状态机)、排房(资源分配)、叫醒和冻结客人(编排)、盘点(对账)
1. 边界划分:什么不用写、什么必须写
css
不用写(行业标准,直接用) 自己写(这就是你的系统,约 5-8k 行)
────────────────────────────── ─────────────────────────────
Firecracker(vanilla,不打补丁) → controller 单二进制(Go 或 Rust)
Linux KVM / netns / cgroup → 模板与快照管理(就是文件管理)
systemd / SQLite → REST API + 状态机 + 资源租约
关键好消息:核心机制 vanilla Firecracker 全都有,不需要 fork------快照恢复 mem_backend + MAP_PRIVATE mmap(多沙箱 COW 共享父内存)、Diff 增量快照(你的 pause)、snapshot/load(你的 resume)、clock_realtime(恢复对时)。vanilla 缺的只有 live-fork 的 MAP_SHARED,而你的场景不需要。
2. 系统构成:三个进程,不能再多
① controller(约 5k 行):单二进制,SQLite 存状态。含沙箱状态机、资源租约表(netns slot、PID、cgroup)、REST API。调用 FC 的方式就是 spawn 进程 + 打它的 Unix socket API。
② per-sandbox 存储代理(约 1k 行,可选):guest 凭证隔离 + 对象存储转发。如果 v1 不需要持久工作区,整个砍掉。
③ guest agent(约 300 行静态二进制) :guest 里的 exec / 文件读写服务,烘进 rootfs。借鉴 E2B envd 的两个设计:鉴权放在 agent 层 (每沙箱独立访问令牌,控制面 API key 不等于能进沙箱);预留恢复时热升级通道------agent 版本随快照冻结必然过时,E2B 的解法是 resume 时经 /upgrade 同 PID exec 替换二进制(fd 与进程表保留),过旧版本走离线 debugfs 替换。
3. 控制面职责一:进程编排
事实:一个沙箱 = 一个 Firecracker 进程。 FC 是个普通 Linux 进程,你的第一职责是会当家长。
- 起进程 :用 posix_spawn(多线程程序里 fork+exec 有坑)启动 FC,命令行只给一个 Unix socket 路径,如
/run/sandboxes/sb-42/fc.sock - 下指令 :往 socket 发 HTTP。常用就 6 条:
PUT /boot-source、PUT /drives/rootfs、PUT /network-interfaces/eth0、PUT /snapshot/load、PATCH /vm、PUT /snapshot/create - 监护:记录每个沙箱的 PID,定期看 /proc/ 是否还在;不在了就标记状态、回收资源
为什么必须你做:FC 自己没有任何"管理"概念------不知道世界上有第二个 FC,不知道自己叫哪个沙箱 ID,死了没人收尸。没有编排它就是个一次性命令行工具;有了编排它才变成有生命周期的沙箱。
效果 :POST /sandboxes 的内部实现 = 起一个进程 + 发三条 HTTP + 记一笔账,全程约 50 ms。
4. 控制面职责二:文件管理
事实:模板和快照都是普通文件。 目录布局:
python
/var/lib/sandbox/
├── templates/
│ ├── python-311/
│ │ ├── vmstate ← CPU/设备状态(KB 级)
│ │ ├── memory.bin ← 内存镜像(稀疏文件,只含非零页),权限 0444
│ │ └── rootfs.ext4 ← 磁盘(只读,所有沙箱共享)
│ └── nodejs-22/ ...
└── paused/
└── sb-42/
├── vmstate
└── memory.bin ← diff 增量,只含 sb-42 写脏的页
文件管理做对了,一切才对:
- create 不复制文件:恢复是 mmap 不是 read,N 个 FC 打开同一文件内核自动共享。复制是要抵抗的邪念------一复制,毫秒级和内存密度全没了
- 模板目录设 0444 只读:官方铁律"内存文件在 VM 生命周期内不可变",用目录权限从机制上杜绝手滑
- pause 产出增量:脏页写成 paused/ 下的新文件,绝不回写共享模板
- GC = 删文件 + 引用计数:模板 memory.bin 只要还有一个沙箱或暂停快照引用就不能删;paused 快照配 TTL(如 7 天未 resume 清理)------磁盘是系统里唯一随时间单调增长的资源
- 模板元数据记 fc_version + cpu_model:快照不能跨 FC 版本 / CPU 微架构恢复,恢复前校验
5. 控制面职责三:状态机 + 资源租约
出发点是"系统会忘事":并发 create 抢同一条 netns 会互串;controller 崩溃重启会失忆产生孤儿沙箱;FC 进程死了没人知道会泄漏资源。
两张表就够:
bash
sandboxes: id | template | state(running/paused/killed) | pid | slot_id | paused_path | created_at
slots: id | netns_name | state(free/leased) | sandbox_id
状态机是系统的"法律":
lua
create pause resume
[无] ──────────→ running ──────────→ paused ──────────→ running
│ │
│ kill │ TTL 到期清理
↓ ↓
killed ←────────────────┘
规则:只有 running 能 pause;只有 paused 能 resume;kill 可从任何状态发生;每次状态迁移是一个数据库事务。
租约解决并发抢资源:create 时在 SQLite 事务里"挑一个 free slot → 标记 leased",提交后才起 FC 进程。两个并发 create 抢同一 slot 时,数据库唯一约束保证只有一个成功------并发安全靠 ACID,不靠小心。
对账(质量的试金石) :controller 重启后读表,对照 /proc 和 ip netns list 核对现实,修正差异。沙箱系统的 bug 九成是状态不一致,对账做扎实了系统就稳了。
6. 网络:slot 池
- 预建 N 个 netns(如
slot-0到slot-1199),每个含 tap + veth + 相同 guest IP(换 slot 无感) - create 时租、pause/kill 归还;绝不能放在 create 热路径上现建(建 netns 要几十毫秒加特权操作)
- 单网桥 1024 端口上限:1200 池要 2 个网桥,超出部分"能分配但连不通"是最坏的一类故障
- v1 的 egress 从简:iptables 给每 slot 配允许的 IP 段;域名白名单留到 v2
7. 端到端走一遍:小王的 Python 沙箱
create :POST /sandboxes {template:"python-311"} → 事务里租 slot-17 → posix_spawn FC → socket 发 PUT /snapshot/load(mmap 模板 memory.bin,resume_vm: true,clock_realtime: restore)→ 记账 → 返回。约 50 ms,小王拿到 Python 已 import 好的 VM。
pause (小王 15 分钟没动):PATCH /vm Paused → PUT /snapshot/create(产物写 paused/sb-42/)→ kill FC 进程 → 归还 slot → 状态改 paused。RAM / CPU / 网络全释放,只剩磁盘一小份增量。
resume:和 create 完全同一条代码路径,只是内存文件换成 paused/sb-42/ 那份 → 租新 slot(无所谓哪个)→ 小王的终端、文件、跑着的进程原样都在。
kill:删 paused/sb-42/ → 模板引用计数减一 → 记账 killed。一秒内片甲不留。
8. 模板:四种基础环境怎么备
模板 = 起 FC → boot → 跑预热命令 → Paused → snapshot/create Full → 得到三个文件。预热是灵魂:
- python-311:基础 rootfs + 科学计算栈预装 + 预热命令 import 一遍
- nodejs-22:Node LTS + 全局常用包 + 预热 require
- jvm-21:JDK + 构建期跑一段负载让 JIT 编译完成------冻进快照的 JVM 是热的,这是 JVM 冷启动抖动的唯一解法
- linux-base:最小 shell 环境
rootfs 制作:从 Docker 镜像导出文件系统打 ext4(一个几十行的脚本:docker create → docker export → mkfs.ext4 → 注入 guest agent)。
9. 容量预期(64C / 200G 单机)
| 维度 | 预期 | 依据 |
|---|---|---|
| create 延迟 | 约 50-100 ms | mmap 恢复,无 boot |
| 驻留沙箱 | 800-1500 个 | idle 约 20-40 MiB PSS/个(COW 共享后) |
| 活跃计算沙箱 | 约 64 个 | 1 活跃/vCPU 物理约束 |
| paused 检查点 | 磁盘 1 TB 约 2 万个 | 增量文件,23 倍压缩量级 |
| pause/resume | 百毫秒内 | 成本随脏页而非标称内存 |
| 服务用户总数 | 5000+ | 按 5:1 注册:活跃比 |
两个克隆安全硬性检查项(官方警告):宿主内核 ≥ 5.18(VMGenID,恢复时重播随机数种子,否则 N 个沙箱 PRNG 序列相同、token 可预测);恢复后所有长连接需重建(vsock/TCP 不保留)。
10. 从单机到集群(演进而非重做)
单机闭环跑通后,集群化只有三个增量:
- 控制面 DB 化:SQLite 换 Postgres,沙箱状态与租约成为全局唯一真相;节点 agent 无状态化------重启后从 DB 领意图、对账现实。收养幸存沙箱从特性变成架构的自然结果
- 模板分发:模板文件上传对象存储,节点按需拉取 + 本地缓存(模板就是文件,scp 都能起步)
- 调度按状态亲和:同一用户钉同一节点(pause 快照在本地磁盘,resume 零搬运);负载只在新会话首次放置时参与决策
多节点共享入口用一个薄 gateway:按 sandbox ID 里的节点标识路由。
对照 E2B runtime 的生产实践,本节有三处可以校准:① 调度从集中式简化为 best-of-K 采样 ------E2B 没有中心调度器,放置时随机采 K 个节点按 CPU/内存负载打分选最低,效果足够好且天然去单点;② resume 偏好源节点 ------与"状态亲和"结论一致,但 E2B 的理由更务实:本地模板/快照缓存命中则完全避免对象存储读取;③ 运行态与冷状态分存储------E2B 把运行中沙箱的权威源放 Redis(热数据走快存储),Postgres 只放团队/模板/快照元数据等冷状态,"控制面 DB 化"应照此细化,而不是所有状态都塞进一种存储。
11. 里程碑(一名工程师)
| 周 | 交付 |
|---|---|
| W1-2 | FC 进程管理 + Unix socket API 封装 + 手烘 python 模板 |
| W3-4 | create / kill + netns 池 + SQLite 状态机,API 可用 |
| W5-6 | pause / resume(diff 快照 + clock_realtime)+ guest agent(exec / files) |
| W7-8 | 崩溃对账(重启后扫描进程/租约对表)+ 配额 + /metrics |
| W9-12 | 压测调优 + 存储代理(如需要)+ 上线 |
性能验收先签军令状:create P50 < 100 ms;驻留 ≥ 1000;pause/resume < 200 ms;对账收敛 < 5 min。
12. 结合 E2B 经验的校准
E2B 开源 runtime(Apache-2.0,与 E2B Cloud 同一套生产代码)是 AWS Lambda 之外最成熟的 Firecracker 生产实践。对照它的架构,本文的设计有三处被验证、两处被修正、三处维持简化。
被验证(信心增强,原样保留):① 快照恢复 + MAP_PRIVATE COW 作为主路径------E2B 同样是"沙箱即恢复的快照",create / pause / resume / fork 走同一条快速路径;② 快照与模板同构------E2B 的暂停产物与模板同格式、同恢复路径、同缓存体系,与本文"paused/ 目录 = 另一份模板文件"的思路一致;③ 状态亲和调度------E2B 的 resume 同样偏好源节点。
被修正(更新原有判断) :① UFFD 懒加载从"禁区"变为 v2 候选 ------E2B 已生产验证(userfaultfd handler 直接从模板 memfile 供页,构建期 optimize 阶段记录热页生成预取提示),冷节点 create 与模板位置解耦是成立的;官方 backend_type:"Uffd" 接口现成,无需 vendor FC。② 调度从集中式简化为 best-of-K 采样 (见第 10 节)。③ guest agent 必须预留热升级通道(见第 2 节③,这是 E2B 踩过才显形的问题:agent 版本随快照冻结必然过时)。
维持简化(E2B 比我们重的地方,v1 不动):① 存储四件套(Postgres/Redis/ClickHouse/对象存储)→ 单机 SQLite 起步,集群化时按第 10 节细化;② 进程内 NBD COW rootfs → v1 只读 ext4 + overlay 文件够用,需要跨机 rootfs diff 时再评估;③ client-proxy 流量唤醒暂停沙箱(serverless 效果)→ 列入 v2 目标,届时其路由记录 + Lua 防误删实现可直接借鉴。
13. 不做什么(克制清单)
不做:自研 VMM、链式 diff 快照(官方还在 developer preview,v1 用单层)、多节点集中式调度器(用 best-of-K 采样替代,见第 10 节)、E2B 协议兼容(等真有客户要再说)、域名级 egress(v2)。UFFD 懒加载移出本清单------经 E2B 生产验证,改列 v2 候选(见第 12 节)。
14. 工作量总账
| 模块 | 技术难度 | 谁干 | 你要写的 |
|---|---|---|---|
| 硬件虚拟化隔离 | 五星 | Firecracker | 0 行 |
| 快照 / COW | 四星 | Firecracker + Linux 内核 | 0 行 |
| 进程编排 | 两星 | 你 | spawn + 6 条 HTTP + PID 监护,约 1k 行 |
| 文件管理 | 两星 | 你 | 目录布局 + 引用计数 + TTL,约 1k 行 |
| 状态机 + 租约 | 两星 | 你 | 两张表 + 事务 + 对账,约 2k 行 |
| 网络 slot 池 | 两星 | 你 | 预建脚本 + 租还逻辑,约 500 行 |
| REST API | 一星 | 你 | 框架自带,约 500 行 |
FC 拿走了 80% 的技术难度,你写约 5k 行普通后端代码,得到一个 100% 的沙箱系统。
一句话:Firecracker 把虚拟机从操作系统工程压缩成一个进程加十几条 HTTP API;你的系统就是给这个进程当个好前台------状态机是法律、文件是记忆、对账是良心。