调度是怎么做的:节点筛选、亲和性与重试
很多人第一次看 CubeSandbox,会把“调度”理解成一个单独的打分函数:给每台节点算个分,选最高的那台。
源码并不是这么工作的。
在 CubeSandbox 里,调度更像一个分阶段裁剪候选集 + 打分 + 带重试的落点过程。这一篇专门拆开 CubeMaster 在创建沙箱时怎样决定“这个沙箱最终落到哪台节点上”。
1. 调度入口不在 scheduler 包,而在创建主流程
创建入口最终会落到 CubeMaster/pkg/service/sandbox/sandbox_run.go 的 CreateSandbox()。
真正负责创建过程的,是 createSandboxContext:
- 它保存请求上下文
selctx - 保存最终选中的
selectHost - 保存发给节点的
cubeletReq - 保存是否允许
reschedule - 保存重试次数、退避时间与响应状态
这意味着:
调度并不是一个纯函数,而是“创建事务”的一部分。
调度结果后面还会影响:
- 调用哪个 Cubelet
- 出错后是否换节点
- 是否触发 circuit break / bad node 记录
- 是否执行 failover 补偿
2. 第一层:请求先被转成调度上下文
newContext() 会把创建请求转成一个调度可用的上下文。
核心动作有三个:
selctx.New(...)创建 selector context。ConstructCubeletReq()把 Master 请求展开成具体的RunCubeSandboxRequest。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.go 的 Select() 是真正值得逐行理解的地方。
它会拿到一批健康节点,然后依次过滤:
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_scoreimage scorerealtime weighted averagemulti factor weighted average- 模板本地性相关过滤/打分
这说明 CubeSandbox 的调度目标不是单一的“空闲最多”,而是平衡多个目标:
- 节点实时资源情况
- 模板 / 镜像是否更接近本地
- 是否满足亲和性
- 是否触发特定策略插件
所以更准确的说法是:
Prefilter 决定“谁还有资格被选”,Score 决定“这些有资格的节点里谁更合适”。
6. 第五层:调度不是一次性决定,而是允许换节点重试
sandbox_run.go 的 handleCubelet() 是理解这个系统的关键。
核心循环大致是:
schedule()选一个节点callCubelet()调该节点创建- 根据错误类型决定:
- 成功:结束
- 同节点重试
- 换节点重试
- 直接失败
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. 调度为什么和模板有关系
很多人容易忽略:模板不是只是“启动镜像”,它也会影响调度。
影响方式至少有两种:
- 模板展开后决定真实资源需求
- 资源越大,候选节点越少
- 模板本地性影响分数或过滤
- 如果某节点已有相关模板副本 / 镜像,本地启动成本更低
所以“为什么同一个 API,在不同模板下会落到不同节点”,从源码角度完全合理。
9. 调度成功并不代表创建成功
即使 scheduler.Select() 选出了节点,也还没结束。
后面至少还有三道关:
- Cubelet 能否真正创建成功
- Master 能否把端口映射和代理路由写入元数据
- 如果后半段失败,是否能正确回滚
这也是为什么不要把调度和创建混为一谈:
- 调度成功:选到了候选节点
- 创建成功:节点创建成功且控制面登记成功
这两个状态之间仍然有失败窗口。
10. 用一句话概括调度模型
如果只用一句话概括 CubeSandbox 的调度模型,可以写成:
请求先被模板与资源语义展开,再通过亲和性和预过滤缩小候选集,之后由插件化评分选点,并在节点反馈失败时进入带退避的重试 / 重调度闭环。
这比“找一台最空闲的机器”要复杂得多,也更贴近真实生产调度系统。