
如果你的团队正在给 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. 架构拆解控制面、节点执行层、沙箱内 Runtime2.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 / FailedBooting正在拉起 microVMReady / FailedReady空闲可接收执行Executing / Snapshotting / Stopping / FailedExecuting有命令在跑Ready完成/ Failed异常/超时Snapshotting正在生成或恢复快照Ready / FailedStopping收到销毁请求回收资源中Stopped / FailedStopped已退出资源已回收物理清理后销毁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 为什么需要沙箱内 RuntimeFirecracker 只负责把一台微型机器给你机器里面跑什么、进程怎么管理它一概不管。所以 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也就是 1289表示被 SIGKILL 干掉有些框架习惯返回 0但 stdout 里全是 error。为了统一Runtime 把所有非 0 退出做了一轮归一化超时映射为 124OOM 映射为 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其中内核启动约 160msRuntime daemon 拉起约 80ms。空载单机可维持 40 个沙箱每沙箱基线内存约 180MB包含 rootfs 页缓存重负载峰值能到 400MB但 40 个沙箱同时跑 CPU 密集任务时节点负载能飙到 30。真实部署我们一般控制在每节点 25 个沙箱留足系统余量。这些数字与你业务强相关不建议原样套用但可以当作量级参考。7. 常见问题排查与避坑实录7.1 高频问题速查表问题表象根因处理建议沙箱启动后网络不通tap 设备残留或 NAT 冲突节点 Agent 销毁时强制清理 tapIPAM 加冲突检测快照恢复后 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 炸弹、写根目录、大文件并发、超长运行、恶意符号链接下载——每个用例能给出确定行为再放量到线上。这份清单在我们历次重构里帮了大忙。