KubeRay 与 Volcano:从 RayJob 到 PodGroup

第六至第八篇已经解释 Volcano 怎样观察一组 Pod、尝试分配和处理资源竞争。这篇回到 KubeRay:用户提交的是 RayJob,调度器需要的却是 PodGroup、Queue 引用和带有分组信息的 Pod。这些对象和字段由谁填写?

继续使用 default/demo-job:新建专用 RayCluster,一个 head Pod、compute 组两个 worker Pod,K8sJobMode,关闭自动扩缩容,程序和依赖在镜像内。worker 组的 numOfHosts 为一,即本例每个副本对应一个 Pod;第 4 节再说明多 host 对数量计算的影响。启用 Volcano,使用 Queue ray-batch。专用集群名仍示意为 demo-job-abcde,实际读取 status.rayClusterName;PodGroup 沿用第六篇的 ray-demo-job-pg

源码基线为 KubeRay 6bf05eb17a3e、Volcano d8984501e4ad。KubeRay 依赖的 volcano.sh/apis v1.13.0 是 API 类型库版本,不能用它推断集群中 Scheduler 的运行版本。下文核对字段与调用链,未执行两端部署兼容性实验。

1. 接入点在 Operator 内部

接入先涉及两边的配置:Kubernetes 中需要有 Volcano 的 CRD 和相关组件;KubeRay Operator 选择 Volcano 适配器,并有读写调度资源的权限。Queue 也要存在且处于可用状态。只在 Ray Pod 模板里写一个 schedulerName,不会替你生成完整的组需求。

这个版本可通过 Operator 参数 --batch-scheduler=volcano 选择适配器;通过 Helm 配置时,对应 batchScheduler.name: volcano。若使用 Operator 配置文件,应以其实际加载值为准。它是 Operator 级的选择,不是每个 RayJob 额外启动一个调度器。

SchedulerManager 按配置选择 factory,也就是负责构造对应适配器的工厂实现,由它创建 VolcanoBatchScheduler。这个对象持有 Kubernetes client,运行在 KubeRay Operator 进程里。这里的 Manager 属于批调度接入模块,与前文启动 Controller 的 controller-runtime Manager 是不同对象。Volcano Scheduler 则是另一进程,两者通过 Kubernetes 资源协作。

KubeRay 的 BatchScheduler 适配器在 Operator 进程内。它创建和更新 Kubernetes 资源;独立的 Volcano Scheduler 观察这些对象并绑定 Pod。两者之间没有直接的作业提交 RPC。
KubeRay 的 BatchScheduler 适配器在 Operator 进程内。它创建和更新 Kubernetes 资源;独立的 Volcano Scheduler 观察这些对象并绑定 Pod。两者之间没有直接的作业提交 RPC。 查看原图

batchscheduler/interface/interface.go 把接入动作分为几处:

接口方法 调用目的 Volcano 实现
DoBatchSchedulingOnSubmission 创建工作负载时表达调度需求 为 RayJob 或直接提交的 RayCluster 创建、更新 PodGroup
AddMetadataToChildResource 构造子资源时传播调度关联 填写分组 annotation、相关 label 和 Pod 的 schedulerName
CleanupOnCompletion 工作负载收尾时调整调度资源 根据存活集群重新计算需求;不同调用方有不同处理

factory 还负责注册 API 类型和配置相关 watch。接口名里的 BatchScheduler 容易让人误以为 KubeRay 自己实现了节点选择;在这个适配器中,核心工作是把已有生命周期转换成调度器能够观察的声明。

2. PodGroup 怎样创建,归谁拥有

RayJob Controller 在 Initializing 分支先调用 DoBatchSchedulingOnSubmission,再调用 getOrCreateRayClusterInstance。这保持了一个明确的顺序:先尝试维护组需求,然后继续维护集群。PodGroup 创建失败时,本轮返回错误,后续协调再试。

适配器的入口按资源类型分派,下面是连续源码节选:

1
2
3
4
5
6
7
8
switch obj := object.(type) {
case *rayv1.RayCluster:
return v.handleRayCluster(ctx, obj)
case *rayv1.RayJob:
return v.handleRayJob(ctx, obj)
default:
return fmt.Errorf("unsupported object type %T, only RayCluster and RayJob are supported", object)
}

handleRayJob 用 RayJob 名生成 ray-demo-job-pg,owner reference 指向 RayJob。RayCluster Controller 后续也会调用适配器,但 handleRayCluster 发现集群来自 RayJob 时直接返回,避免再创建一份以随机集群名命名的 PodGroup。

直接创建 RayCluster 是另一条路径:组名取自 RayCluster 名,owner reference 指向 RayCluster。若 RayJob 使用 clusterSelector 引用已有集群,当前适配器的 handleRayJob 会因没有 RayClusterSpec 而报“不支持引用已有 RayCluster 的 Gang 调度”。专用集群例子不能原样套到已有集群模式。

syncPodGroup 先按 namespace/name 查询,找不到时创建;创建遇到 AlreadyExists 时返回,让后续观察继续收敛。已存在时比较 minMemberminResources,变化才更新。

这里也有一个动态管理限制:这条更新分支只同步最低成员和资源量。Queue、PriorityClass 等字段在创建时填写,不能据此认为运行中改 RayJob label 就会把已有 PodGroup 自动迁移到另一个 Queue。要支持这类操作,需要另外明确更新规则和执行时机。

主案例的拥有关系与调度关联。实线表示 owner 链,虚线表示 PodGroup 关联。submitter Pod 由 Kubernetes Job Controller 创建;三类 Pod 的 task-spec 值各不相同。
主案例的拥有关系与调度关联。实线表示 owner 链,虚线表示 PodGroup 关联。submitter Pod 由 Kubernetes Job Controller 创建;三类 Pod 的 task-spec 值各不相同。 查看原图

3. 为什么最低成员数不包含 submitter

假设 head、一个 worker、submitter 的资源需求向量分别是 H、W、S。没有自动扩缩容时,适配器调用 CalculateDesiredReplicasCalculateDesiredResources,得到 head 加全部期望 worker 的规模。handleRayJob 再追加 submitter 的资源需求。

项目 本例计算结果
minMember 1 个 head + 2 个 worker = 3
minResources H + 2W + S
submitter 是否属于这个 PodGroup 是,稍后生成的 Pod 关联同一组
submitter 是否计入启动时最低成员数

这个差别来自启动依赖。RayJob Controller 要先观察 RayCluster 就绪,再创建 submitter Job。如果把 submitter 也算进最低成员数,组就必须凑够四个成员;但第四个 Pod 此时根本没有创建。Gang 不放行,集群起不来,submitter 也就等不到创建条件。

左侧展示把 submitter 计入 minMember 后的等待环;右侧是当前实现:先让三个 Ray Pod 满足最低成员要求,集群就绪后再创建 submitter。资源记账仍提前包含 S。
左侧展示把 submitter 计入 minMember 后的等待环;右侧是当前实现:先让三个 Ray Pod 满足最低成员要求,集群就绪后再创建 submitter。资源记账仍提前包含 S。 查看原图

handleRayJob 中追加资源的连续节选是:

1
2
3
4
submitterResource := getSubmitterResource(rayJob)
totalResourceList = append(totalResourceList, submitterResource)
_, err := v.syncPodGroup(ctx, rayJob, minMember, utils.SumResourceList(totalResourceList))
return err

minMember 在追加之前已经算好,不会因这段代码增加。相应注释也明确说明,排除 submitter 是为了避免启动死锁。

若另取一组便于计算的资源:H 为 1 核、2 GiB,W 为 2 核、4 GiB,S 为 0.5 核、512 MiB(0.5 GiB),那么组的最低资源量是 5.5 核、10.5 GiB。这只是计算示例,不是前面 YAML 的实际资源值。前三个 Pod 的请求合计为 5 核、10 GiB,余下的差额对应尚未创建的 submitter。

这份差额怎样被 Volcano 看见?第七篇选定配置中的 proportion 和 overcommit 会读取 minResources。在组已运行且满足相应成员条件的分支中,GetInqueueResource 按各资源维度计算最低资源量减去已分配量的正差额,再计入待分配账目。上例的 S 因而可以继续参与后续准入约束。

这个机制没有给 submitter 提前选定某台节点,也没有创建一个占位 Pod。它到来时仍需通过队列可分配性、节点过滤和绑定。节点碎片、放置约束或其他资源变化都可能让它等待,不能把 minResources 写成“保证 submitter 随时启动”的承诺。

4. 资源计算的口径要与实际 Pod 对照

当前 CalculatePodResource 遍历 podSpec.Containers:每个容器先读 requests,某个资源键缺失时再从 limits 补入,然后逐容器求和。这里 requests 是用于调度的资源请求,limits 是相应使用上限;函数读取的是模板配置,不是容器实时用量。

实际 Pod 还可能包含 init container,即承担初始化工作的容器;其中普通 init container 先于应用容器执行。Pod overhead 则描述容器之外、Pod 运行环境自身的资源开销。这个函数没有完整复刻 Kubernetes 对这些项目的资源计算规则。

因此,本例采用普通容器、显式资源请求,不额外引入这些因素。若模板包含资源较大的 init container,或 Admission 阶段自动加入伴随应用运行的 sidecar 辅助容器,应同时核对最终 Pod 请求与适配器生成的 minResources。模板里算出一个数,不代表最终 Pod 的完整资源请求就是这个数。

开启自动扩缩容又是另一项条件变化。calculatePodGroupParams 的函数体节选如下:

1
2
3
4
5
6
rayCluster := &rayv1.RayCluster{Spec: *rayClusterSpec}

if !utils.IsAutoscalingEnabled(rayClusterSpec) {
return utils.CalculateDesiredReplicas(rayCluster) + 1, utils.CalculateDesiredResources(rayCluster)
}
return utils.CalculateMinReplicas(rayCluster) + 1, utils.CalculateMinResources(rayCluster)

扩缩容场景使用最低副本数,加上 head;它不会把最大扩容规模都变成启动门槛。后续扩出的 worker 仍要竞争资源。计算还会将 worker 组的副本数乘以 numOfHosts:例如 2 个副本、每个副本 4 个 host,对应 8 个 worker Pod。暂停的组会被跳过。因此,只有先确定这些配置,才能从 replicas 算出 Pod 总数。

直接 RayCluster 的协调会重新计算其组需求;RayJob 派生集群则跳过这条路径。RayJob 的需求维护集中在初始化与收尾调用中,因此不应把这个适配器描述成“运行时每次 worker 数变化都同步调整整个 RayJob 的 Gang 门槛”。

RayJob 另有一种提交方式叫 SidecarMode,提交程序作为 head Pod 内的辅助容器运行,资源计算因此有专门分支。它与前面泛指辅助容器的 sidecar 概念相关,但这里是具体的模式名。本篇的三个 Ray Pod 加一个独立 submitter Pod,只适用于已固定的 K8sJobMode。

5. Pod 怎样关联到同一组,再完成提交

RayJob 构造专用 RayCluster 时传播相关元数据;RayCluster 构造 head 和 worker Pod 时再次调用 AddMetadataToChildResource。RayJob 创建 submitter Job 前,也会对其 Pod 模板调用这个方法。

核心关联由 populateAnnotations 写入。下面两行保持源码形式,常量值在表中展开:

1
2
annotations[volcanoschedulingv1beta1.KubeGroupNameAnnotationKey] = getAppPodGroupName(parent)
annotations[volcanobatchv1alpha1.TaskSpecKey] = groupName
字段 本例的值或来源 用途
Pod spec.schedulerName volcano 指定处理 Pod 的调度器
annotation scheduling.k8s.io/group-name ray-demo-job-pg 关联 PodGroup
annotation volcano.sh/task-spec head 为 headgroup,worker 为 compute,submitter 为 submittergroup 区分组内成员角色
label volcano.sh/queue-name RayJob 上的 ray-batch 创建 PodGroup 时填写 spec.queue
label ray.io/priority-class-name 若配置则读取对应值 创建 PodGroup 时填写 spec.priorityClassName

以上最后两项说的是适配器创建 PodGroup 的配置来源;并不表示这些 label 会自动替代所有 Pod 级优先级配置。分组 annotation 也不会改变 Pod 的 owner。

这一条接入路径使用 KubeRay 的 Volcano 适配器,以及 Volcano Scheduler 的调度插件。Volcano 源码中的 Job Controller Ray 插件属于原生 Volcano Job 的框架支持路径,不是本例中创建 head、worker 的必经步骤。

从 RayJob 声明到用户程序提交,再到收尾。调度与绑定、容器启动、RayCluster 就绪和 Ray Jobs API 提交是不同阶段;时间间隔只表示先后关系。
从 RayJob 声明到用户程序提交,再到收尾。调度与绑定、容器启动、RayCluster 就绪和 Ray Jobs API 提交是不同阶段;时间间隔只表示先后关系。 查看原图

如果 submitter 已经运行但 Ray 作业没有启动,应继续检查它连接的 Dashboard 地址和 Ray Jobs API 的提交结果。调度器已经把 Pod 放到节点上,不会代替 submitter 上传工作目录,也不会为业务保存版本化的代码包。Ray 的 runtime_envworking_dir 指定工作目录,用 py_modules 提供 Python 模块;本地内容的打包、上传属于相应客户端与 Ray 运行环境流程。若还要保存版本、管理业务记录并重复提交,则需要提交平台承担这些职责。本系列的镜像内程序案例没有执行这套上传流程。

6. 完成、暂停和重试以后,PodGroup 怎么办

主案例保留前五篇的结束配置:正常完成后等待 60 秒删除专用集群,保留 RayJob 等记录。在删除发生之前,存活的 head、worker 仍然占用节点资源。收尾不能只把 PodGroup 的资源量无条件置零。

CleanupOnCompletion 会用 status.rayClusterName 查实际集群,再重算需求:

观察到的情况 当前实现的处理
集群存在且没有进入删除流程 按存活集群 spec 重算最低成员和资源量
worker 组被暂停,例如结束后只删除 worker 的 DeleteWorkers 路径 计算跳过暂停组,保留 head 等仍需资源的部分
集群已不存在,或已有 deletionTimestamp 把最低成员和资源需求更新为空
K8sJobMode 正常完成的独立 submitter 收尾计算不再追加其资源
直接创建的 RayCluster 这个 Cleanup 方法不处理,PodGroup 生命周期跟随其 owner

更新为零也不等于节点资源已经释放。特别是对象刚进入删除流程时,Pod 可能还在终止。实际占用仍要随 Pod 生命周期与调度器观察变化。PodGroup 本身可以继续存在,最终是否由垃圾回收删除,要看拥有它的 RayJob 或 RayCluster 是否被删除。

暂停与整次执行重试使用相同的清理阶段:先等待集群和 submitter Job 删除完成,再调用批调度资源清理,成功后才重置集群名、作业 ID 等状态。这个分支清理出错会返回错误,下一轮继续处理。新的执行重新初始化时,RayCluster 名和 submission ID 可以改变,仍存活的 RayJob 则继续使用按其名称生成的 PodGroup,并重新填写需求。

终态分支的错误处理不同。Complete/Failed 用 Go 的 defer 安排函数返回前执行清理,失败时记录事件和日志,但不会把该错误作为本次 Reconcile 的返回值。是否再次调用清理,要看外层是否安排重查以及后续是否有资源事件,不能一概写成“清理失败一定立即自动重试”。

watch 关系也要一起核对。Volcano factory 给 RayCluster Reconciler 增加 Owns(PodGroup);RayJob 的 SetupWithManager 并没有直接注册 PodGroup watch。本例 PodGroup 的 owner 是 RayJob,因此不能假设它每次变化都会通过 RayCluster 的 Owns 直接触发 RayJob。RayJob 继续依赖自己的资源观察和既有重查流程。

如果要在这套基础上自研任务托管,可以复用这样的分工:提交平台保存代码包和可重复提交的业务记录,KubeRay 维护集群与提交生命周期,Volcano 决定 Pod 如何获得资源。接口之间需要写清创建顺序、资源记账、失败重试和回收条件。尤其是运行中改队列、绑定部分失败、终态清理错误这几处,不能只靠“声明式”三个字省略处理过程。

参考资料


KubeRay 与 Volcano:从 RayJob 到 PodGroup
https://tanxinyu.work/kuberay-volcano-09-kuberay-volcano/
作者
谭新宇
发布于
2026年9月15日
许可协议