恒美微站
首页
关于我们
建站服务
主题模板
案例展示
资讯中心
联系我们
自建AI Agent沙箱集群:Firecracker状态机与进程编排实践
首页
资讯中心
/
自建AI Agent沙箱集群:Firecracker状态机与进程编排实践
自建AI Agent沙箱集群:Firecracker状态机与进程编排实践
发布时间:2026/10/20 14:36:44
几个月前团队找到我说要在私有网络里跑一批 AI Agent它们需要联网查资料、写代码、操作临时文件、调用数据库。需求提得不算复杂但落到基础设施上就有个绕不开的问题——给这些不可信代码提供一个安全、隔离、能随时销毁的执行环境。我第一反应是接入托管沙箱毕竟 E2B 这类服务的 API 做得相当顺手几分钟就能跑通一个代码解释器。可真要进入生产账单、配额、数据合规、异常排查全成了黑盒于是我们最终决定基于 Firecracker 自建一套 Agent 沙箱集群。这篇文章会从状态机、文件管理、进程编排三条主线完整还原这套系统的设计过程并把我对照 E2B 生产配置逐项校准后的参数和经验一并贴出来给打算自己动手的团队一个可以落地的参考。先说明一点自建沙箱不等于自己造一个虚拟化引擎Firecracker 已经帮我们完成了最底层的 microVM 工作我们真正要解决的是它上面那层调度、生命周期、文件注入和进程控制的编排问题。1. 为什么是 Firecracker自建沙箱的选型背景与边界1.1 托管沙箱的甜与痛先说说为什么没有直接全托管。E2B 这类服务确实把 Agent 执行环境包装得很好创建一个 sandbox、写入文件、执行命令、拿回 stdout整个链路对开发极其友好。如果不考虑成本几乎是最短路径。但工程上的“友好”也意味着你失去了对底层行为的掌控——沙箱什么时候被回收镜像更新失败怎么诊断单 pod 承载并发会不会被邻居干扰数据在远端是否留痕这些问题在体量小时都不致命可一旦 Agent 任务从一天几十次涨到每分钟几十次每一个黑盒参数都会变成线上事故的嫌疑点。我们的场景还有一条硬约束部分数据需要在内网环境处理不能出域。托管方案在法律合规这一关就过不去所以自建是必选项不是可选项。1.2 Firecracker 恰好卡在“安全”和“密度”的平衡点选型时我也考虑过普通虚拟机、容器和 gVisor。普通虚拟机隔离最彻底但启动时间和内存开销很难支撑高频短任务容器加 seccomp 虽然轻但同一个内核共享边界在安全敏感场景里还是让人不安gVisor 的性能损耗在偏计算型的 Agent 任务上又太明显。Firecracker 的优势正好落在中间它用 KVM 做硬件虚拟化每个沙箱是一个微 VM客户机内核完全是独立的同时又把设备模型做得极简去掉了不必要的 BIOS 和传统设备使得内存开销可以压到很低的水平启动时间也通常能控制在几百毫秒量级。这个特性意味着单台宿主机可以同时跑几十个互不干扰的 Agent 沙箱密度接近容器隔离性接近虚拟机。从工程角度说Firecracker 也给我们省了很多事。它没有复杂的外设模拟磁盘就是一个块设备镜像网络就是一张 tap通信主要走 vsock。对做平台的人来说这种“少即是多”的接口设计非常友好缺陷暴露面更小排障路径也更短。1.3 自建不是从零开始而是做编排层的增量把话说得更直白一点Firecracker 提供的是“启动一台微 VM”的基础能力真正需要我们设计的是如何把它变成集群。我们不能让业务方直接去调 Firecracker 的 API那太原始了于是要对上层暴露几个核心能力创建带指定镜像和资源规格的沙箱、向沙箱内提交一个进程任务、读取执行结果、销毁沙箱。这套东西听起来像一个小型 PaaS但只要把状态机、文件协议和进程协议定义清楚实现起来并不复杂。而且自建带来的一个隐藏收益是可观测性我们可以在每个环节埋点知道一次任务从调度到退出到底消耗在哪个部分这在托管方案里几乎不可能。2. 沙箱集群的骨架VM 状态机与并发控制2.1 五个核心状态和一组迁移事件我想先说一个很容易被低估的结论如果一个沙箱集群没有清晰的状态机后面所有并发控制和故障恢复都会变成补丁套补丁。因为 Agent 任务天然是短生命周期、高并发、可重试的沙箱本身也是随时会被创建和销毁的资源状态不明确调度器就不知道某个沙箱是否真的可用。我们最终给每个沙箱定义了五个状态Creating、Running、Stopping、Stopped、Failed。创建阶段从收到请求到 Firecracker 进程启动并完成客户机 init 握手为止Running表示可以接收任务Stopping是主动销毁或任务结束后的收尾阶段Stopped是已停止且资源已释放的终态Failed则是异常退出需要进入回收流程。状态迁移有严格约束不允许随意跳转。比如Stopping只能由Running进入Failed可以由Creating或Running进入但已经进入Stopped的沙箱必须走“重新创建”而不是“重新启动”。代码里的表达也很直接type SandboxState string const ( StateCreating SandboxState Creating StateRunning SandboxState Running StateStopping SandboxState Stopping StateStopped SandboxState Stopped StateFailed SandboxState Failed ) // validTransitions 定义允许的状态迁移表 var validTransitions map[SandboxState][]SandboxState{ StateCreating: {StateRunning, StateFailed}, StateRunning: {StateStopping, StateFailed}, StateStopping: {StateStopped, StateFailed}, }你可能注意到这里没有Paused或Suspended。快照恢复阶段单独有标记但沙箱对外不可用所以我不让它单独占一个任务可调度状态而是把它归入调度器内部的“启动中”子流程。状态越少越容易保证一致性。2.2 状态机落地超时、重试和孤儿回收状态机最怕的是卡在中间态。比如 Firecracker 进程已经退出但控制面还没收到通知沙箱会永远停在Running上。所以每个非终态都必须配超时Creating超过 30 秒没有变成Running就置为FailedRunning状态下如果心跳连续三次丢失同样触发失败迁移。心跳怎么设计也值得展开一下。我们让客户机内的 init 负责通过 vsock 向宿主机 driver 上报存活状态driver 再写入控制面。注意这里的心跳不是简单说“虚拟机没死”而是确认客户机内核、文件系统和 Agent 运行时都已就绪。第一次握手尤其关键只有 init 上报了版本号和就绪标志我们才认为沙箱真的进入了Running。另一个容易踩坑的是宿主机关机或重启后产生孤儿 VM。Firecracker 进程本身会被宿主机杀干净但控制面的状态记录还残留着。我们用租约方式解决每次心跳更新租约租约过期后控制面允许新的调度器尝试接管或强制清理。这样任何一台宿主机失联沙箱记录都不会变成永久僵尸。2.3 调度器如何决定“这台 VM 能不能收新任务”调度器不直接操作 Firecracker它只读取状态机和宿主机资源水位然后决定把一个新任务分配给哪台宿主机的哪个沙箱。这里有个细节每个沙箱默认只跑一个 Agent 会话不允许多个任务同时共享同一台微 VM。原因很简单状态污染。如果两个任务在同一个沙箱里同时改了同一个环境变量或写了同名文件排查起来极难哪怕我们在客户机里做了 PID namespace文件系统仍然是共享的。所以我们的基本调度单元是“一个会话等于一个沙箱”。会话结束沙箱就销毁彻底避免残留。调度器在挑选沙箱时还会检查宿主机的可分配容量。我们给每台宿主机设置了两个阈值一个是 CPU 的保守水位比如 70%超过后不再调度新沙箱另一个是内存预留量优先保证现有沙箱不会因为宿主机整体内存不足而被 OOM。这个阈值需要根据业务峰值得出而不是拍脑袋。3. 文件管理三板斧镜像、rootfs 与工作区隔离3.1 基础镜像的最小化与预置依赖Agent 沙箱的文件系统是我们花时间最多的地方。Firecracker 需要的是一个块设备级别的 rootfs我们选择 ext4 镜像文件而不是容器 image 那种分层结构。构建流程类似打包一个轻量 Linux从一个基础根文件系统出发安装必要运行时再做成可启动的 ext4 镜像。这里最重要的取舍是“预置还是后装”。第一次搭的时候我图省事基础系统只装了 shell打算每次创建沙箱的时候再通过脚本安装 Python 和 Node 依赖。结果两个问题立刻冒出来一个是创建时间长用户等得烦躁另一个是镜像仓库和内部依赖源一旦抖动整个沙箱创建成功率暴跌。后来我们把 Node、Python、curl、git 等常用工具直接打进基础镜像冷启动时间一下就降了下来。镜像构建完需要签名和版本管理。每次更新依赖库都会生成一个新版本镜像并在控制面记录 sha256。沙箱启动时只允许拉取白名单内的镜像版本这样既能快速回滚也能防止有人用旧漏洞镜像。3.2 快照复用让冷启动从秒级降到毫秒级的关键如果你的 Agent 任务只有几十个并发冷启动 600 毫秒也能忍。但我们目标是在单台宿主机上追求几十个沙箱的快速拉起这时候就不能每次从头启动内核。Firecracker 支持快照。我们的做法是提前准备一个基础“模板沙箱”用标准镜像启动一台微 VM预加载一些 Agent 运行时要用的依赖比如把某些库的缓存页预热到内存然后暂停 VM生成内存快照和磁盘增量文件。后续创建沙箱时直接从快照恢复而不是冷启动。恢复流程的关键在于磁盘增量。快照之后每个沙箱的写操作都落在diff文件里我们用一个独立的 diff 文件。这样模板镜像本身可以被所有沙箱共享磁盘占用很低。实测下来从控制面收到创建请求到客户机 init 第一次上报就绪冷启动普遍在 500-700 毫秒而快照恢复能压到 100-200 毫秒。对于“一次会话只存活几分钟”的 Agent 负载这个差距非常可观。3.3 工作目录注入、挂载与清理Firecracker 的设备模型比较精简我们没有在里面跑 9p 或 virtiofs 来做动态文件挂载而是选择更直接的协议通过内部 HTTPS 接口向沙箱上传文件由客户机 init 解压到一个统一的工作目录。具体流程是这样的业务方先把 Agent 要用的脚本和资源打包成 zip调用沙箱的upload接口宿主侧 driver 通过 vsock 把这个包传给 initinit 在校验大小和解压路径后写入/workspace/session_id/目录。所有路径都做了严格约束不允许 zip 里的..跳级解压前必须检查每个文件项的相对路径。这个设计在隔离性上更干净宿主机侧不直接挂载客户机目录攻击面小客户机侧文件也不与宿主文件系统直接共享。代价是传输大文件时会多一次拷贝但我们的 Agent 场景里单次任务文件基本在几十 MB 以内完全可接受。工作目录的清理也由状态机驱动沙箱进入Stopping状态时init 先删除/workspace下的会话目录再退出进程最后 Firecracker 的 diff 文件也会被宿主机删除。实测中只有极端情况下宿主侧 driver 异常死了才会留下孤儿 diff 文件。针对这种情况我们加了定时巡检任务清理超过 24 小时未被引用的磁盘文件。4. 进程编排在微 VM 里养一个 Agent 的正确姿势4.1 宿主侧 driver 和客户机侧 init 的分工沙箱拉起来之后不能停在 shell必须要有一个进程管理机制。先明确两边分工宿主侧 driver 负责管理 Firecracker 进程、vsock 的端点、网络以及数据面的心跳上报客户机侧 init 是沙箱内的第一个进程也是生命周期最长的进程负责接收任务、拉起子进程、收集输出、执行清理。通信协议我们选了 JSON-RPC over vsock。之所以不用 TCP/IP是为了少一层网络配置和暴露面。vsock 是虚拟化的专用通道宿主机和客户机之间不需要 IP 地址安全性和可靠性都不错。每个任务被抽象成一个Job包含 exec 命令、环境变量、超时时间和资源上限。driver 收到创建 Job 的请求后会先检查沙箱仍处于Running状态再通过 vsock 把 Job 发给 init。Job 在沙箱内是串行执行的因为我们前面已经约定了一个沙箱只跑一个会话所以不存在并发调度冲突。4.2 进程启停控制pid1、信号转发与僵尸进程在微 VM 里init 是 PID 1。很多人写小工具时会忽略 PID 1 的特殊性它必须负责回收孤儿进程否则僵尸进程会越积越多。我们在 init 里实现了一个简单的 signal handler 和 wait 循环所有由 init 直接派生的任务子进程在退出后都会被 initwaitpid收掉。停止一个 Job 也不能直接杀宿主侧的 Firecracker 进程那样太粗暴而且无法让 Agent 做优雅退出。我们的流程是driver 收到停止请求给 init 发一个stop(job_id)init 先给任务进程组发 SIGTERM等待 10 秒内的优雅退出如果超时再发 SIGKILL最后把退出码和最终输出返回给 driver。有一次我们在压测时发现部分 Agent 会自己 fork 出孙进程任务主进程结束子进程还在跑。针对这个问题init 在启动任务时把子进程放进一个独立的进程组停止时向整个进程组发信号。这样就不会出现“主进程退了后台小工还在跑”的资源残留。4.3 资源配额与异常退出分类Firecracker 本身像一个大进程跑在宿主机上所以第一层资源限制用宿主机 cgroup 控制限制 VM 进程的 CPU、内存和磁盘 IO 权重。第二层限制在客户机里做主要是 rlimit比如RLIMIT_NOFILE、RLIMIT_AS。双层限制的做法是参考 E2B 生产经验后定下来的只在宿主机层限制客户机里的程序可以用ulimit自己放开只做客户机层限制又防不住内核级异常。任务退出时我们不只看退出码还要结合信号类型和日志来分类。下面是我们常用的退出码映射表退出场景信号/退出码处理动作正常完成0返回结果等待沙箱回收任务超时124自定义标记超时允许重试内存超限137SIGKILL标记 OOM不重试被用户显式停止143SIGTERM视为正常终止内部执行错误非 0 其他记录日志按业务策略决定超时的判断由 init 通过时间戳完成而不是完全依赖宿主侧定时器因为网络抖动可能让宿主侧收到结果的时间滞后。init 会在 Job 到达时记录 deadline如果运行超时则主动杀掉任务并返回 124这比宿主机侧强制终止更可控。5. E2B 生产经验校准我按生产负载调整了哪些参数5.1 从 E2B 的默认参数里挖出的基准既然标题里写了“附 E2B 生产经验校准”这部分我多讲一些实际对照。E2B 作为托管沙箱服务其生产配置中透露了不少可靠经验比如沙箱空闲后的回收时间不要太长避免占着资源不干活exec 接口的默认超时会因为命令类型不同而不同不能把镜像预置和启动时安装混在一起。我们最初的配置里沙箱空闲回收时间是 10 分钟后来压测发现很多 Agent 会话在任务间隙会做思考间隔可能超过 10 分钟导致频繁重建。参考 E2B 的做法我们把这个时间上调到了 15 分钟同时增加了一个“活跃会话续租”机制只要沙箱存在未完成任务就不允许被空闲回收。另一个校准点是 exec 超时。E2B 的默认值偏保护型我们做实时性要求高的 Agent 推理任务时单次命令经常会达到 60 秒。最后我们将默认 exec 超时定为 120 秒但允许单次 Job 显式声明更长上限前提是沙箱规格支持。5.2 镜像拉取与磁盘 I/O 优化托管沙箱经常会遇到“创建时拉依赖”导致的高失败率E2B 的做法是提供一个相对完整的预装配环境把依赖安装尽力前移。我们自建后同样沿用所有 Python 包、Node 模块在镜像构建阶段装好沙箱启动时不再触网拉取依赖。还有一个细节是并发创建尖峰下的磁盘 IO 竞争。假设 20 个沙箱同时从同一块宿主机磁盘读取同一份基础镜像IO 会瞬间打满。我们用两层缓存解决宿主机内存里用 page cache 缓存热镜像块宿主机磁盘上把基础镜像文件做成只读多个沙箱共享只有 diff 文件单独写。每台宿主机上我们还设置了镜像预拉取服务控制面会在宿主机负载低时提前把新版本镜像推到本地避免创建请求到了才发现镜像不存在。5.3 网络出口与 SSRF 防护Agent 沙箱需要访问外部 API但不能让它想访问哪里就访问哪里。我们参考 E2B 对沙箱网络的隔离思路给每台 VM 分配一套 NAT 网络并在宿主侧用 iptables 限制出网端口只能走 80/443同时禁止访问内网网段防止 SSRF 打到自己服务。这个限制要非常严格如果 Agent 从 Prompt 里拿到了一个内网地址尝试访问管理接口会被宿主侧网络策略直接丢弃。我们还对每个沙箱做了出网带宽上限防止某个写坏了的 Agent 脚本在沙箱里下载海量数据把宿主机出口带宽拖垮。网络策略的变更需要能灰度发布。我们把这些规则做成下发配置而不是在宿主机脚本里写死这样出现问题时可以快速回滚。5.4 可观测性沙箱层级指标与告警最后校准的是可观测性。E2B 这类托管环境你能看到的指标很少自建的好处是我们可以把每个环节都数据化。我们给每个沙箱上报以下指标VM 创建耗时、vsock 握手耗时、Job 排队时间、进程实际 CPU 时间、内存峰值、磁盘写入量、退出码分布、状态机迁移耗时。这些指标最终都汇入监控系统。我个人的经验是先盯三个最容易出事的阈值沙箱创建失败率超过 5% 要报警因为它通常意味着镜像或存储层有问题单台宿主机上Running状态的沙箱数低于调度器期望值时报警说明宿主机可能进入异常状态Job 平均超时率超过 10% 要排查通常是依赖或网络策略需要调整。最后分享一个我踩了很多次才想明白的小事状态机里所有带超时的操作超时值不要写死在代码里。我把它们放到一个外部配置中心之后排障效率提升了一个量级。自建沙箱集群真正难的不是把 VM 拉起来而是把回收、超时、清理这一堆“看不见的活”做干净。按上面这套思路先跑通一个验证环境再去谈压测和扩容会顺很多。