Agent Substrate 是 GitHub 上 agent-substrate 组织下的一个开源项目,代码的版权头写的是 Google LLC。它的 README 里有一段演示视频,标题下面只有一句话:一个 Agent Substrate 集群,把大约 250 个有状态的 actor 复用在 8 个物理 Pod 上1。
照着 README 在本地 kind 集群里装好以后,创建一个 actor、给它发请求,只要这几行:
kubectl ate create actor my-counter-1 -a ate-demo-counter --template counterkubectl port-forward -n ate-system svc/atenet-router 8000:80curl -X POST -H "ate-target-actor: ate-demo-counter/my-counter-1" -i http://localhost:8000/kubectl ate 是个 kubectl 插件,可 create actor 创建出来的东西,用 kubectl get pods 是看不到的。这个计数器演示的 WorkerPool 只声明了 3 个 Pod,actor 却可以建很多个。那 actor 到底是什么,不跑的时候它在哪?说它“有状态”,计数器数到一半被挂起,醒来还接着数,这听上去和 E2B 把沙箱拍成快照再恢复是一回事,两者又是什么关系?
我们先看清它是什么、长什么样,再分别拿它和 Kubernetes、E2B 对照。
一、Agent Substrate 是什么
智能体的负载特点
Agent Substrate 的架构文档从一个观察开始:智能体这类负载非常“突发”,大部分时间在等,等模型返回、等工具跑完、等人回复,真正干活的时间很短,等待的时间却没有上限。它们又经常执行不可信的代码,只能放进沙箱,一个用户一个实例,于是数量非常多2。
如果每个智能体是一个常驻的 Pod,大部分 Pod 在大部分时间里什么都不做,却一直占着内存和 Pod 配额。最直觉的办法是空闲时把 Pod 删掉、用到时再建,可 Kubernetes 建一个 Pod 要经过调度、拉镜像、启动应用,社区自己的目标是 p99 不超过 5 秒,这还不算拉镜像3。对一个只跑几百毫秒就又开始等待的智能体来说,这个延迟太长了。
actor 和 worker
Agent Substrate 的回答是把“智能体”和“Pod”拆开。文档里把智能体这类负载叫做 actor(不一定真是 AI 智能体,只要是“大部分时间空闲、有状态”的负载都算),把预先拉起、等着接活的 Pod 叫做 worker。actor 空闲时打一个快照,把 worker 腾出来;请求来了,再把快照恢复到任意一个空闲的 worker 里2。actor 多、worker 少,这就是开头那个“250 个跑在 8 个上”的来历。
能这样做,前提是沙箱支持挂起和恢复。Agent Substrate 要求每个 actor 都必须跑在沙箱里,明确不支持普通 Linux 容器1。默认的沙箱是 gVisor:它是 Google 做的一个用户态内核,拦下应用的系统调用、在用户态自己实现,应用碰不到宿主的内核;它的运行时叫 runsc,自带 checkpoint 和 restore,能把整个进程树连同内存存成文件再恢复出来。另一种选择是 Kata Containers 加 Cloud Hypervisor 的轻量虚拟机2。
项目对自己的定位是“没什么主见的运行时”,不是智能体 SDK:它跑的是普通的 OCI 镜像,镜像里是什么框架都行。Google 的 Agent Executor 和 CNCF 沙箱项目 kagent 都把它用作底座1。项目还在 1.0 之前,架构文档开头就写着“其中很多部分还是设想,尚未实现”2。
整体架构
装好以后,集群里多出来的东西可以分成四块,画在一起是这样:
控制面在 ate-system 命名空间里。ate-api-server 是核心,它对外提供 gRPC 接口,管 actor 的创建、挂起、恢复和删除,也负责决定 actor 放进哪个 worker;状态全部存在 PostgreSQL 里。atenet-router 是流量入口,一个带外部处理器(ext_proc)的 Envoy:它读请求头 ate-target-actor: <atespace>/<actor>,发现目标 actor 没在跑,就先让控制面把它恢复起来,再把请求转过去2。
Kubernetes API 里只放三类东西。WorkerPool 声明一个 worker Pod 的池子,被 atecontroller 调和成一个普通的 Deployment;SandboxConfig 记录沙箱用哪个版本的 runsc 或虚拟机内核。描述 actor 长什么样的 ActorTemplate(镜像、资源、快照配置),和 Actor、Worker 这些记录一样,都不是 Kubernetes 对象,而是存在 PostgreSQL 里2。
节点上,每个 worker Pod 里跑着一个 ateworker,它替节点上的 atelet 调用 runsc,在 Pod 内部建出、挂起、恢复一个个沙箱,还带着一个 atunnel,接住 router 打过来的隧道,把请求转进沙箱。atelet 以 DaemonSet 的形式每个节点一个,负责拉镜像、组装 OCI bundle,以及在节点和对象存储之间搬运快照2。
对象存储里放着快照。创建一个 ActorTemplate 时,系统会临时冷启动一次这个模板,跑到就绪后拍一个快照,叫 golden snapshot;之后这个模板的每个 actor 第一次被唤醒,都从它恢复。actor 每次被挂起,又会存下它自己的最新快照4。
所以 kubectl ate create actor 只是往 PostgreSQL 里写了一行,状态是“已挂起”,指向模板的 golden snapshot,不占任何 worker2。actor 真正跑起来,要等第一个请求到达。不过在讲唤醒之前,先看看这份 golden snapshot 是从哪里来的:模板里那个镜像,是怎么变成 gVisor 沙箱里的进程的?
二、镜像与构建
模板里写的是一个普通镜像
上一章说 actor 都跑在 gVisor 沙箱里。那模板里的镜像,要不要专门为 gVisor 准备?看仓库里的演示就知道。让几个 Claude Code 轮流复用 worker 的那个演示,镜像的 Dockerfile 是这样的(省略了版权头和两行 ENV)5:
FROM node:20-slim
RUN apt-get update \ && apt-get install -y --no-install-recommends ca-certificates curl \ && rm -rf /var/lib/apt/lists/*
RUN npm install -g @anthropic-ai/claude-code@latest
COPY run.sh /run.shRUN chmod +x /run.sh
ENTRYPOINT ["/run.sh"]这就是一个普通的 Debian 系镜像,拿去 docker run 也能跑。ActorTemplate 里写的,也只是镜像地址、启动命令、资源和快照配置,和 Pod 模板差不多4。那 gVisor 在哪里?它和 Ubuntu、CentOS 是同一类东西吗?
发行版、镜像和内核
要回答这个问题,得先把三样东西分开。装在一台机器上的 Ubuntu 或 CentOS 是一个完整的发行版:底下是 Linux 内核,上面是 glibc、包管理器(apt 或 yum)、systemd 这些用户态的东西。ubuntu:24.04、node:20-slim 这类容器镜像只打包了用户态,里面没有内核,通常也没有 systemd。镜像要跑起来,总得借一个内核。
gVisor 不是镜像,也不是发行版,它就是那个被借来的“内核”。它是一个用 Go 写、跑在用户态的应用内核,实现了一套类 Linux 的系统调用接口;镜像里的进程发出系统调用,由 gVisor 的 Sentry 接住、自己处理,不交给宿主内核6。镜像面对的仍然像是一个 Linux 内核,所以同一个 node:20-slim 不用改就能跑。
Docker 和 E2B 正好是另外两种借法。把同一个镜像放进三者里看:
Docker(底下是 runc)不借别的内核,容器里的进程直接对宿主内核发系统调用,和这台机器上所有容器共用一个内核,这在《E2B 和 Docker 是什么关系》里做过实验。E2B 走的是另一头:每个沙箱是一台 Firecracker 虚拟机,里面跑着 E2B 自己编的 Linux 内核7。gVisor 夹在中间:它像虚拟机一样给每个沙箱一个独立的内核,却不模拟硬件,本身就是宿主上的一组普通进程,文档说它可以看成“合在一起的客户机内核和虚拟机监控器”6。
代价是兼容性。gVisor 只实现了 Linux 系统调用的一个子集,缺的大多是已有调用的替代版本,比如 io_uring;每个版本都会跑 Python、Java、Node.js、PHP、Go 的回归测试,常见的语言运行时基本都能用8。依赖特殊内核特性的程序,只有放进 gVisor 实际跑一遍才知道。Substrate 也支持另一族沙箱,Kata 加 Cloud Hypervisor 的轻量虚拟机,选了它,就和 E2B 一样有一个真正的客户机内核2。
Substrate:镜像原样跑在 gVisor 上
既然镜像不用改,Substrate 也就没有自己的构建服务。镜像用 docker build 或者 ko 在你自己的机器或 CI 里构建,推到镜像仓库,Substrate 只在用到的时候去拉。从模板到一份可以恢复的快照,两边的流程放在一起看:
Substrate 这一行,拉镜像发生在节点上。atelet 用 go-containerregistry 直接从仓库拉,不经过 Docker 守护进程,各层解压进节点上的镜像缓存。它给每个 actor 准备一个 OCI bundle,却不往里面铺文件,只建好 rootfs、upper、work 三个空目录,再写一份叠层说明;actor 运行前,ateworker 用 overlayfs 把缓存里的镜像层作为只读的下层叠起来,actor 自己写的东西都落在它私有的 upper 里9。一台节点上的很多 actor 共用同一份镜像层,各写各的上层。
启动命令照搬 Pod 的规矩:模板写了 command,就覆盖镜像的 ENTRYPOINT,同时丢掉 CMD;只写 args,就只覆盖 CMD。环境变量以模板里的为先,镜像里的其次9。Substrate 不往镜像里加 init,也不装 systemd,沙箱里跑的就是镜像本来的程序。
接着是 golden snapshot。创建 ActorTemplate 时,Substrate 先建一个临时的 golden actor,像普通 actor 一样唤醒它,等它就绪:每个容器都声明了 wakeupProbe 的,探针通过就算就绪,否则再多等 20 秒。然后按模板的 preferredFidelity 把它挂起,把快照拷成一个以模板 UID 命名的 Tag,最后删掉这个临时 actor10。之后这个模板的新 actor 第一次醒来,都从这个 Tag 恢复,拉镜像和程序启动都省掉了。
E2B:先把镜像改造成一台能开机的机器
E2B 的沙箱有自己的内核,光有用户态不够,镜像得先变成一块能开机的磁盘,所以构建这件事它要自己来做。Dockerfile 在 SDK 里就被解析成一串模板步骤,交给 E2B 的构建服务执行11。构建服务同样用 go-containerregistry 拉镜像,在上面追加一层,放进 envd 等 E2B 自己的程序12;再把各层压平,整个拷进一块 ext4 磁盘13;然后按 /etc/os-release 认出发行版,给它装上 systemd、openssh-server 这些开机和登录要用的包14。Dockerfile 里的 RUN 不在 Docker 里执行,而是在一台真正开了机的 Firecracker 虚拟机里经 envd 执行。最后跑起模板的启动命令,等它就绪,暂停虚拟机拍下快照;之后创建沙箱就是恢复这份快照,官方的说法是约 80 毫秒15。
两边走到最后是同一个办法:跑到就绪,拍快照,以后都从快照起。差别在镜像经历了什么。在 Substrate 里,镜像原封不动,只是下面换了一个“内核”;在 E2B 里,镜像被补成了一台带内核、systemd 和 sshd 的小机器,更接近一台装了 Ubuntu 的虚拟机。和 Docker 放在一起:
| Docker | Agent Substrate(gVisor) | E2B | |
|---|---|---|---|
| 内核 | 宿主内核,和其他容器共用 | gVisor 的 Sentry,在用户态实现系统调用 | 自己的 Linux 内核,跑在 Firecracker 虚拟机里 |
| 镜像怎么用 | 按层解开,overlayfs 叠成根文件系统 | 追加 envd 层,压平进一块 ext4 磁盘 | |
| init | 不加,直接跑镜像的程序 | 装上 systemd,开机后再起 envd 和启动命令 | |
| 启动命令 | 镜像的 ENTRYPOINT、CMD | 按 Pod 规则,模板的 command 可以覆盖 | 模板里另写 start 命令 |
| 谁来构建 | docker build 等工具,在自己的机器或 CI 里 | E2B 的构建服务,RUN 在虚拟机里执行 | |
| 第一次启动 | 冷启动 | 从 golden snapshot 恢复 | 从构建时的快照恢复 |
| 兼容性 | 原生 Linux | 系统调用是子集,常见语言运行时都测过 | 原生 Linux,里面还能再装 Docker |
Docker 和 Substrate 对镜像的处理几乎一样,差别只在下面的内核;E2B 则把镜像当成一台机器的系统盘来装。
镜像准备好了,golden snapshot 也拍好了,可新建的 actor 还是挂起状态。一个请求是怎么把它叫醒的?
三、actor 的唤醒与挂起
唤醒:一路同步到能用
请求到了 atenet-router,ext_proc 先把它停住,调用 ate-api-server 的 ResumeActor。如果 actor 已经在跑,这次调用直接返回它所在 worker 的地址,router 照着转发,这是绝大多数请求走的路。
actor 是挂起状态时,ResumeActor 要做一串事情:先在 PostgreSQL 里给这个 actor 拿一个租约,防止两个请求同时恢复它;找出它最新的一份完整快照;按模板的资源和 worker 选择器挑一个还有空位的 worker,用乐观并发写进数据库,抢不到就以 15 毫秒起步指数退避再试;然后让那个节点上的 atelet 取回快照,由 ateworker 执行 runsc restore;最后把 actor 标成运行中,带着 worker 的 IP 返回16。router 拿到这个地址,才经 atunnel 的 mTLS 隧道把停着的请求送进 actor 的 80 端口。
在超卖的系统里,“挑一个还有空位的 worker”是可能失败的:一阵流量涌来,池子里的槽位会被瞬间占满,ResumeActor 返回“没有 worker 放得下”。这种拥挤通常只持续一眨眼,别的 actor 很快就会被挂起,所以 router 默认不直接回 503,而是把请求“停车”,退避重试,最多等 5 秒,停车场最多容纳 1024 个请求17。
挂起:先落本地,再慢慢上传
挂起要显式调用 SuspendActor。ateworker 执行 runsc checkpoint,把进程连同内存、根文件系统的改动和挂载的卷存成文件,worker 随即被清理,还给池子。默认的挂起是异步的:快照先留在节点本地,actor 被“钉”在这台节点上,这时如果马上又来一个请求,就回到同一台节点用本地文件恢复;atelet 在后台把快照传到对象存储,之后删掉本地文件、解除钉住,actor 就可以在任何节点上醒来了2。
计数器演示里的 actor 挂起再醒来,内存里的计数接着往下数,说明这份快照连进程内存都存下了。一个要跑几天的智能体,它的状态是不是都能这样保住?
四、长时间运行的状态保持
一个智能体如果要跑几天,它身上的状态远不止计数器里的一个数。对话的上下文和中间变量在进程内存里,它用 pip install 装的工具落在根文件系统上,它改到一半的代码和 SQLite 数据库在工作目录里,它也许还连着外部的数据库和一条正在等模型返回的网络连接。挂起和唤醒解决的是“不跑的时候不占资源”,可对一个长期运行的智能体来说,更要紧的是另一个问题:这些状态分别存在哪里,什么时候存下来,出了什么事会丢?
我们先看 Kubernetes 的 Pod 怎么回答这个问题,再看 Substrate,最后和 E2B 放在一起比较。
Pod 的做法:只有卷能活下来
Pod 里的进程一旦重启,内存就没了;容器的可写层也跟着容器走,容器重启,pip install 装上的东西就没了。emptyDir 卷能挺过容器重启,但 Pod 一删也被清空;只有 PersistentVolume 能活过 Pod 本身18。所以 Kubernetes 的模型是“无状态的进程 + 有状态的卷”:应用自己负责把要留下的东西写进卷或者数据库,重启以后再从里面把内存状态重建出来。
Kubernetes 并非完全没有碰过内存。从 1.30 起,kubelet 有一个默认开启的 beta 接口 POST /checkpoint/{namespace}/{pod}/{container},可以把一个运行中的容器连同全部内存页打成一个 tar 包,放在节点的 /var/lib/kubelet/checkpoints 下面。但文档对它的定位是“搬到别的机器上调试”,Kubernetes 自己的 API 里没有对应的“从这个包恢复”19。放在 Pod 里的智能体,要想让对话跨重启延续,只能自己把上下文序列化进外部存储。
Substrate 的做法:整个沙箱按档位存成快照
Substrate 把这件事从应用手里接了过来:挂起时由 runsc checkpoint 把整个沙箱存下来,应用不用知道自己被挂起过。存多少由模板的 preferredFidelity 决定,分三档,每一档包含它下面的所有层4:
VOLUMES:只存模板里声明的durableDir卷。醒来时容器从镜像重新启动,卷里的数据还在。ROOTFS:再加上根文件系统的改动。醒来时还是重新启动进程,但装过的包都在。MEMORY:再加上进程内存,醒来就是挂起前那一刻。计数器演示用的就是这一档。
那么,存快照的时候沙箱要不要关机?不用关机,但要停下来。runsc checkpoint 不会让沙箱里的系统走一遍关机流程,而是把所有进程原地冻住,再把内存和文件系统的改动写成文件。ateworker 调用它时没有带 -leave-running 参数,按 runsc 的默认行为,存完以后沙箱就退出了,worker 随即让给别的 actor16。恢复时进程从冻住的那一刻接着往下跑,应用察觉不到自己停过,只是时钟跳过了一段,外面的长连接也已经断开。这很像笔记本合盖休眠:不是关机,内存原样留着,打开盖子接着用;区别是 Substrate 这台“笔记本”合上以后,可以在另一台机器上打开。
也正因为存快照就意味着停下,Substrate 的快照只在挂起时产生。文档和代码里都没有“边运行边拍快照”的接口,也没有定时自动快照(这一点是按挂起流程推断的,没有找到明文说明)。
durableDir 卷是 Substrate 里专门放“应用数据”的地方,它的内容进每一份快照,用法类似 Kubernetes 的 emptyDir,但能跟着 actor 搬到任何节点。还有一类外部卷走 CSI,按 actor 动态创建、随生命周期挂载卸载,它们从不进快照,目前还是预览功能10。
档位只是上限。快照里的内存是在某个模板、某个沙箱运行时版本下拍的,恢复时条件变了,内存就用不上:actor 换了模板,恢复时只还原卷里的数据、重新启动进程;找不到运行时兼容的 worker,也会退到 VOLUMES 档冷启动10。所以内存更像一层“能省掉启动时间的缓存”,真正不能丢的数据应该放在 durableDir 里。
快照的数量也有规矩。每个 actor 只保留一份外部快照,下一次挂起成功就把上一份删掉。想留住某一刻,要在挂起后给它打一个 Tag,Tag 自己持有一份拷贝,还能拿来创建新的 actor,相当于从这一刻分叉10。
快照之间的空档
快照只在挂起那一刻存在,两次挂起之间的状态只活在运行中的沙箱里。把一个 actor 默认的异步挂起前后画成时间线,就能看出哪些时刻是有风险的:
最常见的风险是 worker Pod 被收回:滚动升级、缩容、节点排空,或者 Spot 实例被回收。这时 ateworker 会给每个 actor 主进程发 SIGTERM,从那一刻起有 30 分钟,但 Substrate 不会替 actor 挂起,得由驱动它的上层(通常是智能体的 harness)在这段时间里调用一次同步的 SuspendActor。没来得及挂起的 actor 被杀掉,变成 CRASHED,上一次快照之后的东西全部丢失,只能用 RevertActor 回到那份快照。Spot 实例默认只给 15 秒,这个窗口就更紧了。回滚时外部卷不会跟着回滚,于是可能出现内存和根文件系统回到了过去、卷里的数据却停在崩溃那一刻的不一致10。
文档里没有看到 Substrate 会定期自动打快照(这是从文档推断的)。换句话说,状态多久落一次盘、在什么时候落,是上层系统的责任:在智能体空闲、没有进行中的模型调用时挂起,既省资源,也缩短了没有快照保护的时间。
与 Pod、E2B 的对照
E2B 也有一整套对应的能力。暂停默认连内存和文件系统一起存,也可以只存文件系统;暂停的沙箱无限期保留,直到显式删除;可以设成超时自动暂停、有请求或 SDK 调用时自动恢复2021。命名快照可以一对多地创建新沙箱22,独立于沙箱的卷还在内测23。内存镜像恢复不了时,可以选择只用磁盘重新启动24。三者放在一起:
| Kubernetes Pod | Agent Substrate | E2B | |
|---|---|---|---|
| 进程内存 | 重启即丢;checkpoint 只留归档,不管恢复 | MEMORY 档存进快照 | 暂停默认存,恢复约 1 秒 |
| 根文件系统 | 随容器重启丢失 | ROOTFS 档及以上 | 暂停、快照都存 |
| 工作数据 | PersistentVolume | durableDir,进每一份快照 | 磁盘随暂停保存 |
| 沙箱外的卷 | PersistentVolume | CSI 外部卷(预览),不进快照 | Volumes(内测) |
| 存的时候停不停 | checkpoint 给运行中的容器拍一份副本 | 进程冻住,存完沙箱退出、让出 worker | 暂停,沙箱停止运行 |
| 谁决定什么时候存 | 应用自己写 | 上层显式挂起,同步或异步 | 显式暂停,或超时自动暂停 |
| 不跑时保留多久 | — | 到下一次挂起替换为止;Tag 长期保留 | 无限期,直到显式删除 |
| 连续运行上限 | 无 | 无;worker 被收回时 30 分钟内要挂起 | Pro 24 小时,Hobby 1 小时 |
| 内存用不上时 | — | 只恢复卷数据,重新启动 | 可选只用磁盘重启 |
| 从某一刻分叉 | — | Tag 创建新 actor | 快照创建新沙箱 |
Pod 把状态留给应用自己管;Substrate 和 E2B 都把整个沙箱存下来,差别在由谁、在什么时候按下保存。
最大的分界线在 Pod 和另外两者之间:Pod 只保证卷,内存和根文件系统要应用自己序列化;Substrate 和 E2B 都把整个沙箱拍成快照,应用不用改。Substrate 和 E2B 之间,差别不在能存什么,而在谁来按保存键、存下的东西归谁管。E2B 是托管服务,暂停由 SDK 调用或超时触发,存好的快照交给 E2B 保管,沙箱最多连续跑 24 小时,长期运行的智能体要靠暂停和恢复一段一段地接上20。Substrate 没有连续运行的上限,但挂起的时机、同步还是异步、worker 被收回时要不要抢着保存,都交给了部署它的人,快照也存在自己的对象存储里。
两者都存不住的,是进行中的网络连接。E2B 明确说暂停以后外部客户端的连接都会断开,恢复后要重连20;Substrate 的 router 按请求重新找 actor,请求级别的 HTTP 流量不受影响,但跨越一次挂起的长连接同样指望不上。
挂起、恢复、状态保持,Agent Substrate 里频繁发生的这些事情,没有一样经过 Kubernetes 的 API。它明明装在 Kubernetes 上,每个 worker 也都是 Pod。它和 Kubernetes 到底是怎么分工的?
五、与 Kubernetes 的分工
异步管 worker,同步管 actor
在《E2B 和 Docker 是什么关系》里我们看过 Kubernetes 创建 Pod 的方式:apiserver 把期望写进 etcd 就返回,Pod 什么时候真正跑起来,由各个组件 watch 接力慢慢收敛,调用方要自己再去等。它是异步的,不管拉起。
Agent Substrate 的分工正好沿着这条线切开。Kubernetes 只负责 worker,也就是那些长期运行、很少变化的 Pod:拉起它们,节点坏了在别处重建,按负载扩缩,套上 NetworkPolicy,选什么样的节点。actor 进出 worker 是另一件事,项目的目标是每秒上千次,调用方要的是“返回时就能用”,所以由 Substrate 自己的控制面同步完成2。
为什么不干脆让每个 actor 就是一个 Pod,挂起时删掉、唤醒时重建?架构文档列了四条理由:空闲的 Pod 也在占资源;apiserver 擅长在许多控制器之间异步调和,但不擅长存数以百万计的对象,也扛不住大量写入;调度一个 Pod 要等几个异步过程收敛,还要拉镜像,几秒的延迟对只跑几百毫秒的负载不可接受;PersistentVolume 也不是为百万个卷高速挂载卸载设计的2。所以 actor、Worker 这些高频变化的记录放在 PostgreSQL,只把 WorkerPool 这种低频的基础设施配置留在 Kubernetes,让平台团队还能用熟悉的 RBAC、审计和策略去管它2。
worker Pod 仍是 Kubernetes 管得着的 Pod
这样分工以后,Kubernetes 并没有被架空。worker Pod 是一个普通的 Pod,不开特权模式,只按需要加了一组 Linux 能力(SYS_ADMIN、NET_ADMIN、SYS_PTRACE 等,runsc 建沙箱、配网络要用),代码注释里说这是在替换早期的特权 worker25。Pod 的资源上限通过 downward API 告诉 ateworker,作为这个 worker 的容量;每个 actor 在 Pod 被委派的 cgroup 下面再开一个子 cgroup,所以 actor 用掉的 CPU 和内存,仍然记在这个 Pod 的账上(这一点是从代码注释推断的,没有在集群里验证)。
也就是说,Kubernetes 看到的是一组有 requests、limits 的 Pod,照常给它们调度、记账、扩缩;它看不到的,只是每个 Pod 里此刻住着哪个 actor。同样在 Kubernetes 上自己拉起沙箱,E2B 又是怎么做的?
六、与 E2B 的对照
同一个问题,同一个核心办法
先说结论:两者在代码和组织上没有关系,Substrate 的仓库里也没有提到 E2B。它们是同一个问题的两种产品形态。
问题是一样的:给智能体一个隔离得住不可信代码、又能很快拿到手的执行环境。核心办法也一样:不冷启动,而是从快照恢复。E2B 在构建模板时把应用跑到就绪再拍快照,创建沙箱就是恢复这份快照26;Substrate 在创建 ActorTemplate 时拍 golden snapshot,actor 第一次醒来也是恢复它。连挂起的做法都很像:E2B 暂停沙箱时先在本地存下差异,异步上传,恢复时优先回到原节点26;Substrate 的异步挂起也是先留在本地、钉住节点、后台上传。两者也都绕开 Kubernetes,用同步调用走热路径。
区别在单位和与 Kubernetes 的分工
真正的区别有两处。
第一处是默认的单位。 第四章看到,E2B 的沙箱设成超时自动暂停、有请求自动恢复以后,也能像 actor 一样长期挂着、来活就醒。但它的默认值是另一回事:沙箱超时就被删掉,智能体调用 Sandbox.create() 要一台,通过沙箱里的 envd 执行命令、读写文件,像是租一台机器来用20。Substrate 的 actor 生来就是一个长期存在、有名字的服务:它的地址是 atespace/name,带着自己的 SPIFFE 身份证书,创建出来就是挂起状态;actor 里没有 envd 这样的代理,跑的就是你的镜像自己的 HTTP 服务,请求经 router 直接打到它的端口上1。前者是一台可以暂停的机器,后者是一个能缩到零、带着内存状态醒来的服务。
第二处是和 Kubernetes 的分工。 两者都是“Kubernetes 拉起一层长期运行的东西,自己在里面同步拉起沙箱”,差别在 Kubernetes 看到的粒度。把一台节点画出来就很清楚:
E2B 那边,每台沙箱节点上只有一个 orchestrator Pod,开着特权模式、共用宿主的网络和 PID namespace,自己 nsenter 进节点准备 hugepage 和 iptables;一台台 Firecracker 虚拟机是它在宿主上的子进程,Kubernetes 一个也看不见27。Kubernetes 在这里只提供一份“哪些节点还活着”的名单。
Substrate 这边,节点上是很多个不特权的 worker Pod,每个都有自己的资源上限,gVisor 沙箱就在 Pod 内部。Kubernetes 不知道某个 Pod 里此刻住着哪个 actor,但它知道每个槽位多大、在哪、还在不在,扩缩容、网络策略、节点选择都照常起作用。代价是一个沙箱的大小受 worker Pod 的规格约束,计数器演示的模板注释里就提醒,actor 的资源要按 worker 的容量来定。
其他几处差别,放在一张表里:
| E2B | Agent Substrate | |
|---|---|---|
| 交付形态 | 托管的云服务,基础设施代码也开源 | 开源软件,装进自己的 Kubernetes 集群 |
| 基本单位 | 沙箱:默认超时即删,可设成自动暂停、自动恢复 | actor:长期存在,有名字和身份,大部分时间挂起 |
| 怎么用 | SDK 经沙箱里的 envd 执行命令、读写文件 | HTTP 请求带 ate-target-actor,打到 actor 自己的服务 |
| 隔离 | Firecracker 虚拟机 | 默认 gVisor,也可选 Cloud Hypervisor 虚拟机 |
| 和 Kubernetes | 每节点一个特权 Pod,沙箱在 Pod 外 | 许多不特权的 worker Pod,沙箱在 Pod 里 |
两者共享“从快照恢复、同步拉起”这个核心;分歧在于默认交出去的是一台机器还是一个常驻的服务,以及节点还归不归 Kubernetes 管。
七、回到开头的问题
现在可以回答开头那个数字了。250 个 actor 跑在 8 个 Pod 上,是因为任何时刻真正在运行的 actor,最多只有 worker 槽位那么多;其余的 actor 都处在挂起状态,在系统里只是 PostgreSQL 里的一行记录,加上对象存储里的一份快照。请求来了,router 把它停住,控制面挑一个空槽,atelet 把快照恢复进去,请求再接着往里走;actor 闲下来,被挂起,槽位又空出来给别人。这和 kubectl get pods 看不到 actor 是同一件事:Kubernetes 只管那 8 个槽位。
这套设计的代价,文档自己也写得很清楚:挂起和恢复做到这个规模,就变成了一个数据管理问题,数以百万计的快照要存、要搬,还要知道每个 actor 最新的状态在哪台机器上;再造一个高可用的控制面也不是小工程;actor 在 worker 之间不停地搬来搬去,监控和排错都会更难2。快照还和沙箱运行时的版本绑在一起,SandboxConfig 会把恢复所需的 runsc 版本钉进快照清单2。README 里“恢复小于 400 毫秒、每秒 1500 次以上激活”的数字是项目自述,北极星目标是 p95 100 毫秒,眼下还没到。
小结
- Agent Substrate 是什么? 一个装在 Kubernetes 上的智能体运行时。它把大量大部分时间空闲的 actor 复用到少量预先拉起的 worker Pod 上,空闲时打快照腾出 worker,请求来了再恢复。
- actor 不跑的时候在哪? 在 PostgreSQL 里的一行记录和对象存储里的一份快照里,不占任何 Pod。每个沙箱都是 gVisor 或轻量虚拟机,不支持普通容器。
- 镜像怎么变成沙箱? 模板里是普通的 OCI 镜像,只有用户态、没有内核。gVisor 不是镜像,而是在用户态顶替内核的运行时,镜像原样跑在它上面。Substrate 不管构建:atelet 拉镜像、overlayfs 叠成根文件系统,创建模板时跑到就绪拍下 golden snapshot。E2B 要给镜像补上 envd 和 systemd、压进 ext4,在 Firecracker 里构建再拍快照。
- 一个请求怎么叫醒它? router 停住请求,
ResumeActor同步地拿租约、抢 worker 槽位、恢复快照,返回时 actor 已在运行,router 再经隧道转发。挂起则先落本地、后台上传。 - 和 Kubernetes 怎么分工? Kubernetes 异步地管 worker Pod 这些低频变化的槽位,Substrate 同步地管 actor 进出槽位这种高频操作。worker Pod 仍是不特权、有资源上限的普通 Pod。
- 长时间运行的状态怎么保住? Pod 只保住卷,内存和根文件系统要应用自己序列化。Substrate 挂起时按
VOLUMES、ROOTFS、MEMORY三档把整个沙箱存成快照,必须留下的数据放durableDir;状态只在挂起时落盘,两次挂起之间、异步上传完成之前都有丢失的窗口,挂起时机由上层决定。E2B 的暂停、快照、卷与之一一对应,但它是托管服务,沙箱最多连续跑 24 小时。 - 和 E2B 什么关系? 没有代码关系,核心办法相同:从快照恢复,绕开 Kubernetes 同步拉起。区别在于 E2B 默认交出的是一台可暂停的机器、orchestrator 接管整台节点;Substrate 的 actor 是常驻的服务、沙箱留在 Pod 里。
一句话概括:E2B 是交给智能体的一台可以暂停的机器,Agent Substrate 是让一群智能体轮流住进 Kubernetes 槽位里的调度层。
参考资料
Footnotes
-
Agent Substrate README收藏github.com本文读的是 2026-10-18 的提交
ddb4665;What is Agent Substrate、Performance & Multiplexing、Quickstart 各节https://github.com/agent-substrate/substrate/blob/main/README.md ↩ ↩2 ↩3 ↩4 -
Agent Substrate Architecture收藏github.comProblem Statement、Relationship to Kubernetes、API Resource Models、Actor Lifecycle 各节;开头注明“Much of this architecture is aspirational”https://github.com/agent-substrate/substrate/blob/main/docs/architecture.md ↩ ↩2 ↩3 ↩4 ↩5 ↩6 ↩7 ↩8 ↩9 ↩10 ↩11 ↩12 ↩13 ↩14 ↩15
-
Pod startup latency SLI/SLOgithub.com ↗无状态 Pod,不含拉镜像和 init 容器,p99 ≤ 5 shttps://github.com/kubernetes/community/blob/master/sig-scalability/slos/pod_startup_latency.md ↩
-
Agent Substrate Glossary收藏github.comSnapshots 一节:
VOLUMES、ROOTFS、MEMORY三档和 Golden Snapshothttps://github.com/agent-substrate/substrate/blob/main/docs/glossary.md ↩ ↩2 ↩3 -
claude-code-multiplex 演示的 Dockerfilegithub.com ↗
FROM node:20-slim,装 Claude Code,ENTRYPOINT ["/run.sh"]https://github.com/agent-substrate/substrate/blob/main/demos/claude-code-multiplex/workload/Dockerfile ↩ -
fc-kernels:E2B 给沙箱编的内核收藏github.com默认内核版本
vmlinux-6.1.158在packages/shared/pkg/featureflags/flags.go的DefaultKernelVersionhttps://github.com/e2b-dev/infra/tree/main/firecracker/fc-kernels ↩ -
gVisor Compatibility收藏gvisor.devWhat works? 一节:系统调用 ABI 的子集、io_uring、语言运行时回归测试https://gvisor.dev/docs/user_guide/compatibility/ ↩
-
oci.go:atelet 为 actor 准备 OCI bundlegithub.com ↗
prepareOCIDirectory、resolveActorEnv、resolveProcessArgs;overlayfs 挂载在internal/imagecache/bundle_linux.gohttps://github.com/agent-substrate/substrate/blob/main/cmd/atelet/oci.go ↩ ↩2 -
Agent Substrate API Configuration Guide收藏github.comSnapshot 字段(
actorTemplateUid、sandboxRuntime的降级规则)、Eviction、SuspendActor、Snapshot lifetime、RevertActor;外部卷见docs/csi-volumes.mdhttps://github.com/agent-substrate/substrate/blob/main/docs/api-guide.md ↩ ↩2 ↩3 ↩4 ↩5 -
dockerfileParser.ts:把 Dockerfile 翻译成模板步骤收藏github.com本文读的是提交
62a17df;handleInstruction、IGNORED_INSTRUCTIONS、applyStartCmdhttps://github.com/e2b-dev/E2B/blob/main/packages/js-sdk/src/template/dockerfileParser.ts ↩ -
rootfs.go:在镜像上追加 E2B 自己的一层收藏github.com
CreateExt4Filesystem、additionalOCILayers;镜像配置里只保留Env见phases/base/builder.gohttps://github.com/e2b-dev/infra/blob/main/packages/orchestrator/pkg/template/build/core/rootfs/rootfs.go ↩ -
oci.go:从 OCI 镜像到 ext4收藏github.com
GetPublicImage、ToExt4、createExport、unpackRootfs;叠层在core/filesystem/ext4.go的MountOverlayFShttps://github.com/e2b-dev/infra/blob/main/packages/orchestrator/pkg/template/build/core/oci/oci.go ↩ -
provision.sh:把容器镜像补成能开机的系统收藏github.com按
/etc/os-release识别发行版;各发行版要装的包在phases/base/distro/distro.go;默认用户在phases/finalize/configure.shhttps://github.com/e2b-dev/infra/blob/main/packages/orchestrator/pkg/template/build/phases/base/provision.sh ↩ -
How it works收藏docs.e2b.dev“load the sandbox in ~80ms any time later with all the processes already running”https://docs.e2b.dev/template/how-it-works ↩
-
workflow_resume.go:ResumeActor 工作流收藏github.com
ResumeActor依次调用loadActorForResume、ensureWorkerAssigned、ensureAteletRestored、finalizeRunning;runsc 的参数在cmd/ateworker-gvisor/runsc.gohttps://github.com/agent-substrate/substrate/blob/main/cmd/ateapi/internal/controlapi/workflow_resume.go ↩ ↩2 -
Request Parking (atenet router)收藏github.com
--parked-request-budget默认 5 s,--parked-request-max默认 1024https://github.com/agent-substrate/substrate/blob/main/docs/request-parking.md ↩ -
Volumes收藏kubernetes.io
emptyDir一节:Pod 从节点上移除时,emptyDir里的数据被永久删除;容器崩溃不会移除 Podhttps://kubernetes.io/docs/concepts/storage/volumes/ ↩ -
Kubelet Checkpoint API收藏kubernetes.ioBeta since v1.30,默认开启;归档写到
/var/lib/kubelet/checkpointshttps://kubernetes.io/docs/reference/node/kubelet-checkpoint-api/ ↩ -
Sandbox persistence收藏docs.e2b.dev暂停存内存和文件系统、无限期保留、Auto-pause、Network、Limitations(Pro 24 小时、Hobby 1 小时)https://docs.e2b.dev/sandbox/persistence ↩ ↩2 ↩3 ↩4
-
Auto-resume on request收藏docs.e2b.devhttps://docs.e2b.dev/sandbox/auto-resume ↩
-
Sandbox snapshots收藏docs.e2b.devhttps://docs.e2b.dev/sandbox/snapshots ↩
-
Volumes收藏docs.e2b.dev内测(private beta)https://docs.e2b.dev/volumes ↩
-
Reboot on resume收藏docs.e2b.devhttps://docs.e2b.dev/sandbox/resume-without-memory ↩
-
workerpool_apply.go:WorkerPool 生成的 Pod收藏github.com
workerSecurityContext和workerGvisorCapabilities;每个 actor 的 cgroup 见internal/ateworker/cgroup/actor.gohttps://github.com/agent-substrate/substrate/blob/main/cmd/atecontroller/internal/controllers/workerpool_apply.go ↩ -
E2B Infrastructure Architecture收藏github.comOrchestrator、Sandbox creation、Pause and resume 各节https://github.com/e2b-dev/infra/blob/main/docs/ARCHITECTURE.md ↩ ↩2
-
E2B Embed on one Kubernetes nodegithub.com ↗orchestrator 所在 Pod 用
hostNetwork、hostPID和特权容器,nsenter进节点https://github.com/e2b-dev/infra/blob/main/embed/kubernetes/README.md ↩