iT邦幫忙

2026 iThome 鐵人賽

DAY 10
0
Kubernetes

凌晨四點,女友帶著 GPU 來我家學習 Kubernetes:打造 K8s AI Infra 的 30 夜系列 第 10 篇

【Day 10】KAI Scheduler:用被搶佔的風險,換配額外的資源

  • 分享至 

  • xImage
  •  

前面兩天的排隊是同一個形狀:資源不夠就等。

Kueue 問「你有沒有額度」,沒有就把 Job 壓在 suspend;Volcano 問「你湊不湊得齊」,湊不齊就不綁。兩個的答案都是等。

但真實的叢集有一種很不甘心的狀況:配額用完了,機器卻還空著。

你的團隊配額是 4 張卡,現在用滿了。隔壁團隊今天沒人上班,他們的 4 張閒著。這時候你想跑一個實驗,要等嗎?

KAI 給的答案是第三種:你可以用,但要接受被趕走;不能接受的,就只能用保證的那一份。


KAI 是什麼

KAI Scheduler 是一個 Kubernetes 排程器,專門為 AI 與機器學習工作負載最佳化 GPU 資源配置。它設計來管理大規模 GPU 叢集,涵蓋從小型互動式工作到大型訓練與推論的整個 AI 生命週期,而且可以與叢集上其他排程器並存。

它建立在 kube-batch 之上。這個名字昨天出現過,Volcano 的排程器同樣以 kube-batch 為基礎,兩者算是同一個祖先的兩條分支。目前它是 CNCF sandbox 專案。

來歷則和 NVIDIA 有關。KAI Scheduler 最初是在 Run:ai 平台內部開發的,NVIDIA 在 2025 年 4 月以 Apache 2.0 授權將它開源,同時它仍然包裝在 NVIDIA Run:ai 平台中出貨。

用法上和 Volcano 一樣是二級排程器。要讓 KAI 排一個工作需要兩件事:用 kai.scheduler/queue label 指定佇列名稱,並在 Pod spec 中把 schedulerName 設為 kai-scheduler。


配額不是一條線,是兩條

Kueue 的 nominalQuota 是一條線:線以內可以用,線以外就等。KAI 的佇列則有兩個數字。

佇列有四個屬性:Quota 是保證的資源配額,Limit 是資源用量的硬上限,Over-Quota Weight 是同一優先權層級內資源分配的權重,Queue Priority 則決定超出配額時的分配順序。

幾個特殊值的行為如下:

欄位 值 行為
quota -1 無限配額
quota 0 或未設定 沒有保證資源(預設)
limit -1 沒有上限
limit 0 或未設定 不允許額外資源(預設)

佇列還有階層,而且只有葉子佇列(底下沒有子佇列的佇列)可以用來排程工作,父佇列只是組織單位,負責在子佇列之間分配資源。

兩條線中間那一段叫 over-quota。quota 是佇列有權取得的最低資源,不論其他佇列在做什麼,這一份都拿得到;over-quota 則是叢集在滿足所有配額之後還有餘裕時,佇列可以額外使用的資源。

分配因此分成兩個階段。第一階段每個佇列拿到自己的保證配額,也就是 min(quota, requested),這個階段佇列優先權完全不影響結果,所有佇列一視同仁。第二階段才把剩下來的容量依佇列優先權分出去,高優先權的佇列先拿,同優先權內再依 over-quota weight 按比例切。


四個優先級,兩種身分

誰可以用到 over-quota 那一段?取決於工作的優先級。KAI 部署時內建四個 priority class:

Priority Class 值 用途
train 50 可搶佔的訓練工作
build-preemptible 75 可搶佔的互動式工作
build 100 不可搶佔的互動式工作
inference 125 不可搶佔的推論工作

分界線寫得很死:值在 100 以上的 PriorityClass 視為不可搶佔,而不可搶佔的工作只能使用佇列的配額內資源,不能超出配額。換個說法就是,不可搶佔的工作永遠不會用到剩餘容量。

所以這是一個交換:可搶佔的工作能用超過配額的資源,代價是可能被驅逐;不可搶佔的工作不會被搶佔,代價是只能用配額以內的。

沒寫 priorityClassName 的話也有規定。KAI 會依工作負載型別套用預設值:Deployment 與 Knative Service 是 inference,Kubeflow Notebook 是 build,其他情況的一般預設則是 train。一般預設是 train,也就是可搶佔的,這會直接決定等一下兩個實驗的結果。要自己指定的話有三種寫法:在工作負載本身加 priorityClassName label、在它的 Pod 加同名 label,或是直接寫 Pod spec 的 priorityClassName。


實驗環境

沿用前兩天 fake-gpu-operator 的叢集,一台 worker 上有 8 張假卡。先確認 Kueue 和 Volcano 都還裝在上面:

kubectl get pods -A | grep -cE "kueue|volcano"
5

五個 Pod 還在跑,而接下來要再裝第三個排程器。Kubernetes 允許多個排程器並存,由 Pod 的 spec.schedulerName 決定由哪一個負責。它們看的是同一批節點,但各自只處理指定給自己的 Pod,今天的實驗也只會用到 KAI 的 Pod。


安裝 KAI

helm upgrade -i kai-scheduler \
  https://github.com/kai-scheduler/KAI-Scheduler/releases/download/v0.17.2/kai-scheduler-v0.17.2.tgz \
  -n kai-scheduler --create-namespace \
  --wait --timeout 5m

本篇用的是 release 附的 tgz,官方目前建議的安裝方式則是 OCI chart,也就是 helm upgrade -i kai-scheduler oci://ghcr.io/kai-scheduler/kai-scheduler/kai-scheduler -n kai-scheduler --create-namespace --version <VERSION>。

kubectl get pods -n kai-scheduler
NAME                                     READY   STATUS    RESTARTS   AGE
admission-74877d84df-n8f5j               1/1     Running   0          66s
binder-654c9b7d4d-xzjgt                  1/1     Running   0          66s
kai-operator-57844668b8-v9xrj            1/1     Running   0          88s
kai-scheduler-default-597766868c-vcpts   1/1     Running   0          66s
pod-grouper-689c447c78-n9qcx             1/1     Running   0          66s
podgroup-controller-6574458f78-wcgtq     1/1     Running   0          66s
queue-controller-7cd46bdf48-n65mc        1/1     Running   0          66s

七個 Pod。 對照前兩天:Kueue 一個、Volcano 三個、KAI 七個。其中兩個元件有專門的文件說明。

第一個是 binder,它負責把 Pod 實際放到選定的節點上,包含磁碟區、resource claim 等相依資源。把它從排程器拆出來有三個理由:綁定可能因節點狀態改變、資源競爭或 API server 問題而失敗,做成同一個元件時會連帶影響其他 Pod 的排程;綁定涉及多次 API 呼叫,遇到 DRA 或磁碟區時可能很慢,拆開之後排程器不必等它,可以先去排別的 Pod;失敗的綁定需要退避重試機制,留在排程器裡會增加複雜度。兩者透過一個叫 BindRequest 的自訂資源溝通,排程器決定好節點後建立 BindRequest,binder 監看到之後執行綁定、處理 DRA 與磁碟區配置、更新狀態,失敗則依退避策略重試。

第二個是 pod-grouper,它依照傳入的 Pod 自動建立與管理 PodGroup,讓屬於同一個邏輯工作負載的 Pod 被正確歸為一組,以支援 gang scheduling。它採用外掛式架構,先沿著 ownerReferences 找出 Pod 的最上層擁有者,再依工作負載型別套用對應的歸組策略,Deployment、Job、PyTorchJob 各有各的外掛。


預設的佇列

安裝完成後會自動建立一組預設佇列階層,不需要手動設定。default-parent-queue 是頂層的父佇列,預設沒有保留資源配額,用來治理它底下的子佇列;default-queue 是父佇列底下的葉子佇列,工作負載應該指向這一個。

kubectl get queues.scheduling.run.ai
NAME                   PRIORITY   PARENT                 CHILDREN
default-parent-queue                                     ["default-queue"]
default-queue                     default-parent-queue

正好演示前面那個階層:工作只能送到葉子佇列。(CRD 的 API group 仍然叫 scheduling.run.ai,開源前的名字留在 API 裡。)

重點在它的 GPU 配額:

kubectl get queue default-queue -o jsonpath='{.spec.resources.gpu}'
{"limit":-1,"overQuotaWeight":1,"quota":0}
欄位 值 意思
quota 0 保證給你零張卡
limit -1 沒有上限
overQuotaWeight 1 剩餘資源的分配權重

保證什麼都沒有,但也什麼都不擋。 叢集的 8 張卡,全部落在 over-quota 那一段。


實驗一:一個普通的 Pod

apiVersion: v1
kind: Pod
metadata:
  name: solo
  namespace: kai-test
  labels:
    kai.scheduler/queue: default-queue      # 指定佇列
spec:
  schedulerName: kai-scheduler              # 交給 KAI 排程
  restartPolicy: Never
  containers:
    - name: w
      image: busybox:1.36
      command: ["sh", "-c", "sleep 120"]
      resources:
        limits: { nvidia.com/gpu: 1 }

兩個標記就是前面提到的那兩項要求,缺一不可。

kubectl apply -f kai-test.yaml
kubectl -n kai-test get pods
NAME   READY   STATUS    RESTARTS   AGE
solo   1/1     Running   0          37s

跑起來了。但佇列的 quota 是 0,這個 Pod 要 1 張卡,它憑什麼跑?

因為它沒寫 priorityClassName,套用的是一般預設值 train,值 50,低於 100,屬於可搶佔的工作。可搶佔的工作能使用超出配額的資源,而 limit: -1 表示沒有上限,所以它落在 over-quota 那一段。

看它的 events:

kubectl -n kai-test describe pod solo | grep -A8 "^Events"
Normal  Scheduled  45s  kai-scheduler  Successfully assigned pod kai-test/solo to node gpu-lab-worker at node-pool default
Normal  Bound      45s  binder         Pod bound successfully to node gpu-lab-worker
Normal  Pulled     44s  kubelet        ...

兩個不同的來源:kai-scheduler 做決定,binder 完成綁定,正是前面那個拆開的設計。另外注意 at node-pool default,KAI 有節點池的概念,這個實驗沒用到。


實驗二:加一行

同一個 Pod 複製一份,只加一行:

spec:
  schedulerName: kai-scheduler
  priorityClassName: inference      # 只加了這一行
  restartPolicy: Never
  ...

其他完全一樣:一樣 1 張卡、一樣的佇列、一樣的 image。inference 的值是 125,在 100 以上,屬於不可搶佔。

kubectl apply -f kai-wall.yaml
sleep 15
kubectl -n kai-test get pods
NAME             READY   STATUS    RESTARTS   AGE
solo-inference   0/1     Pending   0          57s

排不上去,而理由 KAI 說得很完整:

kubectl -n kai-test describe pod solo-inference | grep -A8 "^Events"
Warning  Unschedulable  47s (x25 over 71s)  kai-scheduler
  NonPreemptibleOverQuota: Non-preemptible workload is over quota.
  Workload requested 1 GPUs, but default-queue quota is 0 GPUs,
  while 0 GPUs are already allocated for non-preemptible pods.
  Use a preemptible workload to go over quota.

一句一句拆:

訊息 在說什麼
Non-preemptible workload is over quota 你是不可搶佔的,而你超出配額了
requested 1 GPUs, but quota is 0 GPUs 你要 1,配額是 0
0 GPUs are already allocated for non-preemptible pods 而且不是別人佔走的,本來就沒有
Use a preemptible workload to go over quota 想超額,就別當不可搶佔的

這段訊息等於把前面那條規則念了一遍。


兩個並排

                    solo              solo-inference
image               busybox:1.36      busybox:1.36
GPU                 1                 1
queue               default-queue     default-queue
schedulerName       kai-scheduler     kai-scheduler
priorityClassName   (沒寫,預設 train) inference
                    ────────────      ────────────
結果                 Running           Pending

唯一的差別是一行 priorityClassName。同一張卡、同一個佇列、同樣的資源需求,一個進得去,一個進不去。而那張卡從頭到尾都是空的,8 張裡只有一個 Pod 在用。

它拿不到,不是因為沒有資源,是因為它要的是保證,而這個佇列的保證額度是 0。


PodGroup 是誰建的

昨天 Volcano 的 PodGroup 是一個看得見的東西。KAI 這邊我一個字都沒寫,先看看有沒有東西冒出來。

solo-inference 還 Pending 的時候,去撈 PodGroup:

kubectl get podgroups -A
No resources found

明明有一個 Pending 的 KAI Pod,卻說沒有。查一下就知道為什麼:

kubectl api-resources | grep -i podgroup
podgroups                              scheduling.k8s.io/v1alpha2      true   PodGroup
podgroups     pg                       scheduling.run.ai/v2alpha2      true   PodGroup
podgroups     pg,podgroup-v1beta1      scheduling.volcano.sh/v1beta1   true   PodGroup

這個叢集裡有三個叫 podgroups 的資源:

API group 來源
scheduling.k8s.io/v1alpha2 Kubernetes 上游的 PodGroup API(KEP-5832 Decouple PodGroup API,搭配 KEP-4671 gang scheduling)
scheduling.run.ai/v2alpha2 KAI
scheduling.volcano.sh/v1beta1 Volcano

第一個是 Kubernetes 內建的 API,不是任何人裝的 CRD。指名道姓才撈得到 KAI 那一個:

kubectl get podgroups.scheduling.run.ai -A
NAMESPACE   NAME                                                     AGE
kai-test    pg-solo-inference-d301b8f4-84c1-4285-954a-dc30567cfd1d   14h

所以 No resources found 不是真的沒有,是 kubectl 挑中了上游那張空表。

至於這個 PodGroup 的由來,沒有擁有者的 Pod 會拿到一個 MinMember 1 的 PodGroup,預設優先級是 train,本例則被 Pod 上的 priorityClassName 覆寫成 inference。

那換成有擁有者的工作負載呢?接下來兩個實驗分別看 Job 和 Deployment。


實驗三:一個原生的 Job

apiVersion: batch/v1
kind: Job
metadata:
  name: plain-job
  namespace: kai-pg
spec:
  parallelism: 3
  completions: 3
  template:
    metadata:
      labels:
        kai.scheduler/queue: default-queue
    spec:
      schedulerName: kai-scheduler
      restartPolicy: Never
      containers:
      - name: c
        image: registry.k8s.io/e2e-test-images/busybox:1.29-4
        command: ["sh","-c","sleep 600"]

全篇只有兩處跟 KAI 有關:schedulerName 和那個 queue label,PodGroup 一個字都沒寫。

kubectl create ns kai-pg
kubectl apply -f podgroup-plain-job.yaml
kubectl -n kai-pg get pods
NAME              READY   STATUS    RESTARTS   AGE
plain-job-5c7n2   1/1     Running   0          6s
plain-job-62m9k   1/1     Running   0          6s
plain-job-wqfd6   1/1     Running   0          6s
kubectl -n kai-pg get podgroups.scheduling.run.ai \
  -o custom-columns='NAME:.metadata.name,MIN:.spec.minMember,QUEUE:.spec.queue,PRIO:.spec.priorityClassName,OWNER_KIND:.metadata.ownerReferences[0].kind,OWNER_NAME:.metadata.ownerReferences[0].name'
NAME                                                MIN   QUEUE           PRIO    OWNER_KIND   OWNER_NAME
pg-plain-job-a52a2308-ffc1-4f60-be3c-19c6ed291e83   1     default-queue   train   Job          plain-job

一個 PodGroup、三個 Pod 共用,名字是 pg-<Job 名字>-<Job UID>,ownerReferences 指回 Job。但 minMember 是 1,不是 3,和 parallelism 沒有關係。

這是文件寫明的設計。對 Job 資源,pod-grouper 會建立一個對應 Job 身分的 PodGroup,把 MinMember 預設設為 1,理由是原生的 Kubernetes batch job 通常不需要 gang scheduling;同時把 PodGroup 的 priority class 設為 train,好讓它可以超出配額。換個角度描述同一件事:一般的 batch job 產生的多個 Pod 是各自分開排程的,會同時執行還是先後執行,取決於叢集的可用資源。

要 gang 就得自己宣告,方式是在 Job 或 JobSet 資源上加 kai.scheduler/batch-min-member annotation,指定至少要有幾個 Pod 一起被排程。文件舉的例子是 parallelism 6 但只要求 2 個一起啟動,適用於像超參數搜尋這種需要一定平行度、但不必全部同時執行的工作。


實驗四:換成 Deployment

同一份東西,只把工作負載換成 apps/v1 Deployment、replicas: 3,其他完全一樣:

apiVersion: apps/v1
kind: Deployment
metadata:
  name: plain-deploy
  namespace: kai-pg
spec:
  replicas: 3
  selector:
    matchLabels: {app: plain-deploy}
  template:
    metadata:
      labels:
        app: plain-deploy
        kai.scheduler/queue: default-queue
    spec:
      schedulerName: kai-scheduler
      containers:
      - name: c
        image: registry.k8s.io/e2e-test-images/busybox:1.29-4
        command: ["sh","-c","sleep 600"]
kubectl -n kai-pg get podgroups.scheduling.run.ai \
  -o custom-columns='NAME:.metadata.name,MIN:.spec.minMember,PRIO:.spec.priorityClassName,OWNER_KIND:.metadata.ownerReferences[0].kind,OWNER_NAME:.metadata.ownerReferences[0].name'
NAME                                                                    MIN   PRIO        OWNER_KIND   OWNER_NAME
pg-plain-deploy-69cdc5777c-6khw6-88b53162-4a06-454d-bb94-a9d79a144b84   1     inference   Pod          plain-deploy-69cdc5777c-6khw6
pg-plain-deploy-69cdc5777c-stc67-b1b341aa-f457-4c78-9851-7f74dae8b7f1   1     inference   Pod          plain-deploy-69cdc5777c-stc67
pg-plain-deploy-69cdc5777c-xj66q-f5e86f0d-47c8-4175-992e-8cb78d4ad04c   1     inference   Pod          plain-deploy-69cdc5777c-xj66q
pg-plain-job-a52a2308-ffc1-4f60-be3c-19c6ed291e83                       1     train       Job          plain-job

同樣是三個 Pod、同樣的 label 和 schedulerName,只換了工作負載型別,PodGroup 就從一個變三個、owner 從 Job 變成 Pod 自己、priority class 從 train 變成 inference。pod-grouper 判斷的依據是工作負載型別,不是 Pod 數量。 唯一沒變的是 minMember,兩邊都是 1。


所以 KAI 幫你做了什麼

它幫你把 PodGroup 建出來,但不幫你決定要幾個一起上車。 原生 Job 的 minMember 預設是 1,等於預設沒有 gang,要 gang 得自己加 kai.scheduler/batch-min-member。

但這不代表 KAI 不做 gang。文件的 PyTorchJob 範例就明講,一個 master 加兩個 worker,因為使用 gang scheduling,三個 Pod 會一起被排程,或在資源足夠之前都不排。差別在於 pod-grouper 對不同工作負載型別套用不同的歸組策略。

和 Volcano 並排:

Volcano KAI
入口 要改用 batch.volcano.sh/v1alpha1 的 Job 吃原生 batch/v1 Job、apps/v1 Deployment
PodGroup 誰建的 controller 自動建立 pod-grouper 自動建立
原生 Job 的 gang 不適用(要改用它的 Job 型別) 預設關閉(minMember: 1)
gang 怎麼宣告 Job spec 的 minAvailable kai.scheduler/batch-min-member annotation
安裝的元件數 3 7

兩邊的 PodGroup 都是自動產生的,真正的差別在入口:Volcano 要你改用它自己的 Job CRD,KAI 吃原生的工作負載。


小結

今天介紹了 KAI Scheduler。它的佇列有兩個數字:quota 是保證給你的,limit 是硬上限,兩者之間那段是 over-quota。誰能用到那一段取決於優先級:值在 100 以上的工作不可搶佔,只能用配額以內的資源;100 以下的可搶佔,能超出配額,代價是可能被趕走。實驗中兩個 Pod 完全相同,只差一行 priorityClassName,一個跑起來、一個停在 Pending,而那張卡從頭到尾都空著。

三天下來,三個排程器問的問題不同:Kueue 問你有沒有額度,Volcano 問你湊不湊得齊,KAI 問你接不接受被趕走。KAI 的答案不是「有或沒有」,而是一個交換,剛好對應線上推論與研究實驗這兩種不同的工作。


參考資料

NVIDIA/KAI-Scheduler
KAI Scheduler — Quick Start
KAI Scheduler — Scheduling Queues
KAI Scheduler — Workload Priority
KAI Scheduler — Scheduling Deep Dive
KAI Scheduler — Batch Scheduling
KAI Scheduler — Pod Grouper
KAI Scheduler — Binder
KAI Scheduler — Dynamic Resource Allocation
NVIDIA Developer Blog — NVIDIA Open Sources Run:ai Scheduler
KEP-5832 — Decouple PodGroup API
Kubernetes — Configure Multiple Schedulers


上一篇
【Day 9】Volcano:你是否在火山救過一組永遠拿不到 GPU 的 Pod
下一篇
【Day 11】原生 gang scheduling:Kubernetes 自己的答案
系列文
凌晨四點,女友帶著 GPU 來我家學習 Kubernetes:打造 K8s AI Infra 的 30 夜 共 17 篇
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言