沙盒的「第二控制平面」:AgentENV 与 CubeSandbox 为什么要绕过 kubelet 自建调度器
一句话结论:Agent 沙盒系统绕过 kubelet,不只是为了隔离——调度本身才是更根本的动机。当沙盒生命周期被压缩到毫秒级、当 pause/resume/fork 成为常规操作而非异常事件、当内存超卖成为设计目标而非运维事故,Kubernetes scheduler 那套为「秒级生命周期 Pod、删除是异常事件」设计的调度路径,就从基础设施变成了瓶颈。
AgentENV(kvcache-ai)和 CubeSandbox(腾讯云)给出了两个不同风格的答案:前者做减法,把调度器简化到只剩资源过滤 + 两种朴素策略;后者做分层,把控制面完全做成无状态、协调状态押在 Redis 上。
这是沙盒系列第三篇。前两篇分别回答「选哪个沙盒」和「沙盒插在运行时栈的哪一层」;这篇回答「为什么这两套系统连调度都要自己写」。
本文的组件名、RPC 清单、状态枚举、调度流程逐条核对自两个项目的源码与官方文档。核对中发现原稿有七处需要更正,其中一条数字查无出处已删除,已在文中以 ⚠️ 标注。
一、问题:Agent 沙盒负载对调度器提出了什么要求
先把矛盾摆清楚。传统 K8s 集群里,scheduler 的一次完整调度是:watch informer 感知未绑定 Pod → 预选过滤 → 优选打分 → bind 写回 → kubelet 感知 → 容器创建。这条链路为「Pod 创建后长期运行、删除是异常事件」的世界设计,单次延迟在百毫秒到秒级。
Agent 沙盒的世界完全不同:
- 启动延迟预算是 <50ms(AgentENV resume)/ <60ms(CubeSandbox 冷启动)——调度决策如果本身要花几百毫秒,它就成了整个链路里最慢的一环;
- 每秒可能创建销毁数百个沙盒,pause / resume / fork / snapshot→clone 是常规生命周期操作,要求毫秒内完成,根本来不及走一遍完整调度流程;
- 资源模型无法用 requests/limits 表达——靠的是多个沙盒共享只读块设备和 page cache,这些指标 K8s scheduler 既看不到也不理解;
- 数据面网络不走 CNI——每沙盒独立的 SNAT/DNAT 和网络策略用 eBPF 在节点本地实现(CubeSandbox 的 CubeVS),毫秒级创建销毁的沙盒撑不起「一 Pod 一 IP + kube-proxy」的模型。
⚠️ 措辞要收紧一处:原稿写「K8s 适合作为部署底座,但沙盒调度必须自己做。这不是能力问题,是语义不匹配」。「必须」太强了——kubernetes-sigs/agent-sandbox 那条路线就是走 RuntimeClass 委托给 Kata/gVisor、不自己写调度器,它在自己的负载假设下是成立的。
准确的说法是:在这类负载特征下(毫秒级生命周期 + 高频创建销毁 + 超卖资源模型 + 绕过 CNI 的网络),自建调度是更自然的选择。原稿末尾其实自己补了这个反面,但开头的「必须」会把读者带偏。
二、AgentENV:把调度器做减法
AgentENV 的多节点控制平面在 services/ 下,控制面服务只有两个:Gateway 和 Scheduler(该目录另有 api/、shared/、config/、compose/ 四项,但它们不是控制面服务——项目文档明确说 Compose planner “is not a control-plane service”)。
flowchart TD
C[Client / RL Rollout] -->|"HTTP"| GW[Gateway<br/>services/gateway/]
GW -->|"Schedule() 新建沙盒"| SCH[Scheduler<br/>gRPC 服务]
SCH -->|"filter + round_robin<br/>选定节点"| GW
GW --> N1[Node 1<br/>特权 DaemonSet Pod]
GW -->|"LookupNode() 已有沙盒"| SCH
GW -->|"RecordAssignment()<br/>写回绑定"| SCH
N1 --> ORC[节点 Orchestrator<br/>src/orchestrator/]
ORC --> FC[Firecracker microVM]
N2[Node 2] -.->|"Heartbeat()<br/>上报沙盒 roster"| SCH
N3[Node 3] -.->|"Heartbeat()"| SCHGateway 是流量入口,也是调度决策的消费者。 对新建沙盒调 Schedule() 选节点,对已有沙盒调 LookupNode() 查绑定,创建成功后再调 RecordAssignment() 把 sandbox→node 映射写回。路由依据是请求头 x-agentenv-sandbox-id / e2b-sandbox-id,或配置的 {port}-{sandboxID}.{proxy_domain} 域名规则(仅对显式配置的域生效,要求 sandbox ID 符合 DNS label 规范、整段 ≤63 字符)。
2.1 调度路径:过滤 + 两种策略
源码里 Schedule() 的完整流程只有四步:
1 | nodes.Snapshot() → filterFreshObservations() → FilterByResourceLimit() → strategy.Select() |
⚠️ 更正:原稿写「没有预选优选」。预选是有的——
services/scheduler/internal/filter.go里的FilterByResourceLimit按NodeResourceLimit剔除超阈节点(max_sandbox_count、max_cpu_used_percent、max_cpu_allocated_percent、max_memory_*及*IncludingPaused系列);没有心跳快照的节点一律保留。准确说法是「有预选(资源阈值过滤),没有优选(无打分/评分),也没有优先级抢占和亲和性」。
策略本身确实只有两种(strategy.go):
1 | type RoundRobinStrategy struct{ next uint64 } |
NewStrategy(name) 里 case "random" 走 Random,case "round_robin" 走 fallthrough 到 default——即 round_robin 是默认值,而且这两种就是 Schedule() 的全部策略,不存在「粘性命中则跳过策略」的路径。
2.2 RPC 面:13 个,不是 8 个
⚠️ 更正:原稿写「Scheduler 暴露八个 RPC」,实际 proto 里是 13 个。清单如下(
services/api/proto/scheduler.proto,main 与 v0.2.4 一致):
| # | RPC | 原稿是否列出 |
|---|---|---|
| 1 | Schedule |
✓ |
| 2 | ListNodes |
✗ |
| 3 | LookupNode |
✓ |
| 4 | RecordAssignment |
✓ |
| 5 | Heartbeat |
✓ |
| 6 | ReportSandboxEvent |
✗ |
| 7 | ListObservedNodes |
✓ |
| 8 | ListP2pPeers |
✓ |
| 9 | RecordP2pArtifact |
✗ |
| 10 | ForgetP2pArtifact |
✗ |
| 11 | LookupP2pArtifact |
✗ |
| 12 | GetNode |
✓ |
| 13 | UnregisterNode |
✓ |
漏掉的五个里,ReportSandboxEvent 在 service.go 里目前是空实现(只打 debug 日志),三个 P2P artifact RPC 走内存索引。
一个值得记的细节:AgentENV 自己的文档也不全——architecture.md 只列 8 个、services.md 列 9 个,都漏了 ReportSandboxEvent 和三个 P2P RPC。proto 才是权威。这也解释了原稿的 8 个是从哪来的。
2.3 状态与多实例
⚠️ 更正:原稿写「状态全在内存里,重启即丢」,并称「Scheduler 是潜在单点」。两处都要改。
官方 Limitations 原文确实写了内存绑定的问题:
Limitations: All bindings are in-memory (lost on scheduler restart). After a scheduler restart, bindings are rebuilt from new sandbox creations plus the next heartbeat roster from each runtime node.
但这不等于没有别的选择:源码里有 redis_store.go,scheduler.redis_addr 可以切到 Redis-backed binding store。而且 Scheduler 支持多实例——leader_election 开启后 N 个副本竞争 coordination.k8s.io/Lease,恰好一个 leader 调度,standby 返回 Unavailable;另有 --query-only 只读副本专服务 LookupNode。
配置注释说得很清楚:
A Redis binding store is recommended but not required: without it, failover loses routing for pre-failover sandboxes (degraded mode).
所以准确的说法是:默认(不配 Redis)是纯内存、重启丢绑定;配了 Redis 就能支撑 HA 切换。原稿的「潜在单点」只描述了默认配置。
2.4 节点侧
节点发现支持 static(配置固定列表)和 kubernetes(watch headless Service agentenv-nodes 的 EndpointSlice,用 ready DaemonSet Pod IP 作为后端)两种模式。
节点内部另有一个节点级 Orchestrator(src/orchestrator/),但它做的是单节点生命周期状态机,不参与跨节点调度决策。
⚠️ 更正一处枚举:原稿列的状态是
Creating/Running/Pausing/Paused/Resuming/Killing六个,源码types.rs实际有八个:
1
2
3 pub enum SandboxState {
Creating, Resuming, Running, Snapshotting, Forking, Pausing, Paused, Killing,
}漏了
Snapshotting和Forking。这条很可能也是照抄文档的——AgentENV 架构文档里的表格同样只写了那六个,文档与代码不一致。
运行时节点是特权 DaemonSet Pod:需要 host 的 /dev/kvm、用 host iptables 与 network namespace 做沙盒网络、运行时资产与快照状态缓存在 /var/lib/aenv(hostPath)。
一处推断要标注:原稿写「K8s 只负责把 runtime 节点调度到宿主机上,沙盒本身的调度完全不走 kubelet」。方向正确、可从文档合理推出(runtime 用 DaemonSet,落点由 AgentENV 自己的 Scheduler 决定并写 binding、由 Gateway 转发),但全仓库 grep
kubelet只有一处无关命中,没有明文。本文按「推断」而非「原文」引用。
AgentENV 的立场可以概括为:跨节点调度做到极简(过滤 + round-robin 够用),把复杂度下沉到节点内的生命周期管理和 fork/snapshot 原语上。
三、CubeSandbox:控制面无状态,协调状态押在 Redis
CubeSandbox 走的是另一条路:控制平面与数据平面彻底分离,用 Redis 做协调状态的唯一真实源。
flowchart TD
C[Client] --> API[CubeAPI]
API --> M[CubeMaster<br/>Go 编写]
M -->|"约束解析 → 过滤 →<br/>加权打分 → top-N 随机选"| N1[Node 1<br/>Cubelet]
M -->|"生命周期事件"| R[(Redis<br/>元数据/事件流/<br/>路由表/分布式锁)]
API -.-> R
N1 --> SH[CubeShim<br/>Rust]
SH --> HY[CubeHypervisor<br/>RustVMM + KVM]
N1 -->|"集成 containerd<br/>拉取镜像"| CRIO[containerd]
N2[Node 2<br/>Cubelet / CubeEgress] -.-> R
PRX[CubeProxy<br/>OpenResty] -.->|"路由表"| R
VS[CubeVS<br/>eBPF SNAT/DNAT] -.-> N1官方架构文档把组件明确分成两张表:
| 组件 | 职责 | |
|---|---|---|
| 控制平面 | CubeAPI、CubeMaster、WebUI、Redis | API 网关、调度、状态协调、控制台 |
| 数据平面 | Cubelet、CubeShim、CubeHypervisor、CubeCoW、CubeVS、CubeEgress、CubeProxy | VM 生命周期、存储、网络、安全执行、请求路由 |
各数据面组件的语言与定位(官方原文):Cubelet 是 Go 写的节点本地调度代理,管理单节点所有沙盒的完整生命周期(create → run → pause → resume → snapshot → destroy),集成 containerd 做镜像拉取、集成 CubeCoW 做卷管理;CubeShim 用 Rust 实现 containerd Shim v2 接口;CubeHypervisor 是基于 RustVMM + KVM 的轻量 VMM;CubeCoW 是 Rust 库,通过 XFS 上的 FICLONE ioctl 实现 O(1) 快照与克隆(元数据级,共享 extent 不拷贝字节);CubeVS 是基于 eBPF 的内核态网络数据面;CubeEgress 是每宿主的 L7 透明出口代理(OpenResty + Lua);CubeProxy 是 OpenResty 反向代理。
3.1 调度不是「只有资源过滤」
⚠️ 更正:原稿把 CubeMaster 的调度概括为「NodeSelector + 资源可用性过滤」,并说「比 K8s scheduler 的预选/优选/绑定流程简单得多」。这漏掉了一整个打分阶段。
官方调度器配置文档描述的实际流程是四步:
- 解析请求约束——
instance_type、模板 ID、资源需求、显式 host IP、node affinity / annotations;- 过滤节点——剔除不健康节点、指标过期节点、超过 MVM 上限的节点、没有本地模板副本的节点、实时/本地观测创建数过多的节点、不满足亲和性的节点、磁盘使用率过高的节点;
- 加权打分——对剩余候选打分,权重含
mvm_num、local_create_num、quota_cpu_usage、quota_mem_usage;- 选最终节点——从最高分候选集里选,
priority_select_num控制有多少个 top 节点进入最终随机选择。文档还专门强调了打分的作用:「Without scoring, CubeMaster still filters nodes but may choose from the filtered order, which can concentrate new sandboxes on the first eligible node」。
所以准确的说法是「过滤 + 加权打分 + top-N 随机」,而且已经有 affinity 支持和模板本地性过滤(
template_locality)。「NodeSelector」这个词官方根本没用来描述 CubeMaster 调度(nodeSelector只出现在 K8s 部署的 placement 里)。
3.2 Redis 承担什么
Redis 承担的四类协调状态逐条命中(官方 Redis key 规范文档):
| 论断 | 实际 key | 结论 |
|---|---|---|
| 沙盒元数据 | cube:v1:master:instance:info:{insID}、cube:v1:shared:sandbox:lifecycle:meta |
✅ |
| 生命周期事件流 | cube:v1:shared:sandbox:lifecycle:events(Stream,MAXLEN ~100000) |
✅ |
| CubeProxy 路由表 | cube:v1:shared:sandbox:proxy:{sandboxID}(Writer=CubeMaster,Reader=CubeProxy) |
✅ |
| 分布式锁 | cube:v1:master:lock:sandbox:{sandboxID}(SET NX EX)、...:lifecycle-manager:leader |
✅ |
⚠️ 一处要加限定:原稿写「Redis 承担了所有协调状态」。架构文档的原话是 “all coordination goes through Redis”——协调状态,不是所有状态。Redis 里还有清单外的 key(节点资源指标、Proxy 副本注册与心跳、Pub/Sub 等),更重要的是——持久业务元数据在 MySQL 里:K8s 架构文档明确 CubeAPI / CubeMaster / CubeOps 都 reads/writes MySQL,存 template 定义、sandbox spec、instance 表等。
所以准确说法是「控制面的协调状态走 Redis,持久业务元数据走 MySQL」。
3.3 无状态的边界
「CubeAPI 和 CubeMaster 无状态、可任意水平扩副本」属实——官方设计原则表原文:「Stateless Control Plane: CubeAPI and CubeMaster hold no local state; all coordination goes through Redis, so horizontal scale-out is trivial.」
但要加一条边界:
cube-lifecycle-manager不是任意可扩的——它用 Redis leader lease 做 active-standby(replicas=2+leaderElection.enabled=true,“Only the leader writes shared sandbox state”)。
3.4 roadmap 与版本演进
roadmap 五项全部命中——官方 roadmap.md 的 “Scheduling and Operations Enhancements” 一条原文:
Richer scheduling capabilities including resource-aware placement, affinity/anti-affinity rules, and priority classes. Also covers operational tooling: live resource rebalancing and node drain with sandbox migration.
⚠️ 但这里有个文档层面的自相矛盾要指出:roadmap 把「resource-aware placement」「affinity/anti-affinity」列为 Coming Soon,而调度器配置文档显示当前已经按资源占用加权打分、且已支持 affinity 过滤。
可以解释为 roadmap 措辞是 “Richer scheduling capabilities”(更强的能力,而非从零开始),但读者交叉看两份文档会困惑。写博客时以调度器配置文档为准描述现状,并注明 roadmap 用的是 “richer”。
旁证 drain 确实还没实现:节点运维文档明确写「Isolation is not drain. Isolation does not evict existing sandboxes.」
版本演进核实无误:v0.6(2026-07-24)加入 K8s 部署支持(把控制面组件和计算节点部署到 TKE / 标准 K8s / k3s);v0.7(2026-08-28)把节点管理从 CubeMaster 拆出成 CubeOps,支持多副本部署并附带 cubeopscli 工具。
CubeVS 的 eBPF 实现细节值得一提:SNAT 在 from_cube(TAP 上的 TC ingress)做,DNAT 在 from_world/from_envoy 做,SNAT IP 按沙盒确定性选择——index = jhash(sandbox_ip) % 4,「All connections from the same sandbox therefore use the same SNAT IP」。三个 BPF 程序,无 iptables / bridge / OVS。
3.5 冷启动数字要带并发条件
⚠️ 更正:原稿写「冷启动 <60ms」没带条件。README 确实写「Average <60ms cold start」,官方 CubeBench v0.7.3 基准(Intel Xeon Platinum 8255C 裸金属,2vCPU/2000MiB)也给了数据——但只在低并发下成立:
| 并发 | 冷启动均值 |
|---|---|
| 1 | 41.0 ms(p95 52.3 ms) |
| 10 | 47.9 ms |
| 20 | 56.0 ms |
| 50 | 92.4 ms(p95 121.9 ms) |
另外架构文档写的是 “sub-100ms cold starts”,与 README 的 <60ms 口径不一致。引用时建议以 README + CubeBench 的 <60ms 为准,并注明并发条件;还要注意别和「从快照恢复」(4.3 节,并发 1 时 53.1 ms)混为一谈——两者语义不同。
四、两条路线的对照
| 维度 | AgentENV | CubeSandbox |
|---|---|---|
| 调度组件 | Scheduler(独立 gRPC 服务,13 个 RPC) | CubeMaster(Go 编排器) |
| 调度流程 | 资源阈值过滤 → round_robin(默认)/ random | 约束解析 → 过滤 → 加权打分 → top-N 随机 |
| 状态存储 | 默认内存(重启丢绑定);可选 Redis 支撑 HA | 协调状态走 Redis;持久元数据走 MySQL |
| 多实例 | 支持(Lease 选主 + standby + query-only 副本) | CubeAPI / CubeMaster 任意扩;CLM 是 leader-elected |
| 节点发现 | static / agentenv-nodes EndpointSlice |
CubeOps 节点管理(v0.7+) |
| 节点代理 | 节点级 Orchestrator(Rust,8 个状态) | Cubelet(Go) |
| 与 K8s 关系 | K8s 部署 DaemonSet,不参与沙盒调度(推断) | K8s 部署控制面,不参与沙盒调度(推断) |
| 抢占/优先级/亲和性 | 无 | 部分有(affinity 已有),roadmap 继续增强 |
一个有意思的观察:两者的调度器都比 K8s scheduler 简单,但这是特性而非缺陷。AgentENV 用「调度器简单到可以随时重启」换取实现简单和低延迟;CubeSandbox 用「控制面无状态 + Redis 协调」换取可扩展性和状态安全。
复杂度没有被消灭,只是被转移到了各自更擅长处理的位置——AgentENV 转移到节点内的 Orchestrator 状态机(fork/snapshot 原语),CubeSandbox 转移到数据平面组件(CubeVS 的 eBPF 网络、CubeCoW 的写时复制存储)。
五、这个选择是行业共识,不是个案
回头看 DSec——DeepSeek 的论文(arXiv:2609.22978,标题 DeepSeek Elastic Compute (DSec): A Sandbox Infrastructure for Effective Agentic Training at Scale,2026-09-19 提交,131 位作者,主体单位 DeepSeek-AI 带清华联合署名)。
规模数字(摘要原文):
A single production-scale unit of DSec spans around 160 nodes, serving about 3 million sandboxes per day; in production, it supports over 380,000 concurrent sandboxes and sustains over 5,000 sandbox creations per second.
130+ 作者的团队最终也是自建调度层。它的场景更进一步:异构负载四级后端(FnCall / Container / MicroVM / FullVM)需要调度器理解「这个请求该落到哪一级运行时」——这种多目标决策更是通用调度器无法覆盖的。论文自己也强调这四级的 “startup costs, isolation boundaries, filesystem semantics, and operating-system capabilities” 都不同。
归纳起来,「绕过 kubelet 自建调度」的适用边界大致是:
- 调度对象生命周期 < 秒级,且创建/销毁频率高到调度延迟成为主要瓶颈;
- 常规操作包含 pause / resume / fork / snapshot 这类 K8s Pod 模型里不存在的生命周期转换;
- 资源模型依赖超卖(共享只读块、page cache 复用),requests/limits 无法表达;
- 数据面网络需要每沙盒独立策略且必须绕过 CNI 的创建开销。
反过来,如果你的沙盒负载是「分钟级生命周期的容器、正常 K8s 资源模型、可以容忍秒级启动」,那 kubernetes-sigs/agent-sandbox 这类走 RuntimeClass 委托给 Kata/gVisor 的方案仍然是更省力的选择——不必为了架构上的纯粹性,引入一套自建控制平面的长期维护成本。
六、结语
AgentENV 和 CubeSandbox 绕过 kubelet 的方式,表面上一个是「极简调度器」、一个是「Redis 中心化编排」,但底层判断是一致的:Agent 沙盒的调度问题,在延迟预算、生命周期语义、资源模型、网络路径四个维度上都偏离了 K8s 的设计假设。与其在 scheduler framework 里做扩展硬拗,不如自己写一个匹配负载语义的调度器,把 K8s 降级为部署底座。
对正在做 Agent 基础设施选型的团队,这两套系统的价值不在于可以直接抄(它们的调度器都还很简单,生产特性在 roadmap 上),而在于验证了一个架构判断:
当你的沙盒需要 <60ms 启动、接近 10× 的内存超卖、毫秒级 fork 时,「调度器」就该从「K8s 的一个组件」重新定位为「系统的第二控制平面」——它值得被独立设计、独立演进、独立评估,而不是当作既定设施去适配。
相关阅读
- 从 kubelet 到沙盒:高级运行时与低级运行时的映射全景——沙盒插在运行时栈的哪一层
- 具身智能仿真场景的沙盒选型——仿真主力为什么是裸金属容器而非 microVM
- Agent 沙盒基础设施实践:从 DSec 与 AgentENV 看调度层的选型与落地——DSec 与 AgentENV 的完整拆解(含我们自建沙盒框架的会话粘性设计)
- containerd Snapshotter 接口深度解析——镜像按需加载的抽象边界
- Kubernetes 多调度器共存实践指南——多调度器共存的冲突与兜底
核实说明:本文代码级结论基于 AgentENV
main@6a18edc7(2026-11-04)与 v0.2.4 @9f62cbe0(2026-10-23)——两者的strategy.go与scheduler.proto逐字节相同,故版本差异不能解释任何描述分歧;CubeSandboxmaster分支的架构总览、调度器配置、roadmap、Redis key 规范、K8s 架构、changelog 与 CubeBench 基准文档;DSec 论文 arXiv:2609.22978 摘要与 HTML 全文。原稿更正汇总:AgentENV 的 RPC 数(13 个而非 8 个,且项目自身文档也不全)、「没有预选」(有
FilterByResourceLimit)、「状态全在内存 / 潜在单点」(有 Redis 选项与 Lease 选主多实例)、Orchestrator 状态枚举(8 个而非 6 个,漏Snapshotting/Forking)、CubeMaster 调度流程(漏掉加权打分与 affinity)、「Redis 承担所有状态」(持久元数据在 MySQL)、冷启动 <60ms(需带并发条件)。另有一条数字「CPU 超卖 27.9×」全仓库查无出处,已删除——9.6× 内存超卖确实存在于 README,但项目未给测量方法,且其自身 docs 复述时把数字去掉了。最后,「沙盒调度完全不走 kubelet」是可从文档推出的推断,非明文,已在文中标注。