几个月前,团队找到我,说要在私有网络里跑一批 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就置为Failed;Running状态下如果心跳连续三次丢失,同样触发失败迁移。心跳怎么设计也值得展开一下。我们让客户机内的 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 把这个包传给 init;init 在校验大小和解压路径后,写入/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(自定义)标记超时,允许重试内存超限137(SIGKILL)标记 OOM,不重试被用户显式停止143(SIGTERM)视为正常终止内部执行错误非 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 拉起来,而是把回收、超时、清理这一堆“看不见的活”做干净。按上面这套思路先跑通一个验证环境,再去谈压测和扩容,会顺很多。
几个月前,团队找到我,说要在私有网络里跑一批 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就置为Failed;Running状态下如果心跳连续三次丢失,同样触发失败迁移。心跳怎么设计也值得展开一下。我们让客户机内的 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 把这个包传给 init;init 在校验大小和解压路径后,写入/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(自定义)标记超时,允许重试内存超限137(SIGKILL)标记 OOM,不重试被用户显式停止143(SIGTERM)视为正常终止内部执行错误非 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 拉起来,而是把回收、超时、清理这一堆“看不见的活”做干净。按上面这套思路先跑通一个验证环境,再去谈压测和扩容,会顺很多。