























上一篇调度专题【左扬精讲】讲了多 Profile 与 Coordinated LeaderElection。本篇跳出使用者视角,从开发者视角带大家手写一个 Out-of-Tree 的 Score 插件,把它打包成镜像、注册到集群、观察它在 kube-scheduler 里的实际行为。
读完本篇,你将掌握:ScorePlugin 接口的全部方法签名(Score / ScoreExtensions / SignPod),PluginFactory 与 Registry 的注册机制,以及如何用 go build --buildmode=plugin 之外的"main 包内 init"方式完成插件注入。最后给出一个真实可用的示例:ZoneSpreadScore —— 优先把 Pod 调度到当前 zone 内 Pod 数最少的节点。
Kubernetes Scheduler Out-of-Tree Plugin Score 实战 Go k8s v1.36.1
学习重点提示 — 建议先通读全文,再重点回顾标注内容
重点掌握(必须)
- ScorePlugin 接口 4 个方法:Name() / Score() / ScoreExtensions()(返回 fwk.ScoreExtensions 本身或 nil)/ 可选 SignPod()
- PluginFactory 与 Registry 注册:pkg/scheduler/framework/runtime/registry.go:75-81(Register())
- 3 种集成方式:(1) 替换 kube-scheduler 二进制 + 静态注册;(2) 通过 OutOfTreeRegistry 注入;(3) go build --buildmode=plugin 动态加载
- PluginConfig 解析:registry.go:44-66 DecodeInto + 自定义 Args 类型
次重点(了解即可)
- NormalizeScore 的设计意图:把任意范围的原始分压回 [0, MaxScore](staging/src/k8s.io/kube-scheduler/framework/interface.go:329)
- SignPod 的返回策略:本插件不依赖其他 Pod 位置 → 无条件签名加速
- EnqueueExtensions 实现:本插件不拒绝 Pod → 可不实现
文章目录
k8s 内置的 6 大 Score 插件覆盖了"通用"调度需求:
但业务特定的需求不在内置范围:
这些"千人千面"的规则,必须靠 Out-of-Tree 插件实现。
设计精髓
自研插件有两种成本,常被新人低估:
建议先评估用 NodeAffinity + Taint + Label能解决 80% 的"伪业务需求",剩下 20% 再写插件。
Score 插件要实现 3 个强制接口(staging/src/k8s.io/kube-scheduler/framework/interface.go:617-626)+ 1 个可选接口:
// staging/src/k8s.io/kube-scheduler/framework/interface.go (行 607-626, k8s v1.36.1)
type ScoreExtensions interface {
// NormalizeScore 把任意范围的原始分压到 [0, MaxScore]
NormalizeScore(ctx context.Context, state CycleState, p *v1.Pod, scores NodeScoreList) *Status
}
type ScorePlugin interface {
Plugin // 嵌入:需要 Name() string
// Score 为每个 Feasible Node 打分
Score(ctx context.Context, state CycleState, p *v1.Pod, nodeInfo NodeInfo) (int64, *Status)
// ScoreExtensions 返回 NormalizeScore 实现或 nil
ScoreExtensions() ScoreExtensions
}
// 可选:实现 SignPlugin 加速快路径
type SignPlugin interface {
Plugin
SignPod(ctx context.Context, p *v1.Pod) ([]SignFragment, *Status)
}
// 常量
const (
MaxScore int64 = 100 // 任何 Score 函数返回值最大 100
MinScore int64 = 0 // 任何 Score 函数返回值最小 0
)
Score 调用时机:
┌──────────────────────────────────────────────────────────────────────┐
│ ScheduleOne() 调用流程 │
│ │
│ PreFilter → Filter (并行) → PreScore → Reserve │
│ │ │
│ ▼ │
│ ┌──────────────────┐ │
│ │ Score (并行) │ ←── 你的插件在这里被调用 │
│ │ 每个 Feasible │ 每个 Node 各调一次 │
│ │ Node 一次 │ 返回 0~100 的分 │
│ └────────┬─────────┘ │
│ ▼ │
│ ┌──────────────────┐ │
│ │ NormalizeScore │ ←── 把全集群分数归一化到 [0,100]│
│ │ (集群级,一次) │ │
│ └────────┬─────────┘ │
│ ▼ │
│ ┌──────────────────┐ │
│ │ 加权求和 │ ←── 多插件按 weight 加权 │
│ │ 选出最高分 Node │ │
│ └──────────────────┘ │
└──────────────────────────────────────────────────────────────────────┘
小贴士 — 关于 NormalizeScore
为什么需要 NormalizeScore?假设插件 A 返回分范围 [0, 1000],插件 B 返回分范围 [0, 10]。如果不归一化,A.weight=1, B.weight=1 加权后 A 完全压制 B。NormalizeScore 把分压回 [0, MaxScore] 后,所有插件"同台竞技"。
框架提供了 helper.DefaultNormalizeScore(maxPriority, reverse, scores)(pkg/scheduler/framework/plugins/helper/normalize_score.go:27)开箱即用。
| 方式 | 机制 | 代价 | 适用 |
|---|---|---|---|
| (1) 静态注册 | 把插件源码塞进 plugins/registry.go,重新编译 kube-scheduler | 必须 fork scheduler | 几乎不用 |
| (2) Out-of-Tree Registry | 在 scheduler 启动时通过 --config 加载包含 outOfTreeRegistry 的 main 包 | 重新编译自定义镜像 | 生产首选 |
| (3) go build --buildmode=plugin | 插件编译成 .so 文件,scheduler 启动时 dlopen | 与 scheduler Go 版本严格对齐,运维复杂 | 极少 |
方式 (2) 的源码入口(cmd/kube-scheduler/app/server.go:444-449):
// cmd/kube-scheduler/app/server.go (节选, k8s v1.36.1)
outOfTreeRegistry := make(runtime.Registry)
for _, option := range outOfTreeRegistryOptions {
if err := option(outOfTreeRegistry); err != nil {
return nil, nil, err
}
}
// 后续传给 scheduler.New(... scheduler.WithFrameworkOutOfTreeRegistry(outOfTreeRegistry))
注意
v1.36.1 起 k/k 仓库不再提供独立的 sample-scheduler。官方推荐做法:fork cmd/kube-scheduler 目录,改 main.go 注册自己的插件,再编译成镜像。
本节实现一个生产可用的 Score 插件:ZoneSpreadScore。它的逻辑是:
对每个 Feasible Node,统计其所在 zone 内已调度的同 Priority Pod 数。zone 内 Pod 数越少,节点得分越高。这样能让 Pod 均匀打散到所有 zone。
my-scheduler/
├── go.mod
├── main.go # 注册插件入口
└── pkg/plugins/zonespread/
├── zonespread.go # 插件实现
└── zonespread_test.go # 单元测试
module github.com/myorg/my-scheduler
go 1.26
require (
k8s.io/api v0.36.1
k8s.io/apimachinery v0.36.1
k8s.io/kube-scheduler v0.36.1
k8s.io/kubernetes v1.36.1
)
// pkg/plugins/zonespread/zonespread.go (k8s v1.36.1)
package zonespread
import (
"context"
"fmt"
v1 "k8s.io/api/core/v1"
"k8s.io/apimachinery/pkg/runtime"
"k8s.io/kubernetes/pkg/scheduler/framework/plugins/helper"
fwk "k8s.io/kube-scheduler/framework"
)
// Name 是插件名,必须全局唯一,与 KubeSchedulerConfiguration 中的 name 一致
const Name = "ZoneSpreadScore"
// Args 是从 KubeSchedulerConfiguration.pluginConfig 解析的配置
type Args struct {
// 哪些 label 算 zone(默认用 topology.kubernetes.io/zone)
ZoneLabel string `json:"zoneLabel,omitempty"`
// 分数反转:true 表示 zone 内 Pod 越多 越好(适合 binpack)
Reverse bool `json:"reverse,omitempty"`
}
// 编译期断言:必须实现这些接口
var _ fwk.ScorePlugin = &ZoneSpread{}
var _ fwk.SignPlugin = &ZoneSpread{} // 启用快路径签名
// ZoneSpread 是插件主体
type ZoneSpread struct {
handle fwk.Handle
args *Args
}
func (pl *ZoneSpread) Name() string { return Name }
// New 初始化插件(被 PluginFactory 调用)
func New(ctx context.Context, obj runtime.Object, h fwk.Handle) (fwk.Plugin, error) {
args := &Args{ZoneLabel: "topology.kubernetes.io/zone"}
if obj != nil {
// DecodeInto 把 YAML/JSON 字节流转成 Args
if err := runtime.DefaultUnstructuredConverter.
FromUnstructured(obj.UnstructuredContent(), args); err != nil {
return nil, fmt.Errorf("invalid args for %s: %w", Name, err)
}
}
return &ZoneSpread{handle: h, args: args}, nil
}
// Score 为单个节点打分
func (pl *ZoneSpread) Score(
ctx context.Context, state fwk.CycleState,
pod *v1.Pod, nodeInfo fwk.NodeInfo,
) (int64, *fwk.Status) {
node := nodeInfo.Node()
zoneLabel := pl.args.ZoneLabel
nodeZone, hasLabel := node.Labels[zoneLabel]
if !hasLabel {
return 0, fwk.NewStatus(fwk.Error, fmt.Sprintf("node %s missing label %s", node.Name, zoneLabel))
}
// 1. 统计同 zone 内已调度的同优先级 Pod 数
podsInZone := int64(0)
for _, existing := range nodeInfo.GetPods() {
// 这里简化:只看 Pod 自身的 zone label(实际生产建议从 Node 上读)
if podZone := pl.getPodZone(existing.Pod, zoneLabel); podZone == nodeZone {
podsInZone++
}
}
// 2. 简单映射:pod 越少分越高
// 0 pod → 100, 100+ pod → 0
score := fwk.MaxScore - podsInZone
if score < fwk.MinScore {
score = fwk.MinScore
}
return score, nil
}
// ScoreExtensions 返回 NormalizeScore 实现
func (pl *ZoneSpread) ScoreExtensions() fwk.ScoreExtensions {
return pl
}
// NormalizeScore 把原始分压回 [0, MaxScore]
func (pl *ZoneSpread) NormalizeScore(
ctx context.Context, state fwk.CycleState,
pod *v1.Pod, scores fwk.NodeScoreList,
) *fwk.Status {
reverse := pl.args.Reverse
// 复用框架 helper
return helper.DefaultNormalizeScore(fwk.MaxScore, reverse, scores)
}
// SignPod 返回调度签名(用于 OpportunisticBatching)
func (pl *ZoneSpread) SignPod(
ctx context.Context, pod *v1.Pod,
) ([]fwk.SignFragment, *fwk.Status) {
// 本插件逻辑依赖 nodeInfo.GetPods(),但同一 Pod 调度的 Node 不同 GetPods 也不同
// → 拒绝签名,强制走慢路径(更准)
return nil, fwk.NewStatus(fwk.Unschedulable,
"ZoneSpreadScore cannot sign because scoring depends on per-node pod count")
}
// getPodZone 从 Pod 的 NodeName 反查 Node label(这里简化)
func (pl *ZoneSpread) getPodZone(pod *v1.Pod, label string) string {
if pod.Spec.NodeName == "" {
return ""
}
node, err := pl.handle.SharedInformerFactory().Core().V1().Nodes().
Lister().Get(pod.Spec.NodeName)
if err != nil || node == nil {
return ""
}
return node.Labels[label]
}
设计精髓
看 SignPod 的设计决策:虽然插件本身"似乎"只读 Pod 的 zone 字段(与已有 Pod 无关),但实际打分依赖 nodeInfo.GetPods(),而 GetPods 在不同 Node 上结果不同。如果让 100 个相似 Pod 共用同一个签名,会全部调度到同一 Node,反而违反打散本意。
所以正确做法是拒绝签名,让每个 Pod 都走完整 Score —— 这是 v1.36.1 OpportunisticBatching 的标准取舍。
// main.go (k8s v1.36.1)
package main
import (
"os"
"k8s.io/kubernetes/cmd/kube-scheduler/app"
zonespread "github.com/myorg/my-scheduler/pkg/plugins/zonespread"
)
func main() {
// 注册 Out-of-Tree 插件
command := app.NewSchedulerCommand(
app.WithPlugin(zonespread.Name, zonespread.New),
)
if err := command.Execute(); err != nil {
os.Exit(1)
}
}
# 1. 初始化模块依赖
go mod init github.com/myorg/my-scheduler
go mod tidy
# 2. 编译成二进制
go build -o /usr/local/bin/my-scheduler .
# 3. 验证(这必须能跑)
my-scheduler --version
# Dockerfile
FROM golang:1.26 AS builder
WORKDIR /workspace
COPY . .
RUN go mod download && CGO_ENABLED=0 go build -o /out/my-scheduler .
FROM gcr.io/distroless/static-debian12:nonroot
COPY --from=builder /out/my-scheduler /my-scheduler
USER nonroot:nonroot
ENTRYPOINT ["/my-scheduler"]
# 构建并推送
docker build -t harbor.myorg.com/k8s/my-scheduler:v1.36.1-zone .
docker push harbor.myorg.com/k8s/my-scheduler:v1.36.1-zone
# /etc/kubernetes/manifests/my-scheduler.yaml (k8s v1.36.1)
apiVersion: v1
kind: Pod
metadata:
name: kube-scheduler-my
namespace: kube-system
spec:
containers:
- name: kube-scheduler
image: harbor.myorg.com/k8s/my-scheduler:v1.36.1-zone
command:
- /my-scheduler
- --authentication-kubeconfig=/etc/kubernetes/scheduler.conf
- --authorization-kubeconfig=/etc/kubernetes/scheduler.conf
- --config=/etc/kubernetes/scheduler-config.yaml
- --v=4
livenessProbe:
httpGet: { path: /healthz, port: 10259 }
readinessProbe:
httpGet: { path: /healthz, port: 10259 }
volumeMounts:
- name: kubeconfig
mountPath: /etc/kubernetes
hostNetwork: true
priorityClassName: system-node-critical
volumes:
- name: kubeconfig
hostPath: { path: /etc/kubernetes, type: DirectoryOrCreate }
# /etc/kubernetes/scheduler-config.yaml
apiVersion: kubescheduler.config.k8s.io/v1
kind: KubeSchedulerConfiguration
profiles:
- schedulerName: zone-spread-scheduler
plugins:
score:
enable:
- name: ZoneSpreadScore # 启用我们的插件
disabled:
- name: PodTopologySpread # 关闭内置,避免重复打散
pluginConfig:
- name: ZoneSpreadScore
args:
apiVersion: kubescheduler.config.k8s.io/v1
kind: ZoneSpreadArgs # 必须与 Args 类型名一致
zoneLabel: topology.kubernetes.io/zone
reverse: false
apiVersion: v1
kind: Pod
metadata:
name: spread-test
spec:
schedulerName: zone-spread-scheduler # 指定用我们的 profile
containers:
- name: nginx
image: nginx:1.27
resources:
requests: { cpu: 100m, memory: 64Mi }
# 看 Score 阶段耗时(按插件名)
kubectl logs -n kube-system kube-scheduler-my | grep -E "Score.*ZoneSpread"
# 期望日志(v=4):
# "plugin scored" plugin="ZoneSpreadScore" pod="default/spread-test" node="node-1" score=92
# "plugin scored" plugin="ZoneSpreadScore" pod="default/spread-test" node="node-2" score=85
# "plugin scored" plugin="ZoneSpreadScore" pod="default/spread-test" node="node-3" score=78
Scheduler 框架会自动记录每个插件每次 Score 调用的延迟 + 结果,结合 Prometheus metrics scheduler_plugin_score_duration_seconds 可看到插件是否过热。
小贴士 — 关于 metrics
如果你想给自己的插件加专属 metrics,最方便的是引入 k8s.io/component-base/metrics:
var zoneSpreadScoreTotal = metrics.NewCounterVec(
&metrics.CounterOpts{
Name: "zonespread_score_total",
Help: "ZoneSpreadScore invocation count",
}, []string{"result"})
// 在 Score 函数末尾:
zoneSpreadScoreTotal.WithLabelValues("success").Inc()
需要 metrics.Register() 在 main.go 注册一次。
症状:normalizer panic: score exceeds MaxScore,调度器崩溃。
原因:插件 Score 函数返回了 int64(200),违反 fwk.MaxScore = 100 的契约。
修复:始终在 Score 函数里做边界裁剪:
score := calculatedScore
if score > fwk.MaxScore { score = fwk.MaxScore }
if score < fwk.MinScore { score = fwk.MinScore }
return score, nil
症状:kube-scheduler CrashLoopBackOff,日志 "plugin ZoneSpreadScore initialization failed"。
原因:Args 类型名与 YAML 里 kind 字段不一致。YAML 写 kind: ZoneSpreadScore,Go 里 type Args struct{} 没有匹配。
修复:YAML 的 kind 必须等于 args 类型的 Go 类型名加 Args:
kind: ZoneSpreadArgs # 必须对应 Go type ZoneSpreadArgs(不是 ZoneSpreadScoreArgs)
实际上生产中常用"插件名 + Args"命名规范(如 CapacitySchedulingArgs)。
症状:业务反馈只有 ZoneSpread 插件影响调度,其他插件(如 NodeAffinity)几乎不起作用。
原因:插件 Score 返回范围 [0, 1000] 而内置 NodeAffinity 返回 [0, 100],加权求和时 ZoneSpread 完全压制。
修复:永远实现 NormalizeScore(或 ScoreExtensions() 返回 helper.DefaultNormalizeScore)。
症状:go build 报 undefined: fwk.Handle。
原因:k8s.io/kube-scheduler 与 k8s.io/kubernetes 内部的 framework 包是同一个,但 import 路径写错。
修复:
// 正确的 import:
import fwk "k8s.io/kube-scheduler/framework" // staging 仓库里的 framework
// 错误:
import fwk "k8s.io/kubernetes/pkg/scheduler/framework" // 内部路径,会冲突
Score 插件强制:Plugin(含 Name()) + ScorePlugin。可选:ScoreExtensions(用于 NormalizeScore)、SignPlugin(用于签名加速)、EnqueueExtensions(用于 QueueingHint)。
可以。同一个 Go struct 通过多个编译期断言挂多个扩展点。例如 6 大内置插件 NodeResourcesFit 一次断言 6 个接口(详见本系列上一篇)。
可以,通过 nodeInfo.Node().Status.Allocatable(v1.ResourceList)。还能通过 nodeInfo.GetPods() 拿该节点上所有 Pod。
不推荐。Score 阶段被并行调用,频繁调 apiserver 会拖垮调度。强烈建议只用 informer cache(handle.SharedInformerFactory())。
把 Args 定义成能被 runtime.DefaultUnstructuredConverter 识别的结构(基本类型 + struct tag):
type Args struct {
ZoneLabel string `json:"zoneLabel,omitempty"`
Reverse bool `json:"reverse,omitempty"`
}
三种手段:
不能。插件是编译进二进制的,修改后必须重启 scheduler。这是 k8s 调度的设计取舍。
Score 插件间接影响:抢占(DefaultPreemption)时调度器会跑 NominatedPod 的过滤 + 打分,得分最高的 Node 被腾出来。所以 Score 插件逻辑会间接影响抢占顺序。
用框架的 framework.Handle mock + cache.NewSnapshot() 构造 Node 列表:
func TestZoneSpread_Score(t *testing.T) {
pl := &ZoneSpread{args: &Args{ZoneLabel: "topology.kubernetes.io/zone"}}
node := &v1.Node{ObjectMeta: metav1.ObjectMeta{
Name: "node-1",
Labels: map[string]string{"topology.kubernetes.io/zone": "us-east-1a"},
}}
// 通过 framework.Handle 拿 nodeInfo(生产里用 NewNodeInfo())
score, _ := pl.Score(ctx, nil, &v1.Pod{}, newNodeInfo(node))
assert.Equal(t, int64(100), score) // 没 Pod 时最高分
}
不推荐。每个 Pod 调度都重新创建 Framework 实例(v1.32 之前)/ 复用 Framework 实例(v1.32+),所以插件实例可能被复用。建议用 sync.Map 等线程安全结构存跨 Pod 状态。
在 New() 里用 runtime.DefaultUnstructuredConverter.FromUnstructured() 把 runtime.Object 反序列化成结构体。YAML 的 kind 必须匹配 Go 类型名 + Args 后缀。
不能。schedulerName 是 Pod 入队时根据 spec.schedulerName 决定的,插件在 Score 阶段才被调用,已经太晚。
在 main.go 用 app.WithPlugin(name, factory) 注册。重启后 kube-scheduler --config 配置的 Profile 里 plugins.score.enable 加上插件名即可。
通过 pluginsConfig.score 设置 weight(默认 1):
pluginsConfig:
- name: ZoneSpreadScore
args: {...}
weight: 50 # 高分
通过 handle.KubeClientSet():
cs := pl.handle.KubeClientSet()
pods, _ := cs.CoreV1().Pods("default").List(ctx, metav1.ListOptions{})
但强烈建议用 informer cache(handle.SharedInformerFactory())。
v1.32 起,框架有 pluginMetricsSamplePercent 控制插件 metric 采样率。Score 阶段没有硬超时(与 Permit 不同),但调度器主循环每 100ms 检查一次累计耗时,过长会拖慢整个调度周期。
可以,但需要用 runtime.FactoryAdapter(pkg/scheduler/framework/runtime/registry.go:38)包装 factory,把 feature.Features 注入到构造函数。
两步灰度:
可以。YAML 支持嵌套 + 数组:
pluginConfig:
- name: ZoneSpreadScore
args:
apiVersion: kubescheduler.config.k8s.io/v1
kind: ZoneSpreadArgs
zoneLabel: topology.kubernetes.io/zone
extras:
- key: team
value: ai
Go 结构用 []struct{ Key, Value string } 映射。
需要。k8s 强制 Apache 2.0 license header(hack/verify-boilerplate.sh 校验)。新文件最前面加:
/*
Copyright 2026 The Kubernetes Authors.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
*/
本篇是调度专题实战首篇,演示了 Score 插件从 0 到 1 的全流程。后续主线:
调度专题的目标读者是资深运维开发:能读 Go 源码、有集群运维经验、对 k8s 整体架构已有认知。本系列到本篇告一段落,下一篇会从 抢占机制 切入,把调度失败的兜底路径讲透。
本文参考与源码链接:
• framework/interface.go · ScorePlugin 接口
• registry.go · PluginFactory 与 Registry
• normalize_score.go · DefaultNormalizeScore
• server.go · OutOfTreeRegistry 注入
• scheduler.go · WithFrameworkOutOfTreeRegistry
• noderesources/fit.go · 内置 Score 插件参考
• imagelocality · 简洁的内置 Score 插件示例
此内容由惯性聚合(RSS阅读器)自动聚合整理,仅供阅读参考。 原文来自 — 版权归原作者所有。