Skip to content

一个沙箱的生命周期:从请求进入到最终销毁

这篇文章不再停留在“CubeAPI → CubeMaster → Cubelet”这类框图层面,而是顺着源码里的关键函数,拆开一个沙箱从创建、调度、启动、运行、暂停/恢复,到销毁的完整链路。

如果你想先建立系统全貌,建议先看架构概览;如果你更关心 agent/SDK 的请求最后怎样打到沙箱内的执行器,再看Agent 的 Tool Call 如何在沙箱里执行


1. 先看结论:生命周期分成哪几段

从源码看,一个沙箱的生命周期可以分成 6 个阶段:

  1. 控制面接收创建请求CubeAPI 把 E2B 兼容请求整理成内部 CreateSandboxRequest
  2. CubeMaster 做模板补全与调度:把模板参数、资源需求、节点亲和性、调度上下文整理好,再选中目标节点。
  3. Cubelet 执行本地创建工作流:准备网络、存储、cgroup、OCI 规范与 MicroVM/containerd runtime。
  4. 注册数据面路由:把 sandboxID -> hostIP / 端口映射 写入路由元数据,供 CubeProxy 和 SDK 后续访问使用。
  5. 运行态管理:包括查询状态、暂停、恢复、连接已有沙箱,以及对外暴露 49999 / 49983 等端口。
  6. 销毁与清理:控制面定位目标节点,调用 Cubelet 清理 runtime、容器、rootfs、路由元数据与本地状态。

真正重要的是:创建路径和运行路径是分开的

  • 创建路径主要是控制面编排和节点工作流。
  • 运行路径主要是 CubeProxy + envd/Jupyter + SDK 的数据面访问。

这也是为什么“沙箱创建成功”并不等于“你的 tool call 一定能执行成功”:前者解决的是资源与实例问题,后者解决的是端口、代理、协议与执行器的问题。


2. 创建阶段:请求首先在 CubeAPI 被标准化

以 Go SDK / Python SDK / E2B 兼容客户端发起的创建请求为例,控制面第一站是 CubeAPI/src/services/sandboxes.rs 里的 create_sandbox()

它做了几件关键事:

  • 读取用户传入的 template_idtimeout、metadata。
  • 自动补上和模板快照相关的 annotation,例如:
    • cube.master.appsnapshot.template.id
    • cube.master.appsnapshot.template.version
  • 把宿主目录挂载之类的扩展信息从 metadata 中拆出来,放进 annotations。
  • 生成内部 CreateSandboxRequest,并指定:
    • instance_type
    • network_type = tap
    • cubevs_context
    • timeout
    • annotations
    • labels

也就是说,CubeAPI 不是直接“创建虚机”,而是把一个面向 SDK/E2B 的请求翻译成 CubeMaster 能理解的内部编排请求。

可以把这一层理解成:

  • 对外:E2B 兼容层
  • 对内:统一的控制面请求入口

3. 进入 CubeMaster:模板补全与请求兜底

CubeAPI 随后调用 CubeMaster 的 HTTP 服务入口;在源码里,对应 CubeMaster/pkg/service/httpservice/cube/sandbox_create.gocreateSandbox()

这一层的职责不是“调度算法本身”,而是把请求修整到可以调度

3.1 constructCreateReq():请求兜底

它会补齐一系列默认值:

  • 保证 labelsannotations 不为空。
  • 规范化 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() 主要做四件事:

  1. 创建 SelectorCtx
  2. 调用 ConstructCubeletReq(),把 Master 请求翻译成发给 Cubelet 的 RunCubeSandboxRequest
  3. 解析资源需求,写入 selctx.ReqRes
  4. 基于 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.goCreate();再往下则进入 workflow.Engine 与本地 cubebox 管理器。

5.1 为什么 Cubelet 要有 workflow engine

Cubelet/plugins/workflow/engine.goCreateContext 可以看出,节点侧创建不是单步骤动作,而是组合多个资源域:

  • NetworkInfo
  • StorageInfo
  • CgroupInfo
  • VolumeInfo
  • LocalRunTemplate
  • UserData

这说明 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.godealSuccResult() 在拿到成功响应后,不会立刻结束,而是还要做一件非常关键的事:setProxyToRedis()

这里会把下面这类信息写入 SandboxProxyMap

  • HostIP
  • SandboxID
  • SandboxIP
  • CreatedAt
  • ContainerToHostPorts

这一步的意义是:

  • 控制面知道沙箱建好了。
  • 数据面还需要知道这个 sandboxID 现在在哪台节点上、哪些容器端口映射到了哪些宿主端口
  • CubeProxy、查询接口、后续连接逻辑都要依赖这份映射。

所以创建完成并不是“Cubelet 返回成功”这一刻,而是:

Cubelet 成功 + Master 成功登记路由元数据

这两步都完成后,一个沙箱才真正对后续访问可见。


7. 运行阶段:沙箱活着,不等于业务端口一定可用

进入运行态后,控制面主要负责的是:

  • 查询信息
  • 更新状态
  • 连接已有沙箱
  • 删除沙箱

但真正的业务访问已经切换到数据面。

7.1 状态查询

CubeAPI 会通过 fetch_sandbox_detail() 再拼出对 SDK 友好的响应,包括:

  • sandboxID
  • host/client ID
  • startedAt
  • envdVersion
  • domain
  • state

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 语义

CubeAPIconnect_sandbox() 在发现目标沙箱是 paused 时,会先触发 resume,再重新获取详情。

所以从 SDK 视角,connect 更像是:

  • “确保这个沙箱现在可用”
  • 然后返回最新的数据面连接信息

而不是单纯“查个对象”。


8. 销毁阶段:控制面负责定位,节点负责清理

销毁入口在 CubeAPIkill_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.golocal.Destroy()

它会依次做这些事:

  1. 从本地 store 中取出该沙箱对象并加锁。
  2. 检查删除条件与 filter。
  3. 把状态标记为 Removing
  4. 调用 cbriManager.DestroySandbox() 清理底层 runtime 资源。
  5. 如果是 paused 状态,走专门的 task delete 分支。
  6. 否则遍历所有容器执行销毁:
    • preStop / postStop
    • 删除 containerd task / container
    • 清理本地 store
  7. 执行 runc.Clean() 收尾清理。
  8. 最后把整个 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 串起来,生命周期才完整。

11. 建议继续阅读