Volcano 队列准入 Scheduling Gates:让 Pod 在队列容量就位前对自动扩缩容"隐身"
【免费下载链接】volcanoA Cloud Native Batch System (Project under CNCF)项目地址: https://gitcode.com/GitHub_Trending/vol/volcano
Volcano 默认的调度失败处理会把所有分配失败的 Pod 标记为Unschedulable,这让 Cluster Autoscaler / Karpenter 无法区分"集群真的缺资源"和"只是在等待队列准入",从而触发不必要的节点扩容。本文围绕 Volcano 的SchedulingGatesQueueAdmission功能展开:先说明问题根因,再给出从启用 Feature Gate、配置 capacity 插件、Pod 侧 opt-in 注解到验证生效的完整操作步骤,并结合源码剖析 webhook 注入 gate、调度器异步摘除 gate 以及 capacity 插件预留容量(reserved capacity)的底层实现,帮助你在生产集群中安全启用这一机制并理解其边界。
1. 问题背景:为什么 Volcano 会让 Autoscaler 误扩容
Cluster Autoscaler(CA)与 Karpenter 这类集群自动扩缩容组件,其扩容信号本质上依赖 Pod 的条件:
type: PodScheduled status: "False" reason: Unschedulable对于默认的kube-scheduler,这个条件出现通常意味着集群资源不足,触发扩容是合理的。但 Volcano 的实现不同:在每一个调度周期结束后,调度器缓存的事件记录机制会检查所有未被分配的任务,并统一将它们的PodScheduled条件置为status=False, reason=Unschedulable——无论失败原因是集群资源不足(应当扩容),还是队列容量限制(不该扩容)。Autoscaler 只能看到这个条件,无法区分两种场景,于是在 Pod 仅仅等待 Volcano 队列准入时也会错误地发起扩容。
从源码结构看,这条链路由pkg/scheduler/cache/中的缓存事件记录逻辑驱动:任务只要在当前周期没有获得分配,就会被补上Unschedulable条件。这正是 设计文档 中 Motivation 一节描述的行为,也是该功能要解决的核心矛盾。
2. 解决方案:用 schedulingGates 延迟"可见性"
该功能利用 Kubernetes 原生的 schedulingGates 机制(Pod Scheduling Readiness):Pod 只要spec.schedulingGates非空,kube-scheduler 和各类 Autoscaler 的调度失败检测都看不到它。Volcano 的思路是:
- Pod 创建时,Webhook 为 opt-in 的 Pod 注入名为
scheduling.volcano.sh/queue-allocation-gate的 gate,使其处于 gated 状态(对 Autoscaler 不可见); - 队列有容量后,调度器摘除该 gate;
- 摘除 gate 后若 Pod 能落到节点,则正常调度;若因缺少匹配节点而无法调度,此时才被"合法地"标记为
Unschedulable,Autoscaler 才会做出正确响应。
设计目标(引自设计文档)还包括:提供基于 Pod 注解的 opt-in 机制、保持 Volcano 既有调度语义不变、以及非阻塞实现(异步摘除 gate,避免拖累调度器性能)。非目标则是:不修改 CA/Karpenter 自身逻辑、不在准入时拒绝 Pod、不引入外部控制器。
前置条件
- Volcano v1.15+,并启用
SchedulingGatesQueueAdmissionFeature Gate; - 调度器配置了
capacity插件——该功能中防止"摘除 gate 与 Pod 分配之间出现竞态"的预留资源跟踪就实现在这个插件里。
Feature Gate 在 pkg/features/volcano_features.go 中注册,默认为false,成熟度为Alpha:
// SchedulingGatesQueueAdmission uses Kubernetes schedulingGates to delay // setting the Unschedulable condition on pods until the queue has enough // capacity, preventing cluster autoscalers from triggering unnecessary // scale-ups for pods that are simply waiting for queue admission. SchedulingGatesQueueAdmission featuregate.Feature = "SchedulingGatesQueueAdmission" ... SchedulingGatesQueueAdmission: {Default: false, PreRelease: featuregate.Alpha},需要同时在scheduler和webhook-manager两个组件上开启:webhook 侧决定"是否注入 gate",scheduler 侧决定"是否异步摘除 gate 并执行预留容量逻辑"。
3. 启用 Feature Gate
方式一:Helm 安装
helm install volcano volcano/volcano --namespace volcano-system --create-namespace \ --set custom.scheduler_feature_gates="SchedulingGatesQueueAdmission=true" \ --set custom.admission_feature_gates="SchedulingGatesQueueAdmission=true"方式二:kubectl apply
在volcano-scheduler与volcano-admission两个 Deployment 的容器启动参数中分别追加:
--feature-gates=SchedulingGatesQueueAdmission=true此外可以按需配置异步摘除 gate 的工作协程数量(默认 5):
--gate-removal-worker-num=10该 flag 在 cmd/scheduler/app/options/options.go 中定义,默认值为 5,且注释明确说明"仅在 SchedulingGatesQueueAdmission 启用时生效":
fs.IntVar(&s.GateRemovalWorkerNum, "gate-removal-worker-num", 5, "The number of async workers for scheduling gate removal (used when SchedulingGatesQueueAdmission is enabled).")方式三:源码级的启用时机
在 pkg/scheduler/scheduler.go 的Scheduler.Run()中,只有当 Feature Gate 打开时才会创建并启动 gate 管理器,其生命周期与调度器进程绑定:
// Start the gate manager (if the feature gate is enabled). if utilfeature.DefaultFeatureGate.Enabled(features.SchedulingGatesQueueAdmission) { pc.schGateManager = gate.NewSchGateManager(pc.cache.Client(), options.ServerOpts.GateRemovalWorkerNum) pc.schGateManager.Start() go func() { <-stopCh pc.schGateManager.Stop() }() }4. 配置 capacity 插件
确保调度器配置中启用了capacity插件,预留资源跟踪(防止摘除 gate 与分配之间的竞态)就实现于其中。示例调度器配置:
actions: "enqueue, allocate, backfill" tiers: - plugins: - name: priority - name: gang - plugins: - name: predicates - name: capacity - name: nodeorder5. Pod 侧 Opt-in:注解与 Gate 命名约定
该功能按 Pod 粒度 opt-in。注解键与 gate 名是同一个常量,定义在 staging/src/volcano.sh/apis/pkg/apis/scheduling/v1beta1/labels.go:
// QueueAllocationGateKey is the annotation key to opt-in to queue capacity // gate management and the name of the scheduling gate that controls queue admission. const QueueAllocationGateKey = GroupName + "/queue-allocation-gate"即scheduling.volcano.sh/queue-allocation-gate。为需要 gate 控制的 Pod 加上注解即可:
apiVersion: v1 kind: Pod metadata: name: my-pod annotations: # Opt-in annotation scheduling.volcano.sh/queue-allocation-gate: "true" spec: schedulerName: volcano containers: - name: worker image: nginx resources: requests: cpu: "1" memory: "1Gi"Pod 创建之后会发生什么:
- Volcano webhook 注入
scheduling.volcano.sh/queue-allocation-gate调度 gate; - Pod 保持 gated 状态(对 Autoscaler 不可见),直到队列有容量;
- 队列有容量后,调度器异步摘除 gate;
- 若 Pod 能落到某个节点,正常完成调度;
- 若没有节点匹配(例如需要特定机型、等待 Autoscaler 加节点),此时才被标记
Unschedulable,从而正确地触发 Autoscaler。
注意一个细节:如果有人手工加了 Volcano gate 但没有加 opt-in 注解,调度器不会自动摘除 gate,并会输出告警日志(见下文 allocate 逻辑),Pod 将永远卡在 gated 状态。
6. 验证功能生效
创建 opt-in Pod 后,先确认 mutation webhook 注入了 gate:
kubectl get pod my-pod -o jsonpath='{.spec.schedulingGates}'等待队列容量期间预期输出:
[{"name":"scheduling.volcano.sh/queue-allocation-gate"}]队列有容量、调度器摘除 gate 后,同一条命令输出为空:
kubectl get pod my-pod -o jsonpath='{.spec.schedulingGates}' # empty output此外可通过kubectl describe pod my-pod观察条件变化:gated 期间不应出现Unschedulable条件;gate 摘除后若节点不匹配,才会出现PodScheduled=False/Unschedulable。仓库中还包含针对该流程的 E2E 测试 test/e2e/schedulinggates/scheduling_gates.go,可作为行为基准参考。
7. 源码剖析:Webhook 如何注入 gate
注入逻辑位于 pkg/webhooks/admission/pods/mutate/mutate_pod.go 的patchSchedulingGates,它挂在现有的 Pod 创建(Create)mutation 流程中。核心实现有三个要点:
(1)双重开关校验——Feature Gate 未开启或 Pod 没有 opt-in 注解时直接跳过:
func patchSchedulingGates(pod *v1.Pod) *patchOperation { // Skip if SchedulingGatesQueueAdmission feature gate is not enabled if !utilfeature.DefaultFeatureGate.Enabled(features.SchedulingGatesQueueAdmission) { return nil } // Check if opt-in annotation is present if !api.HasQueueAllocationGateAnnotation(pod) { return nil } ... }(2)幂等性——若 Pod 已存在同名 gate(例如 mutation 重试),不再追加,避免重复 gate。
(3)JSON Patch 的两种形态——Kubernetes 规定schedulingGates在 Pod 创建后只能删、不能加,因此注入必须发生在创建时:
spec.schedulingGates为空时,对整个字段做add /spec/schedulingGates(值为包含该 gate 的数组);- 已存在其他 gate 时,用
add /spec/schedulingGates/-追加到数组末尾,避免覆盖并行 webhook 写入的其他 gate。
判断辅助函数集中在 pkg/scheduler/api/helpers.go,供 scheduler、capacity 插件与 gate 管理器共用,保证语义一致:
// HasOnlyVolcanoSchedulingGate checks if a Pod has only the Volcano queue allocation gate func HasOnlyVolcanoSchedulingGate(pod *v1.Pod) bool { return len(pod.Spec.SchedulingGates) == 1 && pod.Spec.SchedulingGates[0].Name == schedulingv1beta1.QueueAllocationGateKey } // HasQueueAllocationGateAnnotation checks if a Pod has the queue allocation gate annotation func HasQueueAllocationGateAnnotation(pod *v1.Pod) bool { return pod.Annotations != nil && pod.Annotations[schedulingv1beta1.QueueAllocationGateKey] == "true" }8. 源码剖析:调度器的容量准入检查与异步摘 gate
Volcano 此前已支持 Pod Scheduling Readiness:带(外部)scheduling gate 的 Pod 不会被 allocate/backfill/reclaim/preempt 动作分配。本功能的关键改造是让"仅带 Volcano gate"的 Pod 重新进入队列容量计算,从而能参与准入检查。
8.1 作业工作表:放行 Volcano gate Pod
在 pkg/scheduler/actions/allocate/allocate.go 的organizeJobWorksheet中,只有带外部(非 Volcano)gate 的任务才被跳过:
for _, task := range subJob.TaskStatusIndex[api.Pending] { // Skip tasks with external (non-Volcano) scheduling gates // Allow Volcano-managed gates (they'll be handled by capacity plugin) if task.SchGated && !api.HasOnlyVolcanoSchedulingGate(task.Pod) { klog.V(4).Infof("Task <%v/%v> has external scheduling gate, skip it.", ...) continue } ... }同时JobInfo.GetSchGatedPodResources()在扣除"被调度门控的资源"时会排除仅带 Volcano gate 的 Pod——这让它们计入 inqueue 资源、参与队列准入判定,而不会被误当作完全不可见的资源。
8.2 allocate 主循环:先判容量,再排队摘 gate
在 allocate 动作的分配主循环中(allocateResourcesForTasks),任务先过队列容量检查(ssn.Allocatable(queue, task)会驱动 capacity 插件做容量判定)。通过检查且带 opt-in 注解的 gated 任务会被送入异步摘除队列,而当轮不做分配——gate 由后台 worker 摘除、informer 缓存刷新后,下一个调度周期才会真正走到节点过滤与分配:
// If task passed allocation check and has the QueueAllocationGate, initiate async gate removal. // Gate will be removed by the background worker (best effort). if utilfeature.DefaultFeatureGate.Enabled(features.SchedulingGatesQueueAdmission) && task.SchGated && api.HasQueueAllocationGateAnnotation(task.Pod) { klog.V(3).Infof("Task %s/%s has the QueueAllocationGate, queue async gate removal", task.Namespace, task.Name) ssn.SchGateManager().Enqueue(task) } // Skip gated tasks. If someone added the Volcano gate without the opt-in annotation, // warn them since the gate will never be removed automatically. if task.SchGated { if api.HasOnlyVolcanoSchedulingGate(task.Pod) && !api.HasQueueAllocationGateAnnotation(task.Pod) { klog.Warningf("Task %s/%s has Volcano scheduling gate but missing the opt-in annotation %q; gate will not be removed automatically", ...) } continue }这种"当轮跳过、下轮分配"的设计保证了容量判定与节点分配之间的一致性:gated 任务不会被分配,而摘 gate 动作是 best-effort 的异步操作,不阻塞调度循环。
8.3 SchGateManager:异步摘除 gate 的后台管理器
实现位于 pkg/scheduler/gate/schedulinggate.go。要点:
- 默认 5 个 worker(
DefaultWorkerNum = 5),每个 worker 的通道缓冲为 200(bufferPerWorker),总通道容量 = workerNum × 200; Enqueue是非阻塞投递:通道满时打警告并放弃(返回 false),下个周期还会再尝试,保证调度器吞吐不受摘 gate 速度影响;- 投递前会用
HasOnlyVolcanoSchedulingGate再校验一次:Pod 上还挂着其他控制器的 gate 时不摘 Volcano gate(见第 9 节); - worker 实际调用
cache.RemoveVolcanoSchGate(kubeClient, namespace, name)更新 API 中的 Pod。
func (m *SchGateManager) Enqueue(task *api.TaskInfo) bool { if !api.HasOnlyVolcanoSchedulingGate(task.Pod) { return false } op := gateRemovalOp{namespace: task.Namespace, name: task.Name} select { case m.opCh <- op: return true default: klog.Warningf("Gate operation queue full, skipping gate removal for %s/%s", ...) return false } }Scheduler通过framework.OpenSession(...)把该 manager 传入每个调度 session,allocate 动作经ssn.SchGateManager()访问,生命周期随进程启停。
9. 与其他 Scheduling Gate 的交互
如果 Pod 上还带有其他控制器注入的 gate(如example.com/my-gate),Volcano不会在"仅剩 Volcano gate"之前摘除自己的 gate。这由两处保证:
SchGateManager.Enqueue在投递前检查HasOnlyVolcanoSchedulingGate(task.Pod),不满足直接放弃;- webhook 注入时不覆盖已有 gate,只追加。
由此保证 Volcano 不会干扰其他 gate 控制器的语义,多 gate 并存时 Pod 会一直等到所有 gate 都被各自控制器移除。
10. 源码剖析:capacity 插件的预留容量(Reserved Capacity)
这是理解该功能"为什么需要 capacity 插件"的关键。考虑一个竞态场景(引自设计文档):
3 个 opt-in Pod(
pod-1/2/3),各请求1 CPU / 1 GiB,队列 capability 为1 CPU / 1 GiB;pod-2的 nodeSelector 指向尚不存在、等待 Autoscaler 扩容出来的节点机型;初始三者都被 webhook 加上 gate,全部 gated:
NAME PHASE CONDITION GATES pod-1 Pending SchedulingGated scheduling.volcano.sh/queue-allocation-gate pod-2 Pending SchedulingGated scheduling.volcano.sh/queue-allocation-gate pod-3 Pending SchedulingGated scheduling.volcano.sh/queue-allocation-gate第 1 轮:
pod-1通过容量检查、gate 被摘除,随后正常调度到Running;pod-1结束/删除后,pod-2通过容量检查、gate 被摘除,但因 nodeSelector 匹配不到节点而变为Unschedulable(用来触发 Autoscaler)。
问题在于:pod-2摘除 gate 后、尚未分配到节点之前,它既不计入allocated(未绑定),也看不到 gate(已摘除)。如果没有预留机制,队列在容量账本上是"空"的——此时新建的pod-3会通过容量检查并直接跑起来。于是 Autoscaler 为pod-2扩出的新节点永远无法被使用(队列容量已被pod-3占走),形成"扩容了节点却调度不进去"的死局。
10.1 预留缓存的三段式生命周期
实现位于 pkg/scheduler/plugins/capacity/capacity.go。capacity 插件新增一个按队列组织的预留缓存:
// queueGateReservedTasks tracks tasks that passed capacity checks but cannot be scheduled // These tasks reserve queue capacity to prevent other tasks from consuming it // Rebuilt fresh at the start of each scheduling cycle in OnSessionOpen queueGateReservedTasks map[api.QueueID]map[api.TaskID]*api.TaskInfo(1)会话开始时全量重建:OnSessionOpen中调用buildQueueReservedTasksCache,扫描所有 Pending 任务,凡是"没有 gate + 有 opt-in 注解 + Pending"的任务——即已判过容量、正等待节点的任务——都计入预留:
for _, task := range job.TaskStatusIndex[api.Pending] { // Tasks that passed capacity have: NO gate + HAS annotation + Pending status if !task.SchGated && api.HasQueueAllocationGateAnnotation(task.Pod) { ... cp.queueGateReservedTasks[job.Queue][task.UID] = task } }(2)容量检查通过时增量写入:插件注册的AddAllocatableFn回调中,任务通过层级化容量检查且带 opt-in 注解时,写入预留缓存:
allocatable := cp.checkQueueAllocatableHierarchically(ssn, queue, candidate) // If queue has capacity and task has the QueueAllocationGate annotation. if allocatable && utilfeature.DefaultFeatureGate.Enabled(features.SchedulingGatesQueueAdmission) && api.HasQueueAllocationGateAnnotation(candidate.Pod) { cp.addTaskToReservedCache(queue.UID, candidate) }(3)分配/回滚时维护账本:任务真正分配(tentative assign)后,其资源转入allocated统计,必须从预留缓存移除以免重复计数;若发生回滚(如 gang 调度未能全部放置),DeallocateFunc又把它恢复回预留缓存:
// AllocateFunc: 分配成功后 if utilfeature.DefaultFeatureGate.Enabled(features.SchedulingGatesQueueAdmission) { cp.removeTaskFromReservedCache(event.Task.UID) } // DeallocateFunc: 回滚时 if utilfeature.DefaultFeatureGate.Enabled(features.SchedulingGatesQueueAdmission) && api.HasQueueAllocationGateAnnotation(event.Task.Pod) { cp.addTaskToReservedCache(job.Queue, event.Task) }10.2 容量判定中如何计入预留资源
容量检查走queueAllocatable→queueAllocatableWithReserved,把预留缓存中除候选任务自身之外的任务资源一并累加进"未来占用":
// Calculate total reserved resources directly from cache reserved := api.EmptyResource() if queueGateReserved := cp.queueGateReservedTasks[queue.UID]; queueGateReserved != nil { for _, task := range queueGateReserved { if task.UID != candidate.UID { // Skip candidate to avoid double-counting (it will be added in futureUsed below) reserved.Add(task.Resreq) } } }由此,队列容量账本同时覆盖:已分配(bound/binding/running)资源(既有行为)+ 已摘 gate 但未落地的 Pending 资源(新行为)。会话结束时queueGateReservedTasks被整体清空(OnSessionClose置 nil),与"每轮重建"的设计配套,避免跨周期脏数据。
11. 限制与运维注意事项
- 摘除 gate 后没有超时机制:一旦 gate 被摘除,Pod 会一直占用队列预留容量,直到被调度或删除。若它长期 Unschedulable(例如等待 Autoscaler 加节点、或节点始终匹配不上),会持续占用队列容量,可能阻塞其他 Pod;当前版本故意不实现超时释放,以避免在 Pod 即将获得节点时提前放容量造成超卖。运维上需要意识到:ungated-but-unschedulable 的 Pod 可以无限期地持有队列容量。
- 该功能仅在
capacity插件启用时具备完整的预留语义;未启用 capacity 插件时队列容量判定路径不同。 - 仅带 Volcano gate 而缺少 opt-in 注解的 Pod,gate 不会被自动摘除(调度器会打 Warning 日志),Pod 会永久 gated——部署时请保证注解与 gate 成对出现。
- 该功能为 Alpha、默认关闭,且按 Pod opt-in;未加注解的存量工作负载行为完全不变,可以灰度采用。
12. 小结
SchedulingGatesQueueAdmission用一个"创建时注入、准入后异步摘除"的 scheduling gate,把 Volcano 的"队列等待"与"集群缺资源"两种状态在 Autoscaler 视角下彻底分开:前者 Pod 始终 gated、不可见;后者 gate 摘除后才暴露Unschedulable条件,扩容信号恢复可信。整条链路涉及四个部分:webhook 的幂等 gate 注入(pkg/webhooks/admission/pods/mutate/mutate_pod.go)、allocate 动作的容量准入与异步排队(pkg/scheduler/actions/allocate/allocate.go)、后台 gate 管理器(pkg/scheduler/gate/schedulinggate.go)以及 capacity 插件的预留容量账本(pkg/scheduler/plugins/capacity/capacity.go)。理解这套机制后,你可以放心在混合了 Volcano 队列调度与 Cluster Autoscaler/Karpenter 的集群中启用它,同时清楚预留容量无超时释放这一运维边界。更多设计细节可参考 设计文档,操作指引见 用户指南。
【免费下载链接】volcanoA Cloud Native Batch System (Project under CNCF)项目地址: https://gitcode.com/GitHub_Trending/vol/volcano
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考