ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

自建 Agent 沙箱集群:基于 Firecracker 的状态机、文件通道与进程编排实践

自建 Agent 沙箱集群:基于 Firecracker 的状态机、文件通道与进程编排实践

如果你的团队正在给 AI Agent 做执行环境,大概率会被这几件事反复折磨:怎么安全地跑用户提交的代码、怎么把启动延迟压到几百毫秒以内、怎么管理沙箱里产生的各种文件、以及怎么让上千个并行会话不把机器拖垮。这篇分享围绕的就是这四件事——基于 Firecracker 自建一套 Agent 沙箱集群,把沙箱生命周期收敛成严格的状态机,用带配额的文件通道解决数据进出,用可控的进程编排兜住执行乱象,最后再对标业内被广泛参考的开源沙箱方案做一轮生产经验校准。如果你在做 AI 平台、Agent 基础设施,或者正在评估要不要从托管方案迁到自建,这篇内容应该能省下不少调研时间。

1. 项目背景:自建不是炫技,是被需求逼的

1.1 Agent 沙箱要解决的三个核心矛盾

先说场景。我这边的业务是给 Agent 提供一个可以自由发挥的“小电脑”:模型决定调用什么工具、写什么代码、操作什么文件。用户侧看到的是一次次很炫的 Agent 演示,但工程侧看到的全是执行安全问题——这段代码可能来自不可信输入,可能有意无意地用光 CPU,可能写文件把磁盘塞满,甚至可能通过 fork 炸弹把整个节点拖死。

业界比较主流的做法有三类:托管 API、Docker 容器、传统虚拟机。托管 API 方便但黑盒化,不适合需要深度定制的团队;Docker 共享宿主内核,隔离面太大,跑不可信代码时一个提权漏洞就是灾难现场;传统 VM 隔离干净但启动秒级、每实例开销以 GB 计,密度上不去,我没法用 20 台机器开 500 个交互会话。

所以必须找一个“介于容器和虚拟机之间”的隔离方案。Firecracker 就是在这个位置被选中的。它是面向 Serverless 场景设计的微型虚拟机:用 KVM 硬件虚拟化,去掉了传统 VM 里的 PCI 复杂枚举、USB、显卡这些 Agent 根本用不上的外围设备,因此启动快、内存占用小、恶意代码逃逸到宿主机的面非常窄。一句话总结当时的选型结论:隔离性逼近 VM,弹性速度接近容器。

1.2 为什么不是 Docker、gVisor 或传统 VM

方案隔离级别启动耗时单实例开销主要问题
Docker 容器内核共享100ms 级极低逃逸面大,需要大量加固
gVisor用户态拦截200ms+中syscall 兼容性诡异,线上踩坑多
传统 VM硬件虚拟化3–8s高密度差、调度慢、运维重
Firecracker microVM硬件虚拟化去外围150–300ms低镜像/快照链要自己搭

这个表格是我基于实际压测整理的,不同环境会略有波动,但量级大致如此。Firecracker 的短启动时间来自极简设备模型:它把 ACPI 做到极简甚至关闭,内核启动路径很短;没有 USB、没有显卡,磁盘控制器就是精简的 virtio-block。代价是它不打算支持 GPU 透传这类重负载场景,所以如果你的 Agent 要跑大模型推理,Firecracker 不是主选;但做代码执行、工具调用、自动化测试这些轻量负载,它几乎是当前最优解。

2. 架构拆解:控制面、节点执行层、沙箱内 Runtime

2.1 三层职责与调用链

这套集群在逻辑上拆成三层:控制面(API + 调度 + 状态存储)、节点执行层(每台物理机上常驻的 Node Agent)、沙箱内 Runtime(跑在 guest 里的轻量守护进程)。控制面管“什么时候该建、建在哪”;节点层管“怎么把 microVM 拉起来、怎么配网、怎么挂盘”;沙箱内 Runtime 则管“具体这条命令怎么跑、文件从哪个口子进”。

三者通过两条通道通信。控制面与节点之间走标准 HTTP/gRPC,传递的是调度意图和节点状态;节点与沙箱内 Runtime 之间走 virtio-vsock,这是 Firecracker 提供的进程间通道,guest 不需要对外暴露任何 TCP 端口,所有的 exec、文件读写都在这条 vsock 上完成。这个设计有个直接的好处:沙箱在网络上对用户完全不可见,安全基线从“少开端口”变成了“不开端口”。

调度器选型上没有用太重的东西,状态存数据库,节点心跳上报,调度器做两级路由:第一级按资源水位选节点,看 CPU、内存、磁盘;第二级在节点内选当前空闲沙箱或新建沙箱。这套系统不需要 Kubernetes 那套复杂控制器,因为 Firecracker 的调度周期以秒计,控制逻辑收敛在一个小服务里反而好排查。

2.2 存储与网络细节

每个沙箱启动时挂两块盘:/dev/vda 是只读 rootfs,/dev/vdb 是预留的 scratch 块设备。rootfs 只读这一个决定很关键,意味着 guest 内核态写污点和注入后门文件都没地方落,重启即白。scratch 盘则负责承接运行期产生的数据。我们做了一层 overlay,把内部对象存储的路径映射成 guest 里的 /home/agent 工作目录,Agent 以为自己在写本地,实际是写挂载代理。

网络上每台 microVM 配一个独立 tap 设备,出网走节点上的 NAT,入网为零,外部端口一律不映射。Firecracker 的网络走 virtio-net,生产上我们默认用它,而没有走 vhost-user-net,因为发现 vhost-user 的共享内存队列在并发创建沙箱时容易踩到竞态,而这个场景对虚拟交换机性能又不敏感。网络这块被我列为踩坑最多的地方,具体见第七章。

2.3 镜像缓存与运行态管理

运行态镜像维护了两种:标准 Python,包含常见数据处理和办公文件库;轻量 Node,用于前端工具链。镜像由构建管道定期重建,每次重建做一轮冒烟测试:启动后执行 hello、文件读写、网络拉取三个用例。镜像统一放在节点的本地缓存,新沙箱从缓存目录通过 reflink 复制出 rootfs 副本,避免每次启动都去对象存储拉镜像。在 xfs 文件系统上实测,reflink 能省约 70% 的磁盘带宽,创建沙箱的大头时间也从网络下载变成了本地拷贝。

3. 沙箱状态机的设计:把容易失控的事交给迁移规则

3.1 状态定义与业务含义

一开始状态没那么细,只有 running 和 stopped,结果生产上出了问题以后很难追查。比如一条命令执行了二十分钟,它到底是“还在跑”还是“早已卡死”,单靠 running 根本分辨不出来。所以后来加上了 Executing 和 Snapshotting,把“实例活着”和“任务在跑”彻底分开。

状态含义可迁往
Pending已收到创建请求,待调度Booting / Failed
Booting正在拉起 microVMReady / Failed
Ready空闲,可接收执行Executing / Snapshotting / Stopping / Failed
Executing有命令在跑Ready(完成)/ Failed(异常/超时)
Snapshotting正在生成或恢复快照Ready / Failed
Stopping收到销毁请求,回收资源中Stopped / Failed
Stopped已退出,资源已回收物理清理后销毁
Failed异常态,不再接收新任务Stopping / Stopped

原则上,状态机必须做到一条事件只允许一个迁移方向。比如 Ready 状态收到 execute 事件,先原子地迁移到 Executing,再下发命令。这样任何异常都有明确的落点:命令超时从 Executing 进 Failed,而不是整个实例状态变化。

3.2 迁移竞态与双重确认

分布式系统做状态机最怕竞态。我们的处理方法是强制“控制面命令 + 节点上报”双确认:控制面下发 create 后,状态置为 Booting;节点收到指令启动 microVM,等到沙箱内 Runtime 通过 vsock 上报 boot complete,控制面才把状态改成 Ready。如果超过 15 秒没收到确认,控制面自动把状态置为 Failed,并安排健康检查确认这个 microVM 是不是“假活”。

另一个容易踩的坑是状态存储的乐观锁更新。所有状态迁移 SQL 都带where current_state = ?,更新行数小于 1 说明事件过期或重复,直接丢弃。实际压测里并发控制在 200 TPS 以内没什么问题,更高流量的接口在前置层做了请求串行化,没必要在状态库上拼性能。

节点还会周期性上报 heartbeat,内容包含运行中 pid 数、磁盘水位、最近命令退出码。这些数据会进状态机的前置判定:比如节点磁盘低于 15%,调度器就不再给这个节点排新沙箱;guest 内 pid 数快接近上限时,先做一轮清理再服务新请求。

3.3 快照状态与恢复的边界

Firecracker 提供全量快照和 diff 快照。全量快照包含内存和磁盘,diff 快照包含内存脏页和磁盘差异。我们把 Snapshotting 状态用在两个场景:

一是空闲沙箱的预创建池。提前把常用镜像启动到 Ready 并做一次快照,请求来时直接 restore,整体等待能压到 100ms 级别。二是长时间运行的 Agent 会话,每隔一段时间自动 Suspend,把整机快照存下来,后续从快照恢复而不是重新启动。

在跨机器恢复时,CPU 特性一致性必须处理。我们机器代次统一,并在 BIOS 层关闭了部分随机 SKU 特性,防止 cpuid 不兼容导致恢复后随机崩溃。最初没做这一步时,约 10% 的恢复沙箱出现网络不响应或者进程直接 SIGILL,排查了很久才定位到是 CPU 特性差异。快照恢复不是靠运气,它是一门需要前置约束的工程。

4. 文件管理:沙箱不是黑盒,数据要进得来出得去

4.1 三个入口一个出口

Agent 在工作时操作文件的需求五花八门:可能是用户上传的小参数文件,可能是模型生成的代码,也可能是业务系统塞进来的模板。系统设计三个入口:

启动注入针对小文件,总大小限制 10MB,直接放进 seed 分区,Agent 启动就能读;运行期上传走 vsock 文件 API,支撑实时修改;对象存储代理解决大文件场景,guest 内拿到一个预签名的只读拉取链接,由 Runtime 代下到工作目录。

一个出口的意思是:所有产物的传出必须经过统一的 artifact 服务。Agent 调用markAsArtifact把目标文件或目录标记为产物,服务端压缩后放到对象存储,返回一个带有效期的下载地址。这个“无自由外传”的约束,保证文件出口都有审计日志,也方便做内容安全审查。

4.2 配额与并发写保护

文件管理最容易翻车的是配额。光限制磁盘总大小是不够的——我在生产里至少遇到三次:Agent 写了上百万个小文件,把 inode 耗尽,导致整个 scratch 盘没法创建新文件,同节点其它沙箱跟着遭殃。所以 scratch 盘和 seed 分区的 inode 配额、文件数配额、单文件大小都做了独立限制。磁盘总量限制是“块”维度,但真正卡住你的往往是“文件数”维度,两者都要管。

并发写保护方面,我们跟踪每个沙箱同时打开的文件描述符数量,超过阈值就返回 FD 耗尽错误;同时限制单个路径的写并发,避免一个日志文件被多个 Agent 进程同时写穿。机制不复杂,但一定不能在文件系统层裸奔,否则运行一段时间后只能靠重启沙箱自愈。

4.3 写根目录引发的连锁故障

最经典的一次故障:某个脚本把下载内容直接往根目录写。因为 rootfs 挂成只读,它在根目录写失败后开始疯狂重试,最后大量占用的是内存而不是磁盘,重试的临时缓冲区越积越多。当时监控只盯着磁盘,没盯内存,直到节点卡到登录困难才发现。

处理上做了两件事:给沙箱加内存配额,限制重试缓冲区的最大占用;同时在全链路里加了“只读根目录”的错误提示,让沙箱内 Runtime 捕获到写根目录失败时能立刻给用户一个干脆的报错,而不是卡死重试。

5. 进程编排:强制清醒的执行模型

5.1 为什么需要沙箱内 Runtime

Firecracker 只负责把一台微型机器给你,机器里面跑什么、进程怎么管理,它一概不管。所以 guest 里放了一个极轻的 Runtime daemon,它同时是沙箱内的 PID 1,兼顾 init 系统和执行控制器两种职责。作为 init,它要回收孤儿进程、处理僵尸进程、传播信号;作为执行控制器,它监听 vsock 传来的命令请求,负责 fork、启动、等待完成、回收退出码。

这个 Runtime 用 Rust 写,主要是内存安全以及 musl 静态编译后部署简单。它对外暴露极简协议:Open、Exec、Poll、Close四个动词覆盖 90% 的 Agent 执行需求。Open 打开一个执行流,Exec 在流里发起一条命令,Poll 查询状态,Close 终止。没有 shell 自动转发这些锦上添花的功能,一切按最小设计展开。

5.2 执行请求的字段与限制

{ "op": "Exec", "id": "exec_abcdef123", "path": "/usr/bin/python3", "args": ["script.py"], "env": {"WORK_DIR": "/home/agent/work"}, "cwd": "/home/agent/work", "timeout": 600, "limits": { "memory": 2048, "nproc": 32, "devices": false } }

每个 Exec 都强制带 timeout,没有默认值。这个 timeout 是整条命令的生命周期上限,不是 CPU 时间。到了就杀:先 SIGTERM,等 5 秒再 SIGKILL。有些进程需要清理临时文件、flush 自己的输出,给一个软机会再收割,体验会好很多。

5.3 超时、信号和僵尸进程

沙箱里的进程编排最忌讳“假装有超时”。父进程已经退出但子进程还活着,就成了孤儿。如果不用 pid namespace 而直接在宿主视角看,这些孤儿会 reparent 到 guest 的 PID 1 名下,而 PID 1 是唯一的、负责回收的进程。Runtime daemon 作为 PID 1,需要在每轮事件循环里执行waitpid(-1, WNOHANG),把所有退出但还没收尸的子进程全部回收。之前图省事没做,跑一段时间沙箱里堆满 defunct 进程,直到 pid 号被占满,新命令 fork 不出来。

信号路径也值得说。Host 侧想终止沙箱内某条命令,不能直接对 Host 侧 VMM 进程发信号——那是虚拟化管理进程,真正跑用户代码的是 guest 里的子进程。正确做法是:Host 通过 vsock 发送 terminate 请求,Runtime 收到后在 guest 内对具体 pid 发信号。这一步看似绕路,却是隔离性的关键:Host 永远不能直接操作 guest 的进程控制块。

5.4 退出码归一化与执行埋点

退出码这里很有讲究。有些程序返回 137,也就是 128+9,表示被 SIGKILL 干掉;有些框架习惯返回 0,但 stdout 里全是 error。为了统一,Runtime 把所有非 0 退出做了一轮归一化:超时映射为 124,OOM 映射为 137,信号退出映射为 128+信号值。业务侧判断结果只认这组一致的码,不再关心具体信号号。

同时每一次执行都会落一条结构化的执行日志:入参 hash、退出码、耗时、输出大小前 256 字符。审计和追溯全靠这份日志。出问题时能立刻回放当时沙箱里到底发生了什么,而不是对着监控面板猜。

6. 对标业界主流沙箱方案:五个被验证的生产校准

6.1 为什么拿它做基准

开始自建前,我们把业内被广泛参考的那套 Agent 沙箱开源方案拉出来仔细拆了一遍。它最值得学习的地方是向使用者明确了“沙箱不是单独进程,而是带文件系统、带长驻进程和网络出口的迷你环境”这一心智模型。它暴露的 API 习惯和状态模型已经成了这个领域的隐性标准。

直接把它的生产习惯当成既有经验来校准自己的设计,能少走不少弯路。我们在自建过程中做的五个校准决策,每一个都不是拍脑袋,而是对着真实故障记录回溯过的。

6.2 五个校准决策

第一个校准是执行超时分层。那套方案在默认表现上更偏向短任务,但我们业务里有大量长时间运行的 Agent 脚本。最终按任务类型拆成三档:代码执行 60 秒、文件处理 300 秒、编排长任务 1800 秒,每档超时都有独立告警通道。这样既不会因为超时误杀用户任务,也不会让异常长任务占用节点。

第二个校准是持久化目录。那套方案的默认语义是命令结束后文件系统可重置,但我们因为要跑多轮 Agent 会话,需要跨命令保留数据。单独划出持久化工作目录,与临时目录做硬隔离:只有一个目录是可持久化的,其他全部临时可重置。这样保留“重置即干净”的隔离心智,又让长会话不掉线。

第三个校准是规格差异化。那套方案常见规格是 2GB 内存和 2 vCPU,但我们发现不同负载差异很大——轻量文件操作 512MB 足够,重脚本处理要 4GB 才不卡。因此建立规格分级,调度器按任务类型分配不同规格的沙箱,整体资源利用率提升近 30%。

第四个校准是大文件处理。原来直接在沙箱内回传大文件会导致单请求内存打爆,后来改成落盘后分块回传,每块 8MB 上限,带 SHA256 校验,失败断点续传。文件出口稳定性明显改善。

第五个校准是沙箱释放策略。以空闲 TTL 为维度,5 分钟空闲的沙箱安排快照并待命,15 分钟空闲直接销毁;请求进来优先复用预热快照,恢复失败再新建。这样把创建延迟和闲置成本同时压住。

6.3 性能与密度实测参考

列一组我们压测环境的数据:32 核 / 128G 内存 / NVMe 的机器上,单沙箱从调度到 Ready 平均 240ms,其中内核启动约 160ms,Runtime daemon 拉起约 80ms。空载单机可维持 40+ 个沙箱,每沙箱基线内存约 180MB,包含 rootfs 页缓存;重负载峰值能到 400MB,但 40 个沙箱同时跑 CPU 密集任务时,节点负载能飙到 30。

真实部署我们一般控制在每节点 25 个沙箱,留足系统余量。这些数字与你业务强相关,不建议原样套用,但可以当作量级参考。

7. 常见问题排查与避坑实录

7.1 高频问题速查表

问题表象根因处理建议
沙箱启动后网络不通tap 设备残留或 NAT 冲突节点 Agent 销毁时强制清理 tap,IPAM 加冲突检测
快照恢复后 guest 网络卡死CPU 特性不一致或脏页窗口过大统一 CPU 特性开关,暂停写入 1 秒后再取快照
写根目录报错但磁盘没满rootfs 只读,脚本疯狂重试Runtime 捕获写根目录失败直接报错,并限制重试缓冲内存
任务被重复创建或状态回跳状态迁移未做乐观锁所有迁移带 current_state 条件,更新行数不等于 1 即丢弃
僵尸进程堆积Runtime 未 waitpid 回收事件循环统一waitpid(-1, WNOHANG)
大并发创建时镜像拉起慢镜像在远程对象存储节点级镜像缓存 + reflink 本地复制

7.2 三个容易被忽略的系统级细节

第一,Firecracker 的 Jailer。官方推荐用 Jailer 给每个 microVM 做 chroot 隔离,但不少人为了方便直接关掉,我强烈不建议。配置好 Jailer 的可执行位和 seccomp 白名单后,microVM 逃逸后想横向移动的成本会高很多。白名单要覆盖 VMM 使用的所有系统调用,缺一个就启动直接白屏,这个要注意冒烟测试覆盖。

第二,内核启动参数裁剪。给 microVM 用通用发行版内核也能跑,但没必要。把不需要的外设驱动全部编译成模块或移除,减少终端、图形、蓝牙、音频、USB 层,启动时间能缩短 30% 以上,内存基线也降一截。代价是维护成本,需要定期做镜像冒烟测试。我建议至少做一轮裁剪,收益非常明显。

第三,磁盘 I/O 限制。Firecracker 的 virtio-block 如果不限速,一个沙箱的 dd 能把整机磁盘拖垮。我们在 cgroup v2 里为每个沙箱配置了 io.max 读写带宽上限,同时也配了 cpu.max,避免 busy loop 把宿主机核全吞。可以写成节点 Agent 启动时扫描所有 guest 统一设置配额,不用每台机器手工维护。

做这个项目下来,我最大的感受是:沙箱系统不是一个虚拟化配置问题,而是生命周期管理的工程问题。技术选型只是给你一层更好的地基,真正决定生产可用度的,是状态机是否防呆、文件进出是否留有审计、进程是否会变成孤儿、以及是否提前锁定了 CPU 兼容约束。如果你现在也在评估自建 Agent 沙箱,建议先把这些边界条件想清楚,再动 Firecracker。最后留一个小技巧:生产环境接入任何新的沙箱 API 之前,先让团队写一个“最小恶意用例清单”——fork 炸弹、写根目录、大文件并发、超长运行、恶意符号链接下载——每个用例能给出确定行为,再放量到线上。这份清单在我们历次重构里帮了大忙。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进