AI 模型的訓練和推論都吃 GPU,而一座 GPU 叢集要分給很多人用,這件事在雲原生的世界裡是用 Kubernetes 解的。但同樣的問題,大學和研究機構的運算中心早就遇過幾十年了。
Slurm 誕生於 2002 年,比 Kubernetes 早了十二年,處理的是同一件事:把一批運算節點分給很多人用。它從第一天就假設「一個作業要 N 個節點、跑 T 小時、全有全無」,而那正是 Kubernetes 這幾年才慢慢補上的能力。
今天的主角是 Slinky,SchedMD 自己做的一組專案,用來打通這兩個世界。
Slurm 是一個開源、具容錯能力、可擴展的叢集管理與作業排程系統。它做三件事:把運算節點的使用權(獨占或非獨占)在一段時間內分配給使用者;提供啟動、執行、監控平行作業的框架;用一個待處理佇列仲裁資源競爭。
架構上是中央的 slurmctld 加上每個節點的 slurmd,另有選用的 slurmdbd(記帳)與 slurmrestd(REST API)。使用者工具是 sbatch、srun、squeue、sinfo、scancel,管理工具是 scontrol。
它管理四種實體:node(運算資源)、partition(把節點分組的邏輯集合)、job(配給使用者一段時間的資源)、job step(job 內部的平行任務集合)。其中 partition 可以視為作業佇列,各自帶著作業大小上限、時間上限、允許使用的群組等限制。
GPU 這類裝置則由 GRES(Generic Resources) 插件管理。
這些名詞在 Kubernetes 那邊都找得到對應:
| Slurm | Kubernetes 這邊對應到的東西 |
|---|---|
| partition(帶限制的作業佇列) | Kueue 的 ClusterQueue |
| GRES | nvidia.com/gpu 這類 extended resource,或 DRA |
| job 要 N 個節點、全有全無 | Volcano 的 minAvailable、原生的 minCount |
sbatch 送進佇列等 |
Kueue 把 Job 壓在 suspend |
同一批問題,兩套答案,各自演化了很久。
Slinky 是 SchedMD 的一組專案,目的是讓 Slurm 和 Kubernetes 互通。兩邊的分工大致是這樣:Kubernetes 擅長排執行時間不定、資源需求可能模糊、單節點、政策寬鬆的工作,但資源池可以無限擴張;Slurm 擅長快速排定執行時間有限、資源需求與拓撲明確、跨多節點、政策嚴格的工作,但資源池是已知的。
GitHub 上的 SlinkyProject 底下有六個 repo,但重點只有兩個,而且方向剛好相反:
| 專案 | 官方的一句話 |
|---|---|
| slurm-operator | Run Slurm on Kubernetes |
| slurm-bridge | Run Slurm as a Kubernetes scheduler |
今天兩個都會裝。
其餘四個是支援性質的:slurm-client 是 Slurm REST API 的 Go 函式庫,供其他專案共用;containers 提供 Slurm 的容器映像;docs 是文件站的內容;slurm-exporter 則把 Slurm 的指標吐給 Prometheus。

今天在原先 fake gpu 的那台 EC2 上額外起另一個 kind 叢集 slinky-lab,一個 control-plane 加兩個 worker。所有 chart 都是 1.2.2,Slurm 版本 26.05。一樣沒有任何真的 GPU。我們會用自己的方式讓 Slurm 相信自己有卡。
先寫 values。這份檔案裡有四塊要同時對上,Slurm 才會認帳,我在 YAML 裡標了編號:
# slurm-values.yaml
clusterName: slinky-lab
controller:
extraConfMap:
GresTypes:
- gpu # ① 讓 Slurm 認識 gpu 這種資源
podSpec:
tolerations:
- key: slinky.slurm.net/managed-node
operator: Exists
effect: NoExecute
restapi:
podSpec:
tolerations:
- key: slinky.slurm.net/managed-node
operator: Exists
effect: NoExecute
configFiles:
gres.conf: |
AutoDetect=off
Name=gpu Count=3 File=/fakegpu/gpu[0-2] # ② 卡的裝置檔在哪
nodesetDefaults:
scalingMode: DaemonSet # 一個 Kubernetes 節點一個 slurmd
slurmd:
volumeMounts:
- name: fakegpu
mountPath: /fakegpu
podSpec:
tolerations:
- key: slinky.slurm.net/managed-node
operator: Exists
effect: NoExecute
initContainers:
- name: mkfakegpu # ③ 真的把那些裝置檔造出來
image: busybox:1.37
securityContext:
privileged: true
command:
- sh
- -c
- 'for i in 0 1 2; do [ -e /fakegpu/gpu$i ] || mknod /fakegpu/gpu$i c 195 $i; done; ls -l /fakegpu'
volumeMounts:
- name: fakegpu
mountPath: /fakegpu
volumes:
- name: fakegpu
emptyDir: {}
nodesets:
gpu:
enabled: true
extraConfMap:
Gres:
- gpu:3 # ④ 宣告這個節點有三張
partition:
enabled: true
partitions:
all:
enabled: true
nodesets:
- ALL
slurm-bridge:
enabled: true
nodesets:
- ALL
configMap:
MaxTime: UNLIMITED
① 到 ④ 缺一不可:Slurm 得先認識 gpu 這種 GRES,知道裝置檔的路徑,那些檔案要真的存在,節點也要宣告自己有幾張。
另外兩個設定等一下才會用到:scalingMode: DaemonSet 是一個 Kubernetes 節點一個 slurmd,這正是 slurm-bridge 要求的 kubelet 與 slurmd 共置;三處 tolerations 是給 bridge 待會打上的 taint 用的。
Slinky 的 webhook 需要憑證,所以 cert-manager 排第一個:
helm install cert-manager oci://quay.io/jetstack/charts/cert-manager \
-n cert-manager --create-namespace --set crds.enabled=true --wait
Pulled: quay.io/jetstack/charts/cert-manager:1.21.2
NAME: cert-manager
STATUS: deployed
CRD 和 operator 是兩個 chart,順序不能換:
helm install slurm-operator-crds oci://ghcr.io/slinkyproject/charts/slurm-operator-crds \
--version 1.2.2 --wait
Pulled: ghcr.io/slinkyproject/charts/slurm-operator-crds:1.2.2
NAME: slurm-operator-crds
STATUS: deployed
helm install slurm-operator oci://ghcr.io/slinkyproject/charts/slurm-operator \
--version 1.2.2 -n slinky --create-namespace --wait
Pulled: ghcr.io/slinkyproject/charts/slurm-operator:1.2.2
NAME: slurm-operator
STATUS: deployed
CHART VERSION: 1.2.2
APP VERSION: 1.2.2
看它帶了哪些 CRD 進來:
kubectl get crd | grep slinky
accountings.slinky.slurm.net 2026-09-26T14:22:54Z
controllers.slinky.slurm.net 2026-09-26T14:22:54Z
loginsets.slinky.slurm.net 2026-09-26T14:22:54Z
nodesets.slinky.slurm.net 2026-09-26T14:22:54Z
restapis.slinky.slurm.net 2026-09-26T14:22:54Z
tokens.slinky.slurm.net 2026-09-26T14:22:54Z
六個。Slurm 的每個角色在 Kubernetes 上都有一個對應的物件。 controllers 是 slurmctld、nodesets 是一組同質的 slurmd worker、loginsets 是登入節點、accountings 是 slurmdbd、restapis 是 slurmrestd。
裝 Slurm 本體:
helm install slurm oci://ghcr.io/slinkyproject/charts/slurm \
--version 1.2.2 -n slurm --create-namespace -f slurm-values.yaml
Pulled: ghcr.io/slinkyproject/charts/slurm:1.2.2
NAME: slurm
STATUS: deployed
CHART VERSION: 1.2.2
APP VERSION: 26.05
kubectl -n slurm get pods
NAME READY STATUS RESTARTS AGE
slurm-controller-0 2/2 Running 0 5m56s
slurm-restapi-76d768f46-dn9v5 1/1 Running 0 5m56s
slurm-worker-gpu-lqxql 2/2 Running 0 5m29s
slurm-worker-gpu-q24qn 2/2 Running 0 5m29s
一個控制器、一個 REST API、兩個 worker(每個 Kubernetes worker 一個)。
kubectl -n slurm exec slurm-controller-0 -c slurmctld -- sinfo -o "%P %N %t %G"
PARTITION NODELIST STATE GRES
gpu slinky-lab-worker,slinky-lab-worker2 idle gpu:3
all* slinky-lab-worker,slinky-lab-worker2 idle gpu:3
slurm-bridge slinky-lab-worker,slinky-lab-worker2 idle gpu:3
兩個節點各三張,共六張。 三個 partition 是 values 裡設的,all* 的星號表示它是預設 partition。
那三張卡長什麼樣:
W=$(kubectl -n slurm get pods --no-headers | grep worker | head -1 | awk '{print $1}')
kubectl -n slurm exec $W -c slurmd -- ls -l /fakegpu
total 0
crw-r--r-- 1 root root 195, 0 Sep 26 14:24 gpu0
crw-r--r-- 1 root root 195, 1 Sep 26 14:24 gpu1
crw-r--r-- 1 root root 195, 2 Sep 26 14:24 gpu2
total 0。那三個是 mknod 造出來的字元裝置節點(c 是 character device,主編號 195 是 Linux 給 NVIDIA 的號碼),但後面什麼都沒接,就是三個空的 inode。
再確認容器裡真的什麼都沒有:
kubectl -n slurm exec $W -c slurmd -- sh -c 'ls /dev/nvidia* 2>&1 | head -3; which nvidia-smi || echo "(沒有 nvidia-smi)"'
ls: cannot access '/dev/nvidia*': No such file or directory
(沒有 nvidia-smi)
沒有裝置、沒有驅動工具,Slurm 照樣報 gpu:3。
因為 gres.conf 對 gpu 類型的 GRES 要求一定要有 File,而 Slurm 啟動時只檢查那個路徑存不存在,不會去開啟它,所以空的 inode 就夠了。
這是為了在沒有卡的機器上跑通而自己想的繞法,不是正規做法。Slinky 的正規做法是真卡加
AutoDetect=nvidia。
送兩個各要四張卡的 job,總共只有六張:
E="kubectl -n slurm exec slurm-controller-0 -c slurmctld --"
$E sbatch -J team-a -N2 --gres=gpu:2 --wrap="sleep 120"
$E sbatch -J team-b -N2 --gres=gpu:2 --wrap="sleep 120"
Submitted batch job 1
Submitted batch job 2
-N2 是兩個節點、--gres=gpu:2 是每節點兩張卡,所以一個 job 要四張。叢集只有六張,兩個湊不齊。
看誰上車了:
$E squeue -o "%.6i %.8j %.9T %.14b %R"
JOBID NAME STATE TRES_PER_NODE NODELIST(REASON)
2 team-b PENDING gres/gpu:2 (Resources)
1 team-a RUNNING gres/gpu:2 slinky-lab-worker,slinky-lab-worker2
team-a 拿到兩個節點跑起來,team-b 整個停在 PENDING,原因是 Resources。
剩下的兩張卡沒有被 team-b 拿走。它要四張,剩兩張不夠,所以一張都不拿。沒有部分配置這件事。
這不需要任何額外設定。sbatch 加 -N2 就是在說「我要兩個節點」,Slurm 的分配單位本來就是整個 job,湊不齊就整組等。
先記住 TRES_PER_NODE 這欄是 gres/gpu:2,有值。等一下會對照。
$E scancel 1 2
到這裡 Slurm 跑在 Kubernetes 上了,但兩邊各過各的:Slurm 的 job 由 slurmctld 排,Kubernetes 的 Pod 由 kube-scheduler 排。
slurm-bridge 要做的是另一件事:把 Kubernetes 的工作翻譯成 Slurm job,交給 slurmctld 排。
工作可以從兩邊送進來:
| 從哪裡送 | 怎麼送 |
|---|---|
| Kubernetes | Pod、PodGroup、Job、JobSet、LeaderWorkerSet |
| Slurm | salloc、sbatch |
不論從哪邊進來,最後都是 slurmctld 在決定資源怎麼分。
所以 Pod 還是 Pod,只是「放到哪個節點」這個決定被移到 Slurm 那邊做了。
混合節點上的硬體是分時共用的。一台實體節點不能同時跑原生 Slurm 的使用者工作和 bridge 管理的 Kubernetes 使用者工作,只有兩邊的系統 daemon 可以共存在同一台機器上。
slurm-bridge 會在它管的節點上打 NoExecute taint。有五個元件是 slurm-values.yaml 管不到的,先補 toleration:
for x in cert-manager/cert-manager cert-manager/cert-manager-webhook cert-manager/cert-manager-cainjector slinky/slurm-operator slinky/slurm-operator-webhook; do
ns=${x%%/*}; d=${x##*/}
kubectl -n $ns patch deploy $d --type=json -p \
'[{"op":"add","path":"/spec/template/spec/tolerations","value":[{"key":"slinky.slurm.net/managed-node","operator":"Exists","effect":"NoExecute"}]}]' \
&& echo "patched $x"
done
deployment.apps/cert-manager patched
patched cert-manager/cert-manager
deployment.apps/cert-manager-webhook patched
patched cert-manager/cert-manager-webhook
deployment.apps/cert-manager-cainjector patched
patched cert-manager/cert-manager-cainjector
deployment.apps/slurm-operator patched
patched slinky/slurm-operator
deployment.apps/slurm-operator-webhook patched
patched slinky/slurm-operator-webhook
kubectl create namespace slurm-bridge
kubectl create namespace demo
demo 這個名字是刻意的。chart 的 admission.managedNamespaces 預設就是 slurm-bridge 自己,而那正是它的 webhook 會排除掉的 namespace,所以照預設裝完,什麼都不會被接管。
bridge 要用 REST API 跟 Slurm 說話(走的就是 Slinky 的 slurm-client 函式庫),所以先跟 Slurm 要一個 token:
JWT=$(kubectl -n slurm exec slurm-controller-0 -c slurmctld -- \
scontrol token username=root lifespan=31536000 | sed 's/^SLURM_JWT=//' | tr -d '\r\n')
kubectl -n slurm-bridge create secret generic slurm-bridge-token --from-literal=auth-token="$JWT"
secret/slurm-bridge-token created
helm install slurm-bridge oci://ghcr.io/slinkyproject/charts/slurm-bridge \
--version 1.2.2 -n slurm-bridge \
--set-json 'admission.managedNamespaces=["demo"]'
Pulled: ghcr.io/slinkyproject/charts/slurm-bridge:1.2.2
NAME: slurm-bridge
STATUS: deployed
kubectl -n slurm-bridge get pods
NAME READY STATUS RESTARTS AGE
slurm-bridge-admission-59b67b97f4-97hq6 0/1 Pending 0 2s
slurm-bridge-controllers-5fb6bd798f-4j6d8 0/1 Completed 0 12s
slurm-bridge-controllers-5fb6bd798f-npgqv 0/1 Pending 0 2s
slurm-bridge-scheduler-5c974578f8-8pxdn 0/1 Pending 0 2s
helm 說 deployed,但三個元件都沒起來。那個 Completed 是它在 taint 打上去之前就排到節點上,然後被趕下來的。
kubectl get nodes -o custom-columns='NODE:.metadata.name,TAINTS:.spec.taints[*].key'
NODE TAINTS
slinky-lab-control-plane node-role.kubernetes.io/control-plane
slinky-lab-worker slinky.slurm.net/managed-node
slinky-lab-worker2 slinky.slurm.net/managed-node
兩個 worker 被 bridge 打了 taint,control-plane 有內建的,而 chart 預設兩個都不容忍。把它們釘到 control-plane 上:
for d in slurm-bridge-admission slurm-bridge-controllers slurm-bridge-scheduler; do
kubectl -n slurm-bridge patch deploy $d --type=json -p '[
{"op":"add","path":"/spec/template/spec/nodeSelector","value":{"node-role.kubernetes.io/control-plane":""}},
{"op":"add","path":"/spec/template/spec/tolerations","value":[
{"key":"node-role.kubernetes.io/control-plane","operator":"Exists","effect":"NoSchedule"},
{"key":"slinky.slurm.net/managed-node","operator":"Exists","effect":"NoExecute"}]}
]' && echo "patched $d"
done
deployment.apps/slurm-bridge-admission patched
patched slurm-bridge-admission
deployment.apps/slurm-bridge-controllers patched
patched slurm-bridge-controllers
deployment.apps/slurm-bridge-scheduler patched
patched slurm-bridge-scheduler
kubectl -n slurm-bridge get pods -o wide | awk '{print $1,$2,$3,$7}'
NAME READY STATUS NODE
slurm-bridge-admission-599467d9cc-jb8w2 1/1 Running slinky-lab-control-plane
slurm-bridge-controllers-75b8ff9484-h6lqs 1/1 Running slinky-lab-control-plane
slurm-bridge-scheduler-74b5d48749-4rb5v 1/1 Running slinky-lab-control-plane
開一個什麼都沒寫的 Pod,沒有 schedulerName、沒有 annotation、沒有 label:
kubectl -n demo apply -f - <<'EOF'
apiVersion: v1
kind: Pod
metadata: {name: plain, namespace: demo}
spec:
restartPolicy: Never
containers:
- name: c
image: busybox:1.37
command: ["sh","-c","sleep 300"]
resources: {limits: {cpu: "1", memory: 256Mi}}
EOF
kubectl -n demo get pod plain -o custom-columns='NAME:.metadata.name,SCHED:.spec.schedulerName,PHASE:.status.phase,NODE:.spec.nodeName'
NAME SCHED PHASE NODE
plain slurm-bridge-scheduler Running slinky-lab-worker
schedulerName 是 admission webhook 改上去的,因為這個 Pod 落在 managedNamespaces 裡的 demo。在沒被管理的 namespace 裡,自己寫 schedulerName: slurm-bridge-scheduler 一樣有效。
那它在 Slurm 那邊看得到嗎:
kubectl -n slurm exec slurm-controller-0 -c slurmctld -- squeue -o "%.6i %.10j %.9T %.12P %.14b %R"
JOBID NAME STATE PARTITION TRES_PER_NODE NODELIST(REASON)
3 (null) RUNNING slurm-bridge N/A slinky-lab-worker
一個 Kubernetes Pod 出現在 squeue 裡。 名字是 (null),因為它沒有 Slurm job name。
注意 TRES_PER_NODE 是 N/A,對照實驗一的 gres/gpu:2。這個 Pod 沒有帶任何 GRES 需求進來,它本來就沒要 GPU。要把 GRES 傳過去有兩條路:用 slurmjob.slinky.slurm.net/ 前綴的 annotation 指定,或是用 DRA 的 DeviceClass 資源名稱請求裝置。兩條這次都沒走。
看它在 Slurm 帳上的細節:
kubectl -n slurm exec slurm-controller-0 -c slurmctld -- scontrol show job 3 | grep -E "ReqTRES|AllocTRES"
ReqTRES=cpu=1,mem=256M,node=1,billing=1
AllocTRES=cpu=8,mem=256M,node=1,billing=8
Pod 要 1 顆 CPU,Slurm 給了整台 8 顆。 這不是計算錯誤,是 slurm-bridge 的預設行為:它把 Pod 翻譯成 Slurm job 時,要的是整個節點的獨占使用權,而不是 Pod 寫的那點資源。
上一個實驗那條規則的後果馬上就看得到。再放一個 Pod 佔住另一台,然後從 Slurm 側送一個要吃光所有卡的 job:
kubectl -n demo apply -f - <<'EOF'
apiVersion: v1
kind: Pod
metadata: {name: plain2, namespace: demo}
spec:
restartPolicy: Never
containers:
- name: c
image: busybox:1.37
command: ["sh","-c","sleep 300"]
resources: {limits: {cpu: "1", memory: 256Mi}}
EOF
kubectl -n slurm exec slurm-controller-0 -c slurmctld -- sbatch -J hpc-hog -N2 --gres=gpu:3 --wrap="sleep 120"
Submitted batch job 5
kubectl -n slurm exec slurm-controller-0 -c slurmctld -- squeue -o "%.6i %.10j %.9T %.12P %R"
JOBID NAME STATE PARTITION NODELIST(REASON)
5 hpc-hog PENDING all (Resources)
4 (null) RUNNING slurm-bridge slinky-lab-worker2
3 (null) RUNNING slurm-bridge slinky-lab-worker
HPC 的 job 排在兩個 Kubernetes Pod 後面,因為它們現在在同一個佇列裡。
但要看清楚阻塞的原因:不是在搶卡,那兩個 Pod 根本沒要 GPU。是它們各獨占了一整個節點,所以要兩個節點的 hpc-hog 無處可去。
kubectl -n slurm exec slurm-controller-0 -c slurmctld -- scancel 5
開一個「願意共享」的 Pod,和前兩個唯一的差別是那行 annotation:
kubectl -n demo apply -f - <<'EOF'
apiVersion: v1
kind: Pod
metadata:
name: shared
namespace: demo
annotations:
slurmjob.slinky.slurm.net/exclusive: "false"
spec:
restartPolicy: Never
containers:
- name: c
image: busybox:1.37
command: ["sh","-c","sleep 300"]
resources: {limits: {cpu: "1", memory: 256Mi}}
EOF
kubectl -n demo get pods -o custom-columns='NAME:.metadata.name,PHASE:.status.phase,NODE:.spec.nodeName'
NAME PHASE NODE
plain Running slinky-lab-worker
plain2 Running slinky-lab-worker2
shared Pending <none>
願意共享的那個反而進不去,因為兩台都被前面兩個獨占了。獨占會傳染。
放掉一台:
kubectl -n demo delete pod plain2 --wait=true
kubectl -n demo get pods -o custom-columns='NAME:.metadata.name,PHASE:.status.phase,NODE:.spec.nodeName'
NAME PHASE NODE
plain Succeeded slinky-lab-worker
shared Running slinky-lab-worker
(plain 顯示 Succeeded 是 sleep 300 自然跑完了。)
kubectl -n slurm exec slurm-controller-0 -c slurmctld -- scontrol show job 6 | grep -E "JobState|ReqTRES|AllocTRES"
JobState=RUNNING Reason=None Dependency=(null)
ReqTRES=cpu=1,mem=256M,node=1,billing=1
AllocTRES=cpu=2,mem=256M,node=1,billing=2
AllocTRES 從 cpu=8 變成 cpu=2,annotation 有效。
是 2 不是 1,因為這個節點的 ThreadsPerCore=2,Slurm 配的是核心,不是執行緒。
這行 annotation 背後的機制是 MCS:非獨占的工作會以 Shared=mcs 加上設定好的 MCS label 送出,讓同一個 MCS 類別的 bridge 工作可以共用節點,而原生的 Slurm job 進不來。不過這需要兩邊都設定才會生效,bridge 這側要在 values 裡指定 schedulerConfig.mcsLabel,Slurm 那側要在 slurm.conf 加上 MCSPlugin=mcs/label 與 MCSParameters=ondemand,ondemandselect。本篇兩項都沒設,所以只驗到「不獨占」這一個效果。
今天做了兩件方向相反的事。
slurm-operator 把 Slurm 搬進 Kubernetes:六個 CRD、一份 values,Slurm 的控制器、REST API、worker 全部變成 Pod。這和昨天的 KubeRay 是同一種形狀:把一個既有的分散式系統交給 operator 管生命週期。
slurm-bridge 把方向倒過來:一個沒寫任何特殊欄位的 Pod,schedulerName 被 webhook 改成 slurm-bridge-scheduler,然後出現在 squeue 裡,由 slurmctld 決定它去哪台機器。排程器本身被換掉了,換成一個比 Kubernetes 還老的 HPC 排程器。
兩個實驗結果值得記住。
一是實驗一:兩個各要四張卡的 job、只有六張,結果一個跑一個等,沒有部分配置。Slurm 不需要 gang 這個功能,因為它的模型本來就是這樣。
二是實驗二和三:一個只要 1 顆 CPU 的 Pod,在 Slurm 帳上佔了整台機器的 8 顆,還把一個 HPC job 擋在外面。Kubernetes 的習慣是把節點塞滿,Slurm 的習慣是整節點配給一個 job。兩邊的預設假設剛好相反,而接起來之後,Kubernetes 的 Pod 要照 Slurm 的規矩走。
Slurm — Overview
Slurm — Slinky
Slurm — gres.conf
SlinkyProject/slurm-operator
SlinkyProject/slurm-bridge
slurm-bridge — Configuration
slurm-bridge — Quickstart
Slurm - Nvidia developer