Volcano调度器:Kubernetes AI作业的组调度与资源管理实践

发布时间:2026/10/12 16:17:18
Volcano调度器:Kubernetes AI作业的组调度与资源管理实践
1. Volcano 是什么为什么 AI 作业需要它Volcano 这个名字玩 Kubernetes 调度的人应该不陌生。它是云原生计算基金会旗下的一个批量调度系统核心功能就是解决 Kubernetes 原生调度器在批处理、高性能计算和 AI 训练场景下的诸多短板。简单说Kubernetes 默认调度器擅长管微服务但遇到“一次要占几十张卡、多个 Pod 必须同时启动、任务有依赖关系、还要按队列排队”这类 AI 作业就会显得力不从心。Volcano 就是专门补这个缺的。这几年 AI 训练作业上云已经成了常态尤其是大模型训练、分布式深度学习、自动机器学习这类负载动辄需要几十甚至上千张加速卡而且训练任务通常由多个 Worker 组成每个 Worker 都要在集群里申请独立的 Pod。如果只用 Kubernetes 原生调度器会遇到几个很现实的问题一是多个 Pod 同时调度时没有一个统一的“整体”概念经常出现一部分 Pod 调度成功、另一部分卡在 Pending 状态最终整个训练任务启动失败二是缺乏队列和优先级管理重要任务和临时任务挤在一起谁也说不清资源该优先给谁三是调度性能在大规模集群下不够用一个几千节点的集群频繁的调度循环会产生大量延迟。Volcano 正是被设计来解决这些问题的。我最初接触 Volcano是因为一个模拟项目需要在一个约 100 节点、拥有 400 张加速卡的集群上运行多组深度学习训练任务。当时的场景是几组研发团队共用一个集群各自的训练任务规模和优先级都不一样有的任务需要 16 张卡有的只要 2 张卡还有一些是低优先级的探索性实验。用原生调度器跑了两个星期踩了不少坑。后来迁移到 Volcano训练任务的启动成功率、资源利用率和排队体验都有了明显改观。这篇文章不打算贴一堆官网文档而是从实际使用角度拆解 Volcano 接纳与调度 AI 作业的完整过程包括核心概念、调度流程、实用配置以及我踩过的一些坑。如果你是平台工程师、算法工程师或者正在搭建自己的 AI 训练平台希望这篇文章能帮你省掉一些试错时间。2. 从 Kubernetes 原生调度到 Volcano 的迁移思路2.1 原生调度器在 AI 场景下的三个痛点先说清楚 Kubernetes 原生调度器在 AI 作业场景下到底哪里不好用。第一是缺少“作业”这个整体抽象。Kubernetes 里最小的调度单位是 Pod但一个 AI 训练任务通常是由多个 Pod 共同组成的。比如一个使用参数服务器架构的训练任务可能包含 4 个 Parameter Server 和 32 个 Worker这 36 个 Pod 必须都正常运行训练才能开始。原生调度器只会一个一个地调度 Pod它会先调度 Worker 1再调度 Worker 2但并不会意识到“这 36 个 Pod 是一起提交的”。如果集群资源刚好只够调度一部分那么被调度的 Pod 会先启动剩下的 Pod 永远 Pending整个任务就卡住了。更糟的是这些已启动的 Pod 可能还会抢占资源导致其他任务也被拖累。第二是缺少排队和优先级机制。Kubernetes 原生调度器的 PriorityClass 能够定义优先级但在多团队共享集群的场景下光有优先级还不够。不同团队、不同任务之间需要一个“排队”的概念类似医院挂号排队急症优先普通门诊按顺序来。原生的调度行为更接近“谁先提交谁先得”并没有队列和配额的概念。第三是 Gang Scheduling组调度缺失。所谓 Gang Scheduling就是一组 Pod 要么全部被调度要么一个都不调度保证任务作为一个整体启动。深度学习训练任务对这一点特别敏感因为分布式训练要求所有 Worker 同时参与如果有一半 Worker 先启动了它们就会在那里空转等另外一半或者直接因为连接不上而退出。所以没有组调度AI 作业的稳定性非常难保证。2.2 Volcano 的核心设计目标Volcano 的设计目标很直接就是为高性能计算和 AI 场景提供一套增强的批量调度能力。它没有推翻 Kubernetes而是以调度器扩展的方式接入通过一组 CRD自定义资源把“作业”“队列”“优先级”等概念引入 Kubernetes。Volcano 最常用的 CRD 是Job即Volcano Job它定义了一个完整作业的结构。在Volcano Job里你可以声明这个作业包含几个 Task任务每个 Task 对应一组 Pod 模板Task 之间可以设置依赖关系也可以配置最小成员数。比如这样apiVersion: batch.volcano.sh/v1alpha1 kind: Job metadata: name: bert-distributed-training spec: minAvailable: 8 schedulerName: volcano tasks: - name: ps replicas: 2 template: spec: containers: - name: tensorflow-ps image: tensorflow/tensorflow:2.9-gpu command: [python, /app/train.py, --job_nameps] resources: limits: nvidia.com/gpu: 1 - name: worker replicas: 6 template: spec: containers: - name: tensorflow-worker image: tensorflow/tensorflow:2.9-gpu command: [python, /app/train.py, --job_nameworker] resources: limits: nvidia.com/gpu: 1这里minAvailable: 8表示这个作业最少需要 8 个 Pod 全部调度成功才能启动也就是 2 个 PS 加 6 个 Worker。schedulerName: volcano是关键它告诉 Kubernetes 这个作业由 Volcano 调度器接管而不是默认调度器。除了JobVolcano 还提供了Queue和PriorityClass。Queue是资源队列说白了就是给不同业务方划分一组资源池再配上不同的优先级和权重。PriorityClass则是作业内部的优先级决定同一队列里谁的资源配额先用。3. Volcano 调度 AI 作业的完整流程拆解3.1 提交作业时发生了什么当使用 Volcano 调度 AI 作业时整个流程分为几个阶段我从提交开始讲起。第一步用户把定义好的Volcano JobYAML 提交到 Kubernetes API Server。Volcano 的 Controller 组件会始终监听Job资源的变化一旦发现新的Job被创建就会把它放入自己的调度队列等待处理。此时这个Job里的 Pod 都已经通过 Kubernetes API 创建但调度器还没有给它们分配节点因此都处于 Pending 状态。第二步Volcano 的调度器vc-scheduler从调度队列中取出待调度的作业执行完整的调度算法。调度算法不是简单的一个步骤而是由多个 Action动作和 Plugin插件组合协同完成的。默认流程包含enqueue入队、allocate分配资源、preempt抢占、backfill回填等环节。具体到 AI 作业最核心的是allocate阶段。调度器会遍历作业里的所有任务先检查整个作业是否满足minAvailable条件。这里的“满足”不是只看某个 Pod 能不能找到节点而是要看整个作业的所有 Pod 作为整体是否能找到足够的节点资源来容纳。这就是 Gang Scheduling 的实现方式只有当所有 Pod 都有可行节点时整个作业才会被一次性调度否则一个 Pod 都不会调度作业继续在队列里等待。第三步当调度器为作业中的所有 Pod 都找到了合适的节点后会调用 Kubernetes 的 Bind API把每个 Pod 绑定到对应节点。这一步骤本质上和原生调度器没有区别但区别在于 Volcano 是一次性批量绑定多个 Pod而不是逐个独立处理。第四步Kubelet 接收到 Pod 绑定信息后在节点上拉取镜像、启动容器。此时才能在节点上看见 GPU 进程跑起来。3.2 调度器里的核心动作和插件Volcano 的调度过程由actions和plugins组成。不了解这个概念时容易把 Volcano 当成一个黑盒出了问题也不知道从哪排查。我第一次部署的时候就因为没有搞清楚enqueue和allocate的区别导致作业一直处于 Pending 状态后面排查了大半天。默认配置下Volcano 调度器的调度循环会依次执行enqueue、allocate、preempt、backfill这几个动作。下面是一个常见的调度器配置示例spec: actions: enqueue, allocate, backfill tiers: - plugins: - name: priority - name: gang - name: conformance - plugins: - name: overcommit - name: drf我建议在实际项目中先按这个配置跑通基本流程再根据需求调整。enqueue动作负责把符合入队条件的作业从队列中取出。入队的判断依据是队列资源是否足够。每个Queue都有一个capability表示这个队列可以使用多少资源。如果队列的剩余配额不足作业就会留在队列中等待即使集群里空闲了大量资源也没用。这一点和很多人的直觉不一样我第一次配置时给了一个比较小的队列配额结果作业怎么都跑不起来后来才发现是队列capability设得太小了。allocate动作用于为作业中的 Pod 找到节点并完成绑定。这个动作里会调用各种插件来做出决策。backfill动作则是在完成主要调度后检查是否有空闲资源可以填满。对于一些不需要组调度的小任务比如单个 Pod 的推理服务或数据预处理任务它们可以在backfill阶段被放到空闲节点上充分利用碎片资源。插件方面priority插件决定作业在队列中的调度顺序它根据作业的PriorityClass来排序。gang插件是负责实现组调度的核心插件它监控作业中已成功调度的 Pod 数量是否达到minAvailable要求。drf插件实现基于主导资源公平分配的算法确保多个队列之间的资源分配尽量公平。proportional插件则用来根据权重在队列之间按比例分配资源。举一个直观的例子一个Queue里有三个作业作业 A 申请 32 卡作业 B 申请 16 卡作业 C 申请 4 卡而此时集群里刚好只剩 32 卡空闲。如果没有优先级差异drf插件会按照各作业申请的资源的“权重”来决策作业 A 作为最大的请求者可能得到更多份额。但如果你给作业 C 设置了很高的优先级priority插件就会让作业 C 先获得调度机会。这就是 Volcano 的复杂之处它不只是“先到先得”而是综合考虑队列配额、公平性、优先级和资源碎片后做出决策。4. Volcano 调度 AI 作业的实用配置与实操记录4.1 队列Queue的设计与配置在真正开始使用 Volcano 之前我建议先规划好队列结构。一个典型的划分方式是按照业务团队或任务类型来创建队列。比如可以给核心训练团队创建一个生产队列给算法研究团队创建一个开发队列再给临时任务创建一个低优先级队列。创建一个队列的 YAML 如下apiVersion: scheduling.volcano.sh/v1beta1 kind: Queue metadata: name: production spec: weight: 2 capability: cpu: 200 memory: 800Gi nvidia.com/gpu: 128展开说说weight和capability的作用。capability是硬性的资源上限表示这个队列最多能使用多少资源。如果三个队列的capability加起来超过集群总资源那么超过部分只是配额共享并不代表某个队列一定能拿到那么多资源。实际能分配的资源仍然受集群物理资源的限制。weight是一个权重值影响队列之间按比例分配资源的效果。假设集群总共有 400 张 GPUA 队列权重是 3B 队列权重是 1那么在没有其他限制的情况下A 队列可期望拿到的资源大约是 B 队列的三倍。不过这里的实际比例还会受到任务实际申请量、当前空闲资源、优先级等多个因素影响。需要注意capability一旦配置作业调度是严格按照队列剩余配额来检查的。比如production队列的 GPU 上限是 128已经有作业占用了 120那么新作业如果要申请 16 张 GPU只能继续等待哪怕集群其他队列还有大量空闲 GPU系统也不会把作业调度到production队列之外。所以队列配额一定要根据真实需求规划过紧容易导致资源浪费过松则失去了排队控制的意义。4.2 作业优先级配置让关键任务先跑同一个队列内如果有多个作业调度顺序如何决定靠PriorityClass。Volcano 的作业优先级可以用 Kubernetes 标准的PriorityClass来定义。先创建 PriorityClassapiVersion: scheduling.k8s.io/v1 kind: PriorityClass metadata: name: high-priority value: 1000 globalDefault: false description: High priority for critical training jobs然后在Volcano Job的spec里引用它spec: schedulerName: volcano priorityClassName: high-priority minAvailable: 8 tasks: ...多个作业在同一个队列中排队时优先级高的作业会优先获得调度机会。这一点在处理同时提交的一批任务时特别有用。比如某团队同时提交了几个作业其中一个是明天要出结果的模型微调另外几个是探索性实验完全可以通过优先级让关键任务先跑。不过高优先级并不等于“无限抢占”。如果在actions中没有启用preempt动作高优先级作业只能在资源充足时优先调度如果资源已经被低优先级作业占满高优先级作业并不会主动抢占。因此如果想要实现“低优先级作业主动让位”需要启用preempt动作同时给低优先级作业配置preemptable: true。这一点我在实战中踩过坑后面专门讲。4.3 典型的大模型分布式训练作业配置示例下面给一个稍复杂一点的完整示例模拟一个典型的 PyTorch 分布式训练作业假设它需要 4 个节点每节点 8 张 GPU总共 32 张 GPU。这个作业由 4 个相同角色的 Worker 组成它们之间通过环境变量MASTER_ADDR和RANK进行通信协调。apiVersion: batch.volcano.sh/v1alpha1 kind: Job metadata: name: pytorch-distributed-train spec: minAvailable: 4 schedulerName: volcano priorityClassName: high-priority policies: - event: PodEvicted action: RestartJob tasks: - name: worker replicas: 4 template: spec: containers: - name: pytorch-worker image: pytorch/pytorch:2.0.0-cuda11.7-cudnn8-runtime command: - /bin/bash - -c - | MASTER_ADDRworker-0 MASTER_PORT23456 WORLD_SIZE4 RANK${POD_NAME##*-} python /workspace/train.py --master-addr$MASTER_ADDR --master-port$MASTER_PORT --world-size$WORLD_SIZE --rank$RANK env: - name: POD_NAME valueFrom: fieldRef: fieldPath: metadata.name resources: limits: nvidia.com/gpu: 8 cpu: 32 memory: 256Gi这个示例里minAvailable设为 4表示需要 4 个 Worker 全部调度成功才能启动。Volcano 会自动生成这 4 个 Pod 的多副本每个 Pod 的名称带有序号后缀所以脚本里可以用名称后缀作为 RANK。这里值得注意的一个细节是Volcano Job 的 Task 名称会作为 Pod 名称前缀比如上面示例里worker任务生成的 Pod 可能命名为pytorch-distributed-train-worker-0。因此MASTER_ADDRworker-0这样的主机名能否正确解析取决于集群所使用的 DNS 插件是否支持通过 Service 或 Headless Service 进行 Pod 名称解析。在实际集群中通常需要先创建一个 Headless Service 来让这些 Pod 可以互相通过主机名访问。这是我第一次配置时没有考虑到的结果训练任务启动后各个 Worker 之间互相找不到对方。正确做法是先给该作业创建相应的 Service并确保它的名称与 Pod 名称匹配。这一问题在 Kubernetes 网络文档中介绍过实际操作时务必要先确认。4.4 与kubectl配合使用的日常操作使用 Volcano 提交作业和查看状态整体上还是以kubectl为主。常规操作示例如下。提交一个作业kubectl apply -f job.yaml查看 Volcano Job 列表和详细状态kubectl get vcjob kubectl describe vcjob pytorch-distributed-train查看作业对应的 Pod 状态kubectl get pods -l job-namepytorch-distributed-train查看队列状态kubectl get queue kubectl describe queue productiondescribe vcjob的输出特别适合排查调度问题。你可以看到作业当前的阶段Pending/Running/Completed、最小可用成员数、任务的调度失败原因、事件记录等。5. 使用 Volcano 调度 AI 作业的进阶技巧与踩坑记录5.1 坑一minAvailable设置不当导致资源死锁minAvailable是 Volcano 里最核心的参数之一设置不当会引起非常隐蔽的资源死锁。一个典型场景是集群里有 30 张空闲 GPU同时提交了两个作业作业 A 需要 20 张 GPU作业 B 需要 20 张 GPU。如果这两个作业同时进入调度队列Volcano 的gang插件不允许任何一个作业在资源不足时部分启动所以两个作业都会等待直到集群能腾出 40 张 GPU 为止。但如果集群本身就是 30 张 GPU这个状态就会变成一个死锁作业 A 等作业 B 释放作业 B 等作业 A 释放。根本解法是不要把minAvailable设得过高。比如训练任务实际需要 8 个 Worker可以先设置为 6但minAvailable只控制“调度启动的门槛”并不保证所有 Worker 都会一直存活。如果你确实要求所有 Worker 必须同时启动那么设置高minAvailable是合理的但同时要警惕集群资源无法满足全部请求时导致的死锁。一种处理策略是配合超时策略比如在policies中配置spec: policies: - event: JobUnschedulable action: OverrideGang含义是当作业在一段时间内无法满足组调度条件时允许作业以部分副本启动。不过需要注意的是这种策略要根据业务对“训练完整性”的容忍度来谨慎使用。像分布式训练这类任务通常不推荐使用这个策略因为 Worker 数量不足会导致训练直接失败。更好的做法是在平台侧做资源预留或资源配额控制避免多个超大作业同时进入资源不足以全部满足的队列。5.2 坑二开启抢占后低优先级作业被反复杀死Volcano 的preempt动作可以解决高优先级作业在资源不足时仍能运行的问题但如果配置不当会有另一个问题低优先级作业被反复抢占导致集群里大量任务处于“启动-被抢占-重启-再被抢占”的循环中。我遇到过一个真实场景一个低优先级的模型评估作业占用了一批 GPU此时来了一个高优先级的训练作业需要调度。因为启用了preempt高优先级作业会立即抢占低优先级作业的资源低优先级作业的 Pod 被驱逐。然而被驱逐的作业如果在policies里配置了RestartJob它又会重新排队。如果高优先级作业资源需求很大低优先级作业可能会在短时间内被连续抢占多次整体集群频繁波动。要避免这个问题可以从几个角度入手。一是在低优先级作业中显式标注preemptable: false让它不要被抢占二是使用defaultQueue和weight的组合让低优先级作业在队列层面获得较小权重而不是通过抢占来实现资源让位三是给抢占动作增加冷却时间或阈值。这些控制参数在 Volcano 官方配置中都有对应项但需要根据场景调优。5.3 坑三队列capability导致作业“卡死”前面提到enqueue动作会先检查队列剩余配额再决定是否允许作业入队。如果一个作业已经进了队列但队列配额不足它就会一直处于 Pending 状态。这个行为很容易被误判为调度器有问题。我在调试一个推理服务时明明集群有很多空闲 GPU作业却一直无法调度。检查后发现default队列的capability里没有设置 GPU 配额导致任何需要 GPU 的作业都无法入队。因为capability未声明nvidia.com/gpu时队列对 GPU 资源的配额是 0。改成显式声明后问题马上解决。由此得到一个经验对于使用 GPU 的 Volcano 集群创建队列时一定要记得列出 GPU 资源类型。如果队列需要支持多种加速卡比如 A100、V100 或昇腾卡还需要把不同资源必须在capability中分别声明不能只写一个gpu。该capability字段需要配置为实际的资源类型名如nvidia.com/gpu。5.4 坑四节点资源碎片化导致组调度失败组调度对集群资源碎片化程度要求比较高。举个例子集群有 4 个节点每节点 8 张 GPU。需要调度一个请求 16 张 GPU 的作业。最理想的情况是占用两个节点各 8 张但如果此时集群中的空闲 GPU 分布是节点 A 空 1 张、节点 B 空 5 张、节点 C 空 4 张、节点 D 空 6 张那么加起来有 16 张 GPU但没有任何一个节点能单独容纳 8 张 GPU 的容器请求所以这个作业无法调度必须等资源碎片被整理。通常的解法是结合节点亲和性、Kubernetes 的descheduler或弹性资源池来减少碎片化。不过如果业务以中小规模作业为主碎片化问题不明显。对于大量需要整节点资源的大作业建议考虑预留整节点或使用拓扑感知调度能力。6. 在大规模集群中验证 Volcano 调度能力6.1 模拟压测同时提交大量作业我在一个模拟集群中做了这样一次压测集群共 20 个节点每节点 4 张 GPU总共 80 张 GPU。同时向一个队列提交了 30 个作业其中 10 个作业各申请 8 张 GPU20 个作业各申请 1 张 GPU。所有作业的minAvailable与副本数一致。使用原生调度器基线测试时部分 8 卡作业会处于部分启动状态导致作业无法运行同时 1 卡作业的资源也受到影响。切换到 Volcano 后8 卡作业以组调度方式一次性占用整块资源1 卡作业被backfill阶段分配到剩余碎片资源。最终10 个 8 卡作业全部成功运行1 卡作业有 17 个成功运行3 个因资源不足排队等待。整体资源利用率达到 92% 左右。这次压测给我的体会是Volcano 和原生调度器最大的区别不只是“调得快”而是“调得整”。它把作业作为一个整体来分配资源避免了部分启动导致的死锁和资源空转。6.2 观察调度事件如何确认作业真的被组调度要验证 Volcano 是否正确执行了组调度最直接的方法是观察调度事件。先用一条命令持续查看作业状态变化kubectl get vcjob -w然后再看具体作业的 Describe 输出中是否包含类似“Gang scheduling”、“Enqueue”等事件信息kubectl describe vcjob test-job正常情况下一个 8 副本作业的日志中会一次性出现所有 Pod 的绑定事件而不是逐个出现且中间有时间差。如果观察到部分 Pod 已绑定、部分 Pod 一直未绑定并且绑定过程中伴随资源不足的 Event那么大概率是minAvailable或资源碎片的问题。另外可以观察vc-scheduler的日志里面有详细的调度决策过程。比如kubectl logs -n volcano-system deployment/vc-scheduler --tail200日志中可以搜索job 作业名或task 任务名确认作业被分配到的节点列表是否完整。如果发现某个作业经常出现在“enqueue success”但一直没有“allocate success”优先检查队列配额和资源拓扑。6.3 与kube-scheduler并存的实际做法很多团队不会一下子把所有工作负载都迁移到 Volcano而是让默认调度器和 Volcano 共存。这种模式完全可行两种调度器可以同时运行在同一个集群中。具体做法是普通微服务应用继续使用默认调度器AI 作业在 YAML 中显式声明schedulerName: volcano。Kubernetes 会根据 Pod 里schedulerName字段把调度请求路由给对应的调度器。默认调度器只会调度schedulerName为其自身名称的 PodVolcano 只会调度schedulerName为volcano的 Pod。两者互相不影响。不过需要注意如果你在集群中安装了 Volcano 调度器并暴露为schedulerName: volcano那么原有使用默认调度器的 Pod 并不会受影响。但如果某一次 YAML 忘写了schedulerName: volcano这个 AI 作业就会落到默认调度器上表现又回到原来的各种 Pending 随机状态。这个问题的隐蔽性比较高建议在平台层统一维护模板避免业务方手写 YAML 时漏掉。7. 写在最后我的一些实操感悟整套用下来我对 Volcano 的评价是它并不是一个重到需要专门维护一套独立体系的组件而是在 Kubernetes 的基础上做了一组很聪明的增强插件。它对 AI 作业的“接纳”体现在资源入队和配额管理“调度”体现在组调度、优先级和公平性上。两者结合才真正让多团队共享 GPU 集群变得可管理。如果让我给刚开始接触的人推荐一个落地路径第一步是先在测试集群部署 Volcano跑通一个最简单的单任务作业第二步是为项目创建 Queue 并配置 capability第三步是实验不同 priorityClass 下的调度顺序变化第四步才是上一定规模的分布式训练作业。不要一上来就直接复刻大规模生产的复杂配置否则遇到问题很难定位是配置问题还是网络问题、调度问题。另一个我反复体会到的点是Volcano 的参数不是越多越好。默认配置其实已经覆盖了大部分场景很多高级参数只在特定情况下才有意义。理解每个 Action 和 Plugin 的实际含义比盲目调参重要得多。比如preempt、backfill、drf这三个插件的组合在不同业务下差异很大在配置前最好先想清楚你的集群到底缺的是资源利用率还是调度顺序还是排队公平性。最后再分享一个小技巧调试 Volcano 作业时牢牢盯住两个方面一是队列配额和剩余资源二是作业的minAvailable与当前可行节点数量。这两个信息能解决 80% 的“作业无法调度”问题。剩下 20% 的问题通常和网络、镜像拉取、存储卷绑定有关那已经不是调度器能单独解决的范畴了。如果你也在为 AI 训练平台选择调度方案Volcano 值得尝试。它提供的并不只是调度性能的提升而是把“作业”重新放回调度视角的中央让分布式训练这件事在 Kubernetes 上变得顺理成章。