一个沙箱的生命周期:从请求进入到最终销毁
这篇文章不再停留在“CubeAPI → CubeMaster → Cubelet”这类框图层面,而是顺着源码里的关键函数,拆开一个沙箱从创建、调度、启动、运行、暂停/恢复,到销毁的完整链路。
如果你想先建立系统全貌,建议先看架构概览;如果你更关心 agent/SDK 的请求最后怎样打到沙箱内的执行器,再看Agent 的 Tool Call 如何在沙箱里执行。
1. 先看结论:生命周期分成哪几段
从源码看,一个沙箱的生命周期可以分成 6 个阶段:
- 控制面接收创建请求:
CubeAPI把 E2B 兼容请求整理成内部CreateSandboxRequest。 - CubeMaster 做模板补全与调度:把模板参数、资源需求、节点亲和性、调度上下文整理好,再选中目标节点。
- Cubelet 执行本地创建工作流:准备网络、存储、cgroup、OCI 规范与 MicroVM/containerd runtime。
- 注册数据面路由:把
sandboxID -> hostIP / 端口映射写入路由元数据,供CubeProxy和 SDK 后续访问使用。 - 运行态管理:包括查询状态、暂停、恢复、连接已有沙箱,以及对外暴露
49999/49983等端口。 - 销毁与清理:控制面定位目标节点,调用 Cubelet 清理 runtime、容器、rootfs、路由元数据与本地状态。
真正重要的是:创建路径和运行路径是分开的。
- 创建路径主要是控制面编排和节点工作流。
- 运行路径主要是
CubeProxy + envd/Jupyter + SDK的数据面访问。
这也是为什么“沙箱创建成功”并不等于“你的 tool call 一定能执行成功”:前者解决的是资源与实例问题,后者解决的是端口、代理、协议与执行器的问题。
2. 创建阶段:请求首先在 CubeAPI 被标准化
以 Go SDK / Python SDK / E2B 兼容客户端发起的创建请求为例,控制面第一站是 CubeAPI/src/services/sandboxes.rs 里的 create_sandbox()。
它做了几件关键事:
- 读取用户传入的
template_id、timeout、metadata。 - 自动补上和模板快照相关的 annotation,例如:
cube.master.appsnapshot.template.idcube.master.appsnapshot.template.version
- 把宿主目录挂载之类的扩展信息从 metadata 中拆出来,放进 annotations。
- 生成内部
CreateSandboxRequest,并指定:instance_typenetwork_type = tapcubevs_contexttimeoutannotationslabels
也就是说,CubeAPI 不是直接“创建虚机”,而是把一个面向 SDK/E2B 的请求翻译成 CubeMaster 能理解的内部编排请求。
可以把这一层理解成:
- 对外:E2B 兼容层
- 对内:统一的控制面请求入口
3. 进入 CubeMaster:模板补全与请求兜底
CubeAPI 随后调用 CubeMaster 的 HTTP 服务入口;在源码里,对应 CubeMaster/pkg/service/httpservice/cube/sandbox_create.go 的 createSandbox()。
这一层的职责不是“调度算法本身”,而是把请求修整到可以调度:
3.1 constructCreateReq():请求兜底
它会补齐一系列默认值:
- 保证
labels、annotations不为空。 - 规范化 appsnapshot 相关 annotation。
- 默认
instance_type = cubebox。 - 默认
network_type = tap。 - 把模板 ID 同步进 label,便于后续检索和筛选。
- 没显式指定 namespace 时,使用
default。
3.2 dealCubeboxCreateReqWithTemplate():模板请求合并
虽然这个函数体不在本文逐行展开,但从调用位置和整体行为可以看出,它会把模板中保存的运行定义与当前 API 请求合并。
这一步非常关键,因为最终送到节点上的请求,不只是“模板 ID + timeout”,而是一个已经展开后的运行定义,其中包含:
- 容器镜像 / rootfs 来源
- 资源规格
- 暴露端口
- 网络相关配置
- 启动所需 annotations
因此,模板并不是运行时才临时解析,而是在进入调度前就被并入请求语义中。
4. 调度阶段:先做亲和性,再做选点
当 createSandbox() 调用 sandbox.CreateSandbox() 时,真正进入 CubeMaster 的创建主流程,对应 CubeMaster/pkg/service/sandbox/sandbox_run.go。
这个流程不是一次性同步函数,而是围绕 createSandboxContext 展开的状态机式处理。
4.1 newContext():把调度上下文准备好
newContext() 主要做四件事:
- 创建
SelectorCtx。 - 调用
ConstructCubeletReq(),把 Master 请求翻译成发给 Cubelet 的RunCubeSandboxRequest。 - 解析资源需求,写入
selctx.ReqRes。 - 基于
timeout创建带超时的上下文。
这说明:调度不是直接围绕原始 API 请求进行,而是围绕“即将发往 Cubelet 的具体运行请求”进行。
4.2 runInsReq2Affinity():先注入节点亲和性
在 CubeMaster/pkg/service/httpservice/cube/affinityutil.go 中,runInsReq2Affinity() 会根据请求生成节点亲和性条件。
它的来源有三种:
- 调度器配置中的默认 cluster label 亲和性
- 大规格实例的专用节点偏好
- 用户通过 annotation 显式指定的 cluster / instance type 约束
这一步的意义是:很多“调度失败”其实不是资源不够,而是亲和性先把候选节点砍掉了。
4.3 prefilter.Select():第一轮粗筛
在 CubeMaster/pkg/selector/prefilter/prefilter.go 里,预过滤会先拿到健康节点列表,然后逐个剔除:
- 不健康节点
- 被 circuit filter 拉黑的节点
- 不满足 node selector / affinity 的节点
- 当前 MVM 数量已达上限的节点
- 指标过期、元数据过期的节点
这一步不是“打分”,而是“先保证这些节点至少是可用的”。
4.4 调度结果不是固定的,且允许重试与重调度
sandbox_run.go 里最值得注意的不是 schedule() 本身,而是:
callCubelet()失败后会进入errRetry()或errorCodeRetry()。- 某些错误会触发
reschedule = true,也就是换节点重试。 - 某些错误只允许 loop retry,不会重新选节点。
- 某些错误属于 excludes retry,直接结束。
也就是说,CubeMaster 的创建语义不是“选中一个节点并调用一次”,而是“在超时窗口内尽量把请求落到可成功的节点上”。
5. 节点创建阶段:Cubelet 才是真正执行工作流的地方
一旦选中节点,CubeMaster 会调用该节点的 Cubelet gRPC 服务,即 cubelet.Create()。
在节点侧,入口是 Cubelet/services/cubebox/service.go 的 Create();再往下则进入 workflow.Engine 与本地 cubebox 管理器。
5.1 为什么 Cubelet 要有 workflow engine
从 Cubelet/plugins/workflow/engine.go 的 CreateContext 可以看出,节点侧创建不是单步骤动作,而是组合多个资源域:
NetworkInfoStorageInfoCgroupInfoVolumeInfoLocalRunTemplateUserData
这说明 Cubelet 的创建是一个多资源域协同工作流,而不是简单调用一次 containerd。
5.2 创建期间真正落地的事情
虽然创建细节散落在多个插件中,但从 CreateContext 的结构和 cbriManager.CreateSandbox() 的调用方式可以确定,至少会发生这些动作:
- 分配和准备沙箱 ID、运行上下文
- 准备网络设备 / TAP / IP / 相关 netfile
- 准备存储与模板相关的 rootfs / writable layer
- 生成或补全 OCI spec
- 调用 CBRI/runtime 层创建真正的沙箱实例
- 回填端口映射与状态信息
在 CubeSandbox 里,Cubelet 更像节点本地 orchestrator,不是一个薄 RPC 代理。
6. 创建成功后为什么还要写 Redis:因为数据面要靠它路由
CubeMaster/pkg/service/sandbox/sandbox_run.go 的 dealSuccResult() 在拿到成功响应后,不会立刻结束,而是还要做一件非常关键的事:setProxyToRedis()。
这里会把下面这类信息写入 SandboxProxyMap:
HostIPSandboxIDSandboxIPCreatedAtContainerToHostPorts
这一步的意义是:
- 控制面知道沙箱建好了。
- 但数据面还需要知道这个 sandboxID 现在在哪台节点上、哪些容器端口映射到了哪些宿主端口。
CubeProxy、查询接口、后续连接逻辑都要依赖这份映射。
所以创建完成并不是“Cubelet 返回成功”这一刻,而是:
Cubelet 成功 + Master 成功登记路由元数据
这两步都完成后,一个沙箱才真正对后续访问可见。
7. 运行阶段:沙箱活着,不等于业务端口一定可用
进入运行态后,控制面主要负责的是:
- 查询信息
- 更新状态
- 连接已有沙箱
- 删除沙箱
但真正的业务访问已经切换到数据面。
7.1 状态查询
CubeAPI 会通过 fetch_sandbox_detail() 再拼出对 SDK 友好的响应,包括:
sandboxIDhost/client IDstartedAtenvdVersiondomainstate
7.2 Pause / Resume
在 API 层,暂停恢复只是 update_sandbox(action = pause/resume)。
节点侧真正执行逻辑在 Cubelet/services/cubebox/update.go:
UpdateWithPause()- 判断当前是否已 paused / terminating
- 标记
PausingAt - 执行
preStop - 调用底层 task 的
Pause() - 成功后写入
PausedAt
UpdateWithResume()- 要求当前必须已 paused
- 调用底层 task 的
Resume() - 成功后清空
PausedAt / PausingAt
这里可以看到,暂停/恢复不是控制面模拟状态,而是真正作用在节点 runtime task 上的。
7.3 Connect 语义
CubeAPI 的 connect_sandbox() 在发现目标沙箱是 paused 时,会先触发 resume,再重新获取详情。
所以从 SDK 视角,connect 更像是:
- “确保这个沙箱现在可用”
- 然后返回最新的数据面连接信息
而不是单纯“查个对象”。
8. 销毁阶段:控制面负责定位,节点负责清理
销毁入口在 CubeAPI 的 kill_sandbox(),它发给 CubeMaster 的是 DeleteSandboxRequest。
8.1 CubeMaster:先定位到节点
在 CubeMaster/pkg/service/sandbox/sandbox_remove.go 中,DestroySandbox() 会先根据 sandboxID 找到目标节点:
- 优先查
SandboxCache - 查不到再查
SandboxProxyMap
拿到 hostIP 后,才知道该调哪个 Cubelet。
这说明 sandboxID 本身并不天然带有“在哪台节点”信息;节点定位依赖控制面缓存和路由元数据。
8.2 Cubelet:真正做 runtime 与文件清理
节点侧真正的删除实现,在 Cubelet/services/cubebox/destroy.go 的 local.Destroy()。
它会依次做这些事:
- 从本地 store 中取出该沙箱对象并加锁。
- 检查删除条件与 filter。
- 把状态标记为
Removing。 - 调用
cbriManager.DestroySandbox()清理底层 runtime 资源。 - 如果是 paused 状态,走专门的 task delete 分支。
- 否则遍历所有容器执行销毁:
preStop/postStop- 删除 containerd task / container
- 清理本地 store
- 执行
runc.Clean()收尾清理。 - 最后把整个 cubebox 记录从本地状态里删除。
8.3 控制面最后还要删路由元数据
CubeMaster 在调用 Cubelet Destroy 成功后,还会执行:
DeleteSandboxProxyMap()DeleteSandboxCache()
所以一个沙箱“彻底消失”包含两类清理:
- 节点本地资源清理
- 控制面路由与缓存清理
缺一不可。
9. 失败与回滚:为什么源码里有 failover
sandbox_run.go 中的 failover() 很值得注意。
如果 Cubelet 已经创建出 sandboxID,但 Master 后续阶段失败了(例如写代理路由元数据失败),CubeMaster 不会让这个半成品遗留在节点上,而是会补一个异步 DestroySandbox 任务。
这意味着系统在设计上接受这样的事实:
- “节点上创建成功”
- 与
- “整个系统对外可见且可访问”
不是同一个时刻。
中间任何一步失败,都要靠补偿逻辑回滚。
这是一个很典型的控制面分布式补偿设计。
10. 用一句话概括这条生命周期
如果只记一句话,可以记这个:
CubeAPI 负责把外部请求翻译成内部语义,CubeMaster 负责补全模板、调度节点和登记路由,Cubelet 负责真正把沙箱在节点上做出来并在销毁时清理干净。
这也是为什么研究 CubeSandbox 时,不能只盯着某一个组件:
- 只看
CubeAPI,你会以为它只是 API 网关。 - 只看
Cubelet,你会以为它只是本地 runtime 管理。 - 只有把
CreateSandbox -> scheduler -> Cubelet workflow -> proxy map串起来,生命周期才完整。