AI Agent 跑起来之后,第一个让人头疼的问题就是执行环境怎么隔离。代码要能跑,文件要能写,进程要能复现,但是宿主机的安全不能被一波 prompt 注入打穿。我们最初直接复用公司的 Kubernetes 集群,给每个会话发一个 Pod,跑了几周发现性价比和稳定性都撑不住:GC 延迟一上来就是几秒、镜像拉取挤占带宽、回收不干净还经常残留僵尸容器。后来我花了三周时间基于 Firecracker 自建了一套 Agent 沙箱集群,用状态机管生命周期、用分层文件系统管数据、用内置代理进程做编排,最终稳定支撑了每天几十万次的任务执行。这中间还参考了 E2B 在生产环境暴露出来的一些设计取舍,能避的坑都提前避了。这篇把整个方案拆开讲清楚,从选型逻辑到具体参数再到排障经验,给准备自己做 Agent 运行时的团队一个可落地的参考。
1. 集群架构与选型思路:为什么是 Firecracker
1.1 Agent 沙箱到底需要什么
给 Agent 做沙箱和传统微服务隔离是两回事。传统场景追求的是尽量小的开销、尽量多的实例密度,但 Agent 沙箱有几个特殊需求:第一,每次任务的代码可能都是动态生成的,需要秒级启动一个新的、干净的隔离环境;第二,Agent 会操作系统文件、安装软件包、生成图片音频,这些状态必须能在任务生命周期内被持久化,任务结束后又能一键回收;第三,Agent 的执行不是一次性函数调用,而是有工具调用、有中断、有继续执行,沙箱能和宿主机保持一条可靠的控制通道。
这三个需求放在一起,会直接把 Docker 这类面向单机容器部署的方案排除掉:它确实能隔离,但是做不到毫秒级启动大量全新实例,而且镜像层、存储层的管理模型是给长跑服务设计的,不适合高频创建和彻底销毁。真正合适的方向是微虚拟机——强调启动速度、极小的设备模型、但保留硬件虚拟化的隔离边界。
1.2 Firecracker 与 Docker、gVisor、Wasm 的权衡
我当时做了个小对比表,把主流方案按启动速度、隔离强度、生态完善度打了一遍分:
| 方案 | 启动速度 | 隔离强度 | 生态完善度 | 资源开销 | 备注 |
|---|---|---|---|---|---|
| Docker 容器 | 百毫秒级 | 内核共享、依赖系统调用过滤 | 极高 | 低 | 适合常驻服务,不适合高频销毁 |
| gVisor | 百毫秒级 | 用户态内核、系统调用截获 | 较高 | 中高 | 兼容性有坑,重 IO 场景性能差 |
| WASM 运行时 | 毫秒级 | 基于能力模型 | 中等 | 很低 | 但与真实操作系统环境差距大 |
| Firecracker 微VM | 125ms 左右 | 硬件虚拟化、独立内核 | 中等 | 低 | 需要自己拼控制面和存储方案 |
Firecracker 选型纯粹是冲着两个硬指标:启动速度和隔离强度。它不为生产虚拟化提供 BIOS、视频设备、USB 控制器这些用不到的设备,全虚拟化设备只剩 virtio-net、virtio-block、virtio-vsock 三件套。设备模型越小,启动越快,攻击面也越小。每个 sandbox 都是一颗独立内核,宿主机的内核崩溃不会传染到 Agent 执行环境,对于执行不可信代码来说这是 Docker 给不了的保证。
1.3 控制面与数据面分离的集群结构
自建集群没有现成的控制面可用,我按“控制面-数据面”的思路拆了三层,这也是后来好维护的关键:
- 控制面:负责接收 Agent 调度请求、维护沙箱状态机、分配 IP 和资源、下发启动配置。这层是一个无状态 API 服务,可以横向扩展。
- 计算面:每台物理宿主机运行多个 Firecracker 微VM,宿主机只暴露两类接口:一是给微VM下发命令的 vsock 通道,二是从控制面接收启动/销毁指令的 Agent API。
- 存储面:文件分三层管理,镜像仓库只存只读 base 镜像,沙箱运行时的写入落在宿主机上的临时可写层,需要持久化的文件通过显式同步推到对象存储。
三层之间完全通过 API 通信,不搞共享内存、不搞分布式锁。状态全部记在控制面的数据库里,宿主机只是无状态执行节点,这样挂了任意一台机器,控制面重新调度实例就能恢复。
2. 沙箱生命周期状态机:从冷启动到销毁
2.1 为什么必须引入状态机
一开始我图省事,直接用数据库里的字符串字段记录沙箱状态,比如running、stopped,结果三天后就被现实教育了:宿主机网络抖动导致创建超时、Agent 进程崩了需要重启、Jailer 启动报错,各种异常路径交叉在一起,running状态完全没法表达"创建中,但底层已经在清退"这种半中间状况。状态之间迁移权限也口说无凭,运维同学手动改数据库,然后调度系统状态和实际运行状态就分叉了。
状态机不复杂,但是强制把合法的迁移路径固定下来后,混乱局面上来就消失。我设计的状态集合如下:
沙箱状态集合
| 状态 | 含义 |
|---|---|
provisioning | 控制面已收到请求,正在分配资源、组装配置 |
launching | 已调用 Firecracker API 启动微VM,等待 guest 就绪 |
ready | 微VM 启动完成,vsock 通道可用,等待 Agent 会话接入 |
attached | Agent 会话已连接,可以执行命令 |
suspended | 会话超时被临时挂起,微VM 保持运行但 CPU 受限 |
terminating | 收到删除/过期请求,正在保存收尾数据并清理资源 |
dead | 微VM 已退出,资源已释放,等待记录归档 |
这里我特意区分了launching和ready,因为 Firecracker 的 REST API 返回 200 只代表 VMM 进程启动成功,guest 内核还没跑完,此时立刻去跑命令会撞上连接拒绝。很多早期集成的问题都是没做这个区分。
2.2 合法迁移路径与超时设计
定义好状态后,迁移路径显式写出来,只允许下面这些跳转:
provisioning -> launching:资源分配成功launching -> ready:内核启动、vsock 监听就绪ready -> attached:Agent 会话完成鉴权和握手attached -> suspended:会话空闲或外部主动挂起suspended -> attached:会话续期,恢复到可用状态ready/suspended/attached -> terminating:会话过期或收到终止命令terminating -> dead:资源清理完成
整个状态机最核心的经验是:每个迁移都必须有对应的超时。launching超时设成包含内核启动余量的值,具体按镜像复杂度调节,常见是 5~15 秒;attached -> ready/suspended的心跳检测用 30 秒间隔,两次心跳丢失就进terminating;terminating最多等 10 秒,10 秒后强制 kill 宿主机上的 VMM 进程。
超时不是拍脑袋定的,我先把 90 分位的冷启动耗时统计出来,再把超时设置为它的 3~4 倍。这样既不会拿正常启动当异常,也不会让故障恢复迟钝到用户可感知。
2.3 异常路径:失联、OOM、磁盘打满
状态机定义了正常迁移路径,生产环境更要紧的是异常路径怎么处理。我整理出三类高频故障,分别给了兜底策略:
- Guest 失联:vsock 心跳连续两次超时,先把状态置为
suspended,同时后台尝试一次 guest agent 探活,如果探活失败则进入terminating。这套机制避免了一闪而过的网络抖动把整个沙箱删除。 - OOM:微VM 进程 OOM 会导致整个 sandbox 直接退出。控制面收到 exit code 后,把用户 session 内未同步的文件先同步掉,然后直接进清理流程,不回
ready。OOM 场景不值得救,因为内存状态已经不可信。 - 磁盘打满:运行时可写层一旦打满,guest 里的文件读写全部报错。虽然状态机不感知磁盘,我安排了每台宿主机上的一个小守护进程,周期性对每个微VM 发
df指令,超过阈值就触发terminating,避免盘被写爆影响同机其他沙箱。
3. 文件管理:镜像分层与可写层同步
3.1 只读根 + 临时可写层的组合
Firecracker 的块设备给了 virtio-block,但它本质上就是一个 raw 文件,没有任何文件系统层帮你做 CoW。所以文件管理必须自己设计。我采用的是经典分层模型:
- Base 镜像:制作成一个 ext4 格式的 raw 镜像文件,内容是 Ubuntu 最小系统 + 内置 Agent Runtime 相关依赖,打包后交给镜像仓库管理。这个镜像启动时以只读方式挂载,不 mount rw,也不会被改动。
- 可写层:给每个沙箱分配一块独立的 qcow2 或者直接使用 overlay 文件。我这里为了性能和实现简单,直接给每个沙箱单独建了一个 raw 文件,初始大小固定,使用稀疏文件的方式避免提前占满磁盘。
- 挂载组合:启动时用 virtio-block 把 base 镜像和一个空的可写盘同时挂进去,guest 内的 systemd 启动脚本负责把可写盘 mount 到
/workspace,同时把只读 base 的overlay目录和其他需要可写但不需要持久化的区域重定向到 tmpfs。
这一层的关键是:不要试图让 base 镜像可写。每次任务开始都应该从同一份干净镜像开始,否则镜像漂移排查起来会让人疯掉。Mirror 漂移是 E2B 在生产里强调过的大坑之一。
3.2 用户数据持久化与同步时机
Agent 任务执行过程中,用户代码、生成文件、包管理器缓存都会写进/workspace。这些数据默认只在沙箱生命周期内有效,任务结束后沙箱销毁,数据也消失。但很多场景要求用户能拉走结果文件,所以数据同步必须做到显式化。
我给出的同步模型是两级:
- 任务级同步:Agent 会话结束时,控制面通过 vsock 下发
sync_files命令,guest 内的同步代理把/workspace下的所有文件打包压缩,通过预建立的传输通道推到对象存储,并生成清单。 - 快照级同步:如果任务中途要挂起,需要记忆状态,同样走
sync_files,但只同步特定目录(通过 config 里的sync_paths列表声明),粒度更小,传输更快。
同步必须走到“对象存储写入完成”才允许状态机进入terminating,否则任务完成了但结果丢了,那是生产事故。
3.3 快照恢复的限制与改进
快照功能是 Firecracker 很诱人的特性,可以把整个微VM 状态 dumped 出来,之后秒级恢复。E2B 就靠这个做 session 复用。我初期也做过一版全量快照,踩了三个坑,实践后调整方案:
- 全量快照太慢:如果
/workspace里已经有几百 MB 文件,snapshot 会把内存加磁盘全部序列化,拉低恢复速度。改成分层之后,base 镜像本身不需要进快照,只要序列化内存状态和未同步的文件 delta。 - 快照跨机器不通用:Firecracker 快照包含 vCPU 状态、内存布局,跨 CPU 型号迁移之前必须做 CPU 特性对齐。宿主机最好统一 CPU 型号,才能把“恢复”当成一个低成本调度操作。我最后干脆限制了快照只在本机 resume,不从快照跨机恢复。
- 快照恢复后的网络连接要重建:恢复一个微VM 快照不等同于恢复 Agent 会话,TCP 连接、vsock 通道里的既有链接全部失效。恢复完成之后需要让 guest 内的 Agent Runtime 重新主动向控制面发起 handshake,状态机里把握手完成才算
attached。
4. 进程编排:微VM 内的代理进程模型
4.1 为什么要内置代理进程
沙箱只是提供了一个隔离环境,能不能被编排还得看 guest 里有什么程序在配合。直接在 guest 里跑 systemd 管理业务进程当然可以,但控制面想精确控制子进程生命周期的时候就没有抓手。
我的做法是在基础镜像里内置一个 Rust 写的轻量代理(agent),它开机自启,控制面通过 vsock 与它通信。代理进程负责四件事:
- 接收并执行控制面下发的 shell script / 二进制指令;
- 管理子进程:创建、运行、等待退出、收集 stdout/stderr 和 exit code;
- 通过 vsock 维护心跳和文件传输通道;
- 执行文件同步、快照、环境清理等沙箱管理命令。
这个代理本质上就是"微VM 里的 systemd 替代品",只做 Agent 编排相关的功能,不做无关的系统管理工作。自定义代理的好处是:协议由自己控制,可以随时扩展新命令,不会被外部的 agent 协议绑定。
4.2 命令执行与超时控制
Agent 业务经常要执行一段 Python 脚本,或者调用一个 package manager,这些指令通过execute命令下发。我把命令格式做成 JSON,通过 vsock 发给代理,代理内部用fork+exec的方式跑子进程,这样能让命令在独立的进程组里运行,方便后续整体 kill。
{ "cmd": "execute", "id": "task-abc-123", "command": "python3 /workspace/task.py", "timeout_ms": 60000, "env_vars": { "PYTHONUNBUFFERED": "1", "WORKSPACE": "/workspace" } }这里最关键的三个参数:
- timeout_ms:不能默认永久。我们的默认策略是
min(timeout, 60s),超过则向代理发 SIGKILL。子进程如果有孙进程,SIGKILL 只杀父进程是不够的,必须用kill(-pgid)或 cgroup 杀,确保把整个进程树带走。 - workdir:默认设为
/workspace,避免 Agent 后续操作路径产生歧义。 - env_vars:每次执行都要显式声明,不要在代理里缓存环境变量,因为任务之间是隔离的,环境缓存反而会产生脏状态。
命令执行期间,控制面通过 vsock 持续接收代理推送的 stdout/stderr,最多缓存 1MB,超出后丢弃并置一个output_truncated标志。曾经有任务把无限循环输出打到缓存,结果内存被打爆,所以缓存上限和丢弃标志都必须有。
4.3 会话状态与进程关联
Agent 沙箱不是只跑一条命令就结束,会话可能横跨多次工具调用。每次工具调用在微VM 里产生一批进程,这些进程之间如果互相残留会影响下一次调用。
所以安排在每次执行任务前,代理先做一次环境复位:清理临时目录、杀掉残留进程、删除/workspace/.tmp_*文件。复位不是系统重装,而是逻辑上保证"上一次执行的影响不污染下一次执行"。
干净状态对 debug 特别重要。有一次排查问题,发现前一个任务留下了三个后台进程,一直向某个 socket 端口回数据,导致后一个任务每次连接都失败。加了环境复位之后,这种问题从根本上消失,排查时间从小时级降到分钟级。
5. 实操部署:关键参数与性能调优
5.1 VCPU、内存与磁盘的配置
Firecracker 相比完整 KVM 虚拟机省掉了很多设备,但其资源隔离还是得靠 cgroup 和 pid namespace 配合。我的默认配置经过真实流量压测后,定为下面这套:
| 配置项 | 默认值 | 说明 |
|---|---|---|
| vCPU 数 | 2 | Agent 任务普遍是 IO 密集型,不需要 4 vCPU |
| 内存 | 512MB | 低于这个大小跑 Python 3 + 常见依赖有点紧 |
| /workspace 可写盘 | 2GB sparse | 超阈值按策略 kill |
| 内核 panic 停退出 | 开启 | 防止 guest 异常无限重启 |
| 网络模型 | veth 对 + 私有 IP | 配置简单,配合 iptables 限制出网 |
| 控制通道 | virtio-vsock | 不依赖 IP,隔离性更强 |
内存是最容易爆的参数。512M 够跑大多数轻量任务,但装大型依赖(比如跑模型推理)会 OOM。后来我支持了按任务声明内存,比如sandbox_memory_mb字段,调度系统按声明申请,做一个弹性开局。E2B 生产里也做类似工作,内存按需分配是提升资源效率的关键。
5.2 启动流程的具体实现
整个启动代码不复杂,但顺序很重要。我简化后的核心流程:
- 控制面收到
create_sandbox请求,校验配额和参数; - 控制面为沙箱分配唯一 ID、私有 IP、宿主机端口映射;
- 宿主机 API 服务组装 jailer 参数,调用
jailer --id ... --exec-file /path/firecracker -- --api-sock /srv/sandbox-xyz/api.sock --kernel /path/kernel --rootfs /path/rootfs --vsock ...; - 宿主机 API 服务通过 unix socket 与 Firecracker 的 REST API 通信,等待
InstanceStart成功; - 轮询 vsock 通道等待代理进程注册上线;
- 代理上线后,控制面与代理完成握手,进入
ready状态并返回公网连接信息。
实际中多出来的坑是:jailer默认要求指定--uid和--gid,如果不对齐文件权限,guest 会没有权限访问挂载文件。我统一用一个专用低权限系统账户跑所有 jailer 进程,避免 root 运行 VMM 带来的风险。
5.3 网络配置与出网控制
Agent 沙箱不是完全隔离在孤岛的,很多任务需要访问包仓库、API 服务。我采用的网络方案是每个沙箱一对 veth,宿主机上挂一个 Linux bridge,网关指向宿主机。宿主机制定 iptables 规则,只允许目标端口为 80/443 的 TCP 出网,其余全部丢弃。
guest 内部网络配置通过 kernel cmdline 传入 IP 和网关,省掉了 DHCP 带来的延迟。我在 kernel 上直接配置:
ip=172.16.10.2:::255.255.255.0::eth0:off指定静态 IP 之后,微VM 启动时不会再浪费时间做 DHCP 请求。实测启动时间因此减少了约 30ms,对比整体冷启动耗时是不可忽视的一部分。
5.4 冷启动优化的方向
最终优化后的冷启动时间做到了 600ms 左右,这里有 250ms 是 vCPUs 初始化,150ms 是内核启动,200ms 是代理进程等待 vsock ack。又做了几个细节优化:
- 保留 base 镜像页缓存:宿主机的 page cache 中常驻基础镜像,启动时直接复用缓存页,避免从磁盘重新读整块镜像;
- 使用预建的 jailer 命令模板:减少每次拼接字符串和解析参数的 CPU 开销,虽然很少,但整体 latency 目标下不可忽略;
- 把代理进程的启动放到 systemd default target 前的 early boot:代理越早起来,控制面越早能开始握手,整个流程就短。
6. 常见问题与 E2B 生产经验校准
6.1 冷启动与"持久会话"的选择
E2B 在生产中惯用的模式是:Sandbox 创建以后并不立刻绑定一个会话,而是预创建一批热沙箱,等用户请求到达时从中取一个,把会话状态 join 上去。这套"预加载 + 绑定"思路我直接借鉴到了自建集群里。
实现方式是控制面维护一个warm_pool,空闲时按最小水位线提前启动 5~10 个ready状态的沙箱,用户请求来时只要从池里取一个,把用户的 code 注入/workspace然后下发执行命令即可。这种方式下用户感知到的启动时间从 600ms 进一步降到 200ms 以内。
但注意不要把"预加载"做成"预执行",沙箱虽然热,但里面没有任何用户数据,注入数据以后才能执行。如果用宿主机的 overlayfs 做了 session 分离,那可以做到"热沙箱 + 目录切换",不过复杂度高不少,适合用户量大的时候再引入。
6.2 我踩过的一些文件系统大坑
说出来都是泪:
- ext4 的 lazy allocation 导致磁盘占用异常:启动后的空盘看起来只占了几 MB,一旦开始写文件就瞬间暴涨。监控里磁盘使用率曲线很难看,后来直接把
/workspace的 mount option 加了nodelalloc,并设置固定容量告警阈值。 - sparse 文件被 qemu-img 转换后变实盘:如果中途想用 qemu-img 做检查,它会默认把 sparse 区域写满成 0,直接把 2GB 的裸盘整成 2GB 真实占用的文件。不要随便对运行中沙箱的磁盘做 qemu-img 操作。
- overlayfs 在 Firecracker 环境里的 kernel 限制:旧内核上 overlayfs 问题多,直接放弃 overlay,改用 raw 磁盘文件做独立挂载,逻辑最清晰。
- 文件同步与快照的竞争条件:如果 Agent 任务还没结束,控制面就开始 sync_files,会读到进程正在写入的半成品文件。我强制 sync 前先给微VM 发一个
fsfreeze指令,冻结文件系统,sync 完成后再解冻。
6.3 与 E2B 生产经验的校准结果
E2B 的核心逻辑中和自建集群一致的部分,我总结为四点,可以直接复用的经验:
- 沙箱是一个以微VM 为单位的集合,而不是进程集合:控制面只需要管理“沙箱生命周期”,不需要管沙箱内部具体跑了几个进程。
- 冷启动优化永远在最大头:所有能把可达 launch 时间降低的动作都值得投入,因为 Agent 交互过程中用户感知延迟很大部分来自沙箱环境准备。
- 状态机是调度系统不可妥协的骨架:任何状态迁移如果没有超时、没有重入保护,故障恢复就会不可控。
- 文件管理是生产路径中最容易出 bug 的部分:对象存储上传超时、磁盘打满、快照损坏,优先级高于功能开发。
同时也校准出几个我和 E2B 有差异的点:E2B 将大部分沙箱逻辑封装在 managed runtime 层,而自建集群里我更倾向于把控制面逻辑与具体沙箱技术解耦,这样未来如果 Firecracker 被替换成其他 VMM,控制面只需要适配新 adapter 即可。
6.4 运维偶发问题速查表
| 现象 | 可能原因 | 处理办法 |
|---|---|---|
| 沙箱启动超时 | base 镜像页缓存未预热 / 磁盘 IO 瓶颈 | 预热镜像页缓存,检查宿主机磁盘 IO 负载 |
| 沙箱失联 | 网络抖动 / 代理进程崩溃 | 心跳超时后重连尝试一次,失败则销毁重建 |
| 同步文件超时 | /workspace 文件过多或过大 | 检查同步路径中是否有 cache 目录,必要时排除 |
| 沙箱内网络访问失败 | 出网规则只允许 80/443 | 确认目标端口,必要时扩展策略 |
| 内存持续增长 | guest 内残余进程 | 检查代理进程的 zombie 回收逻辑,强制 kill 整个进程组 |
| 快照恢复后命令无响应 | 恢复后 vsock 断开 | 控制面等待代理重新握手,不要直接恢复旧会话 |
这套集群从设计到上线,前后改了不少地方,但最核心的还是状态机与文件管理这两层。把沙箱生命周期想清楚、把文件读写路径理顺,调度层就稳定了一大半。最后再分享一个小技巧:每台宿主机上我都会留一个debug-shell微VM 沙箱,带了一个特权代理,用来手动进入微VM 排障。这个 shell 沙箱放在独立网络 namespace 里,权限也限制在宿主机的 debug 网段,不干扰生产流量。排查问题时不用再去捞宿主机的日志,直接连进去跑几条命令,比什么日志分析都快。