進階排程:讓 Pod 去對的 node
· tech
📑 目錄
第二篇講過 Scheduler 幫 Pending 的 Pod 挑 node 分兩步:先過濾(裝得下、規則允許)、再評分(挑最佳)。 那時我說「過濾與評分背後的旋鈕之後專門一篇講」——就是這篇。預設 Scheduler 已經很聰明,多數時候你什麼都不用管;但當你需要「這批 Pod 給我跑在 GPU 機器」「別跟那個吵鄰居擠同一台」「這池機器只保留給特定服務」時,就得動這些旋鈕。這也是 Airflow + Spark on K8s 那篇「把 Airflow 核心釘穩定 node、把 Spark executor 丟 spot node」背後真正的機制。
先建立一個最容易搞混的心智模型:是誰在挑誰?
先決條件:requests 決定「裝不裝得下」
在挑 node 之前,過濾的第一關永遠是資源。每個 Pod 可以宣告 requests(我至少要這麼多)與 limits(我最多用到這),但對排程來說,只有 requests 有意義:
- Scheduler 把每台 node 上所有 Pod 的
requests加總,看還剩多少,判斷新 Pod 塞不塞得下。 - 它不看 node 的實際使用率,也不看
limits。就算一台 node 上的 Pod 實際只用了 10% CPU,只要requests已經加總到滿,新 Pod 就是排不進去。
這帶出兩個常踩的坑:requests 寫太大 → Pod 卡在 Pending(明明機器很閒卻排不進);requests 寫太小或不寫 → 一台 node 被塞爆,大家搶資源互相拖垮。requests 是排程的依據,limits 是執行期的天花板——排程只認前者。
讓 Pod 挑 node:nodeSelector 與 node affinity
想叫 Pod「去某一種 node」,前提是 node 身上得有 label(kubectl label node node1 disk=ssd)。接著由淺到深有三種寫法:
- nodeSelector:最簡單,Pod 寫
nodeSelector: {disk: ssd},就是「只去 label 完全吻合的 node」。硬性、只能相等比對。 - node affinity(required):
requiredDuringSchedulingIgnoredDuringExecution——一樣硬性「非去不可」,但表達力更強,支援In / NotIn / Exists這種運算子(例如「disk 是 ssd 或 nvme」)。 - node affinity(preferred):
preferredDuringSchedulingIgnoredDuringExecution——軟性偏好,附一個權重。有符合的 node 就優先,沒有也還是排得上去,不會卡 Pending。
那串又臭又長的名字其實是兩段資訊:requiredDuringScheduling=排程時硬性要求;IgnoredDuringExecution=排上去之後,就算 node 的 label 事後被改掉,已經在跑的 Pod 也不會被踢走。記住這個字尾,就懂了 affinity 的作用範圍只在「排程那一刻」。
讓 node 挑 pod:taint 與 toleration
affinity 是 Pod 主動挑,taint 則是 node 反過來排斥 Pod。 你在 node 上打一個污點(kubectl taint node node1 gpu=true:NoSchedule),預設情況下沒有對應 toleration 的 Pod 一律不准排上來。effect 有三種,力道遞增:
| effect | 對還沒排上的 Pod | 對已經在跑的 Pod |
|---|---|---|
PreferNoSchedule | 盡量別排上來(軟性) | 不動 |
NoSchedule | 不准排上來(硬性) | 不動 |
NoExecute | 不准排上來 | 連正在跑的都驅逐(除非它容忍) |
Pod 這邊寫上對應的 toleration,就拿到「這個 taint 擋不住我」的入場券。這裡是全篇最該記牢的一點:toleration 只是「免疫」,不是「吸引」。 一個帶 GPU toleration 的 Pod,並不會因此被拉去 GPU node——它只是「可以」去,Scheduler 大可把它排到別台空的一般 node。想真正做到「這池機器只給某種 Pod、而且那種 Pod 一定來這」,得三個旋鈕一起上:
最後手段:nodeName 直接手動排程
如果你連 Scheduler 都不想經過,Pod 裡直接寫 nodeName: node1——這台是我指定的,繞過整個排程流程,kubelet 直接在那台把它拉起來。代價是:它不做任何檢查,那台裝不下就卡死(Pod 一直 Pending 也沒人幫你換一台),node 掛了也不會被重排到別處。這是 debug 或極特殊需求才用的逃生門,正常情況一律讓 Scheduler 決定——把「放哪」交給會算的它,你只描述約束,別自己指定答案。
順帶一提,除了 Pod 挑 node,還有 Pod 挑 Pod:
podAffinity(把相關的 Pod 湊在同一區,減少跨區延遲)、podAntiAffinity(把同一個服務的副本打散到不同 node,一台掛了不會全滅),以及topologySpreadConstraints(更精細地要求跨 zone / node 平均分布)。原理跟 node affinity 一樣是「拉」與「推」,只是這回挑的是別的 Pod,不是 node。真要打散高可用副本時,podAntiAffinity是最常用的一招。
落成 YAML:三個旋鈕一起上
把「專屬 node」那張組合技圖落成宣告。先給 GPU node 打一個污點(這是指令,不是 YAML):
kubectl label node gpu-1 hw=gpu # 貼標籤:讓 affinity 挑得到
kubectl taint node gpu-1 gpu=true:NoSchedule # 打污點:趕走沒免疫的 Pod
然後在 Pod 樣板裡把三個旋鈕寫齊——resources.requests(排程的容量依據)、nodeAffinity(拉:我要去 hw=gpu 的 node)、tolerations(免疫:我扛得住那個 taint):
spec:
containers:
- name: trainer
image: myrepo/trainer:1.0
resources:
requests: { cpu: "2", memory: "8Gi" } # Scheduler 靠這個判斷裝不裝得下
affinity:
nodeAffinity: # 拉:硬性要求去 hw=gpu 的 node
requiredDuringSchedulingIgnoredDuringExecution:
nodeSelectorTerms:
- matchExpressions:
- { key: hw, operator: In, values: [ "gpu" ] }
tolerations: # 免疫:容忍 gpu=true:NoSchedule
- { key: gpu, operator: Equal, value: "true", effect: NoSchedule }
三段各對應圖裡的一環:少了 tolerations,Pod 被 taint 擋在門外;少了 nodeAffinity,有免疫的 Pod 也可能飄去別台。兩個一起,才鎖得住「這台只給這種 Pod、且這種 Pod 一定來這」。 requests 則是無論如何都要寫對的地基——它是排程的帳本。
反思
「拉、推、免疫」分清楚,taint/toleration 就不再繞
我當初學這塊卡最久的,就是把 toleration 當成「把 Pod 送去那台 node 的咒語」——結果打了 taint、加了 toleration,Pod 卻跑去別台,百思不解。想通的那一刻很簡單:toleration 只解決「能不能進」,不解決「要不要來」。 taint 是門口的保全(推),toleration 是通行證(免疫),affinity 才是真正把人帶進門的邀請(拉)。三者各司其職,少一個就漏。之後每次要設計「專屬機器池」,我都在腦裡跑一遍這張圖:誰擋門、誰有通行證、誰負責把對的人拉進來——一次就設對,不再試錯。
大多數時候,最好的排程策略是「不排」
寫了這麼多旋鈕,我的實際建議反而是能不用就不用。預設 Scheduler 的 bin-packing 已經處理掉九成情況,每多綁一條 affinity / taint,就多一份「以後 node 池變動、標籤改名時會爆」的耦合。我看過團隊把一堆 Pod 硬釘死在特定 node,結果某天那批機器要退役,牽一髮動全身。先讓 Scheduler 自動放,真的有痛點(要 GPU、要隔離吵鄰居、要打散高可用副本)再出手——這跟我對所有 K8s 進階功能的態度一致:預設夠用,別為了展示會用而用。約束加得越少,系統越有餘裕自己找最佳解。
requests 才是排程的真相,不是 CPU 用量
最反直覺、也最常害人 debug 到深夜的一點:Scheduler 從頭到尾不看實際使用率,只看 requests 的加總。 我踩過一次——監控上 node CPU 明明才 30%,新 Pod 卻死活排不進去,查半天才發現是上面幾個 Pod 的 requests 開得太寬,把「帳面額度」佔滿了。從那次起我把 requests 當成跟 node 簽的資源合約在寫,而不是隨手填的數字:填太大排不進、填太小擠爆別人。要調到準,得靠實際用量的觀測回饋——這又接回 上 K8s 就得把 observability 補起來那件事。排程排的是你宣告的數字,不是你真正用的量;帳報得準,叢集才排得準。