度框架插件開發(fā)實(shí)戰(zhàn):從擴(kuò)展點(diǎn)原理到打分插件源碼實(shí)現(xiàn))
簡介本資源為基于K8s調(diào)度框架擴(kuò)展Kubernetes調(diào)度器插件的示例項(xiàng)目源碼面向計(jì)算機(jī)相關(guān)專業(yè)的高校學(xué)生、教師及云原生方向從業(yè)者可用于課程設(shè)計(jì)、畢業(yè)設(shè)計(jì)或調(diào)度器二次開發(fā)的學(xué)習(xí)參考。壓縮包共31個(gè)文件約87KB以Go語言源碼為核心配合yaml、yml配置文件描述調(diào)度策略與部署參數(shù)xml與iml為IDE工程配置另含Dockerfile、Makefile、helm chart模板及項(xiàng)目說明文檔覆蓋從代碼實(shí)現(xiàn)到容器化部署的完整鏈路。項(xiàng)目圍繞K8s調(diào)度框架的插件擴(kuò)展機(jī)制展開包含調(diào)度器測試配置、依賴獲取腳本與實(shí)現(xiàn)思路筆記便于讀者理解調(diào)度插件注冊、打分與過濾等關(guān)鍵流程。目前已有72人學(xué)習(xí)下載適合具備一定Go與Kubernetes基礎(chǔ)、希望深入調(diào)度器擴(kuò)展機(jī)制的讀者借鑒與修改。1. 從一次調(diào)度不均衡說起K8s 調(diào)度框架到底能擴(kuò)展什么線上跑著一批混合負(fù)載的 K8s 集群節(jié)點(diǎn) CPU 平均利用率只有 40%但總有那么兩三臺機(jī)器常年 80% 以上Pod 被反復(fù)驅(qū)逐又重建。你翻kubectl describe node發(fā)現(xiàn)調(diào)度器把同一批帶appgateway標(biāo)簽的 Pod 全塞到了少數(shù)節(jié)點(diǎn)上因?yàn)槟J(rèn)的LeastAllocated打分在資源請求相近時(shí)幾乎打平最后靠隨機(jī)數(shù)決定落點(diǎn)。這時(shí)候你需要的不是調(diào)大集群而是讓調(diào)度器按你自己的規(guī)則打分——這正是 Kubernetes 調(diào)度框架Scheduling Framework要解決的問題。調(diào)度框架是 K8s 1.15 引入、1.19 正式 GA 的一套插件化調(diào)度架構(gòu)。它把原來寫死在kube-scheduler里的 Predicates 和 Priorities 拆成一組擴(kuò)展點(diǎn)Extension Point你可以在這些點(diǎn)上掛自己的插件用 Go 寫幾十行邏輯就能改變 Pod 的落點(diǎn)決策。本文圍繞一份「基于 K8s 調(diào)度框架擴(kuò)展調(diào)度器插件」的示例源碼和項(xiàng)目說明把調(diào)度框架的擴(kuò)展點(diǎn)、插件注冊方式、打分函數(shù)怎么寫、本地怎么編譯驗(yàn)證、以及上線前必須知道的坑一條線講透。適合已經(jīng)能跑起 K8s 集群、想從「會用 kubectl」進(jìn)階到「能改調(diào)度行為」的工程師也適合正在準(zhǔn)備 k8s 面試題里調(diào)度器相關(guān)追問的人。2. 調(diào)度框架的擴(kuò)展點(diǎn)與插件模型先搞清楚你的邏輯該掛在哪2.1 調(diào)度框架把一次調(diào)度拆成了哪些階段要寫插件先得知道調(diào)度器處理一個(gè) Pod 時(shí)按什么順序走。調(diào)度框架把整個(gè)調(diào)度周期分成兩大階段調(diào)度周期Scheduling Cycle和綁定周期Binding Cycle。調(diào)度周期是串行的同一時(shí)刻只處理一個(gè) Pod綁定周期可以異步并行。調(diào)度周期內(nèi)的擴(kuò)展點(diǎn)按順序是PreFilter預(yù)處理 Pod 信息檢查前置條件可以把結(jié)果寫進(jìn) CycleState 供后續(xù)插件讀。Filter過濾節(jié)點(diǎn)返回哪些節(jié)點(diǎn)不可用。等價(jià)于老的 Predicates。PostFilter如果 Filter 后沒有可用節(jié)點(diǎn)進(jìn)入這里做搶占Preemption邏輯。PreScore打分前的預(yù)處理生成供 Score 插件共享的數(shù)據(jù)。Score給每個(gè)通過 Filter 的節(jié)點(diǎn)打分返回 0 到 100 的整數(shù)。NormalizeScore把某個(gè)插件的分?jǐn)?shù)歸一化到 0-100 區(qū)間多個(gè)插件之間才能加權(quán)合并。Reserve為選中的節(jié)點(diǎn)預(yù)留資源失敗會觸發(fā) Unreserve。Permit批準(zhǔn)、拒絕或延遲 Pod 的綁定。綁定周期內(nèi)的擴(kuò)展點(diǎn)是PreBind、Bind、PostBind以及配套的Unreserve。理解這張順序表是選型的第一步。比如你想做的是「按節(jié)點(diǎn)實(shí)際負(fù)載打分」那邏輯應(yīng)該放在Score如果你想做的是「某類 Pod 只能落在帶特定標(biāo)簽的節(jié)點(diǎn)」那應(yīng)該放在Filter。掛錯(cuò)擴(kuò)展點(diǎn)插件要么不生效要么在錯(cuò)誤時(shí)機(jī)讀到臟數(shù)據(jù)。2.2 一個(gè)插件可以同時(shí)實(shí)現(xiàn)多個(gè)擴(kuò)展點(diǎn)調(diào)度框架的插件不是「一個(gè)插件一個(gè)功能」而是一個(gè)插件對象可以實(shí)現(xiàn)多個(gè)擴(kuò)展點(diǎn)的接口。比如官方自帶的NodeResourcesFit插件同時(shí)實(shí)現(xiàn)了PreFilter、Filter、PreScore、Score和NormalizeScore。這樣做的好處是插件內(nèi)部可以共享狀態(tài)PreFilter階段算好的數(shù)據(jù)存進(jìn) CycleStateScore階段直接取出來用避免重復(fù)計(jì)算。示例源碼里通常會有這樣一個(gè)結(jié)構(gòu)體// 插件主體按需實(shí)現(xiàn)多個(gè)擴(kuò)展點(diǎn)接口 type SamplePlugin struct { handle framework.Handle // 插件自己的配置比如權(quán)重、閾值 weight int } // 聲明本插件實(shí)現(xiàn)了哪些擴(kuò)展點(diǎn) var _ framework.PreFilterPlugin SamplePlugin{} var _ framework.ScorePlugin SamplePlugin{} var _ framework.ScoreExtensions SamplePlugin{}這里framework.Handle是調(diào)度器傳給插件的句柄通過它可以拿到SharedInformerFactory、SnapshotSharedLister節(jié)點(diǎn)快照、ClientSet等。關(guān)鍵點(diǎn)插件里讀節(jié)點(diǎn)信息優(yōu)先用handle.SnapshotSharedLister()而不是直接調(diào) API Server??煺帐钦{(diào)度周期開始時(shí)的一致性視圖直接查 API 會引入延遲和競態(tài)。2.3 打分插件的返回值與權(quán)重機(jī)制Score擴(kuò)展點(diǎn)的簽名是Score(ctx, state, pod, nodeName) (int64, *framework.Status)返回 0 到 100 的整數(shù)。多個(gè)插件同時(shí)打分時(shí)調(diào)度器按下面的公式合并finalScore sum(pluginScore_i * weight_i) / sum(weight_i)所以你的插件返回的絕對值不重要重要的是相對大小和權(quán)重配置。如果插件返回了超過 100 的值框架會報(bào)錯(cuò)如果返回負(fù)數(shù)同樣非法。NormalizeScore的作用就是在多節(jié)點(diǎn)打分完成后把本插件的分?jǐn)?shù)線性映射到 0-100避免某個(gè)插件因?yàn)榱烤V不同壓過其他插件。一個(gè)常見的誤區(qū)是以為Score會被調(diào)用一次。實(shí)際上它對每個(gè)通過 Filter 的節(jié)點(diǎn)各調(diào)用一次節(jié)點(diǎn)多的時(shí)候這個(gè)函數(shù)會被調(diào)用幾百上千次所以里面絕對不能有網(wǎng)絡(luò)請求或磁盤 IO。所有需要的外部數(shù)據(jù)都應(yīng)該在PreScore階段準(zhǔn)備好。3. 從零寫一個(gè)打分插件源碼結(jié)構(gòu)、注冊流程與最小可運(yùn)行示例3.1 示例項(xiàng)目的目錄結(jié)構(gòu)與各文件職責(zé)一份典型的調(diào)度器插件示例源碼目錄大致長這樣scheduler-plugin-demo/ ├── go.mod ├── go.sum ├── cmd/ │ └── scheduler/ │ └── main.go # 調(diào)度器入口注冊插件 ├── pkg/ │ └── sampleplugin/ │ ├── plugin.go # 插件核心邏輯 │ ├── plugin_test.go # 單元測試 │ └── config.go # 插件參數(shù)解析 ├── deploy/ │ ├── rbac.yaml # 調(diào)度器所需權(quán)限 │ └── deployment.yaml # 以 Deployment 方式部署 └── README.mdcmd/scheduler/main.go是入口負(fù)責(zé)構(gòu)造調(diào)度器配置、注冊插件、啟動。pkg/sampleplugin/plugin.go是插件本體。deploy/下是部署清單。這個(gè)結(jié)構(gòu)不是強(qiáng)制的但把「調(diào)度器進(jìn)程」和「插件邏輯」分開方便單測和復(fù)用。3.2 插件注冊New 函數(shù)與 Registry調(diào)度框架用注冊表Registry管理插件。每個(gè)插件要提供一個(gè)New函數(shù)簽名固定// New 是插件工廠函數(shù)框架通過它實(shí)例化插件 func New(obj runtime.Object, handle framework.Handle) (framework.Plugin, error) { // obj 是插件配置可能為 nil args, ok : obj.(*config.SampleArgs) if !ok { return nil, fmt.Errorf(want args of type SampleArgs, got %T, obj) } return SamplePlugin{ handle: handle, weight: args.Weight, }, nil }然后在main.go里注冊func main() { // 1. 構(gòu)造默認(rèn)調(diào)度器配置 cfg, err : schedulerapp.NewDefaultSchedulerConfig() if err ! nil { klog.Fatalf(init config failed: %v, err) } // 2. 把自定義插件注冊進(jìn) Registry registry : frameworkruntime.Registry{ SamplePlugin: sampleplugin.New, } // 3. 在 Profile 里啟用插件并設(shè)置權(quán)重 cfg.Profiles[0].PluginConfig append(cfg.Profiles[0].PluginConfig, config.PluginConfig{ Name: SamplePlugin, Args: config.SampleArgs{Weight: 5}, }) cfg.Profiles[0].Plugins.Score.Enabled append( cfg.Profiles[0].Plugins.Score.Enabled, config.Plugin{Name: SamplePlugin, Weight: 5}, ) // 4. 啟動調(diào)度器 cmd : schedulerapp.NewSchedulerCommand( schedulerapp.WithPluginRegistry(registry), schedulerapp.WithKubeConfig(path), ) if err : cmd.Execute(); err ! nil { klog.Fatalf(run scheduler failed: %v, err) } }參數(shù)說明Weight決定本插件在最終打分里的占比默認(rèn) 1。Plugins.Score.Enabled里必須顯式列出插件名否則即使注冊了也不會被調(diào)用。PluginConfig里的Args是傳給New函數(shù)的配置對象類型要和插件里斷言的一致否則啟動時(shí)報(bào)類型錯(cuò)誤。3.3 寫一個(gè)「按節(jié)點(diǎn)已分配 Pod 數(shù)反向打分」的 Score 邏輯下面是一個(gè)能直接跑的最小打分插件邏輯是節(jié)點(diǎn)上已分配的 Pod 越少得分越高從而把負(fù)載攤開。// Score 對每個(gè)候選節(jié)點(diǎn)打分返回 0-100 func (p *SamplePlugin) Score( ctx context.Context, state *framework.CycleState, pod *v1.Pod, nodeName string, ) (int64, *framework.Status) { // 從快照里拿節(jié)點(diǎn)對象避免直接訪問 API Server nodeInfo, err : p.handle.SnapshotSharedLister(). NodeInfos().Get(nodeName) if err ! nil { return 0, framework.AsStatus(err) } // 統(tǒng)計(jì)該節(jié)點(diǎn)上已分配的 Pod 數(shù)量 used : len(nodeInfo.Pods) // 假設(shè)單節(jié)點(diǎn)最多 110 個(gè) Pod做線性反向映射 const maxPods 110 if used maxPods { return 0, framework.NewStatus(framework.Success) } score : int64((maxPods - used) * 100 / maxPods) return score, framework.NewStatus(framework.Success) }邏輯說明nodeInfo.Pods是快照里該節(jié)點(diǎn)已綁定的 Pod 列表長度即已分配數(shù)。maxPods這里寫死 110 是為了演示生產(chǎn)里應(yīng)該從nodeInfo.Allocatable.Pods()讀實(shí)際容量。返回的score越大表示越優(yōu)先。參數(shù)說明state是本次調(diào)度周期的共享狀態(tài)如果PreScore階段寫了數(shù)據(jù)這里用state.Read(key)取。nodeName是候選節(jié)點(diǎn)名框架會對每個(gè)通過 Filter 的節(jié)點(diǎn)調(diào)用一次。3.4 編譯、打包鏡像與本地驗(yàn)證寫完代碼后編譯成二進(jìn)制# 編譯調(diào)度器二進(jìn)制 CGO_ENABLED0 GOOSlinux GOARCHamd64 \ go build -o bin/kube-scheduler ./cmd/scheduler # 構(gòu)建鏡像Dockerfile 基于 distroless 或 alpine docker build -t registry.local/sample-scheduler:v0.1.0 . docker push registry.local/sample-scheduler:v0.1.0本地驗(yàn)證有兩種方式。第一種是直接跑二進(jìn)制指定 kubeconfig./bin/kube-scheduler \ --kubeconfig$HOME/.kube/config \ --config./scheduler-config.yaml \ --v4--v4打開詳細(xì)日志能看到每個(gè)插件的打分過程。第二種是部署到集群用 Deployment 替換默認(rèn)調(diào)度器注意要改--leader-elect和--scheduler-name避免和默認(rèn)調(diào)度器搶活。提示本地調(diào)試時(shí)把--leader-electfalse否則單實(shí)例也會走選主流程日志里會多出一堆干擾信息。4. 插件參數(shù)配置與調(diào)度器 Profile讓同一份代碼適配多套策略4.1 KubeSchedulerConfiguration 的結(jié)構(gòu)調(diào)度器的行為由KubeSchedulerConfiguration這個(gè) CRD 風(fēng)格的配置對象描述。核心字段是profiles每個(gè) profile 是一套獨(dú)立的調(diào)度策略包含schedulerName、plugins、pluginConfig。一個(gè)調(diào)度器進(jìn)程可以同時(shí)服務(wù)多個(gè) profilePod 通過spec.schedulerName選擇用哪套。apiVersion: kubescheduler.config.k8s.io/v1 kind: KubeSchedulerConfiguration profiles: - schedulerName: sample-scheduler plugins: score: enabled: - name: SamplePlugin weight: 5 disabled: - name: NodeResourcesBalancedAllocation pluginConfig: - name: SamplePlugin args: weight: 5 threshold: 0.8參數(shù)說明enabled里的weight和pluginConfig里的args.weight是兩回事——前者是框架合并分?jǐn)?shù)時(shí)的權(quán)重后者是傳給插件New函數(shù)的配置。兩者建議保持一致否則排查問題時(shí)容易懵。disabled用來關(guān)掉官方插件比如你完全用自己的均衡邏輯就可以把NodeResourcesBalancedAllocation關(guān)掉。4.2 多 Profile 場景下的調(diào)度器名匹配Pod 的spec.schedulerName默認(rèn)是default-scheduler。如果你部署的是自定義調(diào)度器Pod 必須顯式指定apiVersion: v1 kind: Pod metadata: name: demo-pod spec: schedulerName: sample-scheduler containers: - name: app image: nginx:1.25常見翻車點(diǎn)Pod 的schedulerName寫錯(cuò)或沒寫Pod 會一直 Pendingkubectl describe pod里顯示no nodes available或干脆沒有調(diào)度事件。這時(shí)候先確認(rèn)kubectl get events里有沒有FailedScheduling再確認(rèn)調(diào)度器日志里有沒有收到這個(gè) Pod。4.3 用 CycleState 在擴(kuò)展點(diǎn)之間傳遞數(shù)據(jù)PreScore和Score之間共享數(shù)據(jù)靠CycleState。它是一個(gè)并發(fā)安全的鍵值存儲鍵必須是可比較類型值可以是任意結(jié)構(gòu)。典型用法// 定義一個(gè)私有 key 類型避免和其他插件沖突 type stateKey struct{} // PreScore 階段預(yù)計(jì)算節(jié)點(diǎn)負(fù)載均值 func (p *SamplePlugin) PreScore( ctx context.Context, state *framework.CycleState, pod *v1.Pod, nodes []*v1.Node, ) *framework.Status { total : 0 for _, n : range nodes { info, err : p.handle.SnapshotSharedLister(). NodeInfos().Get(n.Name) if err ! nil { continue } total len(info.Pods) } avg : 0 if len(nodes) 0 { avg total / len(nodes) } state.Write(stateKey{}, avg) return framework.NewStatus(framework.Success) }邏輯說明stateKey{}是空結(jié)構(gòu)體零內(nèi)存開銷且不會和其他插件的 key 沖突。state.Write在PreScore里寫Score里用state.Read(stateKey{})讀。注意CycleState的生命周期只覆蓋一個(gè) Pod 的一次調(diào)度跨 Pod 不共享。參數(shù)說明nodes是 Filter 之后剩下的候選節(jié)點(diǎn)列表。如果 Filter 階段把所有節(jié)點(diǎn)都過濾掉了PreScore不會被調(diào)用直接進(jìn)PostFilter。5. 避坑與排查調(diào)度插件上線前必須過的五道坎5.1 插件不生效日志里看不到任何調(diào)用現(xiàn)象調(diào)度器啟動成功Pod 也正常調(diào)度但自定義插件的日志一條都沒有。原因最常見的是插件注冊了但沒在Plugins.Score.Enabled里啟用。調(diào)度框架只調(diào)用 Profile 里顯式啟用的插件注冊表里有不代表會被調(diào)用。解決檢查KubeSchedulerConfiguration的profiles[].plugins.score.enabled確認(rèn)插件名和注冊時(shí)的字符串完全一致大小寫敏感。再用--v5啟動日志里會打印每個(gè)擴(kuò)展點(diǎn)實(shí)際調(diào)用了哪些插件。5.2 Score 返回非法值導(dǎo)致調(diào)度失敗現(xiàn)象Pod 一直 Pending調(diào)度器日志報(bào)plugin SamplePlugin returned score 150, which is out of range [0, 100]。原因Score返回值超出 0-100。常見于用原始資源量比如內(nèi)存 MB 數(shù)直接當(dāng)分?jǐn)?shù)返回。解決在Score里做歸一化或者實(shí)現(xiàn)NormalizeScore擴(kuò)展點(diǎn)。歸一化時(shí)注意除零保護(hù)候選節(jié)點(diǎn)數(shù)為 0 時(shí)直接返回。5.3 直接訪問 API Server 導(dǎo)致調(diào)度延遲飆升現(xiàn)象集群規(guī)模上來后調(diào)度一個(gè) Pod 要幾秒甚至十幾秒調(diào)度器 QPS 打滿。原因插件在Score或Filter里直接調(diào)client.CoreV1().Nodes().List()每個(gè)節(jié)點(diǎn)一次請求節(jié)點(diǎn)多了就是 N 倍放大。解決所有節(jié)點(diǎn)信息從handle.SnapshotSharedLister()讀??煺帐钦{(diào)度周期開始時(shí)的一致性視圖讀的是內(nèi)存微秒級。如果確實(shí)需要額外數(shù)據(jù)比如自定義 CRD用 Informer 在插件初始化時(shí)同步到本地緩存。5.4 多副本調(diào)度器搶同一個(gè) Pod現(xiàn)象同一個(gè) Pod 被調(diào)度了兩次或者出現(xiàn)重復(fù)綁定。原因部署了多個(gè)調(diào)度器副本但沒開 leader election或者schedulerName配重了。解決生產(chǎn)環(huán)境必須開--leader-electtrue并確保--lock-object-name唯一。如果同時(shí)跑默認(rèn)調(diào)度器和自定義調(diào)度器兩者的schedulerName必須不同Pod 只能指定其中一個(gè)。5.5 升級 K8s 版本后插件編譯失敗現(xiàn)象集群從 1.26 升到 1.28重新編譯插件報(bào)接口不匹配。原因調(diào)度框架的接口在不同版本間有增減。比如PreScore的簽名在 1.22 前后有過調(diào)整framework.Handle的方法集也在變。解決go.mod里的k8s.io/kubernetes版本必須和集群版本對齊用replace指令鎖定。升級前先看對應(yīng)版本的pkg/scheduler/framework/interface.go確認(rèn)你實(shí)現(xiàn)的接口簽名沒變。血淚經(jīng)驗(yàn)不要跨兩個(gè)大版本直接升中間至少過一版。6. 進(jìn)階用單元測試和調(diào)度器模擬器驗(yàn)證插件行為寫完插件最怕的是「上線才發(fā)現(xiàn)邏輯不對」。調(diào)度框架提供了frameworkruntime包可以在不啟動真實(shí)集群的情況下構(gòu)造調(diào)度上下文直接測Score和Filter。下面是一個(gè)最小測試骨架func TestSamplePluginScore(t *testing.T) { // 1. 構(gòu)造兩個(gè)節(jié)點(diǎn)一個(gè)已分配 10 個(gè) Pod一個(gè) 50 個(gè) nodeLow : v1.Node{ ObjectMeta: metav1.ObjectMeta{Name: node-low}, Status: v1.NodeStatus{ Allocatable: v1.ResourceList{ v1.ResourcePods: resource.MustParse(110), }, }, } nodeHigh : nodeLow.DeepCopy() nodeHigh.Name node-high // 2. 用 snapshot 構(gòu)造 NodeInfo snapshot : frameworkruntime.NewSnapshot() // 這里省略 Pod 列表填充實(shí)際測試?yán)镉?testing.NewNodeInfo 構(gòu)造 // ... // 3. 構(gòu)造插件實(shí)例注入 fake handle p : SamplePlugin{handle: fakeHandle{snapshot: snapshot}} // 4. 分別對兩個(gè)節(jié)點(diǎn)打分?jǐn)嘌缘拓?fù)載節(jié)點(diǎn)分更高 scoreLow, _ : p.Score(context.TODO(), nil, v1.Pod{}, node-low) scoreHigh, _ : p.Score(context.TODO(), nil, v1.Pod{}, node-high) if scoreLow scoreHigh { t.Fatalf(expect low-load node score higher, got %d vs %d, scoreLow, scoreHigh) } }邏輯說明測試的關(guān)鍵是構(gòu)造NodeInfo快照而不是起真實(shí) API Server。testing.NewNodeInfo可以傳入 Pod 列表直接控制每個(gè)節(jié)點(diǎn)的已分配數(shù)。斷言部分只驗(yàn)證相對大小不驗(yàn)證絕對值因?yàn)榻^對值會隨maxPods常量變化。參數(shù)說明fakeHandle是你自己實(shí)現(xiàn)的framework.Handle樁只需要實(shí)現(xiàn)測試用到的方法這里是SnapshotSharedLister。Go 的接口是隱式實(shí)現(xiàn)不需要實(shí)現(xiàn)全部方法但編譯期要保證類型滿足接口。除了單測還可以用kube-scheduler的--config配合kwokKubernetes WithOut Kubelet在本地模擬幾百個(gè)假節(jié)點(diǎn)觀察插件在規(guī)模下的表現(xiàn)。我一般會跑三輪10 節(jié)點(diǎn)、100 節(jié)點(diǎn)、500 節(jié)點(diǎn)看打分耗時(shí)是否線性增長。如果 500 節(jié)點(diǎn)下單次調(diào)度超過 100ms就要回頭檢查Score里有沒有隱藏的 O(n2) 邏輯。最后一個(gè)習(xí)慣每次改完插件先在測試集群用kubectl create -f批量起 200 個(gè)帶schedulerName的 Pod用kubectl get pods -o wide看落點(diǎn)分布是否均勻。分布圖比任何日志都直觀。希望幫到你。本文還有配套的精品資源點(diǎn)擊獲取