Kubernetes Workload API 深度拆解:当调度器终于承认「Pod 不是孤岛」——从四次外部造轮子到 kube-scheduler 原生 PodGroup 的一次调度内核手术
一句话概括:Kubernetes 用了十一年坚持「一次调度一个 Pod」,直到 AI 训练把这个前提彻底打碎。KEP-4671 引入
Workload与PodGroup两个核心资源,把 gang scheduling 焊进 kube-scheduler;v1.35 Alpha 只做了两个屏障,v1.36 把 API 拆成「模板/实例」双对象,v1.37 才真正动刀——把scheduleOne这个跑了十年的主循环,改成能弹出「一整个 PodGroup」的 Workload Scheduling Cycle。本文从 API 字段、调度周期、抢占算法一路拆到生产落地与踩坑。
一、背景:为什么这件事拖了十一年
1.1 一个被写死在骨子里的假设
打开 kube-scheduler 的主循环,你会看到一个从 2015 年就没变过的结构:
// pkg/scheduler/schedule_one.go(简化)
func (sched *Scheduler) scheduleOne(ctx context.Context) {
podInfo, _ := sched.NextPod(ctx) // 从队列弹出「一个 Pod」
// ... PreFilter / Filter / PostFilter / Score / Reserve / Permit
go sched.bindingCycle(ctx, ...) // 异步绑定
}
注意第一行:弹出的是一个 Pod。整个调度框架的扩展点签名、CycleState 的生命周期、抢占算法的定义域、队列的 backoff 策略,全部构建在「调度单元 = 单个 Pod」这个公理之上。
这个假设在无状态微服务时代堪称完美。一个 Deployment 有 10 个副本,先起 3 个后起 7 个,没有任何问题——服务发现会兜底,流量会慢慢打进来。Pod 之间是弱耦合的。
但是把这个假设丢到一个 128 卡的 all-reduce 训练任务上,它就变成了灾难。
1.2 「部分调度」为什么是灾难
考虑一个典型场景:集群里有 100 张空闲 GPU,两个训练任务同时提交,各需要 64 张卡。
pod-by-pod 调度器的行为是这样的:
t=0 JobA 的 64 个 Pod 和 JobB 的 64 个 Pod 一起进队列
t=1 调度器按时间戳交替处理,JobA 抢到 50 张卡,JobB 抢到 50 张卡
t=2 剩余 0 张卡。JobA 还差 14 个 Pod,JobB 还差 14 个 Pod
t=∞ 死锁。两个任务都跑不起来,100 张 GPU 全部空转
这不是理论推演,这是每一个跑过大规模训练的平台工程师都被半夜叫醒过的场景。更恶心的变体是:torch.distributed.init_process_group() 会阻塞等待所有 rank 加入,超时时间默认 30 分钟。于是这 100 张卡不是「空转」,而是以满功耗的形态空转 30 分钟,然后所有进程一起抛 NCCL timeout,任务失败,重新排队,再来一轮。
这就是 Gang Scheduling(也叫 Coscheduling、All-or-Nothing 调度)要解决的问题:要么全部 Pod 拿到资源一起启动,要么一个都不占,把资源让给别人。
1.3 四次外部造轮子
KEP-4671 的 Motivation 里有一句相当克制但信息量极大的话:
Gang scheduling has been implemented outside of kube-scheduler at least 4 times.
生态里的四套方案,各有各的世界观:
| 方案 | 实现路径 | 核心代价 |
|---|---|---|
| Volcano | 完整替换 kube-scheduler,自己一套 Session/Action/Plugin 框架 | 与上游调度特性(DRA、TAS、新插件)持续脱节,需要一直追平 |
| Coscheduling 插件(scheduler-plugins) | 基于调度框架的 PreFilter/Permit/PostBind 扩展点 | 靠 Permit 阻塞等待,占着 Reserve 的资源不放,容易 livelock |
| Kueue | 准入层,在 Pod 创建之前做配额与排队,只放行「确定能跑」的作业 | 无法感知节点级碎片,仍需下游调度器配合,双层决策不一致 |
| YuniKorn | 同样是替换调度器,带层级队列与资源公平 | 同 Volcano,生态割裂 |
四套方案还催生了第五个问题:Workload 控制器开始为多个 gang 调度器写适配代码。JobSet、LeaderWorkerSet、KubeRay、TrainJob,每一个都要判断「当前集群装的是 Volcano 还是 Coscheduling 还是 KAI」,然后打不同的 annotation、创建不同的 CRD。
社区的结论很直接:这个抽象足够基础,用户不该为了跑一个训练任务去装一个第三方调度器。
KEP 原文的 Drawbacks 章节写得更狠:「the introduced concepts are fundamental enough in AI era, that we believe that our users shouldn't need to install any extensions to have them addressed.」
二、API 设计:一次自我否定换来的解耦
2.1 第一版设计:把 PodGroup 塞进 Workload
KEP-4671 最初的设计是「一个对象搞定」:
# ❌ 已被否决的原始设计
apiVersion: scheduling.k8s.io/v1alpha1
kind: Workload
spec:
podGroups:
- name: worker
policy: { gang: { minCount: 100 } }
status: { ... } # 状态也塞在这里
- name: ps
policy: { gang: { minCount: 4 } }
status: { ... }
看起来很优雅。但社区在 2026 年 2 月做了一次结构性推翻(KEP 的 Implementation History 里写着「2026-02: Structural revision for 1.36 to decouple Policy (Workload) and State (PodGroup)」)。
推翻的理由有三条,每一条都是血泪:
理由一:etcd 1.5MB 对象上限。 一个大规模 JobSet 可能有几百个 replica,每个 replica 一个 PodGroup,每个 PodGroup 带 conditions、带 message、带 lastTransitionTime。几百份状态塞进一个 Workload 对象,直接撞墙。
理由二:read-modify-write 争用。 kube-scheduler 更新第 37 个 PodGroup 的状态,需要读整个 Workload 对象、改一个字段、写回去。同一时刻另一个 goroutine 在更新第 88 个。resourceVersion 冲突 → 重试 → 再冲突。在大集群里这就是一个自旋锁,而且锁的粒度是「整个作业」。
理由三:生命周期耦合。 Workload 是长期存在的配置意图(policy intent),PodGroup 是转瞬即逝的运行时单元。把运行时状态绑到配置对象上,意味着 PodGroup 无法独立持有 ResourceClaim 之类的从属资源做垃圾回收。
2.2 最终形态:模板 / 实例 / 引用三层
解耦后的模型是这样的(v1.37 已升到 v1beta1):
# ① Workload:静态策略模板,长生命周期
apiVersion: scheduling.k8s.io/v1beta1
kind: Workload
metadata:
namespace: ml-train
name: llm-pretrain
spec:
# 可选:反向指回真正的 workload 对象,给 CLI/UI 用
controllerRef:
apiGroup: batch
kind: Job
name: llm-pretrain
podGroupTemplates:
- name: worker
schedulingPolicy:
gang:
minCount: 128
# ② PodGroup:运行时调度单元,短生命周期,自带 status
apiVersion: scheduling.k8s.io/v1beta1
kind: PodGroup
metadata:
namespace: ml-train
name: llm-pretrain-worker-0
spec:
workloadRef:
workloadName: llm-pretrain
templateName: worker
# 注意:policy 是从模板「拷贝」过来的,不是引用
schedulingPolicy:
gang:
minCount: 128
# ③ Pod:通过新增的 spec.schedulingGroup 指向 PodGroup
apiVersion: v1
kind: Pod
spec:
schedulingGroup:
podGroupName: llm-pretrain-worker-0
containers:
- name: trainer
image: my-llm-trainer:v3
resources:
limits:
nvidia.com/gpu: 8
三层各司其职:
- Workload = 我这类作业该怎么调度(policy intent)
- PodGroup = 这一次具体的 128 个 Pod 该怎么一起落地(runtime unit + status)
- Pod.spec.schedulingGroup = 我属于哪个运行时单元
2.3 一个值得学习的 API 设计细节:为什么是拷贝而不是引用
注意 PodGroup.spec.schedulingPolicy 是从模板拷贝过来的完整策略,而不是「只存一个 workloadRef,运行时去查模板」。
这是一个非常典型的分布式系统权衡。KEP 里的 SchedulingPolicy Reference vs. Copy/Inline in PodGroup 章节讨论了这个问题,最终选择拷贝的原因:
- 调度器不需要二次解引用。如果只存引用,kube-scheduler 每次调度都要先查 Workload 的 informer cache,如果 Workload 还没同步过来就得等——凭空多一个失败模式。
- 策略变更不会撕裂进行中的调度。Workload 是配置对象,用户随时可能改。如果 PodGroup 引用它,那么一个正在进行的 Workload Scheduling Cycle 中途
minCount从 128 变成 64,语义就无法定义了。 - 对象独立可删。Workload 删了,正在跑的 PodGroup 不受影响。
代价当然是「模板改了,已存在的 PodGroup 不会自动更新」——但这恰恰是想要的行为。
2.4 为什么 podGroupName 是 PodSpec 显式字段,而不是 label 或 ownerReference
这是被反复追问的设计问题,KEP 里专门开了一节回答。核心答案是不可变性:
type PodSpec struct {
// SchedulingGroup provides a reference to the immediate scheduling runtime
// grouping object that this Pod belongs to.
// ...
// This field is immutable, but a group object with the same name may be
// recreated with different policies.
//
// +featureGate=GenericWorkload
// +optional
SchedulingGroup *PodSchedulingGroup
}
// +union
type PodSchedulingGroup struct {
// +oneOf=GroupSelection
PodGroupName *string
}
如果用 label,label 是可变的。想象这个场景:调度器正在为一个 128 Pod 的 gang 寻找放置方案,跑到第 100 个 Pod 时,有人把第 3 个 Pod 的 label 改了,把它踢出了这个组。此时已经 assume 的 99 个 Pod 的资源预留该怎么办?minCount 校验用旧值还是新值?
社区的选择是从根上消灭这个状态空间:字段不可变,Pod 一旦创建就永久属于某个 PodGroup。
另一个被否决的备选是在 PodGroup 上放 PodGroupSelector(label selector 反向匹配 Pod)。否决理由同样干脆:会出现「Pod 声称属于某 Workload,但没有任何 PodGroup 的 selector 能匹配上它」这种孤儿态,而且对于 replicated gang 无法用完整 label selector,只能退化成 MatchLabelKeys 那种「只指定 key」的半吊子模式。
2.5 minCount 的可变性与最终一致性陷阱
GangSchedulingPolicy.MinCount 是可变的(为了支持弹性扩缩),但 KEP 在注释里给了一段罕见的、长达六行的免责声明:
// MinCount is the minimum number of pods that must be schedulable or scheduled
// at the same time for the scheduler to admit the entire group.
//
// Note that the scheduler operates on an eventually consistent model. Updates
// to minCount may not be immediately reflected in scheduling decisions due to
// propagation delays. If minCount is updated while a scheduling cycle is in
// progress for that group, the new value may not take effect until the next
// cycle. Moreover, minCount is only enforced during scheduling, meaning that
// modifications to this field do not affect already-scheduled pods, applying
// only to those evaluated in future cycles.
MinCount int32
翻译成工程语言,三条硬约束:
- 改了不一定马上生效(informer 传播延迟)。
- 调度周期进行中改,本轮不生效。
- 只在调度时强制,不影响已调度的 Pod——你把 minCount 从 128 改到 256,已经在跑的 128 个 Pod 不会被杀掉。
而 Gang 与 Basic 这个「选哪个策略」的选择本身是不可变的:
type PodGroupSchedulingPolicy struct {
// Basic ... this field cannot be changed afterward.
Basic *BasicSchedulingPolicy
// Gang ... this field cannot be set or unset afterward.
// The minCount field within Gang scheduling policy remains mutable.
Gang *GangSchedulingPolicy
}
也就是说:一个 PodGroup 从出生就决定了自己是 gang 还是 basic,只有 gang 的 minCount 数值能改。
2.6 PodGroupStatus:一个单调不可回退的条件
type PodGroupStatus struct {
// Known condition types:
// - "PodGroupInitiallyScheduled"
// - "DisruptionTarget"
Conditions []metav1.Condition
}
PodGroupInitiallyScheduled 的语义值得单独说,因为它违反了大部分人对 Kubernetes Condition 的直觉:
Once this condition transitions to True, it serves as a terminal state and will never revert to False, even if pods are subsequently evicted and group constraints are no longer met.
这个条件一旦变 True 就永远是 True。哪怕之后 Pod 被驱逐、节点挂了、gang 已经不完整了——它不回退。
原因是名字里的 Initially:它记录的是「初次调度是否成功」这个历史事实,而不是「当前 gang 是否健康」这个实时状态。后者需要一个独立的组件去跟踪,KEP 明确把它列为未来扩展。
这是一个巨大的踩坑点:如果你写监控告警,千万不要用 PodGroupInitiallyScheduled != True 来判断 gang 是否健康,那只能测出「从来没调度成功过」。真实的健康度还得回到 Pod 层面统计。
失败时的 reason 分两类:
Unschedulable:资源不够、亲和性冲突、容量不足SchedulerError:调度器内部错误,比如 nodeAffinity 解析失败
Alpha 阶段的实现细节也很有意思:状态更新是同步做的(在调度周期内),而且用的是 StrategicMergePatch 而不是 Server-Side Apply——理由是「SSA 在核心控制器里性能开销太大」,与 Pod status 更新保持一致。异步化要等 SchedulerAsyncAPICalls 特性可用(顺便说一句,这个特性门在 1.36 因性能问题被禁用了)。
三、调度器手术(一):Alpha 的两个屏障,以及它们为什么不够
3.1 最小可行实现
v1.35 的 Alpha 实现极其克制,只加了一个 GangScheduling 插件,挂了三个钩子:
PreEnqueue → 屏障 1:PodGroup 对象存在吗?观察到的 Pod 数够 minCount 吗?
不满足 → 返回 UnschedulableAndUnresolvable,压根不进队列
WaitOnPermit → 屏障 2:等同一个 PodGroup 的所有 Pod 都到达 Permit 阶段
全到齐 → 一起放行进入绑定;超时 → 全部释放
EventsToRegister → 注册两类事件:PodGroup 创建、未调度 Pod 新增
这个实现只满足了 North Star 八条要求里的第一条:「保证 gang 的 Pod 在无法全部调度时不会被绑定」。
3.2 两个屏障漏掉了什么
KEP 自己列的清单:
- 没有解决性能。128 个 Pod 走 128 次完整的 Filter/Score 循环,每次都要遍历所有节点。
- 没有解决 livelock。两个 gang 互相在 Permit 阶段占着对方需要的资源,都在等,都不放。
- 没有解决部分抢占。第 100 个 Pod 需要抢占才能放下,于是触发了一次单 Pod 抢占,杀掉了受害者。结果第 101 个 Pod 发现还是放不下,整个 gang 失败——受害者白死了。这就是 North Star 要求 (5)「避免过早抢占」。
- 没有「足够优的放置」。128 个 Pod 各自独立找最优节点,加起来大概率不是 gang 整体的最优解。
于是 Beta(原计划 v1.36,实际推迟到 v1.37)动了真格。
四、调度器手术(二):Workload Scheduling Cycle
4.1 把主循环改成能弹出「一整个组」
这是整个 KEP 里最激进的改动:scheduleOne 从队列里弹出的东西,现在可能是一个 Pod,也可能是一个 PodGroup。
// 改造后的心智模型(伪代码)
func (sched *Scheduler) scheduleOne(ctx context.Context) {
item := sched.queue.Pop() // 可能是 *QueuedPodInfo,也可能是 *QueuedPodGroupInfo
switch v := item.(type) {
case *QueuedPodInfo:
sched.schedulingCycle(ctx, v) // 传统路径,一字未改
case *QueuedPodGroupInfo:
sched.workloadSchedulingCycle(ctx, v) // 新路径:整组处理
}
}
关键设计:属于 PodGroup 的单个 Pod,不再进入 activeQ / backoffQ / unschedulableQ 任何一个标准队列结构。它们被 QueuedPodGroupInfo 这个聚合对象持有。KEP 原文:
Crucially, the individual Pods themselves are not stored in any standard scheduling queue data structure (active, backoff, or unschedulable), but they are effectively managed via the
QueuedPodGroupInfo.
PreEnqueue 检查也变成了聚合检查——评估的是整个组的准入条件,而不是单个 Pod 的。
4.2 一个周期,一份快照
Workload Scheduling Cycle 的流程:
1. 从队列取出 PodGroup(携带该组所有 pending Pod 的列表)
2. 对整个组操作打【一份】集群快照 —— 保证周期内一致性
3. 跑专用的组放置算法
4. 根据结果三选一:
a) 能放下 ≥ minCount → 这些 Pod 直接进入绑定周期
b) 需要抢占 → PodGroup 回队列,等抢占生效后重跑一轮
c) 放不下且抢占也没用 → 判定不可调度,走 backoff 回队列
第 2 步「一份快照」是有代价的,KEP 在 Risks 章节诚实地承认了:
Since the entire Workload Scheduling Cycle operates on a single cluster snapshot, a long-running cycle means decisions are based on snapshotted state that may become stale.
也就是说:如果这个周期跑了 800ms,期间某个节点硬件故障被删除了,绑定阶段就会有 Pod 失败,可能导致整个 gang 失败。社区的判断是「这个竞态在标准调度周期里本来就存在,Workload Cycle 只是把窗口拉长了,而节点状态的传播延迟本身也不小,边际风险可接受」。
工程上的含义:Workload Scheduling Cycle 的耗时应该被当成一级监控指标。KEP 为此专门加了 scheduler_podgroup_scheduling_algorithm_duration_seconds。
4.3 同构子组 + 签名批处理
组内 128 个 Pod 如果都长得一样(同 resources、同 nodeSelector、同 tolerations),凭什么要跑 128 次 Filter?
这里复用了 KEP-5598 的 Opportunistic Batching:给 Pod 算一个「签名」,签名相同的 Pod 组成同构子组,Filter/Score 结果可以共享。
算法流程:
1. 按签名把未调度 Pod 切成同构子组
2. 子组按时间戳做确定性排序(未来可能改成「大组优先」,先啃硬骨头)
3. 遍历子组:
- Pod 放得下 → 临时 assume 并预留
- Pod 放不下 → 本轮标记未调度,【不立即抢占】
同一同构子组的后续 Pod 可以直接拒绝(结果必然相同)
只要 minCount 仍可满足,就继续处理下一个子组
4. 检查 schedulableCount >= minCount ?
第 3 步「同一同构子组的后续 Pod 可以直接拒绝」是个漂亮的剪枝:既然签名相同意味着调度可行性完全相同,那第一个失败了,剩下的必然也失败。
第 3 步「不立即抢占」是修复 Alpha 那个「受害者白死」问题的关键——所有抢占决策都推迟到组内所有 Pod 都评估完之后,统一执行一次。
4.4 新扩展点:PlacementFeasible
Alpha 用 Permit 扩展点做 minCount 校验,Beta 发现这个复用很别扭:等待阶段被跳过、Permit 在不同周期阶段行为不一致、而且缺一条快速拒绝路径。于是引入了专门的扩展点:
// PlacementFeasiblePlugin is an interface for plugins that are called after
// each pod in a pod group is evaluated.
type PlacementFeasiblePlugin interface {
fwk.Plugin
// Return Wait → 当前部分放置还不满足,但继续评估可能满足
// Return Unschedulable→ 当前放置方案没救了,放弃,剩余 Pod 都不用评估了
// (该放置方案仍可参与抢占)
// Return Success → 当前部分放置已满足;此后应对剩余 Pod 持续返回 Success
PlacementFeasible(
ctx context.Context,
placementCycleState fwk.PlacementCycleState,
podGroupInfo fwk.PodGroupInfo,
placementProgress PlacementProgress,
) *fwk.Status
}
type PlacementProgress struct {
// Remaining:本轮还没评估的 Pod 数
Remaining int
// Scheduled:本轮已经 assume/assigned 的 Pod 数
Scheduled int
}
有了 Remaining 和 Scheduled,插件就能做快速拒绝:
// GangScheduling 插件的核心判断(示意实现)
func (pl *GangScheduling) PlacementFeasible(
ctx context.Context,
cs fwk.PlacementCycleState,
pg fwk.PodGroupInfo,
prog PlacementProgress,
) *fwk.Status {
minCount := int(pg.SchedulingPolicy().Gang.MinCount)
// 模式一:校验——已经够了
if prog.Scheduled >= minCount {
return fwk.NewStatus(fwk.Success)
}
// 模式二:可行性快速拒绝——就算剩下的全成功也不够
if prog.Scheduled+prog.Remaining < minCount {
return fwk.NewStatus(fwk.Unschedulable,
fmt.Sprintf("gang cannot reach minCount=%d: scheduled=%d, remaining=%d",
minCount, prog.Scheduled, prog.Remaining))
}
// 还有希望,继续
return fwk.NewStatus(fwk.Wait)
}
注意扩展点定义在 Placement 层面而不是 PodGroup 层面——这是为拓扑感知调度(TAS,KEP-5732)留的口子:未来一个 PodGroup 可能有多个候选放置方案,每个方案独立评估可行性。
4.5 三条路径:成功、部分成功、失败
这是整个算法里最微妙的部分,值得画成决策树:
schedulableCount >= minCount ?
│
├─ 是 ──┬─ 初次调度(周期开始时组内无已调度 Pod)
│ │ → 可调度的 Pod 直接进绑定周期
│ │ → 【即使有 Pod 没放下,也不抢占】
│ │ → 未放下的 Pod 用【旧时间戳】重新入队,紧接着重试
│ │
│ └─ 后续调度(周期开始时组内已有 Pod 被调度)+ 有 Pod 未放下
│ → PlacementFeasible 返回新状态 PartialSuccess
│ → 框架据此【优先抢占而非绑定】
│ → 抢占能腾出空间 → 执行抢占,所有 Pod(含可调度的)回队列
│ → 抢占也没用 → 可调度的 Pod 进绑定
│
└─ 否 ──→ 尝试 Workload-aware Preemption
├─ 需要抢占 → 提名受害者,Pod 提名到目标节点但进 unschedulable 队列
│ 等受害者终止;重试时【禁止发起新的抢占】
└─ 抢不动 → 走标准失败处理,backoff 后回队列
为什么「初次调度成功时不抢占」?KEP 的解释非常实用主义:
Triggering both binding and preemption in the same cycle would be ambiguous... Alternatively, always attempting preemption to free up space, even for schedulable groups, would be unnecessarily disruptive and delay the startup of the group.
而「后续调度时优先抢占」的理由也很务实:既然这个 gang 已经跑起来了一部分,与其只绑定零星几个新 Pod,不如先抢出足够空间一次性搞定——否则马上又要再来一轮。
对 Basic 策略则永远是绑定优先,以保持与传统 pod-by-pod 行为完全一致。
4.6 算法的诚实局限
KEP 有一节标题就叫 Algorithm Limitations,写得非常坦白:
- For basic homogeneous pod groups without inter-pod dependencies, this algorithm is expected to find a placement whenever one exists.
- For heterogeneous pod groups, finding a valid placement is not guaranteed.
- For pod groups with inter-pod dependencies (e.g., affinity/anti-affinity or topology spreading rules), finding a valid placement is not guaranteed.
翻译成人话:这个算法用的是确定性顺序的贪心,不是求解器。换个处理顺序可能就能找到解,但它不会去试。
举个具体的反例。假设 PodGroup 里有:
- Pod A:要求
nodeSelector: gpu=a100,8 卡 - Pod B:要求
podAffinity: A(必须和 A 同节点),2 卡
集群里有两台 a100 节点:N1 剩 8 卡,N2 剩 10 卡。
贪心算法先处理 A(时间戳早),把 A 放到了打分更高的 N1(比如 N1 更空闲率低、bin-packing 得分高)。然后 B 要求和 A 同节点,但 N1 只剩 0 卡 → B 失败 → 整组失败。
实际上把 A 放 N2 就皆大欢喜。但算法不会回溯。
社区给的缓解手段很有意思——用 Pod 优先级引导处理顺序:
they could mitigate this by assigning a lower priority to the dependent pods. Since the algorithm processes higher-priority pods first, this ensures that the required pods are scheduled earlier.
也就是说,给「被依赖的 Pod」更高优先级,让它先被处理。这是一个需要写进内部 wiki 的实操技巧。
另外还有一条硬校验:
All pods belonging to a single pod group must share the same
.spec.schedulerName.
组内 schedulerName 不一致 → 整组直接判不可调度。这个在多调度器共存的集群里很容易踩到。
五、调度器手术(三):Workload-aware Preemption
KEP-5710 是 KEP-4671 的孪生兄弟,两者在 v1.37 被合并到同一个特性门 GenericWorkload 下,graduate in lockstep(同进同退)。
5.1 先定义「抢占单元」
WorkloadPortion := { 一个 PodGroup replica 的全部 Pod } | { 单个 Pod }
约束:抢占单元永远不大于调度单元。
对应到 API,PodGroupSpec 增加了 DisruptionMode:
// +union
type DisruptionMode struct {
// Single:组内成员可以被独立干扰
Single *SingleDisruptionMode
// All:组内成员只能被【一起】干扰
All *AllDisruptionMode
}
type PodGroupSpec struct {
// One of Single, All. Defaults to Single if unset. Immutable.
DisruptionMode *DisruptionMode
}
语义差异是决定性的:
Single(默认):一个 128 卡训练任务里,可以只杀掉 3 个 Pod。适用于有 checkpoint、能弹性恢复的作业。All:要么全杀,要么一个不动。适用于 all-reduce 紧耦合训练——杀 3 个和杀 128 个的效果一模一样(任务照样挂),那不如直接全释放,把完整的资源块让出来。
额外校验:Basic 策略的 PodGroup 不允许设 All(抢占单元不能大于调度单元)。
命名细节也值得学:社区没叫 PreemptionMode 而叫 DisruptionMode,因为「同样的概念未来会被 Eviction API 复用」——提前避免改名。
5.2 PodGroup 级优先级,以及一个尖锐的一致性校验
type PodGroupSpec struct {
// PriorityClassName ... This field is immutable.
PriorityClassName *string
}
关键规则:当 Pod 属于某个 PodGroup 时,调度与抢占使用 PodGroup 的优先级,Pod 自身的 priority 被忽略。
这显然会让人迷惑。Alpha 阶段只是写进文档;Beta/GA 则直接禁止分歧——调度器发现 Pod 优先级和 PodGroup 优先级数值不一致,整组调度失败:
# Pod.status
conditions:
- type: PodScheduled
status: "False"
reason: SchedulerError
message: 'all pods in a single pod group should match the priority of the pod group, got: 1 and 2'
注意校验的是数值,不是 PriorityClass 名字。所以两个不同名但 value 相同的 PriorityClass 是可以的。
5.3 抢占算法:从「节点」泛化到「域」
先回顾传统单 Pod 抢占:
1. 列出该节点上所有优先级低于 P 的 Pod = 潜在受害者
2. 全杀掉都放不下 P → 该节点不可行
3. 从潜在受害者里「赦免」(reprieve) 那些杀掉会违反 PDB 的
4. 剩下的按【优先级从高到低】依次尝试赦免,直到再赦免就放不下 P
5. 对所有可行节点打分,选最优
第 4 步「从最高优先级开始赦免」是精髓:优先保住重要的,从而最小化后续的级联抢占。
Workload-aware Preemption 要同时支持四种组合:
| 抢占者 | 受害者 |
|---|---|
| 单 Pod | 单 Pod(传统场景) |
| 单 Pod | PodGroup(+ 单 Pod) |
| PodGroup | 单 Pod |
| PodGroup | PodGroup(+ 单 Pod) |
泛化后的算法,最大的变化是定义域从「节点」变成了「域」:
1. 把集群切成互斥的「域」(domain),抢占者将被放在某个域里:
- 抢占者是单 Pod → 域 = 单个节点(退化成传统算法)
- 抢占者是 PodGroup → 域 = 【整个集群】(初版就一个域)
2. 对每个域:
2.1 列出域内所有潜在受害者
- 优先级低于抢占者的【PodGroup】
⚠️ 注意:某些成员 Pod 可能跑在域外——它们【参与打分但不参与可行性计算】
- 优先级低于抢占者的单 Pod
2.2 全杀都放不下 → 该域不可行
2.3 [TAS] 计算候选放置方案(Alpha 只取最优的一个)
2.4 对每个放置方案:
a. 受害者排序:先按优先级,同优先级下【PodGroup 优先于单 Pod】
b. 假设所有受害者都被移除,把抢占者临时放上去
c. 尽力赦免会违反 PDB 的受害者
d. 尽力赦免剩余受害者
3. 对各域/放置方案打分,选最优
第 2.1 步那个 ⚠️ 标注是这个算法最烧脑的地方:一个 PodGroup 的成员可能散布在集群各处。当你考虑「杀掉这个 PodGroup 来给我腾地方」时,只有跑在当前域内的那些 Pod 会真正释放出可用资源,但杀掉这个组的代价(打分)要算整个组的。
5.4 DisruptionMode=All 的原子回滚
赦免(reprieve)操作对 DisruptionMode=All 的 PodGroup 必须是原子的:
When trying to reprieve Pods belonging to the PodGroup with
DisruptionMode=Allthe preemption logic will be responsible for making sure that either all of Pods can be reprieved or none of them will be reprieved.
实现方式是一个标准的两阶段回滚:
// 示意:All 模式 PodGroup 的原子赦免
func reprievePodGroupAll(state *ClusterState, pg *PodGroupInfo) bool {
rollback := make([]*v1.Pod, 0, len(pg.Pods))
for _, pod := range pg.Pods {
if !tryReprieve(state, pod) {
// 任何一个失败 → 全部回滚
for _, done := range rollback {
state.Remove(done)
}
return false
}
state.Add(pod)
rollback = append(rollback, pod)
}
return true
}
5.5 一个必须知道的已知缺陷
KEP-5710 里有一段 Note,直接影响 GPU 场景:
DynamicResources, NodeVolumeLimits and VolumeBinding plugins do not work well with preemption right now. They do not support PreFilterExtension interface and they work on a data provided by informer based managers which does not reflect changes in NodeInfo snapshot. As a result they produce false negatives. We do not expect to improve on this when introducing WAP.
DynamicResources 就是 DRA 插件。也就是说:用 DRA 管理 GPU 的场景下,抢占会产生假阴性——明明抢占之后放得下,算法会认为放不下。而且 WAP 明确表示不打算修。
这条在评估「要不要上生产」时权重极高。
六、代码实战
6.1 最小可运行示例:Job + Workload + PodGroup
Job 控制器在 v1.36 已经通过 KEP-5547 完成了 Alpha 集成。但在集成完善之前,最可靠的做法还是自己创建这三个对象。
---
apiVersion: scheduling.k8s.io/v1beta1
kind: Workload
metadata:
name: allreduce-bench
namespace: ml-train
spec:
controllerRef:
apiGroup: batch
kind: Job
name: allreduce-bench
podGroupTemplates:
- name: worker
schedulingPolicy:
gang:
minCount: 16
---
apiVersion: scheduling.k8s.io/v1beta1
kind: PodGroup
metadata:
name: allreduce-bench-worker-0
namespace: ml-train
spec:
workloadRef:
workloadName: allreduce-bench
templateName: worker
schedulingPolicy:
gang:
minCount: 16
# 紧耦合 all-reduce:要死一起死
disruptionMode:
all: {}
priorityClassName: ml-high
---
apiVersion: batch/v1
kind: Job
metadata:
name: allreduce-bench
namespace: ml-train
spec:
completions: 16
parallelism: 16
completionMode: Indexed
template:
spec:
restartPolicy: OnFailure
# 关键:把 Pod 挂到 PodGroup 上
schedulingGroup:
podGroupName: allreduce-bench-worker-0
priorityClassName: ml-high # 必须与 PodGroup 优先级数值一致
containers:
- name: worker
image: nvcr.io/nvidia/pytorch:25.10-py3
command: ["torchrun", "--nnodes=16", "--nproc_per_node=8", "bench.py"]
resources:
limits:
nvidia.com/gpu: 8
注意 WorkloadMaxPodGroupTemplates = 8——一个 Workload 最多 8 个 PodGroupTemplate,而且模板创建后不能增删(字段级更新允许)。设计多角色作业(PS/Worker/Leader/Router)时要提前规划。
6.2 自定义控制器:为每个 replica 生成 PodGroup
这是 LeaderWorkerSet 那类「每个 replica 是一个原子调度单元」场景的标准模式。用 controller-runtime 写:
package controller
import (
"context"
"fmt"
batchv1 "k8s.io/api/batch/v1"
corev1 "k8s.io/api/core/v1"
schedv1beta1 "k8s.io/api/scheduling/v1beta1"
apierrors "k8s.io/apimachinery/pkg/api/errors"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/types"
ctrl "sigs.k8s.io/controller-runtime"
"sigs.k8s.io/controller-runtime/pkg/client"
)
type LWSReconciler struct {
client.Client
}
// ensureWorkload 创建静态策略模板(幂等)
func (r *LWSReconciler) ensureWorkload(
ctx context.Context, owner *MyLWS, groupSize int32,
) (*schedv1beta1.Workload, error) {
wl := &schedv1beta1.Workload{
ObjectMeta: metav1.ObjectMeta{
Name: owner.Name,
Namespace: owner.Namespace,
},
}
_, err := ctrl.CreateOrUpdate(ctx, r.Client, wl, func() error {
// PodGroupTemplates 创建后不可增删,只能改字段
if len(wl.Spec.PodGroupTemplates) == 0 {
wl.Spec.PodGroupTemplates = []schedv1beta1.PodGroupTemplate{{
Name: "replica",
SchedulingPolicy: schedv1beta1.PodGroupSchedulingPolicy{
Gang: &schedv1beta1.GangSchedulingPolicy{MinCount: groupSize},
},
}}
} else {
// minCount 可变,支持弹性
wl.Spec.PodGroupTemplates[0].SchedulingPolicy.Gang.MinCount = groupSize
}
wl.Spec.ControllerRef = &schedv1beta1.TypedLocalObjectReference{
APIGroup: "example.com",
Kind: "MyLWS",
Name: owner.Name,
}
return ctrl.SetControllerReference(owner, wl, r.Scheme())
})
return wl, err
}
// ensurePodGroup 为第 idx 个 replica 创建运行时调度单元
func (r *LWSReconciler) ensurePodGroup(
ctx context.Context, owner *MyLWS, wl *schedv1beta1.Workload, idx int, groupSize int32,
) (string, error) {
name := fmt.Sprintf("%s-replica-%d", owner.Name, idx)
pg := &schedv1beta1.PodGroup{
ObjectMeta: metav1.ObjectMeta{Name: name, Namespace: owner.Namespace},
}
_, err := ctrl.CreateOrUpdate(ctx, r.Client, pg, func() error {
// workloadRef 不可变,只在创建时设置
if pg.Spec.WorkloadRef == nil {
pg.Spec.WorkloadRef = &schedv1beta1.WorkloadReference{
WorkloadName: wl.Name,
TemplateName: "replica",
}
}
// policy 是【拷贝】不是引用
if pg.Spec.SchedulingPolicy.Gang == nil && pg.Spec.SchedulingPolicy.Basic == nil {
pg.Spec.SchedulingPolicy = schedv1beta1.PodGroupSchedulingPolicy{
Gang: &schedv1beta1.GangSchedulingPolicy{MinCount: groupSize},
}
// 紧耦合:要死一起死;不可变,只在创建时设置
pg.Spec.DisruptionMode = &schedv1beta1.DisruptionMode{
All: &schedv1beta1.AllDisruptionMode{},
}
} else if pg.Spec.SchedulingPolicy.Gang != nil {
pg.Spec.SchedulingPolicy.Gang.MinCount = groupSize // 唯一可变字段
}
// PodGroup 归属 owner,owner 删了自动 GC
return ctrl.SetControllerReference(owner, pg, r.Scheme())
})
return name, err
}
// buildPod 关键:在 PodSpec 上写 schedulingGroup(不可变字段,创建时必须写对)
func buildPod(owner *MyLWS, podGroupName string, idx, rank int) *corev1.Pod {
p := owner.Spec.Template.DeepCopy()
p.Spec.SchedulingGroup = &corev1.PodSchedulingGroup{
PodGroupName: &podGroupName,
}
// 优先级必须与 PodGroup 一致,否则 Beta 起整组失败
p.Spec.PriorityClassName = owner.Spec.PriorityClassName
return &corev1.Pod{
ObjectMeta: metav1.ObjectMeta{
Name: fmt.Sprintf("%s-r%d-%d", owner.Name, idx, rank),
Namespace: owner.Namespace,
Labels: map[string]string{"lws.example.com/replica": fmt.Sprint(idx)},
},
Spec: p.Spec,
}
}
顺序很重要:必须先创建 PodGroup,再创建 Pod。KEP 明确说了:
pods linked to the
PodGroupobject will not be scheduled until thisPodGroupobject is created and observed by the kube-scheduler.
如果先建 Pod,它们会一直卡在 PreEnqueue 返回 UnschedulableAndUnresolvable,直到 PodGroup 出现并被 informer 观测到。虽然不会永久卡死(有事件触发重新入队),但会白白浪费启动时间。
6.3 排查脚本:一眼看清 gang 卡在哪
#!/usr/bin/env bash
# gang-doctor.sh —— PodGroup 调度体检
set -euo pipefail
NS="${1:-default}"
PG="${2:?usage: gang-doctor.sh <namespace> <podgroup-name>}"
echo "=== PodGroup: $NS/$PG ==="
kubectl -n "$NS" get podgroup "$PG" -o json | jq '{
minCount: .spec.schedulingPolicy.gang.minCount,
policy: (.spec.schedulingPolicy | keys[0]),
disruptionMode: (.spec.disruptionMode // {} | keys[0] // "single(default)"),
priorityClass: .spec.priorityClassName,
workloadRef: .spec.workloadRef,
conditions: [.status.conditions[]? | {type, status, reason, message}]
}'
echo
echo "=== 成员 Pod 分布 ==="
kubectl -n "$NS" get pods -o json \
| jq --arg pg "$PG" -r '
[ .items[] | select(.spec.schedulingGroup.podGroupName == $pg) ] as $pods
| "总数: \($pods | length)"
, "按 phase: \($pods | group_by(.status.phase) | map({(.[0].status.phase): length}) | add)"
, "已绑定节点: \($pods | map(select(.spec.nodeName != null)) | length)"
, "有提名节点: \($pods | map(select(.status.nominatedNodeName != null)) | length)"
'
echo
echo "=== 优先级一致性(Beta 起分歧会导致整组失败)==="
kubectl -n "$NS" get pods -o json \
| jq --arg pg "$PG" -r '
[ .items[] | select(.spec.schedulingGroup.podGroupName == $pg) | .spec.priority ]
| unique as $p
| if ($p | length) > 1 then "❌ 组内优先级不一致: \($p)" else "✅ 优先级统一: \($p[0])" end
'
echo
echo "=== schedulerName 一致性(不一致整组直接拒绝)==="
kubectl -n "$NS" get pods -o json \
| jq --arg pg "$PG" -r '
[ .items[] | select(.spec.schedulingGroup.podGroupName == $pg) | .spec.schedulerName ]
| unique as $s
| if ($s | length) > 1 then "❌ schedulerName 不一致: \($s)" else "✅ 统一: \($s[0])" end
'
echo
echo "=== 最近调度失败事件 ==="
kubectl -n "$NS" get events --field-selector reason=FailedScheduling \
--sort-by=.lastTimestamp -o json \
| jq -r '.items[-10:][] | "\(.lastTimestamp) \(.involvedObject.name) \(.message)"'
6.4 关键指标与告警
KEP 定义了三个新指标(均由 kube-scheduler 暴露):
| 指标 | 用途 |
|---|---|
scheduler_podgroup_schedule_attempts_total | > 0 说明 Workload Scheduling Cycle 真的在跑;判断特性是否被使用 |
scheduler_podgroup_scheduling_attempt_duration_seconds | 整个尝试耗时(含队列、状态更新) |
scheduler_podgroup_scheduling_algorithm_duration_seconds | 纯算法耗时——快照过期风险的直接代理指标 |
配套的 PrometheusRule:
groups:
- name: workload-scheduling
rules:
# 组调度算法太慢 → 快照过期风险 → 绑定阶段失败率上升
- alert: PodGroupSchedulingAlgorithmSlow
expr: |
histogram_quantile(0.99,
sum(rate(scheduler_podgroup_scheduling_algorithm_duration_seconds_bucket[5m]))
by (le)
) > 1
for: 10m
annotations:
summary: "Workload Scheduling Cycle P99 > 1s,集群快照过期风险显著"
# 组调度反复失败 → 大概率是 minCount 设置或资源规划问题
- alert: PodGroupScheduleAttemptStorm
expr: |
sum(rate(scheduler_podgroup_schedule_attempts_total{result!="scheduled"}[5m])) > 5
for: 15m
annotations:
summary: "PodGroup 调度失败速率过高,检查 minCount 与集群容量"
# 整体吞吐回归:KEP 定义的 SLO 就是「不回归」
- alert: SchedulerBindingThroughputRegression
expr: |
sum(rate(apiserver_request_total{resource="pods",subresource="binding"}[10m]))
< 0.7 * sum(rate(apiserver_request_total{resource="pods",subresource="binding"}[10m] offset 1d))
for: 30m
annotations:
summary: "Pod 绑定吞吐较昨日同期下降 30%+,疑似 Workload Cycle 引入回归"
第三条告警直接来自 KEP 的 SLO 定义:
There should be no significant regression in the system-wide scheduling throughput (pods/s)... measured by
apiserver_request_total{resource="pods", subresource="binding"}.
七、性能、容量与失败处理
7.1 Backoff:10 秒默认值明显不够
KEP 在 Failure Handling 里承认:
The current default of 10 seconds has proven insufficient in large clusters, so this might be the case for workloads. Crucially, because the Workload Scheduling Cycle can be computationally expensive, retrying it too frequently risks starving individual pods.
这是一个很实际的矛盾:
- backoff 太短 → 一个跑 500ms 的 Workload Cycle 每 10 秒重试一次,把主循环的时间片全吃了,普通 Pod 饿死
- backoff 太长 → 资源释放后 gang 反应迟钝,GPU 空转
社区的方向是「考虑按组内 Pod 数量缩放 backoff」,但初版先用标准 Pod backoff 逻辑。
生产建议:如果你的集群同时跑在线服务和训练任务,务必监控 scheduler_pending_pods 按队列的分布,观察普通 Pod 是否在 backoff 期间被拖累。
7.2 重试触发:Queueing Hints 的取舍
重试依赖 Queueing Hints:只要任意一个成员 Pod 收到 Queue 提示(比如节点新增、Pod 删除),整个 gang 就重试。
这显然是乐观的——单个 Pod 变得可调度不代表整组能放下。但 KEP 说「在事件处理器里计算 gang 级可调度性目前很困难」,所以先乐观重试。
更保守的选项是完全跳过 Queueing Hints,只靠 backoff 定时重试,理由是:单次 Workload Cycle 无法证明「放置方案不存在」(尤其对异构组和有组内依赖的组),所以定期无脑重试反而更对。初版倾向这个方案,后续再考虑只对同构组重新启用 Hints。
7.3 nominatedNodeName 必须被清理
失败处理里有一条容易被忽略但极其关键:
Crucially, any
.status.nominatedNodeNameentries set during the failed attempt (or from previous cycles) must be cleared. This ensures that the resources tentatively reserved for this gang are immediately released for other workloads.
如果不清理,一个失败的 128 卡 gang 会用提名的方式「软占用」128 张卡,导致其他作业被误判为放不下——这就是经典的 gang 调度 livelock 成因之一。
7.4 容量与开销账
| 维度 | 影响 |
|---|---|
| 新 informer | kube-scheduler 增加 PodGroup 的 LIST+WATCH;负载随并发 PodGroup 数增长 |
| etcd 对象数 | 每个 replica 一个 PodGroup,大规模 JobSet 会显著增加对象总数 |
| Pod 对象体积 | spec.schedulingGroup 增加数十到数百字节/Pod |
| API 调用 | 调度器同步 PATCH PodGroup status;GC 控制器 WATCH Workload;保护控制器 WATCH PodGroup |
| Pod 启动 SLI | ⚠️ 明确会受影响 |
最后一条要特别强调,KEP 原文:
Pod startup SLI/SLO may be affected and should be adjusted appropriately. The reason is that scheduling a pod being part of a gang will now be blocked on all pods from a gang to be created and observed by the scheduler (which for large gangs can take non-negligible amount of time).
你现有的「Pod 从创建到 Running 的 P99」SLO,在启用 gang 调度后必须重新定义。对一个 512 Pod 的 gang,光是「所有 Pod 被创建并被 informer 观测到」就可能是几秒级别。用旧 SLO 衡量新特性,只会得到一片虚假的红色告警。
八、与 Volcano / Kueue 的关系:不是替代,是分层
这是最多人误解的一点。KEP 的 Non-Goals 写得清清楚楚:
Bring fairness or multiple workload queues in kube-scheduler. Kueue and Volcano.sh will continue to provide this.
正确的心智模型是分层:
┌───────────────────────────────────────────────────────────┐
│ L3 队列 / 配额 / 公平性 / 借用与回收 │
│ Kueue、Volcano(队列部分)、YuniKorn │
│ —— 决定「哪个作业现在有资格创建 Pod」 │
├───────────────────────────────────────────────────────────┤
│ L2 Workload 控制器 │
│ Job / JobSet / LeaderWorkerSet / KubeRay / TrainJob │
│ —— 创建 Workload + PodGroup + Pod,负责生命周期 │
├───────────────────────────────────────────────────────────┤
│ L1 组放置(本 KEP) │
│ kube-scheduler + Workload/PodGroup API │
│ —— 决定「这 128 个 Pod 一起放在哪,或者一个都不放」 │
├───────────────────────────────────────────────────────────┤
│ L0 设备分配 │
│ DRA(ResourceClaim / ResourceSlice / DeviceClass) │
│ —— 决定「具体哪张卡、哪个 MIG 分区」 │
└───────────────────────────────────────────────────────────┘
三个推论:
- 上了 Workload API 不等于可以卸载 Kueue。配额、层级队列、作业排序、跨租户公平,这些 kube-scheduler 明确不做。
- Volcano 的价值会被压缩到队列层。它的 gang 放置能力会被上游覆盖,但队列与公平调度仍有空间。而 Grove 这类项目已经在讨论直接把
PodCliqueSet转成原生Workload(见 ai-dynamo/grove#275),用上游 kube-scheduler 做 gang 后端。 - DRA 与 gang 是正交但必须协同的。1.36 的 DRA 增强(可消费容量 GA、优先列表 GA、设备污点容忍 Beta、分区设备 Beta)解决「拿到什么卡」,Workload API 解决「一起拿」。但前面提到的
DynamicResources插件抢占假阴性问题,正好卡在两者交界处。
九、升级、降级与破坏性变更
9.1 版本演进时间线
| 版本 | 状态 |
|---|---|
| v1.35 | Alpha:Workload API 引入,GangScheduling 插件用 PreEnqueue + Permit 双屏障 |
| v1.36 | Alpha(结构性重构):拆出独立 PodGroup 对象,v1alpha2,调度器切换到基于 PodGroup;Job 控制器完成 Alpha 集成(KEP-5547) |
| v1.37 | Beta:API 升 v1beta1;新增 v1alpha3 取代 v1alpha2;Workload Scheduling Cycle 落地;工作负载感知抢占(KEP-5710)合并进同一特性门 |
9.2 三个必须提前知道的破坏性变更
① 特性门合并。 原来有 GenericWorkload 和 GangScheduling 两个门,v1.37 合并为一个 GenericWorkload;KEP-5710 的 WorkloadAwarePreemption 门也一并合入。
operators must remove
GangSchedulingfrom their feature gate configurations when upgrading. On a downgrade to v1.36,GangSchedulingmust be manually re-enabled.
升级前忘了删 GangScheduling,kube-apiserver / kube-scheduler 会因为未知特性门直接起不来。
② v1alpha2 被整体移除。
The
scheduling.k8s.io/v1alpha2API is entirely removed in favor ofv1alpha3. Users must delete allv1alpha2resources before upgrading, as they are unsupported in v1.37. Backward conversion fromv1alpha3tov1alpha2is not supported during a downgrade.
这是罕见的「不提供转换、必须先删干净」的破坏性变更。升级前置检查脚本:
#!/usr/bin/env bash
# preflight-v137.sh
set -euo pipefail
echo "[1/4] 检查残留的 v1alpha2 资源..."
for kind in workloads podgroups; do
n=$(kubectl get "$kind.v1alpha2.scheduling.k8s.io" -A --no-headers 2>/dev/null | wc -l | tr -d ' ')
if [ "$n" != "0" ]; then
echo " ❌ 发现 $n 个 v1alpha2 $kind —— 升级 v1.37 前必须删除"
kubectl get "$kind.v1alpha2.scheduling.k8s.io" -A
else
echo " ✅ 无 v1alpha2 $kind"
fi
done
echo "[2/4] 检查特性门配置里是否残留 GangScheduling..."
kubectl -n kube-system get pod -l component=kube-scheduler \
-o jsonpath='{.items[*].spec.containers[*].command}' \
| tr ' ' '\n' | grep -i 'feature-gates' || echo " (未使用 --feature-gates 标志)"
echo "[3/4] 检查自定义调度器插件是否已适配..."
cat <<'EOF'
需人工确认的接口变更:
- PostFilterResult 需返回被抢占的 Pod 列表
- PreBindPreFlightResult 需声明 AllowParallel(1.36 起 PreBind 可并行)
- PodGroupInfo → PodGroupState 结构体重命名
- 新增 PlacementFeasiblePlugin 接口(gang 场景需要实现)
EOF
echo "[4/4] 检查指标改名(1.36)..."
cat <<'EOF'
volume_operation_total_errors → volume_operation_errors_total
etcd_bookmark_counts → etcd_bookmark_total
请同步更新 Grafana 面板与告警规则。
EOF
③ 降级顺序有讲究。
On downgrade, kube-scheduler should be downgraded first (to stop processing the new fields) before kube-apiserver is downgraded.
顺序反了,会出现「apiserver 已经不认 spec.schedulingGroup,但 scheduler 还在按 gang 语义等待」的撕裂态。
9.3 版本 skew
好消息是这个特性完全限定在控制面:
The feature is limited to the control plane, so the version skew with nodes (kubelets) doesn't matter.
gang 调度本身是 kube-scheduler 的内存态特性,而 leader 只有一个,所以多副本 scheduler 之间的 skew 也不构成问题。真正需要注意的只有 API:在所有控制面实例都升级完成之前,不要开始写 spec.schedulingGroup。
十、十条踩坑清单
1. 别用 PodGroupInitiallyScheduled 做健康检查。 它单调不可回退,只反映「初次调度是否成功」这个历史事实。gang 跑起来后被驱逐一半,这个条件仍然是 True。
2. 先建 PodGroup 再建 Pod。 顺序反了,Pod 会在 PreEnqueue 阶段被 UnschedulableAndUnresolvable 挡住,白白浪费启动时间。
3. 组内 schedulerName 必须完全一致。 不一致 → 整组直接判不可调度。多调度器共存的集群、或者用了 mutating webhook 注入 schedulerName 的场景,极易踩到。
4. 组内优先级必须与 PodGroup 优先级数值一致。 Beta 起是硬校验。注意校验的是 value,不是 PriorityClass 名字。
5. spec.schedulingGroup 不可变。 Pod 一旦创建就永久属于某个 PodGroup。想换组只能重建 Pod。
6. Gang / Basic 的选择不可变,DisruptionMode 不可变,workloadRef 不可变。 只有 minCount 可变,且是最终一致的——改了不一定马上生效,进行中的周期不生效,已调度的 Pod 不受影响。
7. 异构组和有组内亲和性依赖的组,找不到放置方案是「预期行为」。 算法是确定性贪心,不回溯。缓解手段:给被依赖的 Pod 更高优先级,让它先被处理。
8. DRA + 抢占 = 已知假阴性。 DynamicResources 插件不支持 PreFilterExtension,抢占算法会产生假阴性,而且 WAP 明确不打算修。GPU 场景上生产前必须实测。
9. Pod 启动 SLO 必须重新定义。 大 gang 的「等所有 Pod 被创建并观测到」这段时间是纯增量。用旧 SLO 会得到一片虚假告警。
10. 升级 v1.37 前,删干净 v1alpha2 资源 + 从特性门配置里移除 GangScheduling。 前者不提供转换,后者会让组件起不来。降级时 scheduler 先降,apiserver 后降。
十一、七条工程规律
从这个 KEP 的设计推演里,能提炼出一些超越 Kubernetes 本身的架构规律:
规律一:策略与状态必须分离,否则 etcd 会替你做决定。 原始设计把 PodGroup 状态嵌在 Workload 里,撞上 1.5MB 对象限制和 read-modify-write 争用。这不是 Kubernetes 独有的问题——任何「一个大对象持有 N 个子单元的实时状态」的设计,最终都会在写放大上崩溃。
规律二:不可变字段是状态空间的剪枝工具。 spec.schedulingGroup 选择显式不可变字段而非 label,本质是用「减少可达状态」换「消除一整类竞态」。设计 API 时,先问「这个字段可变会引入多少边界情况」。
规律三:拷贝优于引用,当引用会引入新的失败模式时。 PodGroup 拷贝 policy 而不是引用 Workload,代价是不自动同步,收益是消除了「模板未同步」这个失败模式和「调度中途策略变更」这个语义黑洞。
规律四:先做屏障,再做算法。 Alpha 只加两个 barrier,明确「有意忽略」另外七条 North Star 要求。这种「先保证正确性,再优化效率」的分阶段策略,让 API 能提早暴露给生态验证。
规律五:诚实地记录算法局限,比假装完备更有价值。 KEP 专门有一节讲「什么场景保证找到解,什么场景不保证」,甚至规定了失败消息要明确区分「真的没解」和「可能有解但我没找到」——因为 Cluster Autoscaler 需要据此做不同反应。
规律六:把「代价」和「可行性」分开算。 抢占算法里,跑在域外的组成员「参与打分但不参与可行性计算」。这个区分是所有分布式资源调度问题的通用模式。
规律七:批处理的收益来自同构性。 同构子组 + 签名剪枝是 Workload Cycle 性能的全部来源。这也解释了为什么 KEP 反复强调「homogeneous pod groups」——你的作业越同构,这套机制收益越大。
十二、总结与展望
已经落地的
Workload(静态策略模板)+PodGroup(运行时调度单元)+Pod.spec.schedulingGroup(不可变引用)三层解耦模型- kube-scheduler 主循环从「弹 Pod」升级为「弹 Pod 或 PodGroup」
- Workload Scheduling Cycle:单快照、同构子组批处理、
PlacementFeasible新扩展点、三态决策 - 工作负载感知抢占:
DisruptionModeSingle/All、PodGroup 级优先级、从节点泛化到域的抢占算法 - Job 控制器 Alpha 集成;JobSet / LeaderWorkerSet / KubeRay / TrainJob 集成推进中
明确列为未来工作的
Reservation 概念是下一块关键拼图。Workload Scheduling Cycle 算出的放置方案将变成一个 Reservation 对象,它会成为:
- 多调度器共享底层基础设施的协调点(解决 North Star 要求 4:跨调度器死锁)
- 工作负载级抢占的构建块(要求 6)
- 解决 DRA 资源阻塞类过早抢占的手段(要求 5)——
NominatedNodeName解决不了这个
层级 PodGroup(PodSubGroup、PodSet、CompositePodGroup)用于表达「一个作业里有多个角色,角色之间也要一起调度」。命名体系已经在 KEP 的 Naming 章节预留好了。
与 Cluster Autoscaler / Karpenter 的集成是最大的一块硬骨头(要求 8)。目前只能用 NominatedNodeName 做缓解。
**拓扑感知调度(TAS,KEP-5732)**已在 1.36 进入 Alpha,PlacementFeasible 扩展点定义在 Placement 层就是为它铺路。gang + 拓扑感知的组合,才是 AI 训练真正想要的东西——不只是「128 个 Pod 一起跑」,而是「128 个 Pod 跑在同一个 NVLink 域 / 同一个交换机下」。
一句话判断要不要现在跟进
如果你在跑分布式训练或多机推理:现在就该在测试集群上跑通这套 API,因为 Workload 控制器生态(JobSet / LWS / KubeRay)正在往这个方向收敛,你的 gang 调度适配层迟早要重写一遍。
如果你只跑在线服务:GenericWorkload 特性门不开,你的集群行为一字未变。但 1.36 那些「顺手」的破坏性变更(指标改名、StrictIPCIDRValidation 默认开启、PreBind 并行、PostFilterResult 接口变更)该查还是要查——这些跟你开不开 gang 调度毫无关系。
如果你在维护自定义调度器插件:这是过去五年调度框架最大的一次接口变更。PostFilterResult、PreBindPreFlightResult、PodGroupInfo → PodGroupState、新增 PlacementFeasiblePlugin——一个都躲不掉。
Kubernetes 花了十一年才承认「Pod 不是孤岛」。这个承认的代价,是把跑了十年的 scheduleOne 主循环重写了一遍。但换来的东西是值钱的:从此以后,「一起启动」不再是需要装第三方调度器才能获得的能力,而是集群自带的语义。
参考资料
- KEP-4671: Gang Scheduling using Workload Object(kubernetes/enhancements,sig-scheduling)
- KEP-5710: Workload-aware Preemption
- KEP-5598: Opportunistic Batching
- KEP-5732: Topology Aware Workload Scheduling
- KEP-5547: Job 控制器与 Workload API 集成
- Kubernetes v1.36 Release Notes(DRA 增强、调度器接口变更、指标重命名)