Skip to content

调度是怎么做的:节点筛选、亲和性与重试

很多人第一次看 CubeSandbox,会把“调度”理解成一个单独的打分函数:给每台节点算个分,选最高的那台。

源码并不是这么工作的。

在 CubeSandbox 里,调度更像一个分阶段裁剪候选集 + 打分 + 带重试的落点过程。这一篇专门拆开 CubeMaster 在创建沙箱时怎样决定“这个沙箱最终落到哪台节点上”。


1. 调度入口不在 scheduler 包,而在创建主流程

创建入口最终会落到 CubeMaster/pkg/service/sandbox/sandbox_run.goCreateSandbox()

真正负责创建过程的,是 createSandboxContext

  • 它保存请求上下文 selctx
  • 保存最终选中的 selectHost
  • 保存发给节点的 cubeletReq
  • 保存是否允许 reschedule
  • 保存重试次数、退避时间与响应状态

这意味着:

调度并不是一个纯函数,而是“创建事务”的一部分。

调度结果后面还会影响:

  • 调用哪个 Cubelet
  • 出错后是否换节点
  • 是否触发 circuit break / bad node 记录
  • 是否执行 failover 补偿

2. 第一层:请求先被转成调度上下文

newContext() 会把创建请求转成一个调度可用的上下文。

核心动作有三个:

  1. selctx.New(...) 创建 selector context。
  2. ConstructCubeletReq() 把 Master 请求展开成具体的 RunCubeSandboxRequest
  3. checkAndGetReqResource() 解析 CPU / 内存等需求,写入 selctx.ReqRes

这个设计很重要,因为它说明调度依据不是抽象模板名,而是展开后的真实资源需求

也就是说,调度时真正考虑的是:

  • 这个沙箱要多少资源
  • 带了哪些约束
  • 模板合并后变成了什么

而不是“用户说他想创建某个模板”。


3. 第二层:节点亲和性先把空间收窄

CubeMaster/pkg/service/httpservice/cube/affinityutil.go 里的 runInsReq2Affinity() 会把请求转成节点选择条件。

3.1 亲和性来自哪里

constructNodeAffinity() 里,来源大概有三类:

  • 调度器配置默认值
    • 某个实例类型默认倾向某些 cluster label
  • 大规格实例特殊策略
    • 大内存 / 大 CPU 请求优先打到特定节点集
  • 请求级 annotation
    • 调用方显式指定 cluster label 或 instance type

3.2 为什么这一步很关键

很多创建失败并不是系统“没有空闲节点”,而是:

  • 你要求特定 cluster
  • 你要求某类 instance type
  • 你请求的规格触发了大规格亲和性
  • 结果剩下的候选节点本来就很少

所以源码视角里,调度第一步不是“算分”,而是约束收缩


4. 第三层:预过滤先把明显不可能的节点剔掉

CubeMaster/pkg/selector/prefilter/prefilter.goSelect() 是真正值得逐行理解的地方。

它会拿到一批健康节点,然后依次过滤:

4.1 健康与熔断

先过滤掉:

  • !n.Healthy
  • selCtx.FilterOut(n) 标记的节点

这说明节点并不是只要注册过就可选,还要看近期是否因为失败被加入 bad node / circuit break 集合。

4.2 亲和性不满足直接出局

如果 NodeSelector 不匹配:

  • 直接 affinity_out
  • 不再进入后续资源判断

所以 affinity 是硬约束,不是加分项。

4.3 容量上限与指标时效

还会过滤:

  • n.MvmNum >= RealMaxMvmLimit(n):当前节点 MVM 数量达到上限
  • CPU 负载异常
  • 指标更新时间超时
  • 本地指标更新时间超时
  • 元数据更新时间超时

这反映了一个非常务实的调度思路:

宁可少选,也不在“指标陈旧”的节点上冒险创建。

在生产里,这比一个理论上更优的打分模型更重要。


5. 第四层:过滤之后才进入评分与选择

虽然本文不逐个展开所有 score 插件,但从 CubeMaster/pkg/selector/score/ 目录和初始化代码可以看出,调度不是单一分数,而是插件化打分体系。

在源码中能看到至少这些方向:

  • affinity_score
  • image score
  • realtime weighted average
  • multi factor weighted average
  • 模板本地性相关过滤/打分

这说明 CubeSandbox 的调度目标不是单一的“空闲最多”,而是平衡多个目标:

  • 节点实时资源情况
  • 模板 / 镜像是否更接近本地
  • 是否满足亲和性
  • 是否触发特定策略插件

所以更准确的说法是:

Prefilter 决定“谁还有资格被选”,Score 决定“这些有资格的节点里谁更合适”。


6. 第五层:调度不是一次性决定,而是允许换节点重试

sandbox_run.gohandleCubelet() 是理解这个系统的关键。

核心循环大致是:

  1. schedule() 选一个节点
  2. callCubelet() 调该节点创建
  3. 根据错误类型决定:
    • 成功:结束
    • 同节点重试
    • 换节点重试
    • 直接失败

6.1 reschedule 的意义

如果错误允许重新调度,reschedule = true,那么下一轮 schedule() 可能选到另一台节点。

这意味着:

  • 调度并不是创建前的单次决策
  • 它和节点侧反馈闭环联动

6.2 errRetry()errorCodeRetry()

  • errRetry() 处理 RPC 级或调用级错误
  • errorCodeRetry() 处理 Cubelet 返回的业务错误码

而且不同错误码会触发不同策略:

  • excludes retry:不重试
  • reuse code:可有限重试
  • loop retry:允许循环重试
  • backoff retry:重试前执行退避

6.3 退避不是常量,而是带抖动增长

backoffRetryDelay() 里,delay 会随着重试增长,并带有随机抖动,还会受最大值限制。

这能避免多个并发创建请求在同一时刻、同一组节点上发生同步重试风暴。


7. 直接指定节点,其实是在绕过调度

newContext() 里还能看到一个很有意思的分支:

  • 如果请求里带 InsId
  • 或带 InsIp

就会:

  • 直接把 selectHost 设为指定节点
  • directHost = true
  • 后续不走正常 reschedule 逻辑

这通常用于调试或特定运维场景。

从架构角度看,这也说明调度器并不是强制不可绕过的黑盒,而是默认路径。


8. 调度为什么和模板有关系

很多人容易忽略:模板不是只是“启动镜像”,它也会影响调度。

影响方式至少有两种:

  1. 模板展开后决定真实资源需求
    • 资源越大,候选节点越少
  2. 模板本地性影响分数或过滤
    • 如果某节点已有相关模板副本 / 镜像,本地启动成本更低

所以“为什么同一个 API,在不同模板下会落到不同节点”,从源码角度完全合理。


9. 调度成功并不代表创建成功

即使 scheduler.Select() 选出了节点,也还没结束。

后面至少还有三道关:

  • Cubelet 能否真正创建成功
  • Master 能否把端口映射和代理路由写入元数据
  • 如果后半段失败,是否能正确回滚

这也是为什么不要把调度和创建混为一谈:

  • 调度成功:选到了候选节点
  • 创建成功:节点创建成功且控制面登记成功

这两个状态之间仍然有失败窗口。


10. 用一句话概括调度模型

如果只用一句话概括 CubeSandbox 的调度模型,可以写成:

请求先被模板与资源语义展开,再通过亲和性和预过滤缩小候选集,之后由插件化评分选点,并在节点反馈失败时进入带退避的重试 / 重调度闭环。

这比“找一台最空闲的机器”要复杂得多,也更贴近真实生产调度系统。


11. 建议继续阅读