一句话结论: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”)。

Gateway 是流量入口,也是调度决策的消费者。 对新建沙盒调 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
2
3
4
5
6
7
8
9
10
type RoundRobinStrategy struct{ next uint64 }
func (s *RoundRobinStrategy) Select(nodes []RichNode, _ *schedulerv1.ScheduleRequestHint) (RichNode, error) {
idx := atomic.AddUint64(&s.next, 1)
return nodes[(idx-1)%uint64(len(nodes))], nil
}

type RandomStrategy struct{}
func (s *RandomStrategy) Select(nodes []RichNode, _ *schedulerv1.ScheduleRequestHint) (RichNode, error) {
return nodes[rand.Intn(len(nodes))], nil
}

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 做协调状态的唯一真实源。

官方架构文档把组件明确分成两张表:

组件 职责
控制平面 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 的预选/优选/绑定流程简单得多」。这漏掉了一整个打分阶段。

官方调度器配置文档描述的实际流程是四步:

  1. 解析请求约束——instance_type、模板 ID、资源需求、显式 host IP、node affinity / annotations;
  2. 过滤节点——剔除不健康节点、指标过期节点、超过 MVM 上限的节点、没有本地模板副本的节点、实时/本地观测创建数过多的节点、不满足亲和性的节点、磁盘使用率过高的节点;
  3. 加权打分——对剩余候选打分,权重含 mvm_num、local_create_num、quota_cpu_usage、quota_mem_usage;
  4. 选最终节点——从最高分候选集里选,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 自建调度」的适用边界大致是:

  1. 调度对象生命周期 < 秒级,且创建/销毁频率高到调度延迟成为主要瓶颈;
  2. 常规操作包含 pause / resume / fork / snapshot 这类 K8s Pod 模型里不存在的生命周期转换;
  3. 资源模型依赖超卖(共享只读块、page cache 复用),requests/limits 无法表达;
  4. 数据面网络需要每沙盒独立策略且必须绕过 CNI 的创建开销。

反过来,如果你的沙盒负载是「分钟级生命周期的容器、正常 K8s 资源模型、可以容忍秒级启动」,那 kubernetes-sigs/agent-sandbox 这类走 RuntimeClass 委托给 Kata/gVisor 的方案仍然是更省力的选择——不必为了架构上的纯粹性,引入一套自建控制平面的长期维护成本。


六、结语

AgentENV 和 CubeSandbox 绕过 kubelet 的方式,表面上一个是「极简调度器」、一个是「Redis 中心化编排」,但底层判断是一致的:Agent 沙盒的调度问题,在延迟预算、生命周期语义、资源模型、网络路径四个维度上都偏离了 K8s 的设计假设。与其在 scheduler framework 里做扩展硬拗,不如自己写一个匹配负载语义的调度器,把 K8s 降级为部署底座。

对正在做 Agent 基础设施选型的团队,这两套系统的价值不在于可以直接抄(它们的调度器都还很简单,生产特性在 roadmap 上),而在于验证了一个架构判断:

当你的沙盒需要 <60ms 启动、接近 10× 的内存超卖、毫秒级 fork 时,「调度器」就该从「K8s 的一个组件」重新定位为「系统的第二控制平面」——它值得被独立设计、独立演进、独立评估,而不是当作既定设施去适配。


相关阅读

核实说明:本文代码级结论基于 AgentENV main @ 6a18edc7(2026-11-04)与 v0.2.4 @ 9f62cbe0(2026-10-23)——两者的 strategy.go 与 scheduler.proto 逐字节相同,故版本差异不能解释任何描述分歧;CubeSandbox master 分支的架构总览、调度器配置、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」是可从文档推出的推断,非明文,已在文中标注。