前面三天都是外掛的解法:Kueue 用額度擋在門口,Volcano 換一套排程器,KAI 用佇列和可搶佔性。今天問的是另一個問題:Kubernetes 自己打算怎麼解?
答案是 scheduling.k8s.io 底下的兩個內建物件——Workload 與 PodGroup。Pod 上加一行 spec.schedulingGroup.podGroupName 就入群,不需要裝第二個排程器,但需要開兩個開關。這也是它和 Volcano、KAI 最根本的差別:那兩個是裝元件,這個是改叢集設定。
Gang scheduling 自 Kubernetes v1.37 起為 Beta,而且預設停用。官方文件的功能狀態欄就是這樣寫的。
要使用它,叢集管理員必須在所有相關元件上啟用 GenericWorkload feature gate,而這個功能依賴 PodGroup API,所以對應的 API group 也要開。
節點方面沿用前幾天的 gpu-lab-worker,上面有 8 張 nvidia.com/gpu。
PodGroup 用 schedulingPolicy 表達這組 Pod 要怎麼被排。實驗組用 gang:
apiVersion: scheduling.k8s.io/v1alpha2
kind: PodGroup
metadata: { name: team-a, namespace: native-gang }
spec:
schedulingPolicy:
gang:
minCount: 3 # 全有全無的門檻
對照組用 basic,也就是一個一個排,跟預設行為一樣:
apiVersion: scheduling.k8s.io/v1alpha2
kind: PodGroup
metadata: { name: team-a, namespace: native-basic }
spec:
schedulingPolicy:
basic: {}
Pod 那邊只多一行:
apiVersion: v1
kind: Pod
metadata:
name: team-a-0
namespace: native-gang
labels: { job: team-a }
spec:
schedulingGroup:
podGroupName: team-a # 這一行就是「我屬於 team-a」
restartPolicy: Never
containers:
- name: worker
image: registry.k8s.io/pause:3.10
resources:
limits:
nvidia.com/gpu: 2
沒有 CRD、沒有第二個排程器、沒有 annotation,schedulerName 也還是 default-scheduler。
官方文件把 gang 的生命週期拆成三步:
minCount。兩個條件沒同時滿足,這個 PodGroup 不會進入 active 排程佇列。minCount 個 Pod 的位置,這些成功放置的 Pod 才會被綁定;找不到足夠的位置就一個都不排,整組移到 unschedulable 佇列等資源釋出,中間讓其他工作先跑。值得注意的是第三步:綁定的是「成功放置的 Pod」,不是「minCount 個 Pod」。這個差別在實驗四會看得很清楚。
而「壓著等」這件事發生在兩個不同的時機,錯誤訊息用字也不同:等待 Pod 出現在排程佇列時是 waiting for minCount pods from a gang to appear in scheduling queue,等待隊友被排上去時是 waiting for minCount pods from a gang to be scheduled。實驗三拿到的是後者。
gpu-lab-worker 上 8 張卡
兩隊,每隊 3 個 worker,每個 worker 要 2 張卡
→ 每隊需要 6 張,兩隊合計 12 張 > 8 張
Pod 的建立順序刻意交錯(a-0, b-0, a-1, b-1, a-2, b-2),讓對照組落在「兩邊各搶到一半」,而不是碰運氣讓某一隊剛好湊滿。
先跑對照組:
kubectl delete ns native-gang native-basic native-elastic --ignore-not-found --wait=true
kubectl apply -f ~/native-gang-lab/02-basic-control.yaml
namespace/native-basic created
podgroup.scheduling.k8s.io/team-a created
podgroup.scheduling.k8s.io/team-b created
pod/team-a-0 created
pod/team-b-0 created
pod/team-a-1 created
pod/team-b-1 created
pod/team-a-2 created
pod/team-b-2 created
kubectl -n native-basic get pods -o custom-columns='NAME:.metadata.name,GROUP:.spec.schedulingGroup.podGroupName,PHASE:.status.phase,NODE:.spec.nodeName'
NAME GROUP PHASE NODE
team-a-0 team-a Running gpu-lab-worker
team-a-1 team-a Running gpu-lab-worker
team-a-2 team-a Pending <none>
team-b-0 team-b Running gpu-lab-worker
team-b-1 team-b Running gpu-lab-worker
team-b-2 team-b Pending <none>
kubectl describe node gpu-lab-worker | grep -E "^ nvidia.com/gpu "
nvidia.com/gpu 8 8
兩隊各搶到 2 個,各差 1 個,8 張卡全部被佔住,但沒有任何一隊是完整的。資源全被吃光,卻沒有任何工作能開始——這正是 gang scheduling 要解決的那個場景。
跑實驗組之前一定要先把卡清回 0:
kubectl delete ns native-basic --wait=true
kubectl describe node gpu-lab-worker | grep -E "^ nvidia.com/gpu "
namespace "native-basic" deleted
nvidia.com/gpu 0 0
接著換 gang:
kubectl apply -f ~/native-gang-lab/01-native-gang.yaml
kubectl -n native-gang get pods -o custom-columns='NAME:.metadata.name,GROUP:.spec.schedulingGroup.podGroupName,PHASE:.status.phase,NODE:.spec.nodeName'
NAME GROUP PHASE NODE
team-a-0 team-a Running gpu-lab-worker
team-a-1 team-a Running gpu-lab-worker
team-a-2 team-a Running gpu-lab-worker
team-b-0 team-b Pending <none>
team-b-1 team-b Pending <none>
team-b-2 team-b Pending <none>
kubectl describe node gpu-lab-worker | grep -E "^ nvidia.com/gpu "
nvidia.com/gpu 6 6
| team-a | team-b | 卡被佔住 | 有隊伍在跑嗎 | |
|---|---|---|---|---|
| basic | 2/3 | 2/3 | 8/8(100%) | 0 隊 |
| gang | 3/3 | 0/3(等) | 6/8(75%) | 1 隊 |
利用率從 100% 掉到 75%,但真正在算的訓練從 0 個變成 1 個。
先看 basic 那兩個:
kubectl -n native-basic get podgroups.scheduling.k8s.io -o custom-columns='NAME:.metadata.name,POLICY:.spec.schedulingPolicy,STATUS:.status'
NAME POLICY STATUS
team-a map[basic:map[]] map[conditions:[map[lastTransitionTime:2026-09-25T11:00:19Z message: reason:Scheduled status:True type:PodGroupScheduled]]]
team-b map[basic:map[]] map[conditions:[map[lastTransitionTime:2026-09-25T11:00:19Z message: reason:Scheduled status:True type:PodGroupScheduled]]]
兩個都是 reason:Scheduled、status:True,message 後面是空的。但上一節才看到,這兩隊各有一個 Pod 卡在 Pending,誰都沒跑起來。
再看 gang 那兩個:
kubectl -n native-gang get podgroups.scheduling.k8s.io -o yaml \
| grep -E "^ name:|minCount|reason:|status:|message:|type:"
{"apiVersion":"scheduling.k8s.io/v1alpha2","kind":"PodGroup","metadata":{"annotations":{},"name":"team-a","namespace":"native-gang"},"spec":{"schedulingPolicy":{"gang":{"minCount":3}}}}
name: team-a
minCount: 3
status:
message: ""
reason: Scheduled
status: "True"
type: PodGroupScheduled
{"apiVersion":"scheduling.k8s.io/v1alpha2","kind":"PodGroup","metadata":{"annotations":{},"name":"team-b","namespace":"native-gang"},"spec":{"schedulingPolicy":{"gang":{"minCount":3}}}}
name: team-b
minCount: 3
status:
message: pod group is unschedulable
reason: Unschedulable
status: "False"
type: PodGroupScheduled
gang 底下分得出 True 和 False,湊不齊的那隊還附上 pod group is unschedulable。basic 底下則兩個都是 True,而且 message 是空的——不是「狀態是 True 但訊息告訴你還缺人」,是連個說明都沒有,你完全無從察覺那兩隊其實都沒跑。
另外注意 basic 那兩個的 lastTransitionTime 是同一秒。它們幾乎是一建立就被標成 Scheduled,根本沒等排程結果。
所以 basic 底下的 Scheduled 不是「我這組跑起來了」的意思。拿這個欄位當監控指標會被騙。
kubectl -n native-gang get events --field-selector reason=FailedScheduling \
-o custom-columns='POD:.involvedObject.name,MSG:.message' | sort -u
POD MSG
team-b-0 0/3 nodes are available: 1 node(s) had untolerated taint(s), 2 Insufficient nvidia.com/gpu. no new claims to deallocate, preemption: 0/3 nodes are available: 1 No preemption victims found for incoming pod, 2 Preemption is not helpful for scheduling.
team-b-1 0/3 nodes are available: 1 node(s) had untolerated taint(s), 2 Insufficient nvidia.com/gpu. no new claims to deallocate, preemption: 0/3 nodes are available: 1 No preemption victims found for incoming pod, 2 Preemption is not helpful for scheduling.
team-b-2 pod group is unschedulable, waiting for minCount pods from a gang to be scheduled, one or more plugins asked to wait and no plugin rejected pod
前兩個 Pod 的訊息是很典型的排不上去:三個節點,一個因為 taint 被排除(control-plane),另外兩個 Insufficient nvidia.com/gpu,而且搶佔也幫不上忙。
第三個完全不一樣,最值得逐字讀的是最後那句:one or more plugins asked to wait and no plugin rejected pod。這個 Pod 通過了所有檢查,沒有任何外掛說它不行,然後被要求等隊友。而且訊息用的是 be scheduled 而不是 appear in scheduling queue,也就是說它不是在等隊友出現,是在等隊友被排上去。
也就是說,gang 不是在 filter 階段把 Pod 擋掉的。四天下來的四種攔法擺在一起:
| 攔在哪 | Pod 存在嗎 | |
|---|---|---|
| Kueue | admission | 不存在(spec.suspend) |
| Volcano | bind | 存在,Pending |
| KAI | 佇列額度 | 存在,Pending |
| 本篇(原生) | 等隊友被排上 | 存在,Pending |
一隊 5 個 Pod,minCount: 3,卡還是 8 張,只夠 4 個 Pod 用。
kubectl delete ns native-gang --wait=true
kubectl describe node gpu-lab-worker | grep -E "^ nvidia.com/gpu " # 確認 0
kubectl apply -f ~/native-gang-lab/03-elastic.yaml
kubectl -n native-elastic get pods -o custom-columns='NAME:.metadata.name,PHASE:.status.phase,NODE:.spec.nodeName'
NAME PHASE NODE
team-c-0 Running gpu-lab-worker
team-c-1 Running gpu-lab-worker
team-c-2 Running gpu-lab-worker
team-c-3 Running gpu-lab-worker
team-c-4 Pending <none>
kubectl -n native-elastic get podgroups.scheduling.k8s.io \
-o custom-columns='NAME:.metadata.name,MIN:.spec.schedulingPolicy.gang.minCount,STATUS:.status.conditions[0].reason'
kubectl describe node gpu-lab-worker | grep -E "^ nvidia.com/gpu "
NAME MIN STATUS
team-c 3 Scheduled
nvidia.com/gpu 8 8
4 個跑起來、第 5 個等著、整組算 Scheduled。
這正好對應文件的說法:找得到至少 minCount 個位置時,那些成功放置的 Pod 就會被綁定。所以 minCount 是門檻,不是配額。
三組並排:
| 卡被佔住 | 有隊伍在跑嗎 | |
|---|---|---|
| basic | 8/8(100%) | 0 隊 |
| gang,minCount = 全部 | 6/8(75%) | 1 隊 |
| gang,minCount < 全部 | 8/8(100%) | 1 隊 |
所以「gang 用利用率換進度」這句話只對了一半。真正決定代價的不是有沒有開 gang,是 minCount 設多少。 設成全部就是全有全無,閒置是誠實的代價;設成一個下限,利用率和進度可以同時拿到。
第三列不是免費的:它要求工作負載少幾個也能跑。彈性訓練與推論服務吃得下這套,需要所有 rank 同時到齊的分散式訓練吃不下。
今天看的是 Kubernetes 自己的 gang scheduling:PodGroup 加上 Pod 的 spec.schedulingGroup.podGroupName,不裝第二個排程器,schedulerName 仍然是 default-scheduler——代價是要開兩個 feature gate,外加在 apiserver 的 runtime-config 打開那個 API group。
basic 的行為就是一個一個排,兩隊各搶到一半、8 張卡全滿、沒有一隊在跑;gang 則是整組一起上,代價是 6/8 的利用率換到一個完整的訓練。而 minCount 設得比總數小的時候,兩邊可以同時拿到——前提是工作負載少幾個也能動。
四天下來,四種做法攔的位置各不相同:Kueue 在 admission、Volcano 在綁定前、KAI 在佇列額度、原生 gang 在等隊友被排上。外掛先做出來的東西,上游正在做成標準 API——今天看到的 PodGroup 就是其中一塊。