Backfill Action 详解

概述

Backfill 是 Volcano 调度流水线中的填充调度 Action,专门为 BestEffort 任务(即不声明资源请求的任务)寻找空闲节点进行分配。它运行在 Allocate 之后,利用集群中分配剩余的碎片资源,将轻量级任务"填充"到可用节点上。

与 Allocate 不同,Backfill 不使用 Statement 事务机制,也不涉及 Gang Scheduling 语义,而是采用逐任务直接分配的简单策略 – 找到合适的节点后直接调用 ssn.Allocate() 绑定。

源码参考:pkg/scheduler/actions/backfill/backfill.go

设计定位

flowchart LR alloc["Allocate Action\n分配常规 Task"] --> backfill["Backfill Action\n填充 BestEffort Task"] backfill --> preempt["Preempt Action\n队列内抢占"] style alloc fill:#e3f2fd style backfill fill:#fff3e0 style preempt fill:#fce4ec

为什么需要 Backfill?

  • 碎片利用:Allocate 为带资源请求的 Task 分配后,集群中通常存在大量零散空闲资源。BestEffort 任务不声明资源需求,可以充分利用这些碎片
  • 简化流程:BestEffort 任务无需 Gang Scheduling(一个任务即可独立运行),省去 Statement 事务的开销
  • 优先级分离:将 BestEffort 任务从 Allocate 中剥离,避免与常规任务竞争调度时间

Action 结构体

type Action struct {
    enablePredicateErrorCache bool  // 默认: true
}
参数默认值说明
enablePredicateErrorCachetrue启用 Predicate 错误缓存,避免对同一 Job 内的相似 Task 重复执行失败的 Predicate

通过 parseArguments() 从 Session 配置中读取参数:

func (backfill *Action) parseArguments(ssn *framework.Session) {
    arguments := framework.GetArgOfActionFromConf(ssn.Configurations, backfill.Name())
    arguments.GetBool(&backfill.enablePredicateErrorCache, conf.EnablePredicateErrCacheKey)
}

整体执行流程

flowchart TB start(["Execute() 开始"]) --> parse["parseArguments()\n解析配置参数"] parse --> pickup["pickUpPendingTasks(ssn)\n收集 BestEffort 待调度任务"] pickup --> t_loop{"pendingTasks\n非空?"} t_loop -->|"是"| pop["取出下一个 Task"] pop --> pre_pred["ssn.PrePredicateFn(task)\n预过滤"] pre_pred -->|"失败"| mark_all["为所有节点记录 FitError"] mark_all --> t_loop pre_pred -->|"通过"| predicate["PredicateNodes()\n过滤可用节点"] predicate --> has_nodes{"有可用节点?"} has_nodes -->|"否"| record_err["记录 FitErrors"] record_err --> t_loop has_nodes -->|"是"| count{"可用节点 > 1?"} count -->|"否"| use_only["使用唯一节点"] count -->|"是"| shard["按 Shard 分组节点"] shard --> score["PrioritizeNodes()\n节点打分"] score --> best["BestNodeFn()\n或 SelectBestNodeAndScore()"] best --> use_only use_only --> do_alloc["ssn.Allocate(task, node)\n直接分配"] do_alloc -->|"成功"| metrics["更新调度 Metrics"] do_alloc -->|"失败"| alloc_err["记录分配错误"] metrics --> t_loop alloc_err --> t_loop t_loop -->|"否"| done(["Execute() 结束"]) style start fill:#e1f5fe style done fill:#e8f5e9 style do_alloc fill:#c8e6c9 style mark_all fill:#ffcdd2 style record_err fill:#ffcdd2

关键步骤说明

  1. 收集任务:调用 pickUpPendingTasks() 按 Queue - Job - Task 三级优先级收集所有 BestEffort 待调度任务
  2. 预过滤:对每个 Task 执行 PrePredicateFn,如果 Task 本身不满足基本条件(如 SchedulingGate 未解除),直接跳过
  3. 节点过滤:通过 PredicateNodes() 筛选满足约束的节点(亲和性、污点容忍等)
  4. 节点打分:若有多个可用节点,按 Shard 分组后执行 Plugin 打分,选择最优节点
  5. 直接分配:调用 ssn.Allocate() 将 Task 绑定到选中的节点

pickUpPendingTasks 详解

pickUpPendingTasks 是 Backfill 的任务收集函数,采用三级优先级队列结构,确保任务按 Queue - Job - Task 的全局优先级有序提取。

收集流程

flowchart TB start(["pickUpPendingTasks() 开始"]) --> init["初始化三级数据结构\nqueues: PriorityQueue\njobs: map QueueID -> PriorityQueue\ntasks: map JobID -> PriorityQueue"] init --> phase1["Phase 1 - 遍历所有 Job 收集任务"] phase1 --> j_loop{"遍历 ssn.Jobs"} j_loop -->|"下一个 Job"| skip_pending{"Job.IsPending()?"} skip_pending -->|"是"| j_loop skip_pending -->|"否"| valid{"ssn.JobValid(job)\n通过?"} valid -->|"否"| j_loop valid -->|"是"| find_q["查找 Job 所属 Queue"] find_q -->|"Queue 不存在"| j_loop find_q -->|"Queue 存在"| scan_pending["扫描 Pending 状态的 Task"] scan_pending --> be_check{"Task.BestEffort\n且 !SchGated?"} be_check -->|"是"| add_task["加入 tasks 优先级队列"] be_check -->|"否"| scan_pending add_task --> scan_pipe["扫描 Pipelined 状态的 Task"] scan_pipe --> be_check2{"Task.BestEffort?"} be_check2 -->|"是"| unpipe["Statement.UnPipeline(task)\n恢复为 Pending"] unpipe --> add_task2["加入 tasks 优先级队列"] be_check2 -->|"否"| scan_pipe add_task2 --> register["将 Job 注册到所属 Queue\n将 Queue 加入队列优先级队列"] register --> j_loop j_loop -->|"遍历完"| phase2["Phase 2 - 按优先级有序提取"] phase2 --> extract["三级循环弹出\nQueue -> Job -> Task\n追加到 pendingTasks"] extract --> done(["返回 pendingTasks"]) style start fill:#e1f5fe style done fill:#e8f5e9 style unpipe fill:#fff3e0 style add_task fill:#c8e6c9 style add_task2 fill:#c8e6c9

Phase 1 - 任务收集

遍历 Session 中的所有 Job,对每个 Job 执行以下操作:

步骤操作说明
1跳过 Pending JobJob 尚未入队(Enqueue 未通过),不参与 Backfill
2JobValid 校验调用 Plugin 验证 Job 有效性
3查找所属 QueueQueue 不存在则跳过
4扫描 Pending Task仅收集 BestEffort && !SchGated 的任务
5扫描 Pipelined TaskBestEffort 的 Pipelined 任务先 UnPipeline 再收集
6注册层级关系将 Job 注册到所属 Queue 的优先级队列

UnPipeline 恢复机制:如果 BestEffort 任务在之前的 Allocate 中被 Pipeline 到某节点(等待资源释放),Backfill 会将其 UnPipeline,释放预占的资源位,重新参与调度。这是因为 BestEffort 任务不应该长期占据 Pipeline 位,它们更适合立即分配到有空闲资源的节点。

Phase 2 - 优先级提取

for !queues.Empty() {
    queue := queues.Pop().(*api.QueueInfo)
    for !jobs[queue.UID].Empty() {
        job := jobs[queue.UID].Pop().(*api.JobInfo)
        for !tasks[job.UID].Empty() {
            pendingTasks = append(pendingTasks, tasks[job.UID].Pop().(*api.TaskInfo))
        }
    }
}

按照 Queue 优先级 > Job 优先级 > Task 优先级 的全局顺序展开为一维列表。高优先级 Queue 中的 Job 的 Task 排在前面,确保重要任务优先获得 Backfill 机会。


节点选择与打分

节点过滤

Backfill 使用 PredicateHelper.PredicateNodes() 对所有候选节点执行 Predicate 过滤:

predicateNodes, fitErrors := ph.PredicateNodes(
    task, ssn.NodeList,
    predicateFunc,                        // ssn.PredicateForAllocateAction
    backfill.enablePredicateErrorCache,   // 启用错误缓存
    ssn.NodesInShard,                     // 分片节点集合
)

PredicateForAllocateAction 会依次检查节点的亲和性、污点容忍、端口冲突等 K8s 原生约束。

Shard 分组与打分

当存在多个候选节点时,Backfill 按 Shard 亲和性进行分组:

flowchart TB nodes["Predicate 通过的节点"] --> shard["GetPredicatedNodeByShard()"] shard --> group1["第一组 - 本 Shard 节点\n(或非 SoftSharding 模式全部节点)"] shard --> group2["第二组 - 其他 Shard 节点\n(仅 SoftSharding 模式)"] group1 --> score1["PrioritizeNodes()\n调用 Plugin 打分"] score1 --> best1["BestNodeFn()\n选择最优节点"] best1 -->|"找到"| result["返回最优节点"] best1 -->|"未找到"| fallback1["SelectBestNodeAndScore()\n回退选择"] fallback1 -->|"找到"| result fallback1 -->|"未找到"| group2 group2 --> score2["PrioritizeNodes()"] score2 --> best2["BestNodeFn()"] best2 -->|"找到"| result best2 -->|"未找到"| fallback2["SelectBestNodeAndScore()"] fallback2 --> result style group1 fill:#c8e6c9 style group2 fill:#fff9c4 style result fill:#c8e6c9

Shard 分组策略:

Shard 模式第一组第二组
非 SoftSharding所有节点空
SoftSharding本 Shard 节点其他 Shard 节点

优先在本 Shard 节点中选择,只有本 Shard 找不到合适节点时才溢出到其他 Shard。

打分与选择

节点打分由以下 Plugin 扩展点协作完成:

  1. BatchNodeOrderFn - 批量预计算(如 binpack、nodeorder 插件)
  2. NodeOrderMapFn - 单节点映射打分
  3. NodeOrderReduceFn - 多维度分数聚合

选择最优节点时,优先调用 BestNodeFn(由 Plugin 提供自定义逻辑),如果返回 nil 则回退到 SelectBestNodeAndScore()(选择得分最高的节点,同分随机)。


直接分配机制

Backfill 与 Allocate 的关键区别在于分配方式:

// Backfill: 直接分配,无事务
if err := ssn.Allocate(task, node); err != nil {
    klog.Errorf("Failed to bind Task %v on %v in Session %v", task.UID, node.Name, ssn.UID)
    fe.SetNodeError(node.Name, err)
    job.NodesFitErrors[task.UID] = fe
    continue
}
  • 无 Statement:不创建事务,不支持 Commit/Discard 回滚
  • 逐任务独立:每个 Task 的分配结果互不影响,一个 Task 分配失败不影响其他 Task
  • 立即生效:Allocate 成功后立即更新 Session 中的资源状态

这种设计与 BestEffort 任务的特性完美匹配 – 它们没有 Gang 约束,无需"全部成功或全部回滚"的语义。


与 Allocate Action 的对比

维度Allocate ActionBackfill Action
目标任务所有 Pending Task(带资源请求)仅 BestEffort Task(无资源请求)
事务机制Statement (Commit/Discard)无,直接 ssn.Allocate()
Gang Scheduling支持,通过 JobReady 判断不支持,逐任务独立分配
Queue 超用检查检查 ssn.Overused(queue)不检查
Allocatable 检查调用 ssn.Allocatable()不调用
HyperNode 拓扑支持 Hard/Soft Topology不支持
节点梯度Idle / FutureIdle 双梯度单梯度(Predicate 过滤)
Pipeline支持,资源不足时 Pipeline不支持,会 UnPipeline 已有的
NominatedNode优先尝试不使用
Shard 分组4 梯度(Idle/FutureIdle x 本Shard/其他Shard)2 梯度(本Shard/其他Shard)
复杂度高(~800行,含 HyperNode 逻辑)低(~200行)

调用的扩展点

扩展点调用位置用途
QueueOrderFnpickUpPendingTasksQueue 优先级排序
JobOrderFnpickUpPendingTasksJob 优先级排序
TaskOrderFnpickUpPendingTasksTask 优先级排序
JobValidpickUpPendingTasks验证 Job 有效性
PrePredicateFnExecuteTask 预过滤(如 SchedulingGate 检查)
PredicateForAllocateActionExecute节点 Predicate 过滤
BatchNodeOrderFnExecute批量节点打分预计算
NodeOrderMapFnExecute单节点映射打分
NodeOrderReduceFnExecute多维度分数聚合
BestNodeFnExecute最优节点选择(Plugin 自定义)
ssn.AllocateExecute直接分配 Task 到 Node

常见问题

Q: 什么样的 Task 会被 Backfill 处理?

只有同时满足以下条件的 Task 才会被 Backfill 收集:

  1. Task 所属 Job 不是 Pending 状态(已通过 Enqueue)
  2. Job 通过 JobValid 校验
  3. Job 所属 Queue 存在
  4. Task 标记为 BestEffort(即不声明资源请求的 Pod)
  5. Task 处于 Pending 或 Pipelined 状态
  6. Task 未被 SchedulingGate 拦截(!SchGated)

Q: Backfill 为什么不检查 Queue 的 Overused 状态?

BestEffort 任务不声明资源请求,因此不会消耗队列的资源配额。Overused 检查基于队列的 Deserved 资源与已分配资源的比较,对零资源请求的任务没有意义。

Q: Pipelined 的 BestEffort 任务为什么要 UnPipeline?

Pipeline 机制是为等待资源释放而设计的 – 当节点当前资源不足但 FutureIdle 资源足够时,Task 被 Pipeline 到该节点等待。但 BestEffort 任务不需要特定的资源量,它们应该被分配到当前有空闲资源的节点,而不是等待某个节点的资源释放。UnPipeline 释放预占位后,让 Backfill 重新选择最合适的节点。

Q: Backfill 分配失败会有什么影响?

单个 Task 的 Allocate 失败只影响该 Task 本身,不会影响其他 Task 的分配。失败原因会记录在 job.NodesFitErrors[task.UID] 中,供 PodGroup Condition 展示。该 Task 会在下一个调度周期重新尝试。

Q: 为什么 Backfill 不使用 Statement 事务?

Statement 事务的核心价值在于支持 Gang Scheduling 的"全部成功或全部回滚"语义。BestEffort 任务没有 Gang 约束(minMember 对它们无意义),每个任务独立分配互不依赖,因此不需要事务机制。直接 Allocate 减少了事务开销,提升了调度效率。


下一步