Day 20 我們介紹了 Pod 擴縮,HPA 和 KEDA 負責決定要開幾個副本。
但副本數只是一個數字。HPA 把它從 1 改成 5,Kubernetes 就會生出五個 Pod 物件,而 Pod 要有節點容得下才會真的跑起來。叢集裡沒有空位的話,新長出來的那幾個會停在 Pending,擴縮等於沒有發生。
今天看的是節點層的擴縮:Pod 排不進去之後,節點從哪裡來,不用了之後又怎麼收掉。負責這件事的是 Karpenter。
Karpenter 是一個開源的節點生命週期管理專案,專門為 Kubernetes 而做。它做四件事:盯著被 Kubernetes 排程器標成 unschedulable 的 Pod,評估這些 Pod 的排程限制(資源請求、nodeSelector、親和性、容忍、拓樸分散),開出符合需求的節點,以及在節點不再需要的時候把它拆掉。
所以它的觸發條件不是使用率,而是「有 Pod 排不進去」這個狀態。
Cluster Autoscaler 擴縮的是事先定義好的 node group,群組裡的機型是固定的。Karpenter 不需要事先定義 node group,它看 Pod 的需求直接決定要開什麼。
| 物件 | 做什麼 |
|---|---|
| NodePool | 定義 Karpenter 能開出什麼節點,以及那些節點上能跑什麼 Pod |
| NodeClass | 雲端專屬的設定,例如 kubelet 的功能和 AMI。NodePool 用 spec.template.spec.nodeClassRef 指向它 |
| NodeClaim | NodePool 當作範本產生出來的物件,由它去開實際的節點 |
Karpenter 把 NodePool 的範本當成最低要求,再疊上 Pod 自己的限制,才產生 NodeClaim。
| 欄位 | 內容 |
|---|---|
template.spec.requirements |
限制節點的參數,例如機型、架構、可用區、容量類型。Pod 的需求必須落在 NodePool 的需求範圍內才排得進去 |
template.spec.taints |
開出來的節點會帶這些 taint,Pod 要有對應的 toleration |
limits |
上限,例如 CPU、記憶體、節點數,避免這個 NodePool 無限長大 |
disruption |
節點什麼時候被整併或替換 |
expireAfter |
節點最長壽命,預設 720 小時,寫成 Never 可以關掉 |
它把待排的 Pod 批次收集起來,依 CPU、記憶體和 GPU 的需求做 binpack,而且會把節點本身的開銷、CNI 需要的資源和 daemonset 算進去。所以不是一個 Pod 配一台機器。它也能挑帶特殊硬體的機型,GPU 就是其中一種。
分成三類:
| 類別 | 包含什麼 |
|---|---|
| 自動且優雅 | consolidation,為了降低叢集成本移除節點;drift,節點的設定和 NodePool 或 NodeClass 的描述不一致。這一類可以用 NodePool 的 disruption budget 限速 |
| 自動但強制 | expiration,壽命超過 expireAfter;interruption,例如 spot 被回收的通知;node auto repair。這一類條件一成立就開始排空,不受 disruption budget 限制 |
| 手動 | kubectl delete node,或者刪掉 NodePool 連帶拆掉它擁有的節點 |
consolidation 看的是 consolidationPolicy:
| 值 | 哪些節點會被動 |
|---|---|
WhenEmpty |
只有空節點。空的定義是節點上只剩沒有擾動成本的 Pod,例如 daemonset |
WhenEmptyOrUnderutilized |
任何能移除或替換來降低成本的節點。這是預設值 |
Balanced |
省下的成本大於對執行中 Pod 的擾動 |
consolidateAfter 決定 Karpenter 要等多久、確認沒有新工作落到節點上,才把它列入整併的考量,沒寫的話預設 0s。
在 Pod 上掛 karpenter.sh/do-not-disrupt 這個 annotation,帶著它的 Pod 不會被 termination controller 優雅驅逐,而且所在的節點會被排除在 consolidation 之外。
這件事對推論服務特別重要。一個已經把權重載進 GPU 的 vLLM Pod 如果被整併搬走,重新起來就要再付一次 Day 18 量到的啟動成本,省下的機器錢會轉成使用者的等待時間。
今天一樣量的是控制器的行為,不需要 GPU,所以同樣在沒有 GPU 的那台 EC2 用 kind 開叢集。Karpenter 的 kwok provider 會把節點開在 kwok 上,那些節點是假的,不會真的有機器。
| 實驗 | 叢集 | 要看什麼 | |
|---|---|---|---|
| 一 | Karpenter 整圈跑一次 | karpenter-lab,單節點加 kwok |
Pod 排不進去之後節點怎麼來、怎麼走,以及機型是誰挑的 |
| 二 | 節點上的 taint | taint-lab,兩個 worker,沒有 Karpenter |
NodePool 可以在開出來的節點上掛 taint,這一節看 taint 掛上去之後實際的效果 |
| 三 | Karpenter 和 DRA | karpenter-lab,單節點加 kwok |
走 DRA 的 Pod 要補哪些東西,Karpenter 才認得它的需求 |
實驗二沒有用到 Karpenter,它在另一個叢集上用 kubectl taint 直接打在節點上。放在這一天是因為 NodePool 的 template.spec.taints 寫下去之後,節點拿到的就是這種 taint,而它的效果值得單獨看一次。
# kind-karpenter.yaml
apiVersion: kind.x-k8s.io/v1alpha4
kind: Cluster
name: karpenter-lab
nodes:
- role: control-plane
image: kindest/node:v1.36.1@sha256:3489c7674813ba5d8b1a9977baea8a6e553784dab7b84759d1014dbd78f7ebd5
kind create cluster --config kind-karpenter.yaml
kubectl config use-context kind-karpenter-lab
git clone --depth 1 https://github.com/kubernetes-sigs/karpenter.git
cd karpenter
git rev-parse --short HEAD
9d3669a
bash hack/install-kwok.sh
kubectl get crd -o name | grep -c kwok
12
kubectl -n kube-system get deploy | grep kwok
kwok-controller-a 1/1 1 1 137m
kwok provider 沒有現成的映像可以拉,所以要自己 build 一份,待會給 chart 用。
# Dockerfile.kwok
FROM golang:1.26 AS build
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 go build -o /karpenter-kwok ./kwok
FROM gcr.io/distroless/static:nonroot
COPY --from=build /karpenter-kwok /karpenter
ENTRYPOINT ["/karpenter"]
docker build -f Dockerfile.kwok -t karpenter-kwok:clean .
docker images karpenter-kwok --format '{{.Repository}}:{{.Tag}} {{.Size}}'
karpenter-kwok:clean 94.6MB
kind load docker-image karpenter-kwok:clean --name karpenter-lab
kubectl apply -f kwok/charts/crds
customresourcedefinition.apiextensions.k8s.io/nodeclaims.karpenter.sh created
customresourcedefinition.apiextensions.k8s.io/nodeoverlays.karpenter.sh created
customresourcedefinition.apiextensions.k8s.io/nodepools.karpenter.sh created
helm upgrade --install karpenter kwok/charts -n kube-system --skip-crds \
--set controller.image.repository=karpenter-kwok --set controller.image.tag=clean \
--set serviceMonitor.enabled=false \
--set settings.featureGates.staticCapacity=false \
--set settings.featureGates.capacityBuffer=false
STATUS: deployed
REVISION: 1
kubectl taint node karpenter-lab-control-plane CriticalAddonsOnly:NoSchedule --overwrite
kubectl -n kube-system rollout status deploy/karpenter --timeout=3m
kubectl -n kube-system get pods -l app.kubernetes.io/name=karpenter
NAME READY STATUS RESTARTS AGE
karpenter-547d8b5b9c-9fzlf 1/1 Running 0 10s
RESTARTS 是 0 才算起來了。不下這個 taint 的話,後面的 Pod 全部排在既有的節點上,Karpenter 不會有事做。
# exp1-nodepool.yaml
apiVersion: karpenter.sh/v1
kind: NodePool
metadata: { name: default }
spec:
template:
spec:
requirements:
- key: kubernetes.io/arch
operator: In
values: ["amd64"]
- key: karpenter.sh/capacity-type
operator: In
values: ["on-demand"]
nodeClassRef: { name: default, kind: KWOKNodeClass, group: karpenter.kwok.sh }
expireAfter: 720h
limits: { cpu: 1000 }
disruption:
consolidationPolicy: WhenEmptyOrUnderutilized
consolidateAfter: 10s
---
apiVersion: karpenter.kwok.sh/v1alpha1
kind: KWOKNodeClass
metadata: { name: default }
這份 NodePool 只限制了兩件事:架構要 amd64、容量類型要 on-demand。機型一個字都沒寫,所以開哪一種機器是 Karpenter 自己決定的。nodeClassRef 指向下面那個 KWOKNodeClass,它是 kwok provider 的 NodeClass,裡面沒有任何欄位要填。
requirements 和 nodeClassRef 是 template.spec 唯一的兩個必填欄位,其餘都有預設值。expireAfter: 720h 和 consolidationPolicy: WhenEmptyOrUnderutilized 寫的剛好就是預設值,不寫結果一樣;limits 是整個 NodePool 的資源上限,這個實驗只會長出一台節點,碰不到它。
真正改掉預設的只有 consolidateAfter: 10s,它的預設是 0s。後面縮回 0 量到的時間要配著這一行讀。
# exp1-inflate.yaml
apiVersion: apps/v1
kind: Deployment
metadata: { name: inflate }
spec:
replicas: 0
selector: { matchLabels: { app: inflate } }
template:
metadata: { labels: { app: inflate } }
spec:
terminationGracePeriodSeconds: 0
containers:
- name: inflate
image: registry.k8s.io/pause:3.10
resources: { requests: { cpu: "1" } }
kubectl apply -f exp1-nodepool.yaml -f exp1-inflate.yaml
kubectl get nodes
NAME STATUS ROLES AGE VERSION
karpenter-lab-control-plane Ready control-plane 4m2s v1.36.1
kubectl get nodeclaim
No resources found
副本數是 0,所以沒有任何 NodeClaim。
kubectl scale deploy inflate --replicas=5
t0=$(date +%s)
for i in $(seq 1 10); do
printf '%3ds nodeclaim=%-2s 節點=%-2s pods(%s)\n' "$(( $(date +%s)-t0 ))" \
"$(kubectl get nodeclaim --no-headers 2>/dev/null | wc -l)" \
"$(kubectl get nodes --no-headers | grep -c kwok)" \
"$(kubectl get pod -l app=inflate --no-headers 2>/dev/null | awk '{print $3}' | sort | uniq -c | tr -s ' ' | tr '\n' ' ')"
sleep 5
done
0s nodeclaim=0 節點=0 pods( 5 Pending )
5s nodeclaim=1 節點=1 pods( 5 Running )
47s nodeclaim=1 節點=1 pods( 5 Running )
叢集本來只有一個 control-plane,而且它被下了 taint,所以五個 Pod 一開始全是 Pending。第二次取樣的時候 nodeclaim 從 0 變成 1、節點從 0 變成 1,五個 Pod 也全部 Running。那台節點是 Karpenter 新開出來的。
取樣是每 5 秒一次,而第一次取樣就已經是 Pending、第二次就全部好了,所以這一整段比 5 秒快,但快多少這個頻率看不出來。
另外五個 Pod 是一起落在這同一台節點上的,它不是看到五個 Pod 就開五台。這是前面講的批次 binpack。
kubectl get nodeclaim -o custom-columns='NAME:.metadata.name,TYPE:.metadata.labels.node\.kubernetes\.io/instance-type,CAPACITY:.metadata.labels.karpenter\.sh/capacity-type,ZONE:.metadata.labels.topology\.kubernetes\.io/zone,READY:.status.conditions[?(@.type=="Ready")].status'
NAME TYPE CAPACITY ZONE READY
default-h8q9r c-8x-amd64-linux on-demand test-zone-b True
kubectl get nodes -o custom-columns='NAME:.metadata.name,CPU:.status.allocatable.cpu,MEM:.status.allocatable.memory' --no-headers | grep kwok
kwok-default-h8q9r-princesssilk-1-gpcithqvmm-192761144 7900m 16374Mi
8 核扣掉 100m 的 kube-reserved。它的推理過程留在日誌裡:
kubectl -n kube-system logs deploy/karpenter --tail=100 | grep "created nodeclaim" | tail -1 | python3 -m json.tool | grep -E '"requests"|cpu|pods|"instance-types"'
"requests": {
"cpu": "5",
"pods": "5"
"instance-types": "c-128x-amd64-linux, c-128x-amd64-windows, c-16x-amd64-linux, c-16x-amd64-windows, c-192x-amd64-linux and 49 other(s)"
叢集裡一共 144 種機型,NodePool 限 amd64 之後剩 72 種,裝得下 5 核的有 54 種,最後挑了裡面最小的 c-8x。NodePool 沒有指定機型,這個選擇是 Karpenter 自己做的。
c 家族的尺寸是 1、2、4、8、16、32、48、64、96、128、192、256 核,所以單純加副本數要加到 300 以上才會逼出第二台節點。想看多節點的話,用反親和性限制一個節點只准放一個比較快。
kubectl scale deploy inflate --replicas=0
t0=$(date +%s)
while :; do
n=$(kubectl get nodeclaim --no-headers 2>/dev/null | wc -l)
printf '%3ds nodeclaim=%-2s 節點=%s\n' "$(( $(date +%s)-t0 ))" "$n" "$(kubectl get nodes --no-headers | grep -c kwok)"
[ "$n" = "0" ] && break
sleep 5
done
0s nodeclaim=1 節點=1
31s nodeclaim=1 節點=1
36s nodeclaim=0 節點=0
副本數歸零之後那台節點就被刪掉了,nodeclaim 和節點數都回到 0,叢集又只剩一個 control-plane。取樣是 5 秒一次,第 31 秒還在、第 36 秒已經不見,所以刪掉是發生在這兩個時間點之間。
日誌裡看得到順序:先判定要拆,打上 taint,最後刪掉 NodeClaim。
kubectl -n kube-system logs deploy/karpenter --tail=200 | grep -iE "disrupting node|tainted node|deleted nodeclaim"
07:17:52 disrupting node(s) Empty/a9dfa87e-...: delete: nodepools=[default]: [kwok-default-h8q9r-...]
07:17:53 tainted node kwok-default-h8q9r-...
07:17:58 deleted nodeclaim kwok-default-h8q9r-...
三行的時間是 52 秒、53 秒、58 秒,從判定到刪掉只花了 6 秒。所以那 36 秒裡大部分是在等,其中 10 秒是 consolidateAfter。
kubectl -n kube-system logs deploy/karpenter --tail=200 | grep "disrupting node" | tail -1 | python3 -m json.tool | grep -E '"command"|"decision"|disrupted-node-count|replacement-node-count|pod-count|instance-type'
"command": "Empty/a9dfa87e-...: delete: nodepools=[default]: [kwok-default-h8q9r-...] (savings: $0.22)",
"decision": "delete",
"disrupted-node-count": 1,
"replacement-node-count": 0,
"pod-count": 0,
"instance-type": "c-8x-amd64-linux"
日誌開頭的 Empty/ 說明了它是照哪一條規則動手的。WhenEmptyOrUnderutilized 管兩種節點:空的,和沒吃滿的。這次是空的那一種,因為五個 Pod 都已經刪掉了。
這一節在另一個叢集上做,沒有裝 Karpenter。要兩個 worker 才看得出 Pod 被趕去哪裡。
# kind-taint.yaml
apiVersion: kind.x-k8s.io/v1alpha4
kind: Cluster
name: taint-lab
nodes:
- role: control-plane
image: kindest/node:v1.36.1@sha256:3489c7674813ba5d8b1a9977baea8a6e553784dab7b84759d1014dbd78f7ebd5
- role: worker
image: kindest/node:v1.36.1@sha256:3489c7674813ba5d8b1a9977baea8a6e553784dab7b84759d1014dbd78f7ebd5
- role: worker
image: kindest/node:v1.36.1@sha256:3489c7674813ba5d8b1a9977baea8a6e553784dab7b84759d1014dbd78f7ebd5
kind create cluster --config kind-taint.yaml
kubectl --context kind-taint-lab get nodes
NAME STATUS ROLES AGE VERSION
taint-lab-control-plane Ready control-plane 26s v1.36.1
taint-lab-worker Ready <none> 15s v1.36.1
taint-lab-worker2 Ready <none> 15s v1.36.1
# exp2-cpu-only.yaml
apiVersion: v1
kind: Namespace
metadata: { name: nodepool }
---
apiVersion: apps/v1
kind: Deployment
metadata: { name: cpu-only, namespace: nodepool }
spec:
replicas: 4
selector: { matchLabels: { app: cpu-only } }
template:
metadata: { labels: { app: cpu-only } }
spec:
containers:
- name: w
image: registry.k8s.io/pause:3.10
resources: { requests: { cpu: "500m", memory: 256Mi } }
K="--context kind-taint-lab"
kubectl $K apply -f exp2-cpu-only.yaml
kubectl $K -n nodepool rollout status deploy/cpu-only --timeout=2m
kubectl $K -n nodepool get pods -o jsonpath='{range .items[*]}{.spec.nodeName}{"\n"}{end}' | sort | uniq -c
2 taint-lab-worker
2 taint-lab-worker2
四個不需要 GPU 的 Pod 平均落在兩台 worker 上。把 taint-lab-worker 想成一台 GPU 節點,它現在被兩個不需要 GPU 的 Pod 佔著。
kubectl $K taint node taint-lab-worker nvidia.com/gpu=present:NoSchedule --overwrite
kubectl $K get node taint-lab-worker -o jsonpath='{.spec.taints}'
[{"effect":"NoSchedule","key":"nvidia.com/gpu","value":"present"}]
kubectl $K -n nodepool get pods -o jsonpath='{range .items[*]}{.spec.nodeName}{"\n"}{end}' | sort | uniq -c
2 taint-lab-worker
2 taint-lab-worker2
taint 已經在節點上,但原本那兩個 Pod 沒有動。NoSchedule 只擋新的排程決策,不驅逐已經排好的 Pod。
kubectl $K -n nodepool rollout restart deploy cpu-only
kubectl $K -n nodepool rollout status deploy/cpu-only --timeout=2m
kubectl $K -n nodepool get pods -o jsonpath='{range .items[*]}{.spec.nodeName}{"\n"}{end}' | sort | uniq -c
4 taint-lab-worker2
重建之後四個全部去了 worker2。
# exp2-gpu-work.yaml
apiVersion: apps/v1
kind: Deployment
metadata: { name: gpu-work, namespace: nodepool }
spec:
replicas: 2
selector: { matchLabels: { app: gpu-work } }
template:
metadata: { labels: { app: gpu-work } }
spec:
tolerations:
- key: nvidia.com/gpu
operator: Exists
effect: NoSchedule
nodeSelector:
kubernetes.io/hostname: taint-lab-worker
containers:
- name: w
image: registry.k8s.io/pause:3.10
resources: { requests: { cpu: "100m" } }
kubectl $K apply -f exp2-gpu-work.yaml
kubectl $K -n nodepool get pods -o wide --no-headers | awk '{printf "%-28s %s\n", $1, $7}'
cpu-only-7c5d8cd484-5p2q5 taint-lab-worker2
cpu-only-7c5d8cd484-5tknv taint-lab-worker2
cpu-only-7c5d8cd484-pfmcf taint-lab-worker2
cpu-only-7c5d8cd484-qghr9 taint-lab-worker2
gpu-work-7ff998cfd6-8l2v2 taint-lab-worker
gpu-work-7ff998cfd6-d6t29 taint-lab-worker
不需要 GPU 的全部在 worker2,帶了 toleration 的在 worker。節點上有這個 taint 之後的效果就是這樣,而 NodePool 可以讓 Karpenter 開出來的節點自動帶上它。
Karpenter 必須在機器還不存在的時候決定要開哪一台。走 device plugin 的時候這件事很單純:Pod 寫 nvidia.com/gpu: 1,機型目錄上也寫 nvidia.com/gpu: 1,同一個名字比得起來。
DRA 不一樣。DRA 的裝置要等機器開起來、driver 上線、發出 ResourceSlice 之後才存在,而 Karpenter 要在那之前就決定開哪一台。
它的解法是請 provider 預先宣告每一種機型「開起來之後會發出什麼裝置」,拿那份宣告去做排程模擬。
| 步驟 | 作用 | 發生在 | |
|---|---|---|---|
| 一 | 設 IGNORE_DRA_REQUESTS |
讓 Karpenter 願意處理帶 DRA 需求的 Pod | 排程前 |
| 二 | RBAC | 讓它讀得到 ResourceClaim 和 ResourceSlice | 排程前 |
| 三 | 機型的裝置宣告 | 讓它知道開哪一台會有裝置 | 排程前 |
| 四 | DRA driver | 讓裝置真的出現在節點上 | 排程後 |
第三步和第四步是同一件事的兩半:第三步是宣告,第四步是兌現。只做第三步的話機器開了但 Pod 排不進去,只做第四步的話根本沒有機器。
kubectl -n kube-system set env deploy/karpenter IGNORE_DRA_REQUESTS=false
Karpenter 預設會在排程模擬的時候忽略 Pod 的 DRA 需求,所以要先把這個環境變數改成 false。
改成 false 之後,Karpenter 會開始 watch ResourceClaim 和 ResourceSlice。但它預設的 ClusterRole 對 resource.k8s.io 一條規則都沒有:
kubectl get clusterrole karpenter -o json | python3 -c "
import json,sys
d=json.load(sys.stdin)
print(len([r for r in d['rules'] if 'resource.k8s.io' in r.get('apiGroups',[])]))"
0
# exp3-02-dra-rbac.yaml
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRole
metadata: { name: karpenter-dra }
rules:
- apiGroups: ["resource.k8s.io"]
resources: ["resourceclaims", "resourceclaimtemplates", "deviceclasses", "resourceslices"]
verbs: ["get", "list", "watch"]
- apiGroups: ["resource.k8s.io"]
resources: ["resourceclaims", "resourceclaims/status"]
verbs: ["update", "patch"]
---
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRoleBinding
metadata: { name: karpenter-dra }
roleRef:
apiGroup: rbac.authorization.k8s.io
kind: ClusterRole
name: karpenter-dra
subjects:
- kind: ServiceAccount
name: karpenter
namespace: kube-system
kubectl apply -f exp3-02-dra-rbac.yaml
kubectl -n kube-system rollout restart deploy/karpenter
Karpenter 的機型目錄上有一個欄位是留給 provider 填的,內容是「這種機型開起來之後,對應的 DRA driver 預期會發出什麼裝置」。那份宣告包含三件事:driver 的名字、裝置池的名字、以及裝置清單。不支援 DRA 的 provider 可以把它留空。
kwok provider 就是留空的,所以要自己補一份:讓 m 家族的 amd64 機型各宣告一張叫 gpu-0 的假 GPU,driver 名字寫 gpu.example.com。補完之後重新 build 映像換上去。
git apply exp3-kwok-dynamicresources.patch
docker build -f Dockerfile.kwok -t karpenter-kwok:dra .
kind load docker-image karpenter-kwok:dra --name karpenter-lab
kubectl -n kube-system set image deploy/karpenter controller=karpenter-kwok:dra
kubectl -n kube-system rollout status deploy/karpenter --timeout=3m
這個實驗裡這份宣告是我們自己補進 kwok provider 的。真實環境裡填它是各家 provider 的事,不是使用者寫 YAML 寫得出來的。
宣告只是給 Karpenter 模擬用的。要讓裝置真的出現在叢集裡,需要節點上的 DRA driver 發出 ResourceSlice。
這一輪用 Karpenter 專案附的 dra-kwok-driver。它是一支獨立程式,跑在叢集外用 kubeconfig 連進來,每 30 秒 reconcile 一次。
它要改一個地方才能用:照原樣 build 出來的程式連自己的 CRD 都讀不到。改完之後 build 起來,再把它的 CRD 裝進叢集。
git apply exp3-dra-driver-scheme.patch
docker run --rm -v /tmp/karpenter:/src -w /src golang:1.26 \
sh -c 'CGO_ENABLED=0 go build -buildvcs=false -o /src/dra-driver-bin ./dra-kwok-driver'
kubectl apply -f dra-kwok-driver/pkg/apis/crds/test.karpenter.sh_draconfigs.yaml
# exp3-03-draconfig.yaml
apiVersion: test.karpenter.sh/v1alpha1
kind: DRAConfig
metadata: { name: fake-gpu }
spec:
driver: gpu.example.com
pools:
- name: fake-gpu-pool
nodeSelectorTerms:
- matchExpressions:
- key: node.kubernetes.io/instance-type
operator: In
values:
- m-1x-amd64-linux
- m-2x-amd64-linux
- m-4x-amd64-linux
- m-8x-amd64-linux
- m-16x-amd64-linux
- m-32x-amd64-linux
- m-48x-amd64-linux
- m-64x-amd64-linux
- m-96x-amd64-linux
- m-128x-amd64-linux
- m-192x-amd64-linux
- m-256x-amd64-linux
resourceSlices:
- devices:
- name: gpu-0
driver 要跟 DeviceClass 的 CEL 比對的名字一致,devices[].name 要跟第三步那份宣告裡的裝置名一致。
kubectl apply -f exp3-03-draconfig.yaml
cd /tmp/karpenter && ./dra-driver-bin > /tmp/dra-driver.log 2>&1 &
真卡環境這一格是現成的,NVIDIA 有自己的 DRA driver,不需要自己做。
最後把 DeviceClass、ResourceClaimTemplate 和測試用的工作負載建起來。測試的 Pod 只要 10m 的 CPU,所以裝置是唯一的約束。
# exp3-00-deviceclass.yaml
apiVersion: resource.k8s.io/v1
kind: DeviceClass
metadata: { name: gpu.example.com }
spec:
selectors:
- cel:
expression: device.driver == "gpu.example.com"
---
apiVersion: resource.k8s.io/v1
kind: ResourceClaimTemplate
metadata: { name: one-fake-gpu, namespace: default }
spec:
spec:
devices:
requests:
- name: gpu
exactly:
deviceClassName: gpu.example.com
allocationMode: ExactCount
count: 1
# exp3-01-dra-test.yaml
apiVersion: apps/v1
kind: Deployment
metadata: { name: dra-test, labels: { app: dra-test } }
spec:
replicas: 2
selector: { matchLabels: { app: dra-test } }
template:
metadata: { labels: { app: dra-test } }
spec:
terminationGracePeriodSeconds: 0
resourceClaims:
- name: gpu
resourceClaimTemplateName: one-fake-gpu
containers:
- name: main
image: registry.k8s.io/pause:3.10
resources:
requests: { cpu: "10m" }
claims: [{ name: gpu }]
kubectl apply -f exp3-00-deviceclass.yaml -f exp3-01-dra-test.yaml
kubectl -n kube-system logs deploy/karpenter --tail=300 | grep "created nodeclaim" | tail -2 | python3 -c "
import sys,json
for l in sys.stdin:
d=json.loads(l)
print(d['time'][11:19], 'NodeClaim =', d['NodeClaim']['name'])
print(' requests =', d.get('requests'))
print(' instance-types =', d.get('instance-types'))"
07:23:28 NodeClaim = default-x2pbp
requests = {'cpu': '10m', 'pods': '1'}
instance-types = m-128x-amd64-linux, m-128x-amd64-windows, m-16x-amd64-linux,
m-16x-amd64-windows, m-192x-amd64-linux and 19 other(s)
07:23:28 NodeClaim = default-q9hkf
requests = {'cpu': '10m', 'pods': '1'}
instance-types = (同上)
5 加 19 等於 24 種,全部是 m-*-amd64-*,也就是 12 個尺寸乘 2 個作業系統。叢集一共 144 種機型,NodePool 限 amd64 之後剩 72 種,而第三步只給了 m 家族的 amd64 機型裝置宣告,所以另外 48 種被剪掉。
剪掉的依據是 DeviceClass 的那條 CEL:
expression: device.driver == "gpu.example.com"
它把這條 CEL 套在每一種機型的宣告裝置上跑一遍,過不了的就不是候選。
kubectl get nodeclaim -o custom-columns='NAME:.metadata.name,TYPE:.metadata.labels.node\.kubernetes\.io/instance-type,ZONE:.metadata.labels.topology\.kubernetes\.io/zone,READY:.status.conditions[?(@.type=="Ready")].status'
NAME TYPE ZONE READY
default-q9hkf m-1x-amd64-linux test-zone-d True
default-x2pbp m-1x-amd64-linux test-zone-b True
兩個 Pod 開了兩台機器,而且各挑家族裡最小的 m-1x。
第三步裡每一種機型只宣告了一個裝置 gpu-0,所以一台放不下兩個要裝置的 Pod。如果它只看 CPU,兩個 Pod 各 10m,一台綽綽有餘,但它沒有這樣算。對照實驗一那五個 Pod 收在同一台上,這裡的兩台就是裝置數量造成的。
kubectl get resourceslice -o custom-columns='DRIVER:.spec.driver,NODE:.spec.nodeName,DEVICES:.spec.devices[*].name'
DRIVER NODE DEVICES
gpu.example.com kwok-...-q9hkf gpu-0
gpu.example.com kwok-...-x2pbp gpu-0
driver 在 Karpenter 開出來的那兩台上各發了一張,driver 名字和裝置名都跟第三步的宣告一致。
kubectl get resourceclaim --no-headers | awk '{print $2}' | sort | uniq -c
2 allocated,reserved
kubectl get resourceclaim -o jsonpath='{range .items[*]}{.metadata.name}{"\n"}{range .status.allocation.devices.results[*]} driver={.driver} pool={.pool} device={.device}{"\n"}{end}{end}'
dra-test-...-gpu-xxxxx
driver=gpu.example.com pool=gpu.example.com/kwok-...-q9hkf device=gpu-0
dra-test-...-gpu-yyyyy
driver=gpu.example.com pool=gpu.example.com/kwok-...-x2pbp device=gpu-0
kubectl get pod -l app=dra-test -o wide --no-headers | awk '{printf "%-30s %-9s %s\n", $1, $3, $7}'
dra-test-559fcccb66-ddqwp Running kwok-default-q9hkf-shriekerbloom-4-...
dra-test-559fcccb66-k5h5s Running kwok-default-x2pbp-collarpond-3-...
每一個 claim 綁到自己節點上的 gpu-0。
從零開始:
kubectl delete -f exp3-01-dra-test.yaml
until [ "$(kubectl get nodeclaim --no-headers 2>/dev/null | wc -l)" = "0" ]; do sleep 5; done
printf '節點=%s slice=%s\n' "$(kubectl get nodes --no-headers | grep -c kwok)" "$(kubectl get resourceslice --no-headers 2>/dev/null | wc -l)"
節點=0 slice=0
kubectl apply -f exp3-01-dra-test.yaml
t0=$(date +%s)
for i in $(seq 1 14); do
printf '%3ds nodeclaim=%-2s 節點=%-2s slice=%-2s claim=%-20s pod=%s\n' "$(( $(date +%s)-t0 ))" \
"$(kubectl get nodeclaim --no-headers 2>/dev/null | wc -l)" \
"$(kubectl get nodes --no-headers | grep -c kwok)" \
"$(kubectl get resourceslice --no-headers 2>/dev/null | wc -l)" \
"$(kubectl get resourceclaim --no-headers 2>/dev/null | awk '{print $2}' | sort -u | tr '\n' ',')" \
"$(kubectl get pod -l app=dra-test --no-headers 2>/dev/null | awk '{print $3}' | sort | uniq -c | tr -s ' ' | tr '\n' ' ')"
sleep 5
done
0s nodeclaim=0 節點=0 slice=0 claim=pending, pod= 2 Pending
5s nodeclaim=2 節點=2 slice=0 claim=pending, pod= 2 Pending
10s nodeclaim=2 節點=2 slice=0 claim=pending, pod= 2 Pending
16s nodeclaim=2 節點=2 slice=2 claim=allocated,reserved, pod= 2 Running
| 段 | 誰做的 | 對應步驟 |
|---|---|---|
| Pending 到節點出現 | Karpenter 用裝置宣告篩機型 | 一、二、三 |
| 節點出現到 slice 發出 | driver 兌現宣告 | 四 |
| slice 到 Pod Running | kube-scheduler 配置 claim |
順序是這一節的重點。節點在第 5 秒就出現了,而那時候 slice 還是 0,裝置還不存在。Karpenter 只憑那份宣告就決定開這兩台,真正的裝置要等 driver 上線才發出來。
全程在 kwok 上,Running 是假的:
kubectl exec <pod> -- echo hello
error: Internal error occurred: unable to upgrade connection: Debug endpoints are disabled.
kubectl logs <pod>
Error from server (MethodNotAllowed): the server does not allow this method on the requested resource ( pods/log dra-test-... )
| 驗到了 | 沒驗到 |
|---|---|
| Karpenter 用裝置宣告篩出正確的機型、數對台數 | 容器真的被啟動 |
| driver 在開出來的節點上發出 ResourceSlice | 容器真的拿到那張卡 |
| 排程器把 claim 配置到那個裝置 |
真環境裡 kubelet 還會問 driver「這個 claim 怎麼接進容器」、拿回 CDI 設定、把 /dev/nvidia* 掛進去。kwok 沒有 kubelet,這一段不存在,所以真正的判準是在節點上跑得起 nvidia-smi,而這次沒有做到那一步。
kubectl delete -f exp3-01-dra-test.yaml -f exp3-03-draconfig.yaml
kubectl delete -f exp3-00-deviceclass.yaml -f exp3-02-dra-rbac.yaml
kind delete cluster --name karpenter-lab
kind delete cluster --name taint-lab
Karpenter 不看使用率,它看的是有沒有 Pod 排不進去。我們只限制了架構和容量類型,開哪一種機器是它從 144 種機型裡自己挑的,而五個 Pod 是整批一起算,最後收在同一台上。
節點不用了也是它收掉。副本數歸零之後 36 秒,那台節點就不見了。而它預設的行為不只收空節點,連還有 Pod 在跑、只是沒吃滿的節點也會為了省錢整併掉,所以一個正在服務的推論 Pod 是可能被搬走的。
今天同時試了 Karpenter 怎麼串接 DRA。Karpenter 必須在機器還不存在的時候就判斷開哪一台會有卡,而它唯一的依據是 provider 事先宣告的「這種機型開起來會有什麼裝置」。這份宣告補上之後,整條路都走得通,換句話說 Karpenter 的部分做完了,等的是各家 provider 把那份資料填進來。
FAQ, Karpenter
NodePools, Karpenter
Disruption, Karpenter
kubernetes-sigs/karpenter