﻿---
title: "Agent Substrate 是什么：和 E2B、Kubernetes 的关系"
description: "Agent Substrate 的演示里，约 250 个有状态的智能体跑在 8 个 Pod 上。顺着这个数字往下追：它管的 actor 是什么、平时在哪，模板里的镜像怎么跑在 gVisor 上、和 E2B 的构建有什么不同，一个请求怎么把它叫醒，一个要跑几天的智能体，状态靠什么保住、什么时候会丢，它为什么装在 Kubernetes 上却不让 Kubernetes 管 actor，又和同样靠快照起沙箱的 E2B 差在哪里。"
pubDate: 2026-10-19
updatedDate: 2026-10-20
tags: ["Agent Substrate", "E2B", "Kubernetes", "gVisor", "沙箱", "源码分析"]
hero: "./hero.svg"
refs:
  - id: sub-readme
    title: "Agent Substrate README"
    url: https://github.com/agent-substrate/substrate/blob/main/README.md
    collect: "github-agent-substrate-substrate-readme"
    site: GitHub · agent-substrate/substrate
    note: "本文读的是 2026-10-18 的提交 `ddb4665`；What is Agent Substrate、Performance & Multiplexing、Quickstart 各节"
  - id: sub-arch
    title: "Agent Substrate Architecture"
    url: https://github.com/agent-substrate/substrate/blob/main/docs/architecture.md
    collect: "github-agent-substrate-substrate-docs-architectu"
    site: GitHub · agent-substrate/substrate
    note: "Problem Statement、Relationship to Kubernetes、API Resource Models、Actor Lifecycle 各节；开头注明“Much of this architecture is aspirational”"
  - id: sub-glossary
    title: "Agent Substrate Glossary"
    url: https://github.com/agent-substrate/substrate/blob/main/docs/glossary.md
    collect: "github-agent-substrate-substrate-docs-glossary-m"
    site: GitHub · agent-substrate/substrate
    note: "Snapshots 一节：`VOLUMES`、`ROOTFS`、`MEMORY` 三档和 Golden Snapshot"
  - id: sub-ccdemo
    title: "claude-code-multiplex 演示的 Dockerfile"
    url: https://github.com/agent-substrate/substrate/blob/main/demos/claude-code-multiplex/workload/Dockerfile
    site: GitHub · agent-substrate/substrate
    note: "`FROM node:20-slim`，装 Claude Code，`ENTRYPOINT [\"/run.sh\"]`"
  - id: sub-oci
    title: "oci.go：atelet 为 actor 准备 OCI bundle"
    url: https://github.com/agent-substrate/substrate/blob/main/cmd/atelet/oci.go
    site: GitHub · agent-substrate/substrate
    note: "`prepareOCIDirectory`、`resolveActorEnv`、`resolveProcessArgs`；overlayfs 挂载在 `internal/imagecache/bundle_linux.go`"
  - id: gvisor-docs
    title: "What is gVisor?"
    url: https://gvisor.dev/docs/
    collect: "gvisor-docs-what-is-gvisor"
    site: gVisor 文档
  - id: gvisor-compat
    title: "gVisor Compatibility"
    url: https://gvisor.dev/docs/user_guide/compatibility/
    collect: "gvisor-docs-user-guide-compatibility"
    site: gVisor 文档
    note: "What works? 一节：系统调用 ABI 的子集、io_uring、语言运行时回归测试"
  - id: e2b-dfparser
    title: "dockerfileParser.ts：把 Dockerfile 翻译成模板步骤"
    url: https://github.com/e2b-dev/E2B/blob/main/packages/js-sdk/src/template/dockerfileParser.ts
    collect: "github-e2b-dev-e2b-packages-js-sdk-src-template"
    site: GitHub · e2b-dev/E2B
    note: "本文读的是提交 `62a17df`；`handleInstruction`、`IGNORED_INSTRUCTIONS`、`applyStartCmd`"
  - id: e2b-rootfs
    title: "rootfs.go：在镜像上追加 E2B 自己的一层"
    url: https://github.com/e2b-dev/infra/blob/main/packages/orchestrator/pkg/template/build/core/rootfs/rootfs.go
    collect: "github-e2b-dev-infra-packages-orchestrator-pkg-t-2"
    site: GitHub · e2b-dev/infra
    note: "`CreateExt4Filesystem`、`additionalOCILayers`；镜像配置里只保留 `Env` 见 `phases/base/builder.go`"
  - id: e2b-oci
    title: "oci.go：从 OCI 镜像到 ext4"
    url: https://github.com/e2b-dev/infra/blob/main/packages/orchestrator/pkg/template/build/core/oci/oci.go
    collect: "github-e2b-dev-infra-packages-orchestrator-pkg-t"
    site: GitHub · e2b-dev/infra
    note: "`GetPublicImage`、`ToExt4`、`createExport`、`unpackRootfs`；叠层在 `core/filesystem/ext4.go` 的 `MountOverlayFS`"
  - id: e2b-provision
    title: "provision.sh：把容器镜像补成能开机的系统"
    url: https://github.com/e2b-dev/infra/blob/main/packages/orchestrator/pkg/template/build/phases/base/provision.sh
    collect: "github-e2b-dev-infra-packages-orchestrator-pkg-t-3"
    site: GitHub · e2b-dev/infra
    note: "按 `/etc/os-release` 识别发行版；各发行版要装的包在 `phases/base/distro/distro.go`；默认用户在 `phases/finalize/configure.sh`"
  - id: e2b-kernel
    title: "fc-kernels：E2B 给沙箱编的内核"
    url: https://github.com/e2b-dev/infra/tree/main/firecracker/fc-kernels
    collect: "github-e2b-dev-infra-firecracker-fc-kernels"
    site: GitHub · e2b-dev/infra
    note: "默认内核版本 `vmlinux-6.1.158` 在 `packages/shared/pkg/featureflags/flags.go` 的 `DefaultKernelVersion`"
  - id: e2b-howitworks
    title: "How it works"
    url: https://docs.e2b.dev/template/how-it-works
    collect: "e2b-template-how-it-works"
    site: E2B 文档
    note: "“load the sandbox in ~80ms any time later with all the processes already running”"
  - id: sub-resume
    title: "workflow_resume.go：ResumeActor 工作流"
    url: https://github.com/agent-substrate/substrate/blob/main/cmd/ateapi/internal/controlapi/workflow_resume.go
    collect: "github-agent-substrate-substrate-cmd-ateapi-inte"
    site: GitHub · agent-substrate/substrate
    note: "`ResumeActor` 依次调用 `loadActorForResume`、`ensureWorkerAssigned`、`ensureAteletRestored`、`finalizeRunning`；runsc 的参数在 `cmd/ateworker-gvisor/runsc.go`"
  - id: sub-workerpool
    title: "workerpool_apply.go：WorkerPool 生成的 Pod"
    url: https://github.com/agent-substrate/substrate/blob/main/cmd/atecontroller/internal/controllers/workerpool_apply.go
    collect: "github-agent-substrate-substrate-cmd-atecontrol"
    site: GitHub · agent-substrate/substrate
    note: "`workerSecurityContext` 和 `workerGvisorCapabilities`；每个 actor 的 cgroup 见 `internal/ateworker/cgroup/actor.go`"
  - id: sub-parking
    title: "Request Parking (atenet router)"
    url: https://github.com/agent-substrate/substrate/blob/main/docs/request-parking.md
    collect: "github-agent-substrate-substrate-docs-request-pa"
    site: GitHub · agent-substrate/substrate
    note: "`--parked-request-budget` 默认 5 s，`--parked-request-max` 默认 1024"
  - id: sub-apiguide
    title: "Agent Substrate API Configuration Guide"
    url: https://github.com/agent-substrate/substrate/blob/main/docs/api-guide.md
    collect: "github-agent-substrate-substrate-docs-api-guide-m"
    site: GitHub · agent-substrate/substrate
    note: "Snapshot 字段（`actorTemplateUid`、`sandboxRuntime` 的降级规则）、Eviction、`SuspendActor`、Snapshot lifetime、`RevertActor`；外部卷见 `docs/csi-volumes.md`"
  - id: k8s-volumes
    title: "Volumes"
    url: https://kubernetes.io/docs/concepts/storage/volumes/
    collect: "kubernetes-docs-concepts-storage-volumes"
    site: Kubernetes 文档
    note: "`emptyDir` 一节：Pod 从节点上移除时，`emptyDir` 里的数据被永久删除；容器崩溃不会移除 Pod"
  - id: k8s-ckpt
    title: "Kubelet Checkpoint API"
    url: https://kubernetes.io/docs/reference/node/kubelet-checkpoint-api/
    collect: "kubernetes-docs-reference-node-kubelet-checkpoin"
    site: Kubernetes 文档
    note: "Beta since v1.30，默认开启；归档写到 `/var/lib/kubelet/checkpoints`"
  - id: e2b-persistence
    title: "Sandbox persistence"
    url: https://docs.e2b.dev/sandbox/persistence
    collect: "e2b-docs-sandbox-persistence"
    site: E2B 文档
    note: "暂停存内存和文件系统、无限期保留、Auto-pause、Network、Limitations（Pro 24 小时、Hobby 1 小时）"
  - id: e2b-autoresume
    title: "Auto-resume on request"
    url: https://docs.e2b.dev/sandbox/auto-resume
    collect: "e2b-docs-sandbox-auto-resume"
    site: E2B 文档
  - id: e2b-snapshots
    title: "Sandbox snapshots"
    url: https://docs.e2b.dev/sandbox/snapshots
    collect: "e2b-docs-sandbox-snapshots"
    site: E2B 文档
  - id: e2b-volumes
    title: "Volumes"
    url: https://docs.e2b.dev/volumes
    collect: "e2b-docs-volumes"
    site: E2B 文档
    note: "内测（private beta）"
  - id: e2b-reboot
    title: "Reboot on resume"
    url: https://docs.e2b.dev/sandbox/resume-without-memory
    collect: "e2b-docs-sandbox-resume-without-memory"
    site: E2B 文档
  - id: k8s-slo
    title: "Pod startup latency SLI/SLO"
    url: https://github.com/kubernetes/community/blob/master/sig-scalability/slos/pod_startup_latency.md
    site: Kubernetes SIG Scalability
    note: "无状态 Pod，不含拉镜像和 init 容器，p99 ≤ 5 s"
  - id: e2b-arch
    title: "E2B Infrastructure Architecture"
    url: https://github.com/e2b-dev/infra/blob/main/docs/ARCHITECTURE.md
    collect: "github-e2b-dev-infra-architecture-md"
    site: GitHub · e2b-dev/infra
    note: "Orchestrator、Sandbox creation、Pause and resume 各节"
  - id: e2b-embed-k8s
    title: "E2B Embed on one Kubernetes node"
    url: https://github.com/e2b-dev/infra/blob/main/embed/kubernetes/README.md
    site: GitHub · e2b-dev/infra
    note: "orchestrator 所在 Pod 用 `hostNetwork`、`hostPID` 和特权容器，`nsenter` 进节点"
---

Agent Substrate 是 GitHub 上 agent-substrate 组织下的一个开源项目，代码的版权头写的是 Google LLC。它的 README 里有一段演示视频，标题下面只有一句话：一个 Agent Substrate 集群，把大约 250 个有状态的 actor 复用在 8 个物理 Pod 上[^sub-readme]。

照着 README 在本地 kind 集群里装好以后，创建一个 actor、给它发请求，只要这几行：

```bash
kubectl ate create actor my-counter-1 -a ate-demo-counter --template counter
kubectl port-forward -n ate-system svc/atenet-router 8000:80
curl -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 的架构文档从一个观察开始：智能体这类负载非常“突发”，大部分时间在等，等模型返回、等工具跑完、等人回复，真正干活的时间很短，等待的时间却没有上限。它们又经常执行不可信的代码，只能放进沙箱，一个用户一个实例，于是数量非常多[^sub-arch]。

如果每个智能体是一个常驻的 Pod，大部分 Pod 在大部分时间里什么都不做，却一直占着内存和 Pod 配额。最直觉的办法是空闲时把 Pod 删掉、用到时再建，可 Kubernetes 建一个 Pod 要经过调度、拉镜像、启动应用，社区自己的目标是 p99 不超过 5 秒，这还不算拉镜像[^k8s-slo]。对一个只跑几百毫秒就又开始等待的智能体来说，这个延迟太长了。

### actor 和 worker

Agent Substrate 的回答是把“智能体”和“Pod”拆开。文档里把智能体这类负载叫做 **actor**（不一定真是 AI 智能体，只要是“大部分时间空闲、有状态”的负载都算），把预先拉起、等着接活的 Pod 叫做 **worker**。actor 空闲时打一个快照，把 worker 腾出来；请求来了，再把快照恢复到任意一个空闲的 worker 里[^sub-arch]。actor 多、worker 少，这就是开头那个“250 个跑在 8 个上”的来历。

能这样做，前提是沙箱支持挂起和恢复。Agent Substrate 要求每个 actor 都必须跑在沙箱里，明确不支持普通 Linux 容器[^sub-readme]。默认的沙箱是 gVisor：它是 Google 做的一个用户态内核，拦下应用的系统调用、在用户态自己实现，应用碰不到宿主的内核；它的运行时叫 `runsc`，自带 `checkpoint` 和 `restore`，能把整个进程树连同内存存成文件再恢复出来。另一种选择是 Kata Containers 加 Cloud Hypervisor 的轻量虚拟机[^sub-arch]。

项目对自己的定位是“没什么主见的运行时”，不是智能体 SDK：它跑的是普通的 OCI 镜像，镜像里是什么框架都行。Google 的 Agent Executor 和 CNCF 沙箱项目 kagent 都把它用作底座[^sub-readme]。项目还在 1.0 之前，架构文档开头就写着“其中很多部分还是设想，尚未实现”[^sub-arch]。

### 整体架构

装好以后，集群里多出来的东西可以分成四块，画在一起是这样：

<figure class="dd dd-figure">
<div class="dd-scroll">
<svg class="dd-svg" viewBox="0 0 800 560" xmlns="http://www.w3.org/2000/svg" role="img" aria-labelledby="sub-arch-title sub-arch-desc">
<title id="sub-arch-title">Agent Substrate 的整体架构</title>
<desc id="sub-arch-desc">控制面有 atenet-router、ate-api-server 和存 ActorTemplate、Actor、Worker 记录的 PostgreSQL；Kubernetes API 里只有 WorkerPool、Deployment、SandboxConfig。节点上有三个 worker Pod，各自运行 ateworker-gvisor，其中两个 Pod 里有 runsc 沙箱分别跑着 my-counter-1 和 my-counter-2，一个空闲；atelet 负责在节点上恢复沙箱，并和对象存储之间搬运快照。router 经 atunnel 隧道把请求送进 my-counter-1 所在的 Pod。</desc>
<defs>
<marker id="sub-arch-arrow" markerWidth="8" markerHeight="8" refX="7" refY="4" orient="auto"><path class="dd-head" d="M1 1 L7 4 L1 7"/></marker>
<marker id="sub-arch-arrow-acc" markerWidth="8" markerHeight="8" refX="7" refY="4" orient="auto"><path class="dd-head-acc" d="M1 1 L7 4 L1 7"/></marker>
</defs>
<rect class="dd-frame" width="800" height="560" rx="10"/>
<rect class="dd-zone" x="16" y="16" width="560" height="176" rx="8"/>
<text class="t-zone" x="36" y="44">Substrate 控制面 · ate-system</text>
<rect class="dd-zone" x="592" y="16" width="192" height="176" rx="8"/>
<text class="t-zone" x="612" y="44">Kubernetes API</text>
<rect class="dd-zone" x="16" y="208" width="560" height="336" rx="8"/>
<text class="t-zone" x="36" y="236">节点 · node-a</text>
<rect class="dd-zone" x="592" y="208" width="192" height="336" rx="8"/>
<text class="t-zone" x="612" y="236">对象存储 · GCS / S3</text>
<path class="dd-edge" d="M176 90 H198" marker-end="url(#sub-arch-arrow)"/>
<path class="dd-edge" d="M344 90 H366" marker-end="url(#sub-arch-arrow)"/>
<path class="dd-edge" d="M320 116 V192 Q320 200 328 200 H558 Q566 200 566 208 V388 Q566 396 558 396 H548 Q540 396 540 404 V410" marker-end="url(#sub-arch-arrow)"/>
<text class="t-note" x="486" y="248">Restore</text>
<path class="dd-edge-acc" d="M168 116 V236 Q168 244 176 244 H286 Q294 244 294 252 V254" marker-end="url(#sub-arch-arrow-acc)"/>
<text class="t-note" x="304" y="248"><tspan class="t-acc">隧道 · atunnel :8443</tspan></text>
<path class="dd-edge" d="M114 412 V382" marker-end="url(#sub-arch-arrow)"/>
<path class="dd-edge" d="M294 412 V382" marker-end="url(#sub-arch-arrow)"/>
<path class="dd-edge" d="M474 412 V382" marker-end="url(#sub-arch-arrow)"/>
<path class="dd-edge" d="M556 430 H606" marker-end="url(#sub-arch-arrow)"/>
<rect class="dd-card" x="32" y="64" width="144" height="52" rx="6"/>
<text class="t-name" x="104" y="86" text-anchor="middle">atenet-router</text>
<text class="t-note" x="104" y="105" text-anchor="middle">Envoy · ext_proc</text>
<text class="t-mono" x="32" y="144">ate-target-actor:</text>
<text class="t-mono" x="32" y="164"><tspan class="t-acc">…/my-counter-1</tspan></text>
<rect class="dd-card" x="200" y="64" width="144" height="52" rx="6"/>
<text class="t-name" x="272" y="86" text-anchor="middle">ate-api-server</text>
<text class="t-note" x="272" y="105" text-anchor="middle">生命周期 · 调度</text>
<rect class="dd-card" x="368" y="64" width="192" height="110" rx="6"/>
<path class="dd-card-head" d="M374 64 H554 Q560 64 560 70 V96 H368 V70 Q368 64 374 64 Z"/>
<text class="t-name" x="380" y="85">PostgreSQL</text>
<line class="dd-hair" x1="368" y1="122" x2="560" y2="122"/>
<line class="dd-hair" x1="368" y1="148" x2="560" y2="148"/>
<text class="t-mono" x="380" y="113">template</text>
<text class="t-val" x="548" y="113" text-anchor="end">counter</text>
<text class="t-mono" x="380" y="139">actor</text>
<text class="t-val" x="548" y="139" text-anchor="end"><tspan class="t-acc">my-counter-1</tspan></text>
<text class="t-mono" x="380" y="165">worker</text>
<text class="t-val" x="548" y="165" text-anchor="end">×3</text>
<rect class="dd-card" x="608" y="56" width="160" height="36" rx="6"/>
<text class="t-name" x="620" y="79">WorkerPool</text>
<text class="t-mono" x="756" y="79" text-anchor="end">counter</text>
<rect class="dd-card" x="608" y="100" width="160" height="36" rx="6"/>
<text class="t-name" x="620" y="123">Deployment</text>
<text class="t-mono" x="756" y="123" text-anchor="end">×3</text>
<rect class="dd-card" x="608" y="144" width="160" height="36" rx="6"/>
<text class="t-name" x="620" y="167">SandboxConfig</text>
<rect class="dd-card" x="32" y="256" width="164" height="120" rx="6"/>
<path class="dd-card-head" d="M38 256 H190 Q196 256 196 262 V288 H32 V262 Q32 256 38 256 Z"/>
<text class="t-name" x="44" y="277">Pod</text>
<text class="t-mono" x="184" y="277" text-anchor="end">worker-1</text>
<text class="t-mono" x="44" y="306">ateworker-gvisor</text>
<line class="dd-hair" x1="32" y1="314" x2="196" y2="314"/>
<rect class="dd-derived" x="44" y="322" width="140" height="44" rx="6"/>
<text class="t-note" x="114" y="349" text-anchor="middle">空闲 · 等分配</text>
<rect class="dd-card" x="212" y="256" width="164" height="120" rx="6"/>
<path class="dd-card-head" d="M218 256 H370 Q376 256 376 262 V288 H212 V262 Q212 256 218 256 Z"/>
<text class="t-name" x="224" y="277">Pod</text>
<text class="t-mono" x="364" y="277" text-anchor="end">worker-2</text>
<text class="t-mono" x="224" y="306">ateworker-gvisor</text>
<line class="dd-hair" x1="212" y1="314" x2="376" y2="314"/>
<rect class="dd-zone" x="224" y="322" width="140" height="44" rx="6"/>
<text class="t-mono" x="236" y="340">runsc 沙箱</text>
<text class="t-mono" x="236" y="358"><tspan class="t-acc">my-counter-1</tspan></text>
<rect class="dd-card" x="392" y="256" width="164" height="120" rx="6"/>
<path class="dd-card-head" d="M398 256 H550 Q556 256 556 262 V288 H392 V262 Q392 256 398 256 Z"/>
<text class="t-name" x="404" y="277">Pod</text>
<text class="t-mono" x="544" y="277" text-anchor="end">worker-3</text>
<text class="t-mono" x="404" y="306">ateworker-gvisor</text>
<line class="dd-hair" x1="392" y1="314" x2="556" y2="314"/>
<rect class="dd-zone" x="404" y="322" width="140" height="44" rx="6"/>
<text class="t-mono" x="416" y="340">runsc 沙箱</text>
<text class="t-mono" x="416" y="358">my-counter-2</text>
<rect class="dd-card" x="32" y="412" width="524" height="36" rx="6"/>
<text class="t-name" x="48" y="435">atelet</text>
<text class="t-note" x="540" y="435" text-anchor="end">DaemonSet · 拉镜像 · 组 OCI bundle · 搬快照</text>
<text class="t-say" x="32" y="484">Pod 由 Kubernetes 按 WorkerPool 拉起；actor 跑在 Pod 里 ateworker 起的沙箱中。</text>
<text class="t-say" x="32" y="506">不在跑的 actor 不占 Pod，只剩数据库里的一行和存储里的快照。</text>
<text class="t-note" x="608" y="276">挂起的 actor</text>
<text class="t-note" x="608" y="296">状态都存在这里</text>
<text class="t-note" x="608" y="326">上传 / 下载快照</text>
<rect class="dd-card" x="608" y="338" width="160" height="110" rx="6"/>
<path class="dd-card-head" d="M614 338 H762 Q768 338 768 344 V370 H608 V344 Q608 338 614 338 Z"/>
<text class="t-name" x="620" y="359">快照</text>
<line class="dd-hair" x1="608" y1="396" x2="768" y2="396"/>
<line class="dd-hair" x1="608" y1="422" x2="768" y2="422"/>
<text class="t-mono" x="620" y="387">counter · golden</text>
<text class="t-mono" x="620" y="413"><tspan class="t-acc">my-counter-1</tspan></text>
<text class="t-mono" x="620" y="439">my-counter-3 …</text>
</svg>
</div>
<figcaption>图 1 · Kubernetes 里只有 worker 的池子，actor 的记录和快照都在 Substrate 自己手里</figcaption>
</figure>

**控制面**在 `ate-system` 命名空间里。`ate-api-server` 是核心，它对外提供 gRPC 接口，管 actor 的创建、挂起、恢复和删除，也负责决定 actor 放进哪个 worker；状态全部存在 PostgreSQL 里。`atenet-router` 是流量入口，一个带外部处理器（ext_proc）的 Envoy：它读请求头 `ate-target-actor: <atespace>/<actor>`，发现目标 actor 没在跑，就先让控制面把它恢复起来，再把请求转过去[^sub-arch]。

**Kubernetes API** 里只放三类东西。WorkerPool 声明一个 worker Pod 的池子，被 `atecontroller` 调和成一个普通的 Deployment；SandboxConfig 记录沙箱用哪个版本的 `runsc` 或虚拟机内核。描述 actor 长什么样的 ActorTemplate（镜像、资源、快照配置），和 Actor、Worker 这些记录一样，都不是 Kubernetes 对象，而是存在 PostgreSQL 里[^sub-arch]。

**节点上**，每个 worker Pod 里跑着一个 `ateworker`，它替节点上的 `atelet` 调用 `runsc`，在 Pod 内部建出、挂起、恢复一个个沙箱，还带着一个 `atunnel`，接住 router 打过来的隧道，把请求转进沙箱。`atelet` 以 DaemonSet 的形式每个节点一个，负责拉镜像、组装 OCI bundle，以及在节点和对象存储之间搬运快照[^sub-arch]。

**对象存储**里放着快照。创建一个 ActorTemplate 时，系统会临时冷启动一次这个模板，跑到就绪后拍一个快照，叫 golden snapshot；之后这个模板的每个 actor 第一次被唤醒，都从它恢复。actor 每次被挂起，又会存下它自己的最新快照[^sub-glossary]。

所以 `kubectl ate create actor` 只是往 PostgreSQL 里写了一行，状态是“已挂起”，指向模板的 golden snapshot，不占任何 worker[^sub-arch]。actor 真正跑起来，要等第一个请求到达。不过在讲唤醒之前，先看看这份 golden snapshot 是从哪里来的：**模板里那个镜像，是怎么变成 gVisor 沙箱里的进程的？**

## 二、镜像与构建

### 模板里写的是一个普通镜像

上一章说 actor 都跑在 gVisor 沙箱里。那模板里的镜像，要不要专门为 gVisor 准备？看仓库里的演示就知道。让几个 Claude Code 轮流复用 worker 的那个演示，镜像的 Dockerfile 是这样的（省略了版权头和两行 `ENV`）[^sub-ccdemo]：

```dockerfile
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.sh
RUN chmod +x /run.sh

ENTRYPOINT ["/run.sh"]
```

这就是一个普通的 Debian 系镜像，拿去 `docker run` 也能跑。ActorTemplate 里写的，也只是镜像地址、启动命令、资源和快照配置，和 Pod 模板差不多[^sub-glossary]。那 gVisor 在哪里？它和 Ubuntu、CentOS 是同一类东西吗？

### 发行版、镜像和内核

要回答这个问题，得先把三样东西分开。装在一台机器上的 Ubuntu 或 CentOS 是一个完整的发行版：底下是 Linux 内核，上面是 glibc、包管理器（apt 或 yum）、systemd 这些用户态的东西。`ubuntu:24.04`、`node:20-slim` 这类容器镜像只打包了用户态，里面没有内核，通常也没有 systemd。镜像要跑起来，总得借一个内核。

gVisor 不是镜像，也不是发行版，它就是那个被借来的“内核”。它是一个用 Go 写、跑在用户态的应用内核，实现了一套类 Linux 的系统调用接口；镜像里的进程发出系统调用，由 gVisor 的 Sentry 接住、自己处理，不交给宿主内核[^gvisor-docs]。镜像面对的仍然像是一个 Linux 内核，所以同一个 `node:20-slim` 不用改就能跑。

Docker 和 E2B 正好是另外两种借法。把同一个镜像放进三者里看：

<figure class="dd dd-figure">
<div class="dd-scroll">
<svg class="dd-svg" viewBox="0 0 800 408" xmlns="http://www.w3.org/2000/svg" role="img" aria-labelledby="sub-kernel-title sub-kernel-desc">
<title id="sub-kernel-title">同一个镜像，三种借内核的方式</title>
<desc id="sub-kernel-desc">三列分别是 Docker（runc）、Agent Substrate（gVisor）和 E2B（Firecracker），每列自上而下是进程 /run.sh、镜像 node:20-slim 的用户态，再往下是内核。Docker 没有自己的内核，系统调用直接交给宿主内核；Substrate 由 gVisor 的 Sentry 在用户态实现系统调用；E2B 先给镜像追加 envd、systemd、sshd，再在 Firecracker 虚拟机里跑自己的 Linux 内核 vmlinux-6.1.158。三列底下是同一个宿主 Linux 内核。</desc>
<rect class="dd-frame" width="800" height="408" rx="10"/>
<rect class="dd-zone" x="16" y="16" width="244" height="320" rx="8"/>
<rect class="dd-zone" x="276" y="16" width="248" height="320" rx="8"/>
<rect class="dd-zone" x="540" y="16" width="244" height="320" rx="8"/>
<text class="t-zone" x="32" y="44">Docker · runc</text>
<text class="t-zone" x="292" y="44">Agent Substrate · gVisor</text>
<text class="t-zone" x="556" y="44">E2B · Firecracker</text>
<rect class="dd-card" x="32" y="64" width="212" height="36" rx="6"/>
<text class="t-name" x="48" y="87">进程</text>
<text class="t-mono" x="88" y="87">/run.sh</text>
<rect class="dd-card" x="292" y="64" width="216" height="36" rx="6"/>
<text class="t-name" x="308" y="87">进程</text>
<text class="t-mono" x="348" y="87">/run.sh</text>
<rect class="dd-card" x="556" y="64" width="212" height="36" rx="6"/>
<text class="t-name" x="572" y="87">进程</text>
<text class="t-mono" x="612" y="87">/run.sh</text>
<rect class="dd-card" x="32" y="116" width="212" height="56" rx="6"/>
<text class="t-name" x="48" y="138">镜像</text>
<text class="t-mono t-acc" x="88" y="138">node:20-slim</text>
<text class="t-note" x="48" y="160">用户态：glibc · apt · npm</text>
<rect class="dd-card" x="292" y="116" width="216" height="56" rx="6"/>
<text class="t-name" x="308" y="138">镜像</text>
<text class="t-mono t-acc" x="348" y="138">node:20-slim</text>
<text class="t-note" x="308" y="160">用户态：glibc · apt · npm</text>
<rect class="dd-card" x="556" y="116" width="212" height="56" rx="6"/>
<text class="t-name" x="572" y="138">镜像</text>
<text class="t-mono t-acc" x="612" y="138">node:20-slim</text>
<text class="t-note" x="572" y="160">用户态：glibc · apt · npm</text>
<text class="t-note" x="48" y="216">镜像原样使用，不加 init</text>
<text class="t-note" x="308" y="216">镜像原样使用，不加 init</text>
<rect class="dd-card" x="556" y="184" width="212" height="56" rx="6"/>
<text class="t-name" x="572" y="206">E2B 追加的一层</text>
<text class="t-mono" x="572" y="228">envd · systemd · sshd</text>
<rect class="dd-derived" x="32" y="252" width="212" height="56" rx="6"/>
<text class="t-name" x="48" y="274">镜像里没有内核</text>
<text class="t-note" x="48" y="296">系统调用直接交给宿主内核</text>
<rect class="dd-card" x="292" y="252" width="216" height="56" rx="6"/>
<text class="t-name" x="308" y="274">Sentry · gVisor 应用内核</text>
<text class="t-note" x="308" y="296">Go 写的，在用户态实现系统调用</text>
<rect class="dd-card" x="556" y="252" width="212" height="56" rx="6"/>
<text class="t-name" x="572" y="274">客户机 Linux 内核</text>
<text class="t-mono" x="572" y="296">vmlinux-6.1.158</text>
<text class="t-say" x="32" y="328">虚线：这一层并不存在</text>
<text class="t-say" x="292" y="328">宿主只看到 Sentry 发出的调用</text>
<text class="t-say" x="556" y="328">中间隔着 KVM 硬件虚拟化</text>
<rect class="dd-card" x="16" y="352" width="768" height="40" rx="6"/>
<text class="t-name" x="400" y="377" text-anchor="middle">宿主 Linux 内核（每台节点只有这一个）</text>
</svg>
</div>
<figcaption>图 2 · 同一个镜像只有用户态：Docker 借宿主的内核，gVisor 在用户态顶替内核，E2B 带上一个真正的客户机内核</figcaption>
</figure>

Docker（底下是 runc）不借别的内核，容器里的进程直接对宿主内核发系统调用，和这台机器上所有容器共用一个内核，这在[《E2B 和 Docker 是什么关系》](/blog/e2b-and-docker/)里做过实验。E2B 走的是另一头：每个沙箱是一台 Firecracker 虚拟机，里面跑着 E2B 自己编的 Linux 内核[^e2b-kernel]。gVisor 夹在中间：它像虚拟机一样给每个沙箱一个独立的内核，却不模拟硬件，本身就是宿主上的一组普通进程，文档说它可以看成“合在一起的客户机内核和虚拟机监控器”[^gvisor-docs]。

代价是兼容性。gVisor 只实现了 Linux 系统调用的一个子集，缺的大多是已有调用的替代版本，比如 `io_uring`；每个版本都会跑 Python、Java、Node.js、PHP、Go 的回归测试，常见的语言运行时基本都能用[^gvisor-compat]。依赖特殊内核特性的程序，只有放进 gVisor 实际跑一遍才知道。Substrate 也支持另一族沙箱，Kata 加 Cloud Hypervisor 的轻量虚拟机，选了它，就和 E2B 一样有一个真正的客户机内核[^sub-arch]。

### Substrate：镜像原样跑在 gVisor 上

既然镜像不用改，Substrate 也就没有自己的构建服务。镜像用 `docker build` 或者 `ko` 在你自己的机器或 CI 里构建，推到镜像仓库，Substrate 只在用到的时候去拉。从模板到一份可以恢复的快照，两边的流程放在一起看：

<figure class="dd dd-figure">
<div class="dd-scroll">
<svg class="dd-svg" viewBox="0 0 800 368" xmlns="http://www.w3.org/2000/svg" role="img" aria-labelledby="sub-build-title sub-build-desc">
<title id="sub-build-title">从镜像到第一份快照</title>
<desc id="sub-build-desc">上一行是 Agent Substrate 创建 ActorTemplate 时的流程：镜像在用户自己的机器或 CI 里用 docker build 或 ko 构建并推到仓库（这一步不在 Substrate 里）；atelet 拉镜像、各层解进节点缓存、overlayfs 叠成 rootfs；临时的 golden actor 原样运行镜像，等 wakeupProbe 通过或再等 20 秒；按 preferredFidelity 挂起并存为 Tag，新 actor 从它恢复。下一行是 E2B 构建模板：Dockerfile 在 SDK 里解析成步骤；template-manager 拉镜像、追加 envd 层、压平进 ext4、装上 systemd 和 sshd；在 Firecracker 虚拟机里经 envd 执行 RUN，开机进 systemd，跑 start 命令到就绪；暂停并拍下 memfile 和 rootfs 快照，之后创建沙箱就是恢复它，约 80 毫秒。</desc>
<defs>
<marker id="sub-build-arrow" markerWidth="8" markerHeight="8" refX="7" refY="4" orient="auto"><path class="dd-head" d="M1 1 L7 4 L1 7"/></marker>
<marker id="sub-build-arrow-acc" markerWidth="8" markerHeight="8" refX="7" refY="4" orient="auto"><path class="dd-head-acc" d="M1 1 L7 4 L1 7"/></marker>
</defs>
<rect class="dd-frame" width="800" height="368" rx="10"/>
<rect class="dd-zone" x="16" y="16" width="768" height="160" rx="8"/>
<rect class="dd-zone" x="16" y="192" width="768" height="160" rx="8"/>
<text class="t-zone" x="32" y="44">Agent Substrate · 创建 ActorTemplate 时</text>
<text class="t-say" x="764" y="44" text-anchor="end">虚线：这一步不在 Substrate 里做</text>
<text class="t-zone" x="32" y="220">E2B · 构建模板时</text>
<line class="dd-edge" x1="200" y1="110" x2="223" y2="110" marker-end="url(#sub-build-arrow)"/>
<line class="dd-edge" x1="388" y1="110" x2="411" y2="110" marker-end="url(#sub-build-arrow)"/>
<line class="dd-edge-acc" x1="576" y1="110" x2="599" y2="110" marker-end="url(#sub-build-arrow-acc)"/>
<line class="dd-edge" x1="200" y1="286" x2="223" y2="286" marker-end="url(#sub-build-arrow)"/>
<line class="dd-edge" x1="388" y1="286" x2="411" y2="286" marker-end="url(#sub-build-arrow)"/>
<line class="dd-edge-acc" x1="576" y1="286" x2="599" y2="286" marker-end="url(#sub-build-arrow-acc)"/>
<rect class="dd-derived" x="36" y="60" width="164" height="100" rx="6"/>
<text class="t-name" x="52" y="84">构建镜像</text>
<text class="t-mono" x="52" y="106">docker build / ko</text>
<text class="t-note" x="52" y="126">在你自己的机器或 CI</text>
<text class="t-note" x="52" y="146">推到镜像仓库</text>
<rect class="dd-card" x="224" y="60" width="164" height="100" rx="6"/>
<text class="t-name" x="240" y="84">atelet 拉镜像</text>
<text class="t-note" x="240" y="106">各层解进节点缓存</text>
<text class="t-note" x="240" y="126">overlayfs 叠成 rootfs</text>
<text class="t-note" x="240" y="146">写入进私有 upper</text>
<rect class="dd-card" x="412" y="60" width="164" height="100" rx="6"/>
<text class="t-name" x="428" y="84">golden actor</text>
<text class="t-note" x="428" y="106">镜像原样，不加 init</text>
<text class="t-note" x="428" y="126">wakeupProbe 通过</text>
<text class="t-note" x="428" y="146">没有探针再等 20 秒</text>
<rect class="dd-card" x="600" y="60" width="164" height="100" rx="6"/>
<text class="t-name t-acc" x="616" y="84">挂起，存为 Tag</text>
<text class="t-mono" x="616" y="106">preferredFidelity</text>
<text class="t-note" x="616" y="126">临时 actor 随后删掉</text>
<text class="t-note" x="616" y="146">新 actor 从这里恢复</text>
<rect class="dd-card" x="36" y="236" width="164" height="100" rx="6"/>
<text class="t-name" x="52" y="260">模板写成代码</text>
<text class="t-note" x="52" y="282">Dockerfile 在 SDK 里</text>
<text class="t-note" x="52" y="302">解析成构建步骤</text>
<text class="t-note" x="52" y="322">交给 E2B 去构建</text>
<rect class="dd-card" x="224" y="236" width="164" height="100" rx="6"/>
<text class="t-name" x="240" y="260">template-manager</text>
<text class="t-note" x="240" y="282">拉镜像，加 envd 层</text>
<text class="t-note" x="240" y="302">压平进 ext4 磁盘</text>
<text class="t-note" x="240" y="322">装上 systemd、sshd</text>
<rect class="dd-card" x="412" y="236" width="164" height="100" rx="6"/>
<text class="t-name" x="428" y="260">Firecracker 里构建</text>
<text class="t-note" x="428" y="282">RUN 经 envd 执行</text>
<text class="t-note" x="428" y="302">开机进 systemd</text>
<text class="t-note" x="428" y="322">跑 start 命令到就绪</text>
<rect class="dd-card" x="600" y="236" width="164" height="100" rx="6"/>
<text class="t-name t-acc" x="616" y="260">暂停，拍快照</text>
<text class="t-mono" x="616" y="282">memfile + rootfs</text>
<text class="t-note" x="616" y="302">创建沙箱 = 恢复它</text>
<text class="t-note" x="616" y="322">约 80 毫秒</text>
</svg>
</div>
<figcaption>图 3 · 两边最后都是“跑到就绪、拍快照”；Substrate 不碰镜像，E2B 要先把镜像改造成一块能开机的磁盘</figcaption>
</figure>

Substrate 这一行，拉镜像发生在节点上。atelet 用 go-containerregistry 直接从仓库拉，不经过 Docker 守护进程，各层解压进节点上的镜像缓存。它给每个 actor 准备一个 OCI bundle，却不往里面铺文件，只建好 `rootfs`、`upper`、`work` 三个空目录，再写一份叠层说明；actor 运行前，ateworker 用 overlayfs 把缓存里的镜像层作为只读的下层叠起来，actor 自己写的东西都落在它私有的 `upper` 里[^sub-oci]。一台节点上的很多 actor 共用同一份镜像层，各写各的上层。

启动命令照搬 Pod 的规矩：模板写了 `command`，就覆盖镜像的 `ENTRYPOINT`，同时丢掉 `CMD`；只写 `args`，就只覆盖 `CMD`。环境变量以模板里的为先，镜像里的其次[^sub-oci]。Substrate 不往镜像里加 init，也不装 systemd，沙箱里跑的就是镜像本来的程序。

接着是 golden snapshot。创建 ActorTemplate 时，Substrate 先建一个临时的 golden actor，像普通 actor 一样唤醒它，等它就绪：每个容器都声明了 `wakeupProbe` 的，探针通过就算就绪，否则再多等 20 秒。然后按模板的 `preferredFidelity` 把它挂起，把快照拷成一个以模板 UID 命名的 Tag，最后删掉这个临时 actor[^sub-apiguide]。之后这个模板的新 actor 第一次醒来，都从这个 Tag 恢复，拉镜像和程序启动都省掉了。

### E2B：先把镜像改造成一台能开机的机器

E2B 的沙箱有自己的内核，光有用户态不够，镜像得先变成一块能开机的磁盘，所以构建这件事它要自己来做。Dockerfile 在 SDK 里就被解析成一串模板步骤，交给 E2B 的构建服务执行[^e2b-dfparser]。构建服务同样用 go-containerregistry 拉镜像，在上面追加一层，放进 `envd` 等 E2B 自己的程序[^e2b-rootfs]；再把各层压平，整个拷进一块 ext4 磁盘[^e2b-oci]；然后按 `/etc/os-release` 认出发行版，给它装上 systemd、openssh-server 这些开机和登录要用的包[^e2b-provision]。Dockerfile 里的 `RUN` 不在 Docker 里执行，而是在一台真正开了机的 Firecracker 虚拟机里经 `envd` 执行。最后跑起模板的启动命令，等它就绪，暂停虚拟机拍下快照；之后创建沙箱就是恢复这份快照，官方的说法是约 80 毫秒[^e2b-howitworks]。

两边走到最后是同一个办法：跑到就绪，拍快照，以后都从快照起。差别在镜像经历了什么。在 Substrate 里，镜像原封不动，只是下面换了一个“内核”；在 E2B 里，镜像被补成了一台带内核、systemd 和 sshd 的小机器，更接近一台装了 Ubuntu 的虚拟机。和 Docker 放在一起：

<figure class="dd dd-figure dt-figure">
<div class="dt-panel">
<div class="dt-title" id="sub-image-cmp-title">同一个镜像，在三者里的待遇</div>
<div class="dt-scroll">
<table class="dt dt-sticky" style="min-width:600px" aria-labelledby="sub-image-cmp-title">
<colgroup><col style="width:16%"><col><col class="hl"><col></colgroup>
<thead>
<tr><th scope="col"></th><th scope="col">Docker</th><th scope="col" class="acc">Agent Substrate（gVisor）</th><th scope="col">E2B</th></tr>
</thead>
<tbody>
<tr><th scope="row">内核</th><td>宿主内核，和其他容器共用</td><td>gVisor 的 Sentry，在用户态实现系统调用</td><td>自己的 Linux 内核，跑在 Firecracker 虚拟机里</td></tr>
<tr><th scope="row">镜像怎么用</th><td colspan="2">按层解开，overlayfs 叠成根文件系统</td><td>追加 envd 层，压平进一块 ext4 磁盘</td></tr>
<tr><th scope="row">init</th><td colspan="2">不加，直接跑镜像的程序</td><td>装上 systemd，开机后再起 envd 和启动命令</td></tr>
<tr><th scope="row">启动命令</th><td>镜像的 <code>ENTRYPOINT</code>、<code>CMD</code></td><td>按 Pod 规则，模板的 <code>command</code> 可以覆盖</td><td>模板里另写 start 命令</td></tr>
<tr><th scope="row">谁来构建</th><td colspan="2"><code>docker build</code> 等工具，在自己的机器或 CI 里</td><td>E2B 的构建服务，<code>RUN</code> 在虚拟机里执行</td></tr>
<tr><th scope="row">第一次启动</th><td>冷启动</td><td>从 golden snapshot 恢复</td><td>从构建时的快照恢复</td></tr>
<tr><th scope="row">兼容性</th><td>原生 Linux</td><td>系统调用是子集，常见语言运行时都测过</td><td>原生 Linux，里面还能再装 Docker</td></tr>
</tbody>
</table>
</div>
<p class="dt-note">Docker 和 Substrate 对镜像的处理几乎一样，差别只在下面的内核；E2B 则把镜像当成一台机器的系统盘来装。</p>
</div>
<figcaption>图 4 · Substrate 原样使用镜像、换掉内核；E2B 改造镜像、带上内核</figcaption>
</figure>

镜像准备好了，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 返回[^sub-resume]。router 拿到这个地址，才经 `atunnel` 的 mTLS 隧道把停着的请求送进 actor 的 80 端口。

<figure class="dd dd-figure">
<div class="dd-scroll">
<svg class="dd-svg" viewBox="0 0 800 456" xmlns="http://www.w3.org/2000/svg" role="img" aria-labelledby="sub-life-title sub-life-desc">
<title id="sub-life-title">actor 的唤醒和挂起</title>
<desc id="sub-life-desc">唤醒：atenet-router 停住请求，ate-api-server 的 ResumeActor 依次拿租约、在 PostgreSQL 里抢 worker 槽位、让 atelet 取快照、执行 runsc restore、把 actor 标成 RUNNING，全程同步，返回后 router 才经隧道转发请求。挂起：SuspendActor 执行 runsc checkpoint，worker 立即释放，快照先留在节点本地并把 actor 钉在该节点，atelet 在后台上传完成后删除本地文件、解除钉住。</desc>
<defs>
<marker id="sub-life-arrow" markerWidth="8" markerHeight="8" refX="7" refY="4" orient="auto"><path class="dd-head" d="M1 1 L7 4 L1 7"/></marker>
<marker id="sub-life-arrow-acc" markerWidth="8" markerHeight="8" refX="7" refY="4" orient="auto"><path class="dd-head-acc" d="M1 1 L7 4 L1 7"/></marker>
</defs>
<rect class="dd-frame" width="800" height="456" rx="10"/>
<rect class="dd-zone" x="16" y="16" width="768" height="232" rx="8"/>
<text class="t-zone" x="36" y="44">唤醒 · 请求到达 atenet-router</text>
<rect class="dd-zone" x="16" y="264" width="768" height="176" rx="8"/>
<text class="t-zone" x="36" y="292">挂起 · SuspendActor（默认异步）</text>
<path class="dd-edge" d="M136 98 H150" marker-end="url(#sub-life-arrow)"/>
<path class="dd-edge" d="M260 98 H274" marker-end="url(#sub-life-arrow)"/>
<path class="dd-edge" d="M384 98 H398" marker-end="url(#sub-life-arrow)"/>
<path class="dd-edge" d="M508 98 H522" marker-end="url(#sub-life-arrow)"/>
<path class="dd-edge" d="M632 98 H646" marker-end="url(#sub-life-arrow)"/>
<path class="dd-edge-acc" d="M710 124 V140 Q710 148 702 148 H92 Q84 148 84 140 V130" marker-end="url(#sub-life-arrow-acc)"/>
<rect class="dd-card" x="32" y="72" width="104" height="52" rx="6"/>
<text class="t-name" x="84" y="94" text-anchor="middle">router</text>
<text class="t-note" x="84" y="113" text-anchor="middle">停住请求</text>
<rect class="dd-card" x="156" y="72" width="104" height="52" rx="6"/>
<text class="t-name" x="208" y="94" text-anchor="middle">租约</text>
<text class="t-note" x="208" y="113" text-anchor="middle">按 actor 加锁</text>
<rect class="dd-card" x="280" y="72" width="104" height="52" rx="6"/>
<text class="t-mono" x="332" y="94" text-anchor="middle">AssignWorker</text>
<text class="t-note" x="332" y="113" text-anchor="middle">PG 里抢槽位</text>
<rect class="dd-card" x="404" y="72" width="104" height="52" rx="6"/>
<text class="t-name" x="456" y="94" text-anchor="middle">atelet</text>
<text class="t-note" x="456" y="113" text-anchor="middle">取回快照</text>
<rect class="dd-card" x="528" y="72" width="104" height="52" rx="6"/>
<text class="t-mono" x="580" y="94" text-anchor="middle">runsc restore</text>
<text class="t-note" x="580" y="113" text-anchor="middle">进程接着跑</text>
<rect class="dd-card" x="652" y="72" width="116" height="52" rx="6"/>
<text class="t-mono" x="710" y="94" text-anchor="middle">RUNNING</text>
<text class="t-note" x="710" y="113" text-anchor="middle">带 worker IP</text>
<text class="t-note" x="408" y="168" text-anchor="middle"><tspan class="t-acc">ResumeActor 返回 → 经 atunnel 隧道转给 actor :80</tspan></text>
<text class="t-say" x="32" y="204">实线全是同步调用：ResumeActor 返回的那一刻，actor 已经在运行，</text>
<text class="t-say" x="32" y="224">router 这才把停住的请求转进去。</text>
<text class="t-note" x="768" y="224" text-anchor="end">槽位全满：请求停车重试，最多 5 s</text>
<path class="dd-edge" d="M172 338 H186" marker-end="url(#sub-life-arrow)"/>
<path class="dd-edge" d="M328 338 H342" marker-end="url(#sub-life-arrow)"/>
<path class="dd-edge" d="M484 338 H498" marker-end="url(#sub-life-arrow)"/>
<path class="dd-edge" d="M640 338 H654" marker-end="url(#sub-life-arrow)"/>
<rect class="dd-card" x="32" y="312" width="140" height="52" rx="6"/>
<text class="t-mono" x="102" y="334" text-anchor="middle">SuspendActor</text>
<text class="t-note" x="102" y="353" text-anchor="middle">上层系统调用</text>
<rect class="dd-card" x="188" y="312" width="140" height="52" rx="6"/>
<text class="t-mono" x="258" y="334" text-anchor="middle">runsc checkpoint</text>
<text class="t-note" x="258" y="353" text-anchor="middle">内存 · rootfs · 卷</text>
<rect class="dd-card" x="344" y="312" width="140" height="52" rx="6"/>
<text class="t-name" x="414" y="334" text-anchor="middle">本地快照</text>
<text class="t-note" x="414" y="353" text-anchor="middle">钉在 node-a</text>
<rect class="dd-card" x="500" y="312" width="140" height="52" rx="6"/>
<text class="t-name" x="570" y="334" text-anchor="middle">后台上传</text>
<text class="t-note" x="570" y="353" text-anchor="middle">对象存储</text>
<rect class="dd-card" x="656" y="312" width="112" height="52" rx="6"/>
<text class="t-name" x="712" y="334" text-anchor="middle">删本地文件</text>
<text class="t-note" x="712" y="353" text-anchor="middle">解除钉住</text>
<text class="t-note" x="188" y="396">这一步之后 worker 就空出来了</text>
<text class="t-note" x="344" y="420">这期间再被唤醒：回 node-a 用本地文件恢复</text>
</svg>
</div>
<figcaption>图 5 · 唤醒是一次同步调用，返回时 actor 已在运行；挂起先在本地落盘，worker 马上空出来，上传放到后台</figcaption>
</figure>

在超卖的系统里，“挑一个还有空位的 worker”是可能失败的：一阵流量涌来，池子里的槽位会被瞬间占满，`ResumeActor` 返回“没有 worker 放得下”。这种拥挤通常只持续一眨眼，别的 actor 很快就会被挂起，所以 router 默认不直接回 503，而是把请求“停车”，退避重试，最多等 5 秒，停车场最多容纳 1024 个请求[^sub-parking]。

### 挂起：先落本地，再慢慢上传

挂起要显式调用 `SuspendActor`。ateworker 执行 `runsc checkpoint`，把进程连同内存、根文件系统的改动和挂载的卷存成文件，worker 随即被清理，还给池子。默认的挂起是异步的：快照先留在节点本地，actor 被“钉”在这台节点上，这时如果马上又来一个请求，就回到同一台节点用本地文件恢复；atelet 在后台把快照传到对象存储，之后删掉本地文件、解除钉住，actor 就可以在任何节点上醒来了[^sub-arch]。

计数器演示里的 actor 挂起再醒来，内存里的计数接着往下数，说明这份快照连进程内存都存下了。**一个要跑几天的智能体，它的状态是不是都能这样保住？**

## 四、长时间运行的状态保持

一个智能体如果要跑几天，它身上的状态远不止计数器里的一个数。对话的上下文和中间变量在进程内存里，它用 `pip install` 装的工具落在根文件系统上，它改到一半的代码和 SQLite 数据库在工作目录里，它也许还连着外部的数据库和一条正在等模型返回的网络连接。挂起和唤醒解决的是“不跑的时候不占资源”，可对一个长期运行的智能体来说，更要紧的是另一个问题：这些状态分别存在哪里，什么时候存下来，出了什么事会丢？

我们先看 Kubernetes 的 Pod 怎么回答这个问题，再看 Substrate，最后和 E2B 放在一起比较。

### Pod 的做法：只有卷能活下来

Pod 里的进程一旦重启，内存就没了；容器的可写层也跟着容器走，容器重启，`pip install` 装上的东西就没了。`emptyDir` 卷能挺过容器重启，但 Pod 一删也被清空；只有 PersistentVolume 能活过 Pod 本身[^k8s-volumes]。所以 Kubernetes 的模型是“无状态的进程 + 有状态的卷”：应用自己负责把要留下的东西写进卷或者数据库，重启以后再从里面把内存状态重建出来。

Kubernetes 并非完全没有碰过内存。从 1.30 起，kubelet 有一个默认开启的 beta 接口 `POST /checkpoint/{namespace}/{pod}/{container}`，可以把一个运行中的容器连同全部内存页打成一个 tar 包，放在节点的 `/var/lib/kubelet/checkpoints` 下面。但文档对它的定位是“搬到别的机器上调试”，Kubernetes 自己的 API 里没有对应的“从这个包恢复”[^k8s-ckpt]。放在 Pod 里的智能体，要想让对话跨重启延续，只能自己把上下文序列化进外部存储。

### Substrate 的做法：整个沙箱按档位存成快照

Substrate 把这件事从应用手里接了过来：挂起时由 `runsc checkpoint` 把整个沙箱存下来，应用不用知道自己被挂起过。存多少由模板的 `preferredFidelity` 决定，分三档，每一档包含它下面的所有层[^sub-glossary]：

- **`VOLUMES`**：只存模板里声明的 `durableDir` 卷。醒来时容器从镜像重新启动，卷里的数据还在。
- **`ROOTFS`**：再加上根文件系统的改动。醒来时还是重新启动进程，但装过的包都在。
- **`MEMORY`**：再加上进程内存，醒来就是挂起前那一刻。计数器演示用的就是这一档。

那么，存快照的时候沙箱要不要关机？不用关机，但要停下来。`runsc checkpoint` 不会让沙箱里的系统走一遍关机流程，而是把所有进程原地冻住，再把内存和文件系统的改动写成文件。ateworker 调用它时没有带 `-leave-running` 参数，按 runsc 的默认行为，存完以后沙箱就退出了，worker 随即让给别的 actor[^sub-resume]。恢复时进程从冻住的那一刻接着往下跑，应用察觉不到自己停过，只是时钟跳过了一段，外面的长连接也已经断开。这很像笔记本合盖休眠：不是关机，内存原样留着，打开盖子接着用；区别是 Substrate 这台“笔记本”合上以后，可以在另一台机器上打开。

也正因为存快照就意味着停下，Substrate 的快照只在挂起时产生。文档和代码里都没有“边运行边拍快照”的接口，也没有定时自动快照（这一点是按挂起流程推断的，没有找到明文说明）。

`durableDir` 卷是 Substrate 里专门放“应用数据”的地方，它的内容进每一份快照，用法类似 Kubernetes 的 `emptyDir`，但能跟着 actor 搬到任何节点。还有一类外部卷走 CSI，按 actor 动态创建、随生命周期挂载卸载，它们从不进快照，目前还是预览功能[^sub-apiguide]。

档位只是上限。快照里的内存是在某个模板、某个沙箱运行时版本下拍的，恢复时条件变了，内存就用不上：actor 换了模板，恢复时只还原卷里的数据、重新启动进程；找不到运行时兼容的 worker，也会退到 `VOLUMES` 档冷启动[^sub-apiguide]。所以内存更像一层“能省掉启动时间的缓存”，真正不能丢的数据应该放在 `durableDir` 里。

快照的数量也有规矩。每个 actor 只保留一份外部快照，下一次挂起成功就把上一份删掉。想留住某一刻，要在挂起后给它打一个 Tag，Tag 自己持有一份拷贝，还能拿来创建新的 actor，相当于从这一刻分叉[^sub-apiguide]。

### 快照之间的空档

快照只在挂起那一刻存在，两次挂起之间的状态只活在运行中的沙箱里。把一个 actor 默认的异步挂起前后画成时间线，就能看出哪些时刻是有风险的：

<figure class="dd dd-figure">
<div class="dd-scroll">
<svg class="dd-svg" viewBox="0 0 800 372" xmlns="http://www.w3.org/2000/svg" role="img" aria-labelledby="sub-state-title sub-state-desc">
<title id="sub-state-title">异步挂起前后，actor 的状态存在哪里</title>
<desc id="sub-state-desc">进程内存、根文件系统改动和 durableDir 卷三层一起变化：运行中只在沙箱里；SuspendActor 之后只有 node-a 上的本地快照；约 5 分钟后上传到对象存储，两边都有；约 30 分钟后删除本地文件、解除钉住，只剩对象存储。外部 CSI 卷始终在存储后端，不进快照。</desc>
<rect class="dd-frame" width="800" height="372" rx="10"/>
<rect class="dd-zone" x="16" y="16" width="768" height="340" rx="8"/>
<text class="t-zone" x="36" y="44">一个 actor 的状态在哪 · 默认的异步挂起</text>
<path class="dd-edge dd-dash" d="M330 82 V234"/>
<path class="dd-edge dd-dash" d="M470 82 V234"/>
<path class="dd-edge dd-dash" d="M610 82 V234"/>
<text class="t-note" x="240" y="76" text-anchor="middle">运行中</text>
<text class="t-mono" x="330" y="76" text-anchor="middle">SuspendActor</text>
<text class="t-note" x="470" y="76" text-anchor="middle">约 5 min 上传完</text>
<text class="t-note" x="610" y="76" text-anchor="middle">约 30 min 解除钉住</text>
<text class="t-name" x="32" y="117">进程内存</text>
<text class="t-name" x="32" y="161">根文件系统改动</text>
<text class="t-name" x="32" y="205">durableDir 卷</text>
<text class="t-name" x="32" y="261">外部卷 · CSI</text>
<rect class="dd-card" x="200" y="98" width="128" height="28" rx="6"/>
<text class="t-note" x="264" y="117" text-anchor="middle">沙箱里</text>
<rect class="dd-card" x="332" y="98" width="136" height="28" rx="6"/>
<text class="t-note" x="400" y="117" text-anchor="middle"><tspan class="t-acc">只在 node-a</tspan></text>
<rect class="dd-card" x="472" y="98" width="136" height="28" rx="6"/>
<text class="t-note" x="540" y="117" text-anchor="middle">node-a + 对象存储</text>
<rect class="dd-card" x="612" y="98" width="156" height="28" rx="6"/>
<text class="t-note" x="690" y="117" text-anchor="middle">只在对象存储</text>
<rect class="dd-card" x="200" y="142" width="128" height="28" rx="6"/>
<text class="t-note" x="264" y="161" text-anchor="middle">沙箱里</text>
<rect class="dd-card" x="332" y="142" width="136" height="28" rx="6"/>
<text class="t-note" x="400" y="161" text-anchor="middle"><tspan class="t-acc">只在 node-a</tspan></text>
<rect class="dd-card" x="472" y="142" width="136" height="28" rx="6"/>
<text class="t-note" x="540" y="161" text-anchor="middle">node-a + 对象存储</text>
<rect class="dd-card" x="612" y="142" width="156" height="28" rx="6"/>
<text class="t-note" x="690" y="161" text-anchor="middle">只在对象存储</text>
<rect class="dd-card" x="200" y="186" width="128" height="28" rx="6"/>
<text class="t-note" x="264" y="205" text-anchor="middle">沙箱里</text>
<rect class="dd-card" x="332" y="186" width="136" height="28" rx="6"/>
<text class="t-note" x="400" y="205" text-anchor="middle"><tspan class="t-acc">只在 node-a</tspan></text>
<rect class="dd-card" x="472" y="186" width="136" height="28" rx="6"/>
<text class="t-note" x="540" y="205" text-anchor="middle">node-a + 对象存储</text>
<rect class="dd-card" x="612" y="186" width="156" height="28" rx="6"/>
<text class="t-note" x="690" y="205" text-anchor="middle">只在对象存储</text>
<rect class="dd-derived" x="200" y="242" width="568" height="28" rx="6"/>
<text class="t-note" x="484" y="261" text-anchor="middle">一直在存储后端：不进快照，RevertActor 也不回滚它</text>
<text class="t-say" x="32" y="316">SuspendActor 到上传完成之间，快照只有 node-a 上这一份，节点没了它也跟着没了。</text>
<text class="t-say" x="32" y="338">运行中出事更早：只能回到上一次挂起留下的快照，之后的改动全丢。</text>
</svg>
</div>
<figcaption>图 6 · 状态只在挂起时落盘，落盘后还要过一段时间才离开节点；外部卷是唯一不跟着快照走的一层</figcaption>
</figure>

最常见的风险是 worker Pod 被收回：滚动升级、缩容、节点排空，或者 Spot 实例被回收。这时 ateworker 会给每个 actor 主进程发 `SIGTERM`，从那一刻起有 30 分钟，但 Substrate **不会替 actor 挂起**，得由驱动它的上层（通常是智能体的 harness）在这段时间里调用一次同步的 `SuspendActor`。没来得及挂起的 actor 被杀掉，变成 `CRASHED`，上一次快照之后的东西全部丢失，只能用 `RevertActor` 回到那份快照。Spot 实例默认只给 15 秒，这个窗口就更紧了。回滚时外部卷不会跟着回滚，于是可能出现内存和根文件系统回到了过去、卷里的数据却停在崩溃那一刻的不一致[^sub-apiguide]。

文档里没有看到 Substrate 会定期自动打快照（这是从文档推断的）。换句话说，状态多久落一次盘、在什么时候落，是上层系统的责任：在智能体空闲、没有进行中的模型调用时挂起，既省资源，也缩短了没有快照保护的时间。

### 与 Pod、E2B 的对照

E2B 也有一整套对应的能力。暂停默认连内存和文件系统一起存，也可以只存文件系统；暂停的沙箱无限期保留，直到显式删除；可以设成超时自动暂停、有请求或 SDK 调用时自动恢复[^e2b-persistence][^e2b-autoresume]。命名快照可以一对多地创建新沙箱[^e2b-snapshots]，独立于沙箱的卷还在内测[^e2b-volumes]。内存镜像恢复不了时，可以选择只用磁盘重新启动[^e2b-reboot]。三者放在一起：

<figure class="dd dd-figure dt-figure">
<div class="dt-panel">
<div class="dt-title" id="sub-state-cmp-title">长时间运行的智能体，状态怎么留下来</div>
<div class="dt-scroll">
<table class="dt dt-sticky" style="min-width:600px" aria-labelledby="sub-state-cmp-title">
<colgroup><col style="width:16%"><col><col class="hl"><col></colgroup>
<thead>
<tr><th scope="col"></th><th scope="col">Kubernetes Pod</th><th scope="col" class="acc">Agent Substrate</th><th scope="col">E2B</th></tr>
</thead>
<tbody>
<tr><th scope="row">进程内存</th><td class="bad">重启即丢；checkpoint 只留归档，不管恢复</td><td><code>MEMORY</code> 档存进快照</td><td>暂停默认存，恢复约 1 秒</td></tr>
<tr><th scope="row">根文件系统</th><td class="bad">随容器重启丢失</td><td><code>ROOTFS</code> 档及以上</td><td>暂停、快照都存</td></tr>
<tr><th scope="row">工作数据</th><td>PersistentVolume</td><td><code>durableDir</code>，进每一份快照</td><td>磁盘随暂停保存</td></tr>
<tr><th scope="row">沙箱外的卷</th><td>PersistentVolume</td><td>CSI 外部卷（预览），不进快照</td><td>Volumes（内测）</td></tr>
<tr><th scope="row">存的时候停不停</th><td>checkpoint 给运行中的容器拍一份副本</td><td>进程冻住，存完沙箱退出、让出 worker</td><td>暂停，沙箱停止运行</td></tr>
<tr><th scope="row">谁决定什么时候存</th><td>应用自己写</td><td>上层显式挂起，同步或异步</td><td>显式暂停，或超时自动暂停</td></tr>
<tr><th scope="row">不跑时保留多久</th><td>—</td><td>到下一次挂起替换为止；Tag 长期保留</td><td>无限期，直到显式删除</td></tr>
<tr><th scope="row">连续运行上限</th><td>无</td><td>无；worker 被收回时 30 分钟内要挂起</td><td>Pro 24 小时，Hobby 1 小时</td></tr>
<tr><th scope="row">内存用不上时</th><td>—</td><td>只恢复卷数据，重新启动</td><td>可选只用磁盘重启</td></tr>
<tr><th scope="row">从某一刻分叉</th><td>—</td><td>Tag 创建新 actor</td><td>快照创建新沙箱</td></tr>
</tbody>
</table>
</div>
<p class="dt-note">Pod 把状态留给应用自己管；Substrate 和 E2B 都把整个沙箱存下来，差别在由谁、在什么时候按下保存。</p>
</div>
<figcaption>图 7 · Pod 只保住卷，Substrate 和 E2B 连内存一起保住</figcaption>
</figure>

最大的分界线在 Pod 和另外两者之间：Pod 只保证卷，内存和根文件系统要应用自己序列化；Substrate 和 E2B 都把整个沙箱拍成快照，应用不用改。Substrate 和 E2B 之间，差别不在能存什么，而在谁来按保存键、存下的东西归谁管。E2B 是托管服务，暂停由 SDK 调用或超时触发，存好的快照交给 E2B 保管，沙箱最多连续跑 24 小时，长期运行的智能体要靠暂停和恢复一段一段地接上[^e2b-persistence]。Substrate 没有连续运行的上限，但挂起的时机、同步还是异步、worker 被收回时要不要抢着保存，都交给了部署它的人，快照也存在自己的对象存储里。

两者都存不住的，是进行中的网络连接。E2B 明确说暂停以后外部客户端的连接都会断开，恢复后要重连[^e2b-persistence]；Substrate 的 router 按请求重新找 actor，请求级别的 HTTP 流量不受影响，但跨越一次挂起的长连接同样指望不上。

挂起、恢复、状态保持，Agent Substrate 里频繁发生的这些事情，没有一样经过 Kubernetes 的 API。它明明装在 Kubernetes 上，每个 worker 也都是 Pod。**它和 Kubernetes 到底是怎么分工的？**

## 五、与 Kubernetes 的分工

### 异步管 worker，同步管 actor

在[《E2B 和 Docker 是什么关系》](/blog/e2b-and-docker/)里我们看过 Kubernetes 创建 Pod 的方式：apiserver 把期望写进 etcd 就返回，Pod 什么时候真正跑起来，由各个组件 watch 接力慢慢收敛，调用方要自己再去等。它是异步的，不管拉起。

Agent Substrate 的分工正好沿着这条线切开。Kubernetes 只负责 worker，也就是那些长期运行、很少变化的 Pod：拉起它们，节点坏了在别处重建，按负载扩缩，套上 NetworkPolicy，选什么样的节点。actor 进出 worker 是另一件事，项目的目标是每秒上千次，调用方要的是“返回时就能用”，所以由 Substrate 自己的控制面同步完成[^sub-arch]。

<figure class="dd dd-figure">
<div class="dd-scroll">
<svg class="dd-svg" viewBox="0 0 800 484" xmlns="http://www.w3.org/2000/svg" role="img" aria-labelledby="sub-k8s-title sub-k8s-desc">
<title id="sub-k8s-title">Kubernetes 和 Substrate 的两层分工</title>
<desc id="sub-k8s-desc">上层是 Substrate：约 250 个 actor 记录，正在运行的 my-counter-1、my-counter-2 分别占着 worker-2、worker-5 两个槽位，挂起的 actor 只是记录。下层是 Kubernetes：WorkerPool 经 Deployment、kube-scheduler 和 kubelet 异步拉起 8 个 worker Pod，每个 Pod 对应上层的一个 Worker 记录。</desc>
<defs>
<marker id="sub-k8s-arrow" markerWidth="8" markerHeight="8" refX="7" refY="4" orient="auto"><path class="dd-head" d="M1 1 L7 4 L1 7"/></marker>
<marker id="sub-k8s-arrow-acc" markerWidth="8" markerHeight="8" refX="7" refY="4" orient="auto"><path class="dd-head-acc" d="M1 1 L7 4 L1 7"/></marker>
</defs>
<rect class="dd-frame" width="800" height="484" rx="10"/>
<rect class="dd-zone" x="16" y="16" width="768" height="236" rx="8"/>
<text class="t-zone" x="36" y="44">Substrate · 同步：请求来了才放进槽位，返回时已在运行</text>
<rect class="dd-zone" x="16" y="268" width="768" height="200" rx="8"/>
<path class="dd-edge-acc" d="M82 100 V140 Q82 148 90 148 H158 Q166 148 166 156 V174" marker-end="url(#sub-k8s-arrow-acc)"/>
<path class="dd-edge" d="M194 100 V124 Q194 132 202 132 H434 Q442 132 442 140 V174" marker-end="url(#sub-k8s-arrow)"/>
<text class="t-note" x="460" y="148">ResumeActor 占槽 · SuspendActor 让槽</text>
<text class="t-note" x="460" y="166">下面一行是 Worker 记录，一个 Pod 一行</text>
<rect class="dd-card" x="32" y="64" width="100" height="36" rx="6"/>
<text class="t-mono" x="82" y="87" text-anchor="middle"><tspan class="t-acc">my-counter-1</tspan></text>
<rect class="dd-card" x="144" y="64" width="100" height="36" rx="6"/>
<text class="t-mono" x="194" y="87" text-anchor="middle">my-counter-2</text>
<rect class="dd-derived" x="256" y="64" width="100" height="36" rx="6"/>
<text class="t-mono" x="306" y="87" text-anchor="middle">my-counter-3</text>
<rect class="dd-derived" x="368" y="64" width="100" height="36" rx="6"/>
<text class="t-mono" x="418" y="87" text-anchor="middle">my-counter-4</text>
<rect class="dd-derived" x="480" y="64" width="100" height="36" rx="6"/>
<text class="t-mono" x="530" y="87" text-anchor="middle">my-counter-5</text>
<text class="t-note" x="596" y="80">… 共约 250 个 actor</text>
<text class="t-note" x="596" y="98">虚线：挂起，只是一行记录</text>
<rect class="dd-card" x="32" y="176" width="84" height="36" rx="6"/>
<text class="t-note" x="74" y="199" text-anchor="middle">空闲</text>
<rect class="dd-card" x="124" y="176" width="84" height="36" rx="6"/>
<text class="t-mono" x="166" y="199" text-anchor="middle"><tspan class="t-acc">counter-1</tspan></text>
<rect class="dd-card" x="216" y="176" width="84" height="36" rx="6"/>
<text class="t-note" x="258" y="199" text-anchor="middle">空闲</text>
<rect class="dd-card" x="308" y="176" width="84" height="36" rx="6"/>
<text class="t-note" x="350" y="199" text-anchor="middle">空闲</text>
<rect class="dd-card" x="400" y="176" width="84" height="36" rx="6"/>
<text class="t-mono" x="442" y="199" text-anchor="middle">counter-2</text>
<rect class="dd-card" x="492" y="176" width="84" height="36" rx="6"/>
<text class="t-note" x="534" y="199" text-anchor="middle">空闲</text>
<rect class="dd-card" x="584" y="176" width="84" height="36" rx="6"/>
<text class="t-note" x="626" y="199" text-anchor="middle">空闲</text>
<rect class="dd-card" x="676" y="176" width="84" height="36" rx="6"/>
<text class="t-note" x="718" y="199" text-anchor="middle">空闲</text>
<path class="dd-edge dd-dash" d="M74 212 V280"/>
<path class="dd-edge-acc dd-dash" d="M166 212 V280"/>
<path class="dd-edge dd-dash" d="M258 212 V280"/>
<path class="dd-edge dd-dash" d="M350 212 V280"/>
<path class="dd-edge dd-dash" d="M442 212 V280"/>
<path class="dd-edge dd-dash" d="M534 212 V280"/>
<path class="dd-edge dd-dash" d="M626 212 V280"/>
<path class="dd-edge dd-dash" d="M718 212 V280"/>
<rect class="dd-card" x="32" y="280" width="84" height="36" rx="6"/>
<text class="t-mono" x="74" y="303" text-anchor="middle">worker-1</text>
<rect class="dd-card" x="124" y="280" width="84" height="36" rx="6"/>
<text class="t-mono" x="166" y="303" text-anchor="middle"><tspan class="t-acc">worker-2</tspan></text>
<rect class="dd-card" x="216" y="280" width="84" height="36" rx="6"/>
<text class="t-mono" x="258" y="303" text-anchor="middle">worker-3</text>
<rect class="dd-card" x="308" y="280" width="84" height="36" rx="6"/>
<text class="t-mono" x="350" y="303" text-anchor="middle">worker-4</text>
<rect class="dd-card" x="400" y="280" width="84" height="36" rx="6"/>
<text class="t-mono" x="442" y="303" text-anchor="middle">worker-5</text>
<rect class="dd-card" x="492" y="280" width="84" height="36" rx="6"/>
<text class="t-mono" x="534" y="303" text-anchor="middle">worker-6</text>
<rect class="dd-card" x="584" y="280" width="84" height="36" rx="6"/>
<text class="t-mono" x="626" y="303" text-anchor="middle">worker-7</text>
<rect class="dd-card" x="676" y="280" width="84" height="36" rx="6"/>
<text class="t-mono" x="718" y="303" text-anchor="middle">worker-8</text>
<path class="dd-edge dd-dash" d="M232 358 H262" marker-end="url(#sub-k8s-arrow)"/>
<path class="dd-edge dd-dash" d="M424 358 H454" marker-end="url(#sub-k8s-arrow)"/>
<path class="dd-edge dd-dash" d="M656 358 H686" marker-end="url(#sub-k8s-arrow)"/>
<rect class="dd-card" x="32" y="340" width="200" height="36" rx="6"/>
<text class="t-name" x="48" y="363">WorkerPool</text>
<text class="t-mono" x="216" y="363" text-anchor="end">replicas: 8</text>
<rect class="dd-card" x="264" y="340" width="160" height="36" rx="6"/>
<text class="t-name" x="344" y="363" text-anchor="middle">Deployment</text>
<rect class="dd-card" x="456" y="340" width="200" height="36" rx="6"/>
<text class="t-name" x="556" y="363" text-anchor="middle">kube-scheduler · kubelet</text>
<rect class="dd-card" x="688" y="340" width="80" height="36" rx="6"/>
<text class="t-name" x="728" y="363" text-anchor="middle">Pod ×8</text>
<text class="t-zone" x="36" y="412">Kubernetes · 异步：写下期望，各组件 watch 接力</text>
<text class="t-say" x="32" y="448">这一层很少变：扩缩容、换版本、节点坏了重建 Pod。</text>
</svg>
</div>
<figcaption>图 8 · Kubernetes 只负责把槽位（worker Pod）准备好，actor 进出槽位是 Substrate 自己的同步操作</figcaption>
</figure>

为什么不干脆让每个 actor 就是一个 Pod，挂起时删掉、唤醒时重建？架构文档列了四条理由：空闲的 Pod 也在占资源；apiserver 擅长在许多控制器之间异步调和，但不擅长存数以百万计的对象，也扛不住大量写入；调度一个 Pod 要等几个异步过程收敛，还要拉镜像，几秒的延迟对只跑几百毫秒的负载不可接受；PersistentVolume 也不是为百万个卷高速挂载卸载设计的[^sub-arch]。所以 actor、Worker 这些高频变化的记录放在 PostgreSQL，只把 WorkerPool 这种低频的基础设施配置留在 Kubernetes，让平台团队还能用熟悉的 RBAC、审计和策略去管它[^sub-arch]。

### worker Pod 仍是 Kubernetes 管得着的 Pod

这样分工以后，Kubernetes 并没有被架空。worker Pod 是一个普通的 Pod，不开特权模式，只按需要加了一组 Linux 能力（`SYS_ADMIN`、`NET_ADMIN`、`SYS_PTRACE` 等，`runsc` 建沙箱、配网络要用），代码注释里说这是在替换早期的特权 worker[^sub-workerpool]。Pod 的资源上限通过 downward API 告诉 ateworker，作为这个 worker 的容量；每个 actor 在 Pod 被委派的 cgroup 下面再开一个子 cgroup，所以 actor 用掉的 CPU 和内存，仍然记在这个 Pod 的账上（这一点是从代码注释推断的，没有在集群里验证）。

也就是说，Kubernetes 看到的是一组有 requests、limits 的 Pod，照常给它们调度、记账、扩缩；它看不到的，只是每个 Pod 里此刻住着哪个 actor。**同样在 Kubernetes 上自己拉起沙箱，E2B 又是怎么做的？**

## 六、与 E2B 的对照

### 同一个问题，同一个核心办法

先说结论：两者在代码和组织上没有关系，Substrate 的仓库里也没有提到 E2B。它们是同一个问题的两种产品形态。

问题是一样的：给智能体一个隔离得住不可信代码、又能很快拿到手的执行环境。核心办法也一样：不冷启动，而是从快照恢复。E2B 在构建模板时把应用跑到就绪再拍快照，创建沙箱就是恢复这份快照[^e2b-arch]；Substrate 在创建 ActorTemplate 时拍 golden snapshot，actor 第一次醒来也是恢复它。连挂起的做法都很像：E2B 暂停沙箱时先在本地存下差异，异步上传，恢复时优先回到原节点[^e2b-arch]；Substrate 的异步挂起也是先留在本地、钉住节点、后台上传。两者也都绕开 Kubernetes，用同步调用走热路径。

### 区别在单位和与 Kubernetes 的分工

真正的区别有两处。

**第一处是默认的单位。** 第四章看到，E2B 的沙箱设成超时自动暂停、有请求自动恢复以后，也能像 actor 一样长期挂着、来活就醒。但它的默认值是另一回事：沙箱超时就被删掉，智能体调用 `Sandbox.create()` 要一台，通过沙箱里的 `envd` 执行命令、读写文件，像是租一台机器来用[^e2b-persistence]。Substrate 的 actor 生来就是一个长期存在、有名字的服务：它的地址是 `atespace/name`，带着自己的 SPIFFE 身份证书，创建出来就是挂起状态；actor 里没有 `envd` 这样的代理，跑的就是你的镜像自己的 HTTP 服务，请求经 router 直接打到它的端口上[^sub-readme]。前者是一台可以暂停的机器，后者是一个能缩到零、带着内存状态醒来的服务。

**第二处是和 Kubernetes 的分工。** 两者都是“Kubernetes 拉起一层长期运行的东西，自己在里面同步拉起沙箱”，差别在 Kubernetes 看到的粒度。把一台节点画出来就很清楚：

<figure class="dd dd-figure">
<div class="dd-scroll">
<svg class="dd-svg" viewBox="0 0 800 440" xmlns="http://www.w3.org/2000/svg" role="img" aria-labelledby="sub-node-title sub-node-desc">
<title id="sub-node-title">同一台节点上的 E2B 和 Substrate</title>
<desc id="sub-node-desc">E2B 的沙箱节点上，kubelet 只看见一个 orchestrator Pod，它开着特权、hostNetwork、hostPID，Firecracker 虚拟机是它在宿主上的子进程，在 Pod 之外。Substrate 的节点上，kubelet 看见 atelet 和多个不特权的 worker Pod，每个 Pod 有自己的资源上限，gVisor 沙箱在 Pod 内部。</desc>
<defs>
<marker id="sub-node-arrow" markerWidth="8" markerHeight="8" refX="7" refY="4" orient="auto"><path class="dd-head" d="M1 1 L7 4 L1 7"/></marker>
<marker id="sub-node-arrow-acc" markerWidth="8" markerHeight="8" refX="7" refY="4" orient="auto"><path class="dd-head-acc" d="M1 1 L7 4 L1 7"/></marker>
</defs>
<rect class="dd-frame" width="800" height="440" rx="10"/>
<rect class="dd-zone" x="16" y="16" width="376" height="408" rx="8"/>
<text class="t-zone" x="36" y="44">E2B · 沙箱节点</text>
<rect class="dd-zone" x="408" y="16" width="376" height="408" rx="8"/>
<text class="t-zone" x="428" y="44">Substrate · 节点</text>
<path class="dd-edge-acc" d="M204 222 V236" marker-end="url(#sub-node-arrow-acc)"/>
<rect class="dd-card" x="32" y="60" width="344" height="36" rx="6"/>
<text class="t-name" x="48" y="83">kubelet</text>
<text class="t-note" x="360" y="83" text-anchor="end">看见 1 个 Pod</text>
<rect class="dd-card" x="32" y="112" width="344" height="110" rx="6"/>
<path class="dd-card-head" d="M38 112 H370 Q376 112 376 118 V144 H32 V118 Q32 112 38 112 Z"/>
<text class="t-name" x="48" y="133">Pod</text>
<text class="t-mono" x="360" y="133" text-anchor="end">orchestrator</text>
<line class="dd-hair" x1="32" y1="170" x2="376" y2="170"/>
<line class="dd-hair" x1="32" y1="196" x2="376" y2="196"/>
<text class="t-mono" x="48" y="161">privileged</text>
<text class="t-val" x="360" y="161" text-anchor="end">true · nsenter</text>
<text class="t-mono" x="48" y="187">hostNetwork / hostPID</text>
<text class="t-val" x="360" y="187" text-anchor="end">true</text>
<text class="t-mono" x="48" y="213">hugepages-2Mi</text>
<text class="t-val" x="360" y="213" text-anchor="end">4Gi</text>
<rect class="dd-derived" x="32" y="240" width="344" height="136" rx="6"/>
<text class="t-note" x="48" y="262"><tspan class="t-acc">沙箱在 Pod 之外：宿主上的子进程</tspan></text>
<rect class="dd-card" x="48" y="274" width="312" height="28" rx="6"/>
<text class="t-name" x="60" y="293">Firecracker</text>
<text class="t-mono" x="348" y="293" text-anchor="end">sb-7f3a · envd</text>
<rect class="dd-card" x="48" y="306" width="312" height="28" rx="6"/>
<text class="t-name" x="60" y="325">Firecracker</text>
<text class="t-mono" x="348" y="325" text-anchor="end">sb-91c2 · envd</text>
<rect class="dd-card" x="48" y="338" width="312" height="28" rx="6"/>
<text class="t-name" x="60" y="357">Firecracker</text>
<text class="t-mono" x="348" y="357" text-anchor="end">sb-c04e · envd</text>
<text class="t-say" x="32" y="404">整台节点交给 orchestrator，Kubernetes 看不见沙箱。</text>
<rect class="dd-card" x="424" y="60" width="344" height="36" rx="6"/>
<text class="t-name" x="440" y="83">kubelet</text>
<text class="t-note" x="752" y="83" text-anchor="end">看见 atelet 和 N 个 worker Pod</text>
<rect class="dd-card" x="424" y="112" width="344" height="36" rx="6"/>
<text class="t-name" x="440" y="135">atelet</text>
<text class="t-mono" x="752" y="135" text-anchor="end">DaemonSet</text>
<rect class="dd-card" x="424" y="164" width="164" height="168" rx="6"/>
<path class="dd-card-head" d="M430 164 H582 Q588 164 588 170 V196 H424 V170 Q424 164 430 164 Z"/>
<text class="t-name" x="436" y="185">Pod</text>
<text class="t-mono" x="576" y="185" text-anchor="end">worker-1</text>
<line class="dd-hair" x1="424" y1="222" x2="588" y2="222"/>
<line class="dd-hair" x1="424" y1="248" x2="588" y2="248"/>
<text class="t-mono" x="436" y="213">privileged</text>
<text class="t-val" x="576" y="213" text-anchor="end">false</text>
<text class="t-mono" x="436" y="239">limits</text>
<text class="t-val" x="576" y="239" text-anchor="end">1 CPU · 1Gi</text>
<rect class="dd-zone" x="436" y="260" width="140" height="60" rx="6"/>
<text class="t-note" x="448" y="280"><tspan class="t-acc">沙箱在 Pod 里</tspan></text>
<text class="t-mono" x="448" y="298">runsc</text>
<text class="t-mono" x="448" y="314">my-counter-1</text>
<rect class="dd-card" x="604" y="164" width="164" height="168" rx="6"/>
<path class="dd-card-head" d="M610 164 H762 Q768 164 768 170 V196 H604 V170 Q604 164 610 164 Z"/>
<text class="t-name" x="616" y="185">Pod</text>
<text class="t-mono" x="756" y="185" text-anchor="end">worker-2</text>
<line class="dd-hair" x1="604" y1="222" x2="768" y2="222"/>
<line class="dd-hair" x1="604" y1="248" x2="768" y2="248"/>
<text class="t-mono" x="616" y="213">privileged</text>
<text class="t-val" x="756" y="213" text-anchor="end">false</text>
<text class="t-mono" x="616" y="239">limits</text>
<text class="t-val" x="756" y="239" text-anchor="end">1 CPU · 1Gi</text>
<rect class="dd-zone" x="616" y="260" width="140" height="60" rx="6"/>
<text class="t-note" x="628" y="280"><tspan class="t-acc">沙箱在 Pod 里</tspan></text>
<text class="t-mono" x="628" y="298">runsc</text>
<text class="t-mono" x="628" y="314">my-counter-2</text>
<text class="t-note" x="596" y="356" text-anchor="middle">…</text>
<text class="t-say" x="424" y="404">沙箱留在 Pod 里，Kubernetes 仍按 Pod 记账。</text>
</svg>
</div>
<figcaption>图 9 · E2B 把整台节点从 Kubernetes 手里接过去；Substrate 把沙箱留在 Pod 里，Kubernetes 管槽位，Substrate 管谁住进去</figcaption>
</figure>

E2B 那边，每台沙箱节点上只有一个 orchestrator Pod，开着特权模式、共用宿主的网络和 PID namespace，自己 `nsenter` 进节点准备 hugepage 和 iptables；一台台 Firecracker 虚拟机是它在宿主上的子进程，Kubernetes 一个也看不见[^e2b-embed-k8s]。Kubernetes 在这里只提供一份“哪些节点还活着”的名单。

Substrate 这边，节点上是很多个不特权的 worker Pod，每个都有自己的资源上限，gVisor 沙箱就在 Pod 内部。Kubernetes 不知道某个 Pod 里此刻住着哪个 actor，但它知道每个槽位多大、在哪、还在不在，扩缩容、网络策略、节点选择都照常起作用。代价是一个沙箱的大小受 worker Pod 的规格约束，计数器演示的模板注释里就提醒，actor 的资源要按 worker 的容量来定。

其他几处差别，放在一张表里：

<figure class="dd dd-figure dt-figure">
<div class="dt-panel">
<div class="dt-title" id="sub-cmp-title">E2B 与 Agent Substrate</div>
<div class="dt-scroll">
<table class="dt" aria-labelledby="sub-cmp-title">
<colgroup><col style="width:16%"><col><col class="hl"></colgroup>
<thead>
<tr><th scope="col"></th><th scope="col">E2B</th><th scope="col" class="acc">Agent Substrate</th></tr>
</thead>
<tbody>
<tr><th scope="row">交付形态</th><td>托管的云服务，基础设施代码也开源</td><td>开源软件，装进自己的 Kubernetes 集群</td></tr>
<tr><th scope="row">基本单位</th><td>沙箱：默认超时即删，可设成自动暂停、自动恢复</td><td>actor：长期存在，有名字和身份，大部分时间挂起</td></tr>
<tr><th scope="row">怎么用</th><td>SDK 经沙箱里的 <code>envd</code> 执行命令、读写文件</td><td>HTTP 请求带 <code>ate-target-actor</code>，打到 actor 自己的服务</td></tr>
<tr><th scope="row">隔离</th><td>Firecracker 虚拟机</td><td>默认 gVisor，也可选 Cloud Hypervisor 虚拟机</td></tr>
<tr><th scope="row">和 Kubernetes</th><td>每节点一个特权 Pod，沙箱在 Pod 外</td><td>许多不特权的 worker Pod，沙箱在 Pod 里</td></tr>
</tbody>
</table>
</div>
<p class="dt-note">两者共享“从快照恢复、同步拉起”这个核心；分歧在于默认交出去的是一台机器还是一个常驻的服务，以及节点还归不归 Kubernetes 管。</p>
</div>
<figcaption>图 10 · E2B 和 Agent Substrate 的主要差别</figcaption>
</figure>

## 七、回到开头的问题

现在可以回答开头那个数字了。250 个 actor 跑在 8 个 Pod 上，是因为任何时刻真正在运行的 actor，最多只有 worker 槽位那么多；其余的 actor 都处在挂起状态，在系统里只是 PostgreSQL 里的一行记录，加上对象存储里的一份快照。请求来了，router 把它停住，控制面挑一个空槽，atelet 把快照恢复进去，请求再接着往里走；actor 闲下来，被挂起，槽位又空出来给别人。这和 `kubectl get pods` 看不到 actor 是同一件事：Kubernetes 只管那 8 个槽位。

这套设计的代价，文档自己也写得很清楚：挂起和恢复做到这个规模，就变成了一个数据管理问题，数以百万计的快照要存、要搬，还要知道每个 actor 最新的状态在哪台机器上；再造一个高可用的控制面也不是小工程；actor 在 worker 之间不停地搬来搬去，监控和排错都会更难[^sub-arch]。快照还和沙箱运行时的版本绑在一起，SandboxConfig 会把恢复所需的 `runsc` 版本钉进快照清单[^sub-arch]。README 里“恢复小于 400 毫秒、每秒 1500 次以上激活”的数字是项目自述，北极星目标是 p95 100 毫秒，眼下还没到。

## 小结

1. **Agent Substrate 是什么？** 一个装在 Kubernetes 上的智能体运行时。它把大量大部分时间空闲的 actor 复用到少量预先拉起的 worker Pod 上，空闲时打快照腾出 worker，请求来了再恢复。
2. **actor 不跑的时候在哪？** 在 PostgreSQL 里的一行记录和对象存储里的一份快照里，不占任何 Pod。每个沙箱都是 gVisor 或轻量虚拟机，不支持普通容器。
3. **镜像怎么变成沙箱？** 模板里是普通的 OCI 镜像，只有用户态、没有内核。gVisor 不是镜像，而是在用户态顶替内核的运行时，镜像原样跑在它上面。Substrate 不管构建：atelet 拉镜像、overlayfs 叠成根文件系统，创建模板时跑到就绪拍下 golden snapshot。E2B 要给镜像补上 envd 和 systemd、压进 ext4，在 Firecracker 里构建再拍快照。
4. **一个请求怎么叫醒它？** router 停住请求，`ResumeActor` 同步地拿租约、抢 worker 槽位、恢复快照，返回时 actor 已在运行，router 再经隧道转发。挂起则先落本地、后台上传。
5. **和 Kubernetes 怎么分工？** Kubernetes 异步地管 worker Pod 这些低频变化的槽位，Substrate 同步地管 actor 进出槽位这种高频操作。worker Pod 仍是不特权、有资源上限的普通 Pod。
6. **长时间运行的状态怎么保住？** Pod 只保住卷，内存和根文件系统要应用自己序列化。Substrate 挂起时按 `VOLUMES`、`ROOTFS`、`MEMORY` 三档把整个沙箱存成快照，必须留下的数据放 `durableDir`；状态只在挂起时落盘，两次挂起之间、异步上传完成之前都有丢失的窗口，挂起时机由上层决定。E2B 的暂停、快照、卷与之一一对应，但它是托管服务，沙箱最多连续跑 24 小时。
7. **和 E2B 什么关系？** 没有代码关系，核心办法相同：从快照恢复，绕开 Kubernetes 同步拉起。区别在于 E2B 默认交出的是一台可暂停的机器、orchestrator 接管整台节点；Substrate 的 actor 是常驻的服务、沙箱留在 Pod 里。

一句话概括：E2B 是交给智能体的一台可以暂停的机器，Agent Substrate 是让一群智能体轮流住进 Kubernetes 槽位里的调度层。

## 参考资料
