Kubernetes 1.36 原生 Gang 调度深度拆解:PodGroup/Workload API 如何终结 AI 训练的「半个作业」困局
〇、先看一个能把人熬到凌晨三点的现场
场景很典型:一个 8 卡的 PyTorch DDP 训练任务,用 Job + completionMode: Indexed 提交,每个 Pod 要 1 张 A100。集群里恰好剩 5 张卡。
默认 kube-scheduler 的处理是:能调度几个就调度几个。于是 5 个 Pod 起来了,rank 0 在 dist.init_process_group() 里死等剩下 3 个 rank,超时时间被工程师设成了 timeout=timedelta(minutes=30)(甚至有人写 1800s 以上)。剩下 3 个 Pod 停在 Pending,因为没卡。
结果是一个非常反直觉的状态:
- 集群 GPU 利用率监控显示 5 张 A100 被占用;
nvidia-smi里这 5 张卡的 SM 利用率是 0%;- 作业既没成功也没失败,就那么挂着;
- 更糟的是,另一个 4 卡任务此时提交,抢不到卡,也进入
Pending; - 如果两个任务都用了「部分占用」的策略,就会形成典型的资源互锁:A 占 5 张等 3 张,B 占 2 张等 2 张,谁都不肯让,谁都跑不动。
这不是配置问题,是调度模型的语义缺陷:默认调度器的调度单元是 Pod,而分布式训练的语义单元是「作业」。二者之间差着一个 All-or-Nothing。
Kubernetes 1.36 在调度器侧终于把这件事往上游推了一大步:引入 scheduling.k8s.io/v1alpha2 的 Workload / PodGroup API,支持工作负载级调度(Gang Scheduling),配套还有拓扑感知工作负载调度(TAS,Alpha)、工作负载感知抢占(抢占整个 PodGroup 而非单个 Pod)、PodGroupPodsCount 打分插件。同一版本里 DRA(动态资源分配)也进了一批特性:优先级列表 GA、管理员访问 GA、可消费容量、可切分设备、设备污点与容忍、设备健康监控。
这篇文章不打算做 Release Note 搬运。我想讲清楚四件事:
- 为什么「All-or-Nothing」在 Kubernetes 里是个结构性难题,而不是加个字段就能解决的;
- 社区三代解法(Volcano / coscheduling 插件 / Kueue)到底各自解决了什么、留下了什么坑;
- 1.36 原生方案的设计取舍,以及它没有解决什么;
- Gang 调度和 DRA 相遇时那个最要命的交叉点——DRA 目前不支持抢占,这对 AI 集群意味着什么,以及现在能怎么绕。
代码、YAML、源码扩展点、排障命令、容量模型都会给。能直接抄走的部分我尽量写全。
一、为什么默认调度器天生做不到 All-or-Nothing
1.1 调度单元的错位
kube-scheduler 的主循环,本质是一个单 Pod 的状态机:
activeQ 取出一个 Pod
→ PreEnqueue(1.26+ 门控)
→ PreFilter → Filter → PostFilter(抢占)
→ PreScore → Score → NormalizeScore
→ Reserve → Permit
→ [binding cycle 异步] PreBind → Bind → PostBind
注意两个关键事实:
第一,调度器一次只看一个 Pod。 它不知道这个 Pod 属于哪个作业,也不关心作业还有几个兄弟没落地。Job、StatefulSet、JobSet 这些控制器在 API 层有「组」的概念,但那是控制器的世界,调度器的世界里只有 Pod 和 Node。
第二,调度是「假定即提交」的。 通过 Reserve 之后,Pod 会被 assume 进 scheduler cache,节点资源立刻被扣减。哪怕 binding 还没完成,这份资源在调度器眼里已经属于这个 Pod 了。也就是说,部分调度成功的代价是立刻发生的。
这两条加起来,就得到了那个恼人的结论:默认调度器天然倾向于「尽可能多地放下 Pod」,而分布式作业需要的是「要么全放下,要么一个都别放」。
1.2 死锁不是玄学,是可以算出来的
把问题抽象一下。设集群可用资源为 $R$(这里就当 GPU 卡数),有 $n$ 个作业,作业 $i$ 需要 $g_i$ 张卡才能真正开跑,当前已占 $h_i$ 张($0 \le h_i < g_i$,即部分调度)。
只要满足:
Σ h_i > R - min(g_i - h_i)
即「所有作业已经吃掉的碎片资源」多到连最容易凑齐的那个作业都补不满,系统就进入了活锁:没有任何作业能前进,也没有任何作业会主动释放。
这跟哲学家就餐问题是同构的:每个哲学家拿了一只筷子(部分资源),都在等第二只。经典解法有两条路——要么一次性申请全部资源(Gang Scheduling),要么允许抢占/回滚(Preemption + Rollback)。Kubernetes 社区这些年做的所有努力,本质都在这两条路上打转。
1.3 浪费到底有多大:一个能拿去说服老板的模型
很多团队直觉上觉得「偶尔卡住一下没啥」。算一笔账就知道有多疼。
设:
- 单卡小时成本 $c$(自建集群按折旧+电力+运维摊,公有云按 list price);
- 作业规模 $g$ 张卡;
- 部分调度后的平均卡死时长 $T$(等于框架的
init_process_group超时或人工发现时间,取小者); - 部分调度发生的概率 $p$(可以用
kube_pod_status_phase{phase="Pending"}与作业提交数做估算); - 每天提交作业数 $N$。
日均浪费:
Waste_daily ≈ N × p × (g - 1) × T × c
代入一组不算极端的数:$N=60$、$p=0.08$、$g=8$、$T=0.5h$、$c=15$ 元/卡时:
60 × 0.08 × 7 × 0.5 × 15 ≈ 252 元/天 ≈ 9.2 万元/年
这还只是直接的卡空转。没算进去的隐性成本更大:
- 排队队列被卡住的 head-of-line blocking,后面的作业整体延迟上升;
- 研发人员发现问题、kill、重提交的心智负担(这个最贵);
- 为了「保险」而给作业申请冗余资源,导致集群整体装箱率下降。
在 $g$ 变大时这个模型是超线性恶化的:$g=64$ 的大模型预训练任务,一次部分调度卡住半小时,就是 31.5 卡时的净损失,而且这类任务往往优先级最高、最不能等。
二、社区三代解法:各自解决了什么,留下了什么坑
2.1 第一代:kube-batch / Volcano —— 干脆换一个调度器
思路最直接:既然默认调度器的模型不对,那就写一个新的。
Volcano 引入了 PodGroup 这个 CRD,核心字段是 minMember(也叫 minAvailable):
apiVersion: scheduling.volcano.sh/v1beta1
kind: PodGroup
metadata:
name: ddp-train-8gpu
namespace: ai-train
spec:
minMember: 8
minResources:
nvidia.com/gpu: "8"
cpu: "64"
memory: 512Gi
queue: research
priorityClassName: high-priority
Volcano 的调度是 session 化的:每个调度周期开始时对集群状态打快照,在这个快照里跑一整轮 enqueue → allocate → preempt → reclaim → backfill 的 action 链,然后一次性提交。因为它在一个 session 里能同时看到一个 PodGroup 的所有 Pod,所以 gang 判定是自然的:凑不齐 minMember 就整体不分配,已分配的在 session 结束时回滚(pipelined 状态不落盘)。
它真正的优势不只是 gang,而是那套 queue + reclaim 的多租户模型:队列有 weight 和 capability,队列间可以借用资源,被借走的资源在原主人需要时可以 reclaim 回来。这套东西默认调度器到今天也没有等价物。
坑在哪:
- 双调度器的一致性问题。 Volcano 和 kube-scheduler 各自维护一份 cache,看到的是同一个 apiserver 但状态更新有时间差。如果一部分 Pod 走
schedulerName: volcano、一部分走默认调度器,两边可能同时认为某个节点还有 4 张卡,各自 assume 一个 Pod 上去,最后 kubelet 侧 admit 失败,Pod 进入UnexpectedAdmissionError。规避方式是按节点池物理隔离,或者集群内全量统一走一个调度器。 - 生态插件的重复建设。 默认调度器社区做的新特性(比如 DRA 的完整支持、各种拓扑感知打分),Volcano 需要跟进移植,存在滞后。
- 运维成本。 多一个控制面组件,多一套 CRD、RBAC、监控指标、升级窗口。
2.2 第二代:scheduler-plugins 的 Coscheduling —— 在原调度器里长出 gang
sigs.k8s.io/scheduler-plugins 项目里的 coscheduling 插件是另一条路:不换调度器,用 Scheduling Framework 的扩展点在原地实现 gang。
关键在 Permit 扩展点。它的语义是:Reserve 之后、进入 binding cycle 之前,插件可以让这个 Pod「等一等」。返回 framework.Wait 加一个超时时间,Pod 就进入 waiting 状态,绑定被挂起,但调度循环本身不会被阻塞(这点非常关键,Permit 的等待发生在异步的 binding cycle 里,scheduling cycle 会继续处理下一个 Pod)。
于是 gang 的实现就成了:
- 每个 Pod 走到
Permit时,数一数同 group 里已经处于 waiting/assumed 的兄弟有多少; - 不够
minMember,返回Wait(timeout); - 够了,把所有 waiting 的兄弟一次性
Allow(),全组同时进入绑定; - 超时没凑齐,
Reject()全组,回滚 Reserve,Pod 回到队列重来。
我把一个可用的插件骨架写全,注释里标了每个扩展点的陷阱:
package coscheduling
import (
"context"
"fmt"
"time"
v1 "k8s.io/api/core/v1"
"k8s.io/apimachinery/pkg/runtime"
"k8s.io/kubernetes/pkg/scheduler/framework"
)
const (
Name = "Coscheduling"
PodGroupLabel = "scheduling.x-k8s.io/pod-group"
)
type Coscheduling struct {
handle framework.Handle
mgr *PodGroupManager // 负责查 PodGroup CRD、统计已就绪成员数
}
var (
_ framework.PreEnqueuePlugin = &Coscheduling{}
_ framework.QueueSortPlugin = &Coscheduling{}
_ framework.PreFilterPlugin = &Coscheduling{}
_ framework.PostFilterPlugin = &Coscheduling{}
_ framework.PermitPlugin = &Coscheduling{}
_ framework.ReservePlugin = &Coscheduling{}
)
func (cs *Coscheduling) Name() string { return Name }
// ---------------------------------------------------------------------------
// 1) PreEnqueue:门控。凑不齐总数的 group,连 activeQ 都别进。
// 这是避免 head-of-line blocking 的第一道闸,非常重要:
// 如果让注定失败的 Pod 反复进入调度循环,会白白吃掉调度吞吐。
// ---------------------------------------------------------------------------
func (cs *Coscheduling) PreEnqueue(ctx context.Context, p *v1.Pod) *framework.Status {
pg, err := cs.mgr.GetPodGroup(ctx, p)
if err != nil || pg == nil {
return framework.NewStatus(framework.Success) // 非 gang Pod,放行
}
// 集群里这个 group 实际创建出来的 Pod 数还不够 minMember,
// 说明控制器还没把 Pod 全建出来,先别进队列。
total, err := cs.mgr.CountPodsInGroup(ctx, pg)
if err != nil {
return framework.AsStatus(err)
}
if int32(total) < pg.Spec.MinMember {
return framework.NewStatus(framework.UnschedulableAndUnresolvable,
fmt.Sprintf("podgroup %s: %d/%d pods created", pg.Name, total, pg.Spec.MinMember))
}
return framework.NewStatus(framework.Success)
}
// ---------------------------------------------------------------------------
// 2) QueueSort:同一个 group 的 Pod 必须挨在一起出队。
// 否则 group A 的 Pod 和 group B 的 Pod 交替调度,
// 双方都在 Permit 里等对方让资源,直接互锁到超时。
// ---------------------------------------------------------------------------
func (cs *Coscheduling) Less(a, b *framework.QueuedPodInfo) bool {
prioA, prioB := corev1helpers.PodPriority(a.Pod), corev1helpers.PodPriority(b.Pod)
if prioA != prioB {
return prioA > prioB
}
// 关键:用 group 的创建时间而不是 Pod 的创建时间做二级排序,
// 保证整组的相对顺序稳定,避免组内 Pod 被其他组插队。
creationA := cs.mgr.GetCreationTimestamp(a.Pod, *a.InitialAttemptTimestamp)
creationB := cs.mgr.GetCreationTimestamp(b.Pod, *b.InitialAttemptTimestamp)
if !creationA.Equal(creationB) {
return creationA.Before(creationB)
}
return cs.mgr.GetPodGroupKey(a.Pod) < cs.mgr.GetPodGroupKey(b.Pod)
}
// ---------------------------------------------------------------------------
// 3) PreFilter:快速失败。集群剩余可分配资源明显小于 minResources 时,
// 没必要走完整的 Filter/Score,直接判死刑,省调度器的 CPU。
// ---------------------------------------------------------------------------
func (cs *Coscheduling) PreFilter(ctx context.Context, state *framework.CycleState,
p *v1.Pod) (*framework.PreFilterResult, *framework.Status) {
pg, _ := cs.mgr.GetPodGroup(ctx, p)
if pg == nil {
return nil, framework.NewStatus(framework.Success)
}
if err := cs.mgr.PreFilter(ctx, p); err != nil {
return nil, framework.NewStatus(framework.UnschedulableAndUnresolvable, err.Error())
}
return nil, framework.NewStatus(framework.Success)
}
// ---------------------------------------------------------------------------
// 4) Permit:gang 的心脏。
// 注意 Permit 的等待发生在 binding cycle,不阻塞 scheduling cycle。
// 超时时间要 > 整组 Pod 走完调度循环的预期耗时,否则永远凑不齐。
// ---------------------------------------------------------------------------
func (cs *Coscheduling) Permit(ctx context.Context, state *framework.CycleState,
p *v1.Pod, nodeName string) (*framework.Status, time.Duration) {
pg, _ := cs.mgr.GetPodGroup(ctx, p)
if pg == nil {
return framework.NewStatus(framework.Success), 0
}
ready := cs.countWaitingAndAssumed(pg) + 1 // +1 是当前这个
if int32(ready) < pg.Spec.MinMember {
return framework.NewStatus(framework.Wait, "waiting for gang members"),
gangWaitTimeout(pg) // 建议 60~300s,视集群规模
}
// 凑齐了:一次性放行全组。
cs.handle.IterateOverWaitingPods(func(wp framework.WaitingPod) {
if cs.mgr.GetPodGroupKey(wp.GetPod()) == cs.mgr.GetPodGroupKey(p) {
wp.Allow(cs.Name())
}
})
return framework.NewStatus(framework.Success), 0
}
// ---------------------------------------------------------------------------
// 5) Unreserve:回滚。这是最容易写漏的地方。
// 任何一个成员失败,必须把整组还在 waiting 的兄弟 Reject 掉,
// 否则它们会一直占着 assumed 资源直到 Permit 超时——
// 这段时间里,资源在 cache 里是"被占用"的,别的作业也进不来。
// ---------------------------------------------------------------------------
func (cs *Coscheduling) Unreserve(ctx context.Context, state *framework.CycleState,
p *v1.Pod, nodeName string) {
pg, _ := cs.mgr.GetPodGroup(ctx, p)
if pg == nil {
return
}
cs.handle.IterateOverWaitingPods(func(wp framework.WaitingPod) {
if wp.GetPod().UID != p.UID &&
cs.mgr.GetPodGroupKey(wp.GetPod()) == cs.mgr.GetPodGroupKey(p) {
wp.Reject(cs.Name(), "gang member failed to schedule")
}
})
cs.mgr.MarkGroupFailed(pg) // 打上 backoff 标记,避免立刻重试打爆调度器
}
这套实现的隐藏成本,是「假性资源占用窗口」。 从第一个 Pod 进入 Reserve 到最后一个成员 Allow 或整组 Reject,这段时间里资源在 scheduler cache 里已经被扣掉了,但实际上没有任何容器在跑。窗口越长,集群的有效利用率越低。所以 gangWaitTimeout 不是越大越安全——它是一个把「作业成功率」和「集群利用率」对冲的旋钮。经验取值:小集群(<100 节点)60s 起步,大集群或者 group 规模大的场景放到 180~300s,同时必须配合 PreEnqueue 门控,否则超时会变成常态。
2.3 第三代:Kueue —— 在准入层就把问题挡住
Kueue 的思路又不一样:它根本不改调度器,而是在作业准入这一层做文章。
Job 提交后先被 suspend(spec.suspend: true),Kueue 把它抽象成一个 Workload 对象,放进 LocalQueue → ClusterQueue 的配额体系里排队。只有当 ClusterQueue 里确认有足够的配额能容纳整个 Workload 时,才会 unsuspend,让控制器去创建 Pod。
apiVersion: kueue.x-k8s.io/v1beta1
kind: ClusterQueue
metadata:
name: research-cq
spec:
namespaceSelector: {}
cohort: shared-gpu # 同 cohort 的队列之间可以借还资源
resourceGroups:
- coveredResources: ["cpu", "memory", "nvidia.com/gpu"]
flavors:
- name: a100-flavor
resources:
- name: "nvidia.com/gpu"
nominalQuota: 64
borrowingLimit: 32 # 最多向 cohort 借 32 张
- name: "cpu"
nominalQuota: 512
- name: "memory"
nominalQuota: 4Ti
preemption:
reclaimWithinCohort: Any # 借出去的资源可以要回来
withinClusterQueue: LowerPriority
Kueue 解决的是「配额层面的 all-or-nothing」,不是「调度层面的」。 这个区别很多人搞混。Kueue 确认的是「账面上有 8 张卡的配额」,但账面有不等于物理上能凑出 8 张卡在合适的拓扑位置。集群碎片严重时,Kueue 放行的作业照样可能在调度器这一层卡住。
所以生产上的正解从来不是三选一,而是 Kueue(配额与排队)+ gang 调度器(物理放置)叠加:Kueue 负责「该不该现在跑」,gang 负责「能不能整体落地」。
三、1.36 的原生方案:Workload / PodGroup API
3.1 设计要点
1.36 引入 scheduling.k8s.io/v1alpha2,把 PodGroup 提升为调度器原生理解的一等公民。这带来几个质变:
第一,调度器内部有了「组」的状态机。 1.36 把调度框架里的 PodGroupInfo 重命名为 PodGroupState(这是个 breaking change,自定义插件必须改),说明社区把它当成一个真正的调度状态实体在维护,而不是插件私有的缓存。
第二,抢占从 Pod 级升级到 Workload 级。 这是我认为 1.36 最有价值的一条。以前抢占是「为了让 1 个 Pod 落地,驱逐 N 个低优先级 Pod」——问题是被驱逐的那 N 个 Pod 可能分属 3 个不同的 gang 作业,结果是杀掉 3 个作业只为了满足 1 个 Pod,而那 1 个 Pod 所属的作业最后还不一定能凑齐。这种「杀敌八百自损三千」的抢占,在 AI 集群里造成的破坏远大于收益。
Workload 感知抢占的语义是:以 PodGroup 为单位评估「抢占谁、抢占后我这一整组能不能落地」,抢不动就不抢。配套的接口变更是 PostFilterResult 现在需要返回被抢占的 Pod 列表——自定义插件必须适配。
第三,PodGroupPodsCount 打分插件。 倾向于把 Pod 放到「同组成员已经比较多」的位置。这本质是一种亲和性驱动的装箱:分布式训练的通信开销对拓扑极其敏感,把 8 个 rank 散到 8 台机器上跨 TOR 通信,和塞进 1 台 8 卡机走 NVLink,性能能差出一个数量级。
第四,拓扑感知工作负载调度(TAS,Alpha):支持基于拓扑域(机架/交换机/NVLink 域)对 PodGroup 做整体放置。这是把上面那条从「打分倾向」升级成「可声明的约束」。
一个示意性的清单大概长这样:
# 注意:v1alpha2 处于 Alpha,字段名与结构会随版本调整。
# 生产使用前请以你集群里实际安装的 CRD 为准:
# kubectl explain podgroup --api-version=scheduling.k8s.io/v1alpha2
apiVersion: scheduling.k8s.io/v1alpha2
kind: PodGroup
metadata:
name: llm-pretrain-64
namespace: ai-train
spec:
# 整组要么全调度,要么全不调度的最小成员数
minCount: 64
# 组内 Pod 的调度超时,超时后整组回滚重排
schedulingTimeoutSeconds: 300
# 拓扑约束(TAS):尽量收敛到同一个 NVLink/机架域
topologyConstraint:
required: kubernetes.io/hostname
preferred: topology.kubernetes.io/rack
Pod 侧通过标签或 spec 引用关联到这个 group(不同 alpha 版本的关联方式不同,实测请以 CRD 为准)。
3.2 PreBind 并行化:一个容易被忽略但对 DRA 至关重要的改动
1.36 还有一条不起眼的改动:调度框架支持并行运行 PreBind 插件。自定义插件需要在 PreBindPreFlight 方法中返回 PreBindPreFlightResult 并设置 AllowParallel: true 才会启用并行;返回 nil 保持原有串行行为。
为什么这条对 AI 集群重要?因为 DRA 把 PreBind 变成了瓶颈。
在 DRA 的流程里,调度器决定了设备分配之后,需要把分配详情写回 ResourceClaim 的 status——这是一次 apiserver 写操作,有网络往返、有乐观锁冲突重试。串行执行时,一个 64 Pod 的 gang 作业就是 64 次串行 API 写,每次哪怕只有 20ms,累计也有 1.3 秒纯等待,而且这段时间调度器的 binding 通道是被占着的。
并行化之后这部分开销可以摊平。但要注意 1.36 同时禁用了 SchedulerAsyncAPICalls(性能问题)——说明社区在异步化这条路上踩了坑,回退了一步。这是个有意思的信号:调度器的异步化收益和状态一致性风险是一对硬矛盾,不是随便加个 goroutine 就能解决的。
写自定义插件的同学,1.36 的破坏性变更清单记一下:
| 变更 | 影响 | 动作 |
|---|---|---|
PodGroupInfo → PodGroupState | 编译失败 | 全局改名 |
PostFilterResult 需返回被抢占 Pod 列表 | 抢占逻辑行为变化 | 补充返回值 |
PreBindPreFlight / PreBindPreFlightResult | 不改则保持串行 | 显式声明 AllowParallel |
SchedulerAsyncAPICalls 被禁用 | 依赖它的优化失效 | 回退到同步路径 |
3.3 原生方案没解决什么
必须说清楚,避免过度期待:
- 它是 Alpha。
v1alpha2意味着 API 会变、可能被重构、跨版本不保证兼容。生产集群现在上它,等于给自己埋一个升级地雷。 - 它不带队列和配额。 Volcano 的 queue/reclaim、Kueue 的 cohort/borrowing,原生 PodGroup 都没有。多租户配额还是得靠 Kueue。
- 社区已经识别出 Workload API 的设计缺口:KEP-4671 目前把每个 PodGroup(或 PodGroup replica)当作完全独立的调度单元,这对「组内 gang」是够的,但对分离式推理(prefill 池 + decode 池 + KV 传输,多个异构组件必须协同就位)就不够了。这类工作负载需要的是「跨 PodGroup 的组合调度」,目前还在讨论中。
- 和 DRA 的抢占问题没解决——下一节专门讲。
四、Gang × DRA:真正难的地方在这里
4.1 先把 DRA 的模型讲清楚
DRA 在 1.35 已经 GA(resource.k8s.io/v1),它是 Device Plugin 的下一代。核心对象四个:
| 对象 | 谁创建 | 作用 |
|---|---|---|
DeviceClass | 驱动/集群管理员 | 定义一类设备,以及用 CEL 按属性选择设备的规则 |
ResourceClaim | 工作负载运维/控制器 | 一次具体的设备申领,可被多个 Pod 共享 |
ResourceClaimTemplate | 工作负载运维 | 模板,为每个 Pod 自动生成独立 claim,生命周期跟随 Pod |
ResourceSlice | DRA 驱动 | 驱动向集群上报「本节点/本池有哪些设备、什么属性」 |
相比 Device Plugin 那种「nvidia.com/gpu: 2 一个整数」的粗粒度模型,DRA 的三个质变是:
1)用 CEL 做细粒度过滤。
apiVersion: resource.k8s.io/v1
kind: DeviceClass
metadata:
name: a100-80g-nvlink
spec:
selectors:
- cel:
expression: |-
device.driver == 'gpu.nvidia.com' &&
device.attributes['gpu.nvidia.com'].productName == 'A100-SXM4-80GB' &&
device.capacity['gpu.nvidia.com'].memory.compareTo(quantity('79Gi')) >= 0
以前你只能靠 node label 打「这台机是 A100」,现在可以直接按设备属性和容量做表达式匹配。
2)设备共享与容量拆分。 1.36 的可消费容量(consumable capacity)允许同一台设备被多个独立 ResourceClaim 同时使用,由调度器管理每个申领消耗了多少容量。可切分设备(partitionable devices)则用 CounterSet + ConsumesCounter 建模——比如一张 8Gi 显存的卡上定义两个各消耗 6Gi 的逻辑设备,那么同一时刻只能分配其中一个,调度器自动处理这种互斥:
apiVersion: resource.k8s.io/v1
kind: ResourceSlice
metadata:
name: slice-counters
spec:
nodeName: worker-1
pool: {name: pool, generation: 1, resourceSliceCount: 2}
driver: dra.example.com
sharedCounters:
- name: gpu-1-counters
counters:
memory: {value: 8Gi}
---
apiVersion: resource.k8s.io/v1
kind: ResourceSlice
metadata:
name: slice-devices
spec:
nodeName: worker-1
pool: {name: pool, generation: 1, resourceSliceCount: 2}
driver: dra.example.com
devices:
- name: device-1
consumesCounters:
- counterSet: gpu-1-counters
counters: {memory: {value: 6Gi}}
- name: device-2
consumesCounters:
- counterSet: gpu-1-counters
counters: {memory: {value: 6Gi}}
这个模型对 MIG 切分、多实例 GPU、共享 NIC 的建模能力,是 Device Plugin 时代完全做不到的。
3)优先级列表(1.36 GA)。 首选设备拿不到时退而求其次,而不是干等:
apiVersion: resource.k8s.io/v1
kind: ResourceClaimTemplate
metadata:
name: gpu-with-fallback
spec:
spec:
devices:
requests:
- name: req-0
firstAvailable:
- name: prefer-a100
deviceClassName: a100-80g-nvlink
- name: fallback-a10
deviceClassName: a10-24g
count: 2 # 一张 A100 拿不到,退成两张 A10
调度器还会把「选中的是第几优先级的子请求」作为节点打分的输入之一——能给到高优先级设备的节点更容易被选中。但官方文档有个非常重要的提醒:这个决策是 per-Pod 独立做的。也就是说,同一个 ReplicaSet/gang 里的不同 Pod,完全可能一个拿到 A100、另一个退化成 2×A10。对数据并行训练来说,这是灾难——最慢的 rank 决定整体速度,异构 rank 会让整个作业退化到最慢设备的水平,还可能因为显存不一致直接 OOM。
所以:在 gang 作业里用 prioritized list,必须确保你的工作负载能容忍组内异构,否则宁可不用。 需要同构时,正确做法是在 ResourceClaim 里用 constraints 强制组内设备属性一致。
4.2 那个致命的交叉点:DRA 不支持抢占
Kubernetes 官方文档在 DRA 的「限制」一节里写得明明白白:
Kubernetes 调度器当前不支持对 DRA 资源进行抢占。已经在某个节点上运行并使用了 DRA 资源的现有 Pod,无法被另一个同样需要 DRA 资源的更高优先级 Pod 抢占。该高优先级 Pod 将保持在 Pending 状态,直到设备资源变为可用。
把这句话和 gang 调度放在一起看,问题就严重了。
回顾一下第一节的结论:解决资源死锁只有两条路——一次性申请全部(gang),或者允许抢占回滚(preemption)。1.36 给了你 workload 级抢占,但那套抢占对 DRA 管理的设备无效。
于是在一个用 DRA 管理 GPU 的集群里,你会看到这样的组合拳:
- 低优先级的调参小作业先到,吃掉了 40 张卡;
- 高优先级的预训练大作业后到,需要 64 张卡;
- 集群总共 80 张,剩 40 张,凑不齐;
- 抢占对 DRA 设备不生效,所以调度器无法驱逐小作业;
- 大作业无限期 Pending,直到小作业自己跑完。
这就是所谓的优先级反转:你设置的 PriorityClass 在 DRA 场景下形同虚设。
现阶段的四种绕法(按推荐程度排序):
① 用 Kueue 的准入抢占顶上。 Kueue 的抢占发生在准入层:它可以主动驱逐已准入的低优先级 Workload(把 Job 重新 suspend、删掉 Pod),从而释放 DRA 设备。这绕开了调度器抢占的限制,因为释放动作是控制器做的,不是调度器做的。这是目前最干净的方案。
apiVersion: kueue.x-k8s.io/v1beta1
kind: ClusterQueue
metadata:
name: gpu-cq
spec:
preemption:
withinClusterQueue: LowerPriority # 队列内可抢占低优先级
reclaimWithinCohort: LowerPriority
borrowWithinCohort:
policy: LowerPriority
② 物理分池。 高优先级作业专用节点池 + 低优先级抢占池,用 taint/toleration 隔离。简单粗暴,代价是利用率下降。适合「大作业不能等」是硬需求的团队。
③ 用 1.36 的设备污点与容忍(device taints & tolerations,Beta)做软隔离。 给设备打污点,只有声明了容忍的 claim 能用。这比节点级隔离粒度细,可以在同一台机器上区分「这 4 张卡只给生产训练」。
④ 给低优先级作业强制设置 activeDeadlineSeconds。 保证它们一定会在有限时间内释放资源,把「无限期等待」变成「有界等待」。工程上很土,但有效。
4.3 组内设备约束:让 8 个 rank 落在同一个 NVLink 域
数据并行训练的通信模式是 AllReduce,带宽敏感。8 张卡在同一台 SXM 机器上走 NVLink(数百 GB/s)和跨机走 RoCE(几十 GB/s)差着数量级。
DRA 提供了 constraints 来表达「这些设备必须共享某个属性」:
apiVersion: resource.k8s.io/v1
kind: ResourceClaim
metadata:
name: nvlink-domain-8gpu
namespace: ai-train
spec:
devices:
requests:
- name: gpus
exactly:
deviceClassName: a100-80g-nvlink
count: 8
allocationMode: ExactCount
constraints:
# 8 张卡必须来自同一个 NVLink 域
- requests: ["gpus"]
matchAttribute: "gpu.nvidia.com/nvlinkDomain"
注意这里用的是 ResourceClaim 而不是 ResourceClaimTemplate。这是个关键选择:
ResourceClaimTemplate→ 每个 Pod 一个独立 claim,claim 之间互相不知道对方分配到了什么,约束只在单个 claim 内生效;ResourceClaim(共享)→ 一个 claim 里申请 8 个设备,constraints可以横跨这 8 个设备生效。
但共享 claim 有个前提:多个 Pod 共享同一个 claim 时,它们通常需要落在同一个节点上(设备是节点本地的)。如果你的 8 卡作业就是要跑在一台 8 卡机上,这个方案完美。如果是 64 卡跨 8 台机,就得配合 TAS 的拓扑约束在节点组层面做收敛,claim 层面则按机器切分。
这也解释了 1.36 为什么要做拓扑感知工作负载调度:设备级约束(DRA constraints)解决「一台机内」,拓扑级约束(TAS)解决「机器之间」,两层必须配合。
4.4 设备健康监控(1.36 Beta):让训练作业能自愈
1.36 的设备健康监控是个容易被低估的特性。启用 ResourceHealthStatus 门控且驱动实现了 DRAResourceHealth gRPC 服务后,kubelet 会在每个容器的 allocatedResourcesStatus 字段里填充分配给该容器的每个设备的健康状况。
对 AI 集群的意义在于:GPU 掉卡、ECC 错误、XID 报错这类硬件故障,以前是训练进程崩了之后才发现,排障要人肉登机器看 dmesg 和 nvidia-smi。现在可以直接从 Pod status 读出来:
kubectl get pod train-worker-3 -o jsonpath='{.status.containerStatuses[*].allocatedResourcesStatus}' | jq
配套可以写一个简单的控制器:检测到 Unhealthy 设备 → 给节点打 taint → 驱逐并重建整个 gang 作业(因为分布式训练缺一个 rank 就得整体重启)。这比等 NCCL timeout 快得多。
五、实战:把一个 64 卡训练作业完整跑起来
下面给一套能直接改造使用的清单。用 JobSet 组织多角色(这是目前 AI 训练在 K8s 上最合适的载体),gang 用 coscheduling 插件(稳定,能上生产),设备用 DRA。
5.1 设备类定义
apiVersion: resource.k8s.io/v1
kind: DeviceClass
metadata:
name: train-a100
spec:
selectors:
- cel:
expression: |-
device.driver == 'gpu.nvidia.com' &&
device.attributes['gpu.nvidia.com'].productName.startsWith('A100') &&
!device.attributes['gpu.nvidia.com'].migEnabled
config:
- opaque:
driver: gpu.nvidia.com
parameters:
apiVersion: gpu.nvidia.com/v1
kind: GpuConfig
sharing:
strategy: None # 训练场景不共享
5.2 每机 8 卡的共享 claim 模板
apiVersion: resource.k8s.io/v1
kind: ResourceClaimTemplate
metadata:
name: node-local-8gpu
namespace: ai-train
spec:
spec:
devices:
requests:
- name: gpus
exactly:
deviceClassName: train-a100
count: 8
allocationMode: ExactCount
constraints:
- requests: ["gpus"]
matchAttribute: "gpu.nvidia.com/nvlinkDomain"
5.3 PodGroup + JobSet
apiVersion: scheduling.x-k8s.io/v1alpha1
kind: PodGroup
metadata:
name: llm-pretrain
namespace: ai-train
spec:
minMember: 8 # 8 个 Pod,每 Pod 8 卡 = 64 卡
scheduleTimeoutSeconds: 300
---
apiVersion: jobset.x-k8s.io/v1alpha2
kind: JobSet
metadata:
name: llm-pretrain
namespace: ai-train
spec:
# 任一 Job 失败即整体失败,符合分布式训练语义
failurePolicy:
maxRestarts: 3
replicatedJobs:
- name: worker
replicas: 1
template:
spec:
parallelism: 8
completions: 8
completionMode: Indexed
backoffLimit: 0
template:
metadata:
labels:
scheduling.x-k8s.io/pod-group: llm-pretrain
spec:
schedulerName: default-scheduler # 用带 coscheduling 插件的调度器
restartPolicy: Never
# 同一 group 的 Pod 分散到不同节点(每机 8 卡,一个 Pod 吃满一台)
topologySpreadConstraints:
- maxSkew: 1
topologyKey: kubernetes.io/hostname
whenUnsatisfiable: DoNotSchedule
labelSelector:
matchLabels:
scheduling.x-k8s.io/pod-group: llm-pretrain
resourceClaims:
- name: gpus
resourceClaimTemplateName: node-local-8gpu
containers:
- name: trainer
image: registry.example.com/train:torch2.9-cu13
command: ["torchrun"]
args:
- "--nnodes=8"
- "--nproc_per_node=8"
- "--rdzv_backend=c10d"
- "--rdzv_endpoint=$(MASTER_ADDR):29500"
- "train.py"
resources:
claims:
- name: gpus
limits:
cpu: "96"
memory: 900Gi
# RDMA 走 DRA 或 device plugin,视驱动而定
env:
- name: NCCL_IB_DISABLE
value: "0"
- name: NCCL_DEBUG
value: "WARN"
# 关键:把 rendezvous 超时设短,让失败快速暴露,
# 而不是让 Pod 在那里假活半小时
- name: TORCH_NCCL_BLOCKING_WAIT
value: "1"
- name: TORCH_NCCL_ASYNC_ERROR_HANDLING
value: "1"
几个容易踩的点,标一下:
backoffLimit: 0+ JobSet 的failurePolicy组合,是为了让失败在 JobSet 层统一处理,而不是让单个 Job 自己无脑重试——单 rank 重试对分布式训练没意义,必须整组重来。TORCH_NCCL_ASYNC_ERROR_HANDLING=1很关键。默认情况下 NCCL 通信卡住时进程不会退出,Pod 保持Running,K8s 认为一切正常,卡就那么空转着。开启后通信异常会转成进程退出,Pod 变Failed,控制器才能介入。这是"僵尸训练作业"最常见的成因,和调度器无关但比调度问题更普遍。topologySpreadConstraints用DoNotSchedule而非ScheduleAnyway。gang 场景下,宁可不调度,也不要出现「两个 Pod 挤在一台机上抢 16 张卡但机器只有 8 张」的情况。
5.4 排障命令清单
# 1) 看 group 卡在哪一步(PreEnqueue 门控 / Permit 等待 / 资源不足)
kubectl get events -n ai-train --field-selector reason=FailedScheduling \
--sort-by='.lastTimestamp' | tail -20
# 2) 1.36 新增:describe node 直接显示 ResourceSlices,
# 这是排查"驱动到底上报了什么设备"的第一现场
kubectl describe node gpu-worker-01 | grep -A 30 "ResourceSlices"
# 3) 看 claim 到底分配到了哪些设备
kubectl get resourceclaim -n ai-train -o custom-columns=\
'NAME:.metadata.name,STATE:.status.allocation.devices.results[*].device,NODE:.status.allocation.nodeSelector'
# 4) 设备健康(1.36 Beta)
kubectl get pod -n ai-train -o json | \
jq '.items[].status.containerStatuses[]?.allocatedResourcesStatus'
# 5) 调度器自身的健康度:这四个指标是判断 gang 是否在拖垮调度器的核心
# scheduler_pending_pods{queue="unschedulable"} 积压量
# scheduler_pod_scheduling_attempts 重试次数分布
# scheduler_scheduling_attempt_duration_seconds 单次调度耗时
# scheduler_permit_wait_duration_seconds Permit 等待耗时(gang 专属)
kubectl -n kube-system port-forward svc/kube-scheduler 10259:10259 &
curl -sk https://localhost:10259/metrics | grep -E 'scheduler_(pending_pods|permit_wait)'
第 5 条里的 scheduler_permit_wait_duration_seconds 是 gang 调度的体检指标。如果 P99 逼近你设的 gangWaitTimeout,说明整组很难凑齐,此时应该:调大 PreEnqueue 门控的严格度(让不可能成功的组根本不进队列),或者检查是不是碎片太严重需要做集群整理。
六、性能与规模化:Gang 调度的代价在哪
6.1 重试放大效应
单 Pod 调度失败,代价是 1 次重试。Gang 调度失败,代价是整组回滚 + 整组重试。
设 group 规模 $g$,单 Pod 调度平均耗时 $t$,整组失败重试 $k$ 次才成功,则调度器为这个作业花的总时间是:
T_total ≈ k × g × t
$g=64$、$t=3ms$、$k=5$ 时,就是接近 1 秒的调度器纯 CPU 时间。在一个每秒要处理上百个 Pod 的大集群里,几个大 gang 作业反复失败就能把调度吞吐吃掉一大块。
这就是为什么 PreEnqueue 门控是必需品而不是优化项。 让注定失败的组根本不进 activeQ,把 $k$ 压到接近 1,收益是线性的。
6.2 队列的 head-of-line blocking
默认调度器的 activeQ 是优先级队列。一个高优先级的大 gang 作业凑不齐,如果它反复进出队列,会持续占据队头,后面的小作业全被堵住——即使集群完全有能力立刻跑完那些小作业。
对策有三层:
- PreEnqueue 门控:凑不齐就别进队列(前面已讲);
- backoff:整组失败后指数退避,避免高频重试。
Unreserve里MarkGroupFailed就是干这个的; - backfill:Volcano 有专门的 backfill action,在大作业等待期间用小作业填充空隙。原生调度器目前没有等价机制,这是 Volcano 至今不可替代的原因之一。
6.3 碎片化:gang 的天敌
Gang 调度对碎片极其敏感。集群总量够但分布不对,就是凑不齐。
一个实用的度量方式,定义碎片指数:
F = 1 - (能容纳一个完整 g 卡作业的节点数 × g) / 集群剩余总卡数
$F$ 越接近 1,说明剩余资源越是「散装」的。$F > 0.7$ 时基本可以断定 gang 作业会频繁失败。
降低碎片的手段:
- 打分策略从
LeastAllocated换成MostAllocated。 默认的均衡打散策略对在线服务是对的(容灾),对 AI 训练是错的(制造碎片)。训练集群应该主动做紧凑装箱:
apiVersion: kubescheduler.config.k8s.io/v1
kind: KubeSchedulerConfiguration
profiles:
- schedulerName: gpu-scheduler
plugins:
score:
enabled:
- name: NodeResourcesFit
weight: 5
- name: PodGroupPodsCount # 1.36:倾向已有同组成员的位置
weight: 3
disabled:
- name: NodeResourcesBalancedAllocation # 均衡分配,对 gang 是反效果
pluginConfig:
- name: NodeResourcesFit
args:
scoringStrategy:
type: MostAllocated # 紧凑装箱,把碎片挤到一起
resources:
- name: nvidia.com/gpu
weight: 10
- name: cpu
weight: 1
- name: memory
weight: 1
- 按作业规模分池。 8 卡池、16 卡池、64 卡池物理隔离。牺牲一点弹性,换取大作业的确定性。
- 定期整理。 用 descheduler 在低峰期把零散 Pod 迁移聚拢。注意训练作业不能随便迁,这条主要针对推理和在线服务。
6.4 为什么「一次性申请全部」的策略在超大规模下会退化
还有一个反直觉的现象值得警惕:group 规模越大,gang 调度的成功率是超线性下降的。
假设每个 Pod 独立找到合适节点的概率是 $p$(受碎片、亲和性、设备约束共同影响),那么整组成功的概率近似:
P_gang ≈ p^g
$p=0.99$、$g=8$ 时,$P \approx 0.92$,还行;$g=64$ 时,$P \approx 0.53$;$g=256$ 时,$P \approx 0.08$。
(实际情况因为节点选择不独立会比这个乐观一些,但趋势是对的。)
这就是为什么超大规模训练在实践中很少纯靠 gang 硬调度,而是走预留式路线:提前用 ReservationRequest 之类的机制圈定一批节点,作业提交时直接落到预留资源上,把调度的不确定性从「提交时」提前到「预留时」。Volcano 和各家云厂商的调度器都有类似能力,原生 K8s 目前还没有。
七、生产落地建议:现在到底该怎么选
结论先给,理由在后面。
| 场景 | 推荐组合 | 理由 |
|---|---|---|
| 中小集群(<200 节点),单一团队 | Kueue + coscheduling 插件 | 组件少,都在默认调度器里,运维成本低 |
| 多租户、需要配额借还 | Kueue + Volcano | Kueue 管配额准入,Volcano 管物理放置与 reclaim |
| 强 SLA 的大模型预训练 | 节点池隔离 + 预留 + Kueue 抢占 | 确定性 > 利用率 |
| 混合在线+离线 | Volcano(backfill 是刚需) | 原生调度器缺 backfill |
| 想试原生 PodGroup | 测试集群 only | v1alpha2,API 会变 |
关于 1.36 原生方案的态度:这是正确的方向,但 Alpha 就是 Alpha。我的建议是在测试集群跟进验证,在生产集群继续用成熟方案,等它到 Beta(大概率还要 2~3 个版本)再考虑迁移。现在投入的价值在于:提前理解它的模型,为将来迁移做准备;以及,把你自定义调度器插件的代码按 1.36 的新接口改好——PodGroupState 改名、PostFilterResult 返回值、PreBindPreFlight,这三个变更是升级 1.36 的硬门槛,跑不掉。
升级 1.36 的检查清单
除了调度器相关,1.36 还有几处容易踩的:
StrictIPCIDRValidation默认启用:禁止前导零(010.000.000.005这种)和语义模糊的子网掩码。老集群里手写的 ServiceexternalIPs、ServiceCIDR配置要全量扫一遍。- 监控指标重命名:
volume_operation_total_errors→volume_operation_errors_total,etcd_bookmark_counts→etcd_bookmark_total。Grafana 面板和告警规则要改。 - DRA 的 RBAC 细化:驱动和控制器需要更细粒度的权限来更新
ResourceClaim状态;ResourceSlice控制器发布集群范围资源时必须显式设置ReconcilePoolWithName,"All nodes" 不再是默认值。不改的话驱动会静默失效。 MaxUnavailableStatefulSet默认禁用(修复 1.35 的回归),依赖这个特性做滚动更新的要注意。git-repo卷插件默认禁用且无法重新启用,还在用的赶紧迁到 initContainer + git clone。- Ingress NGINX 已于 2026 年 3 月退役,不再有新发布、bug 修复或安全更新。这个必须尽快规划替代方案(Gateway API 实现或其他 Ingress controller)。
八、总结:调度器正在变成「作业编排器」
把这些年的变化连起来看,有一条清晰的主线:
Kubernetes 调度器正在从「Pod 放置器」演化成「作业编排器」。
- 2019 年前后,kube-batch/Volcano 在体外造了一个批调度器,证明了需求真实存在;
- 2020~2023,Scheduling Framework 提供了扩展点,coscheduling 插件证明了 gang 可以在框架内实现;
- 2023~2025,Kueue 补上了配额与准入层,DRA 重构了设备模型;
- 2026 的 1.36,PodGroup/Workload API 把「组」的概念正式写进了 API,抢占也升级到了 workload 级。
这条演化路径背后是负载结构的变化:Kubernetes 诞生时的假设是「大量小而同质的无状态 Pod」,而 AI 时代的负载是「少量大而有状态、成员间强耦合的作业」。前者要的是弹性和打散,后者要的是原子性和聚拢。两种需求在调度器里几乎是对立的——这也是为什么这些特性都要单独做插件、单独加 API,而不能改默认行为。
给几条最后的实操建议:
先把
TORCH_NCCL_ASYNC_ERROR_HANDLING之类的框架侧超时配好。 我见过太多团队在纠结调度器的时候,实际浪费的卡时大头是「进程没退出但也没在算」的僵尸作业。这个改动成本几乎为零,收益立竿见影。把
scheduler_permit_wait_duration_seconds和碎片指数 $F$ 加进监控面板。 没有这两个数,gang 调优就是拍脑袋。打分策略要按集群定位改。 训练集群用
MostAllocated,别用默认的均衡策略——这一条的收益往往比换调度器还大。如果你用 DRA 管 GPU,务必想清楚抢占缺失的影响。 优先级在 DRA 场景下不生效,这个坑不提前设计,上线后会被业务方追着问「为什么我 P0 的任务排在 P3 后面」。
别急着上 Alpha API。 但要提前读它的 KEP,理解它的模型。等它 GA 的时候,你就是团队里唯一知道该怎么迁的人。
调度这件事的本质,从来不是「找到最优解」,而是「在不确定的资源分布下,用可接受的代价,做出足够好的决策」。理解了这一层,你就知道为什么每个方案都在做取舍,也就知道自己该取哪一边。