适合人群: 已掌握 NodeAffinity / Taints & Tolerations / QoS / PriorityClass,想向 K8s 调度进阶的工程师
实验环境: Windows WSL2 + Docker Desktop + Kind(1 主 2 从,集群名
learn-scheduler,K8s v1.35)你将收获:
- 理解拓扑分布约束 vs Pod 反亲和性的本质差异
- 踩透"零值数学陷阱"这个连资深运维都会中招的隐藏大坑
- 从 0 到 1 用 Go 语言完成 Filter / Score 调度插件的开发、编译、镜像化、部署全链路
- 手把手解决 K8s Mod 依赖地狱、高版本函数签名、RBAC 权限等连环大坑
Hi 大家好,我是"折腾派程序员"!
俗话说得好,不把系统重构个三五遍,不踩穿几个底层的深坑,怎么配叫"折腾派"?今天我们不聊虚的概念,直接上硬菜。如果你在 K8s 调度上被各种反亲和性逼疯过,或者被 Go 的依赖地狱折磨过,那么恭喜你,这篇万字硬核实战指南,就是为你准备的。搬好小板凳,我们直接开折腾!
一、为什么反亲和性不够用?先聊清楚问题
在深入拓扑分布约束之前,我们必须先明确一个经典的生产痛点。
假设你有 2 个 Worker 节点 ,需要部署一个 3 副本的 Service。此时你会发现,Pod 反亲和性在这个场景下完全是个"非黑即白的暴力工具":
| 策略 | 结果 | 问题 |
|---|---|---|
| 硬反亲和性(Required) | 节点A 放 1 个,节点B 放 1 个,第 3 个 Pending | 找不到第 3 个"没有同类 Pod 的节点" |
| 软反亲和性(Preferred) | 尽力后发现节点满了,随机塞进某个节点 | 大规模扩容时,打分权重被 ImageLocality 等因素带偏,Pod 分布极不均匀 |
拓扑分布约束(Topology Spread Constraints)就是为了解决这个问题而生的。 它引入了"数学差值"的精细化控制,不搞绝对独占,只要求各拓扑域之间的 Pod 数量差不超过阈值。
二、拓扑分布约束核心原理
2.1 三个关键字段
yaml
topologySpreadConstraints:
- maxSkew: 1 # ① 允许的最大不均衡度
topologyKey: kubernetes.io/hostname # ② 以什么作为拓扑边界
whenUnsatisfiable: DoNotSchedule # ③ 策略:无法满足时怎么办
labelSelector:
matchLabels:
app: topo-nginx # 统计哪些 Pod 的数量
① maxSkew(最大不均衡度)
这是核心数学公式:
ini
Skew = Count_max - Count_min
Count_max 是 Pod 数量最多的拓扑域,Count_min 是 Pod 数量最少的拓扑域(包括空域,计数为 0 )。你设定的 maxSkew 就是允许的最大 Skew 值。
② topologyKey
用于划分"拓扑域"的节点标签键。kubernetes.io/hostname 以节点为单位划分;在多可用区场景下,可使用 topology.kubernetes.io/zone,这样调度器就以"可用区"为单位来衡量均衡度。
③ whenUnsatisfiable
DoNotSchedule:硬策略,宁可 Pending 也不破坏差值(推荐用于高可用场景)ScheduleAnyway:软策略,尽力满足,实在不行就随机调度
三、实战演练:3 副本在 2 Worker 节点上的部署
3.1 演练 YAML
在宿主机上创建 topology-spread-demo.yaml:
yaml
apiVersion: apps/v1
kind: Deployment
metadata:
name: topo-spread-nginx
labels:
app: topo-nginx
spec:
replicas: 3 # 3 个副本,而我们只有 2 个 Worker 节点
selector:
matchLabels:
app: topo-nginx
template:
metadata:
labels:
app: topo-nginx
spec:
affinity:
nodeAffinity:
requiredDuringSchedulingIgnoredDuringExecution:
nodeSelectorTerms:
- matchExpressions:
- key: kubernetes.io/hostname
operator: In
values:
- learn-scheduler-worker
- learn-scheduler-worker2
containers:
- name: nginx
image: nginx:alpine
resources:
requests:
cpu: "100m"
topologySpreadConstraints:
- maxSkew: 1
topologyKey: kubernetes.io/hostname
whenUnsatisfiable: DoNotSchedule
labelSelector:
matchLabels:
app: topo-nginx
⚠️ 注意: 这里的
nodeAffinity是关键,原因在下一节揭晓。
应用并观察:
bash
kubectl apply -f topology-spread-demo.yaml
kubectl get pods -l app=topo-nginx -o wide
你将看到 3 个 Pod 全部 Running,分布为 2:1!
3.2 调度器的数学心智模型(Step by Step)
| 步骤 | 动作 | 各节点计数 | Skew 计算 | 是否合法 |
|---|---|---|---|---|
| 调度第 1 个 Pod | 落在 worker |
A:1, B:0 | 1-0=1 | ✅ ≤ maxSkew:1 |
| 调度第 2 个 Pod | 落在 worker2(平衡优先) |
A:1, B:1 | 1-1=0 | ✅ |
| 调度第 3 个 Pod | 放 A 或 B 都变 2:1 | A:2, B:1 | 2-1=1 | ✅ 1 并没有超过 1! |
这就是为什么反亲和性的第 3 个 Pod 会 Pending,而拓扑约束能优雅地处理这个场景。
四、⚠️ 隐藏大坑:零值数学陷阱(Missing Domain Problem)
这是连很多资深 K8s 运维都会踩进去的顶级大坑,务必看完。
4.1 问题复现
如果你在 3 节点 Kind 集群(1 主 2 从)上,不加 nodeAffinity 约束,直接部署上面的 YAML,你会看到:
css
0/3 nodes are available:
1 node(s) had untolerated taint(s),
2 node(s) didn't match pod topology spread constraints.
第 3 个 Pod 会永远 Pending!
4.2 根本原因
K8s 在计算 Count_min 时,看的是集群中所有符合 topologyKey 的有效拓扑域,而不仅仅是已经有目标 Pod 的域。
你的 3 节点集群中,调度器眼中的"世界地图"是这样的:
拓扑域一:learn-scheduler-control-plane → Pod 数量:0
拓扑域二:learn-scheduler-worker → Pod 数量:1
拓扑域三:learn-scheduler-worker2 → Pod 数量:1
此时调度第 3 个 Pod,假设塞进 worker:
ini
Count_max = 2(worker 的数量)
Count_min = 0(control-plane 的数量,它是空的!)
Skew = 2 - 0 = 2 > maxSkew:1 ❌ 直接拒绝!
因为带污点的 control-plane 节点无法接收 Pod,却仍然作为"空拓扑域"参与 Skew 计算,导致任何一个 Worker 接收第 3 个 Pod 后,Skew 都会超过阈值。
4.3 破局方案
方案 A:加 nodeAffinity 缩小计算范围(推荐)
告诉拓扑约束,只把 Worker 节点当作有效的拓扑域(即上面 YAML 中已经包含的写法):
yaml
affinity:
nodeAffinity:
requiredDuringSchedulingIgnoredDuringExecution:
nodeSelectorTerms:
- matchExpressions:
- key: kubernetes.io/hostname
operator: In
values:
- learn-scheduler-worker
- learn-scheduler-worker2
加上这段后,调度器会把 control-plane 从"有效拓扑域"中剔除,Count_min 变为 1,第 3 个 Pod 顺利调度。
方案 B:使用 K8s 1.25+ 的 nodeInclusionPolicies(调度器配置层)
⚠️ 特别注意:
nodeInclusionPolicies不是Pod.spec.topologySpreadConstraints的字段,直接写在 Deployment YAML 里会报unknown field错误!它是调度器配置文件(KubeSchedulerConfiguration)的一部分,写法不同,请勿混淆。
五、破坏性实验:验证硬限制边界
把副本数扩到 5 个:
bash
kubectl scale deployment topo-spread-nginx --replicas=5
你会看到:4 个 Running,1 个 Pending。
- 4 个 Running 的分布是完美的 2:2(两节点各 2 个)
- 第 5 个为什么 Pending?因为如果把它塞入任何节点,格局变成 3:2,此时
Skew = 3-2 = 1,虽然仍然合法。但如果我们的 maxSkew=1,而此时的最小值依然是 2,那么 3-2=1 应该合法------
等等,这里让我们重新精确地算一下:
css
节点A=3, 节点B=2 → Skew = 3-2 = 1 ≤ maxSkew:1 ✅
所以理论上是合法的。如果你的实验中第 5 个 Pod 还是 Pending,通常是因为 control-plane 这个空域仍然在参与计算(如果你遗漏了 nodeAffinity)。这正是零值陷阱的延伸。记住:加了 nodeAffinity 才是正确姿势。
六、进入硬核领域:用 Go 编写自定义调度插件
拓扑约束解决了"Pod 怎么分布"的问题。但如果你需要完全自定义的调度逻辑呢?比如:
- 根据节点上的自定义标签优先调度(VIP 节点关照)
- 按照业务部门的资源配额分配节点
- 实现特殊的亲和性规则,K8s 原生 API 根本表达不了
这就是 Scheduling Framework(调度器插件框架) 登场的时候了。
6.1 调度器流水线 & 扩展点概览
K8s 调度器是一条有序的插件化流水线,每个阶段都开放了扩展点:
css
[Pod 进入调度队列]
│
▼
┌─────────────────────────────────────────────────────┐
│ PreFilter → Filter → PostFilter │ ← 过滤阶段(黑名单逻辑)
└─────────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────┐
│ PreScore → Score → NormalizeScore │ ← 打分阶段(优先级逻辑)
└─────────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────┐
│ Reserve → Permit → Bind │ ← 绑定阶段(最终决策)
└─────────────────────────────────────────────────────┘
- Filter(过滤) :做减法,返回
Unschedulable即一票否决 - Score(打分) :做优选,给每个通过 Filter 的节点打 0~100 分,最终选择分数最高的节点
6.2 实验目标插件设计
| 插件 | 名称 | 逻辑 |
|---|---|---|
| Filter 插件 | SampleHostNameFilter |
如果 Pod 带标签 filter-node: deny-worker2,则拒绝 learn-scheduler-worker2 节点 |
| Score 插件 | SampleLabelScorer |
节点有 priority=high 标签给 100 分,否则给 10 分(VIP 节点优先调度) |
七、项目搭建:目录结构 & 依赖安装
7.1 标准项目结构
go
my-scheduler/
├── go.mod # Go 模块依赖文件
├── go.sum
├── Dockerfile
├── main.go # 调度器主入口
└── pkg/
└── plugins/
├── sample/
│ └── sample.go # Filter 插件
└── score/
└── score.go # Score 插件
7.2 初始化 Go 模块
bash
go mod init my-scheduler
7.3 ⚠️ K8s Mod 依赖地狱(血泪踩坑记录)
这是整个开发过程中最容易翻车的地方,没有之一。
坑一:版本号不对齐
bash
# ❌ 错误:k8s.io/kubernetes 的版本号不是 v0.x.x 格式!
go get k8s.io/kubernetes@v0.30.0
# ✅ 正确:主仓库用 v1.x.x,周边包用 v0.x.x
go get k8s.io/kubernetes@v1.30.0
坑二:K8s Monorepo 的 v0.0.0 虚构版本
K8s 是一个庞大的单体代码库(Monorepo),官方在主仓库 go.mod 中,给所有子仓库写的版本号全是虚构的 v0.0.0!官方发布时通过工具在流水线里动态替换,但普通人直接 go mod tidy 就会报 unknown revision v0.0.0,直接罢工。
解决方案:在 go.mod 中手动加 replace 指令
创建 go.mod 并写入以下完整内容(这是经过多轮踩坑验证的终极版):
bash
module my-scheduler
go 1.22.0
require (
k8s.io/api v0.30.0
k8s.io/apimachinery v0.30.0
k8s.io/component-base v0.30.0
k8s.io/kubernetes v1.30.0
)
// 关键:把所有子模块的 v0.0.0 强行重定向到正确版本
replace (
k8s.io/api => k8s.io/api v0.30.0
k8s.io/apiextensions-apiserver => k8s.io/apiextensions-apiserver v0.30.0
k8s.io/apiserver => k8s.io/apiserver v0.30.0
k8s.io/cli-runtime => k8s.io/cli-runtime v0.30.0
k8s.io/client-go => k8s.io/client-go v0.30.0
k8s.io/cloud-provider => k8s.io/cloud-provider v0.30.0
k8s.io/cluster-bootstrap => k8s.io/cluster-bootstrap v0.30.0
k8s.io/code-generator => k8s.io/code-generator v0.30.0
k8s.io/component-base => k8s.io/component-base v0.30.0
k8s.io/component-helpers => k8s.io/component-helpers v0.30.0
k8s.io/controller-manager => k8s.io/controller-manager v0.30.0
k8s.io/cri-api => k8s.io/cri-api v0.30.0
k8s.io/cri-client => k8s.io/cri-client v0.30.0
k8s.io/csi-translation-lib => k8s.io/csi-translation-lib v0.30.0
k8s.io/dynamic-resource-allocation => k8s.io/dynamic-resource-allocation v0.30.0
k8s.io/endpointslice => k8s.io/endpointslice v0.30.0
k8s.io/kms => k8s.io/kms v0.30.0
k8s.io/kube-aggregator => k8s.io/kube-aggregator v0.30.0
k8s.io/kube-controller-manager => k8s.io/kube-controller-manager v0.30.0
k8s.io/kube-proxy => k8s.io/kube-proxy v0.30.0
k8s.io/kube-scheduler => k8s.io/kube-scheduler v0.30.0
k8s.io/kubectl => k8s.io/kubectl v0.30.0
k8s.io/kubelet => k8s.io/kubelet v0.30.0
k8s.io/metrics => k8s.io/metrics v0.30.0
k8s.io/mount-utils => k8s.io/mount-utils v0.30.0
k8s.io/pod-security-admission => k8s.io/pod-security-admission v0.30.0
k8s.io/sample-apiserver => k8s.io/sample-apiserver v0.30.0
k8s.io/sample-cli-plugin => k8s.io/sample-cli-plugin v0.30.0
k8s.io/sample-controller => k8s.io/sample-controller v0.30.0
)
然后执行:
bash
go mod tidy
八、编写 Filter 插件:SampleHostNameFilter
8.1 插件的四大件(类比理解)
| 四大件 | 类比 | 代码对应 |
|---|---|---|
| 结构体 | 机器人外壳 | type SampleHostNameFilter struct |
| Name() 方法 | 身份证 | const Name = "SampleHostNameFilter" |
| New() 工厂函数 | 诞生记 | 调度器启动时按名创建插件 |
| Filter() 核心函数 | 大招 | 实际的过滤业务逻辑 |
💡
framework.Handle是 K8s 给你插件的"VIP 通行证",通过它可以查询集群内节点、Pod 等信息快照。💡
framework.CycleState是每个 Pod 调度周期的"共享笔记本",前一阶段的插件写数据,后一阶段的插件读数据,无需重复查询集群。
8.2 pkg/plugins/sample/sample.go 完整代码
go
package sample
import (
"context"
"fmt"
v1 "k8s.io/api/core/v1"
"k8s.io/apimachinery/pkg/runtime"
"k8s.io/kubernetes/pkg/scheduler/framework"
)
// 1. 插件结构体
type SampleHostNameFilter struct {
handle framework.Handle
}
// 2. 插件的唯一身份证
const Name = "SampleHostNameFilter"
func (pl *SampleHostNameFilter) Name() string {
return Name
}
// 3. ⚠️ 关键:高版本 K8s 工厂函数签名必须带 ctx context.Context!
// 很多教程漏掉这个参数,会导致编译时 PluginFactory 类型不匹配
func New(ctx context.Context, obj runtime.Object, h framework.Handle) (framework.Plugin, error) {
return &SampleHostNameFilter{handle: h}, nil
}
// 4. Filter 核心业务逻辑
func (pl *SampleHostNameFilter) Filter(ctx context.Context, state *framework.CycleState, pod *v1.Pod, nodeInfo *framework.NodeInfo) *framework.Status {
node := nodeInfo.Node()
if node == nil {
return framework.NewStatus(framework.Error, "node not found")
}
// 逻辑:如果 Pod 带 deny-worker2 标签 且 当前节点是 worker2 → 拒绝
if pod.Labels["filter-node"] == "deny-worker2" && node.Name == "learn-scheduler-worker2" {
return framework.NewStatus(
framework.Unschedulable,
fmt.Sprintf("Node %s is blocked by SampleHostNameFilter", node.Name),
)
}
return framework.NewStatus(framework.Success, "")
}
// 编译期接口实现检查(强烈推荐,能提前发现接口不匹配)
var _ framework.FilterPlugin = &SampleHostNameFilter{}
8.3 ⚠️ 高版本函数签名陷阱
很多教程的 New 函数签名是这样的:
go
// ❌ 旧版签名(1.28 以前)
func New(_ runtime.Object, h framework.Handle) (framework.Plugin, error)
在 K8s 1.29+ 版本,官方给 PluginFactory 签名加上了 ctx context.Context:
go
// ✅ 新版签名(1.29+)
func New(ctx context.Context, obj runtime.Object, h framework.Handle) (framework.Plugin, error)
如果签名不对,会报 cannot use sample.New as runtime.PluginFactory value 错误。这也是本次实验中最难排查的一个坑------因为接口形状看起来很像,但 Go 编译器在类型系统层面认为它们不是同一个东西。
九、编写 Score 插件:VIP 节点优先调度
9.1 Filter vs Score 的本质区别
css
Filter 插件 = 做减法(行或不行,一票否决)
Score 插件 = 做优选(大家都行,谁更合适?0-100 分)
9.2 pkg/plugins/score/score.go 完整代码
go
package score
import (
"context"
"fmt"
v1 "k8s.io/api/core/v1"
"k8s.io/apimachinery/pkg/runtime"
"k8s.io/kubernetes/pkg/scheduler/framework"
)
type SampleLabelScorer struct {
handle framework.Handle
}
const Name = "SampleLabelScorer"
func (pl *SampleLabelScorer) Name() string {
return Name
}
// ⚠️ 同样需要 ctx 参数
func New(ctx context.Context, obj runtime.Object, h framework.Handle) (framework.Plugin, error) {
return &SampleLabelScorer{handle: h}, nil
}
// Score 函数:入参只给 nodeName,需通过 handle 句柄查询节点详情
func (pl *SampleLabelScorer) Score(ctx context.Context, state *framework.CycleState, pod *v1.Pod, nodeName string) (int64, *framework.Status) {
// 通过 handle 去集群快照里查节点信息(Score 阶段没有 NodeInfo 入参!)
nodeInfo, err := pl.handle.SnapshotSharedLister().NodeInfos().Get(nodeName)
if err != nil {
return 0, framework.AsStatus(fmt.Errorf("failed to get node %s from snapshot: %w", nodeName, err))
}
node := nodeInfo.Node()
if node == nil {
return 0, framework.NewStatus(framework.Error, "node not found")
}
// VIP 节点打 100 分,普通节点打 10 分
if node.Labels["priority"] == "high" {
return 100, framework.NewStatus(framework.Success, "")
}
return 10, framework.NewStatus(framework.Success, "")
}
// Score 插件特有接口:用于归一化处理,不需要就返回 nil
func (pl *SampleLabelScorer) ScoreExtensions() framework.ScoreExtensions {
return nil
}
var _ framework.ScorePlugin = &SampleLabelScorer{}
💡 Score vs Filter 的重要区别: Score 函数的入参没有
*framework.NodeInfo,只有nodeName string。如果你需要查节点详情,必须通过handle.SnapshotSharedLister().NodeInfos().Get(nodeName)手动去集群快照拉取。
十、主入口 main.go:注册双插件
go
package main
import (
"os"
"k8s.io/component-base/logs"
"k8s.io/kubernetes/cmd/kube-scheduler/app"
"my-scheduler/pkg/plugins/sample"
"my-scheduler/pkg/plugins/score"
)
func main() {
command := app.NewSchedulerCommand(
app.WithPlugin(sample.Name, sample.New), // 注册 Filter 插件
app.WithPlugin(score.Name, score.New), // 注册 Score 插件
)
logs.InitLogs()
defer logs.FlushLogs()
if err := command.Execute(); err != nil {
os.Exit(1)
}
}
十一、编译 → 镜像化 → 注入 Kind 集群
11.1 跨平台编译(Windows PowerShell)
因为 Kind 运行在 WSL2 Linux 环境中,需要编译 Linux 版本的二进制:
ruby
$env:GOOS="linux"; $env:GOARCH="amd64"; go build -o my-kube-scheduler main.go
编译成功后,目录下会生成 my-kube-scheduler 文件(无扩展名的 Linux 二进制)。
11.2 编写极简 Dockerfile
perl
FROM alpine:3.19
COPY my-kube-scheduler /usr/local/bin/my-kube-scheduler
CMD ["my-kube-scheduler"]
perl
docker build -t my-custom-scheduler:v1.0 .
11.3 将镜像注入 Kind 集群(无需推送 Docker Hub)
lua
kind load docker-image my-custom-scheduler:v1.0 --name learn-scheduler
十二、部署"第二调度官":完整 YAML + RBAC 配置
12.1 ⚠️ RBAC 坑:kube-scheduler ServiceAccount 不存在
很多教程建议复用 serviceAccountName: kube-scheduler,但在较新的 K8s 版本中,官方调度器是以 Static Pod 直接由 kubelet 启动的,它使用宿主机证书认证,并没有在集群里创建显式的 ServiceAccount。
直接使用会报:serviceaccount "kube-scheduler" not found
正确做法:创建专属账号并授权。
12.2 完整部署 YAML:my-scheduler-deploy.yaml
yaml
# 1. 创建专属 ServiceAccount
apiVersion: v1
kind: ServiceAccount
metadata:
name: my-custom-scheduler-sa
namespace: kube-system
---
# 2. 绑定官方调度器的核心权限
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRoleBinding
metadata:
name: my-custom-scheduler-binding
subjects:
- kind: ServiceAccount
name: my-custom-scheduler-sa
namespace: kube-system
roleRef:
kind: ClusterRole
name: system:kube-scheduler # 官方内置的调度器权限角色
apiGroup: rbac.authorization.k8s.io
---
# 3. 存储卷调度权限(Score 插件需要)
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRoleBinding
metadata:
name: my-custom-scheduler-volume-binding
subjects:
- kind: ServiceAccount
name: my-custom-scheduler-sa
namespace: kube-system
roleRef:
kind: ClusterRole
name: system:volume-scheduler
apiGroup: rbac.authorization.k8s.io
---
# 4. 配置文件(启用双插件,设置权重)
apiVersion: v1
kind: ConfigMap
metadata:
name: my-scheduler-config
namespace: kube-system
data:
scheduler-config.yaml: |
apiVersion: kubescheduler.config.k8s.io/v1
kind: KubeSchedulerConfiguration
leaderElection:
leaderElect: false # 本地测试,不需要选主
profiles:
- schedulerName: my-custom-scheduler
plugins:
filter:
enabled:
- name: SampleHostNameFilter
score:
enabled:
- name: SampleLabelScorer
weight: 5 # 权重调高,让我们的打分成为决定性因素
---
# 5. 部署主体
apiVersion: apps/v1
kind: Deployment
metadata:
name: my-custom-scheduler
namespace: kube-system
labels:
app: my-custom-scheduler
spec:
replicas: 1
selector:
matchLabels:
app: my-custom-scheduler
template:
metadata:
labels:
app: my-custom-scheduler
spec:
serviceAccountName: my-custom-scheduler-sa # 使用我们创建的专属账号
containers:
- name: scheduler
image: my-custom-scheduler:v1.0
imagePullPolicy: Never # 使用 kind load 注入的本地镜像
command:
- my-kube-scheduler
- --config=/etc/kubernetes/scheduler/scheduler-config.yaml
- --v=3 # 调高日志级别,方便观察插件调用
volumeMounts:
- name: config-volume
mountPath: /etc/kubernetes/scheduler
volumes:
- name: config-volume
configMap:
name: my-scheduler-config
perl
kubectl apply -f my-scheduler-deploy.yaml
kubectl get pods -n kube-system -l app=my-custom-scheduler
看到 Running 说明你的魔改调度器已经入驻 K8s 核心层!
12.3 消灭日志红字噪音(可选)
运行后,日志中会一直刷 configmaps "extension-apiserver-authentication" is forbidden 红字。这不是你的代码问题,是 client-go 在启动时尝试同步证书信息,但我们的账号没有这个权限。
补一个内置角色绑定即可:
yaml
apiVersion: rbac.authorization.k8s.io/v1
kind: RoleBinding
metadata:
name: my-custom-scheduler-auth-reader
namespace: kube-system
roleRef:
apiGroup: rbac.authorization.k8s.io
kind: Role
name: extension-apiserver-authentication-reader
subjects:
- kind: ServiceAccount
name: my-custom-scheduler-sa
namespace: kube-system
十三、大结局:现场压测验证
13.1 验证 Filter 插件
提交一个带"嫌弃 worker2"标签的 Pod:
yaml
# test-deny-worker2.yaml
apiVersion: v1
kind: Pod
metadata:
name: test-deny-worker2-pod
labels:
filter-node: deny-worker2 # 触发 Filter 插件的关键标签
spec:
schedulerName: my-custom-scheduler # 指定找我们的调度器
containers:
- name: nginx
image: nginx:alpine
bash
kubectl apply -f test-deny-worker2.yaml
kubectl get pod test-deny-worker2-pod -o wide
预期结果: Pod 绝对不会落在 learn-scheduler-worker2,只会落在 learn-scheduler-worker。
查看调度日志验证:
bash
kubectl logs -n kube-system -l app=my-custom-scheduler --tail=50
你会看到这两行关键日志:
ini
"Attempting to bind pod to node" pod="default/test-deny-worker2-pod" node="learn-scheduler-worker"
"Successfully bound pod to node" pod="default/test-deny-worker2-pod" node="learn-scheduler-worker" evaluatedNodes=3 feasibleNodes=1
evaluatedNodes=3(评估了 3 个节点),feasibleNodes=1(只有 1 个通过过滤)------你的 Go 代码完美生效!
13.2 验证 Score 插件
给 worker2 打上 VIP 标签:
bash
kubectl label node learn-scheduler-worker2 priority=high
提交一个普通 Pod(不带 filter 标签,让两个节点都能参与打分):
yaml
# test-score.yaml
apiVersion: v1
kind: Pod
metadata:
name: test-vip-pod
spec:
schedulerName: my-custom-scheduler
containers:
- name: nginx
image: nginx:alpine
arduino
kubectl apply -f test-score.yaml
kubectl get pod test-vip-pod -o wide
预期结果: 哪怕两个 Worker 都空闲,Pod 100% 落在 worker2 上(它的得分是 100 分,而 worker 只有 10 分)。
十四、全链路总结 & 坑位地图
完整开发链路
lua
① 编写插件 Go 代码
↓
② 处理 K8s Mod 依赖地狱(go.mod replace 大法)
↓
③ 对齐高版本 PluginFactory 签名(ctx context.Context 不能省!)
↓
④ 跨平台编译 Linux 二进制
↓
⑤ 打包 Docker 镜像
↓
⑥ kind load 注入本地集群
↓
⑦ 创建 ServiceAccount + RBAC 绑定
↓
⑧ 编写 KubeSchedulerConfiguration(ConfigMap 形式)
↓
⑨ 部署 Deployment 到 kube-system
↓
⑩ 提交测试 Pod 并观察日志
坑位速查表
| 坑 | 表现 | 解法 |
|---|---|---|
| 拓扑约束空域陷阱 | 第 3 个 Pod Pending | 加 nodeAffinity 缩小有效拓扑域范围 |
nodeInclusionPolicies 字段错误 |
unknown field 报错 |
该字段属于调度器配置层,不是 Pod Spec 字段 |
| K8s 主仓库版本号错误 | unknown revision v0.30.0 |
主仓库用 v1.30.0,周边包用 v0.30.0 |
K8s 子模块 v0.0.0 幽灵版本 |
unknown revision v0.0.0 |
go.mod 加 replace 块强制重定向 |
csi-translation-lib 漏网之鱼 |
mod tidy 最后报错 |
在 replace 块补一行该模块 |
| PluginFactory 签名不匹配 | cannot use sample.New as PluginFactory |
新版必须加 ctx context.Context 第一个参数 |
kube-scheduler SA 不存在 |
serviceaccount not found |
手动创建专属 SA + ClusterRoleBinding |
| 日志 ConfigMap 权限报错 | extension-apiserver-authentication is forbidden |
绑定 extension-apiserver-authentication-reader 内置 Role |
结语
从拓扑约束的数学差值模型,到 Go 语言插件的编译部署全链路,再到 RBAC 权限精细化配置------整个过程踩遍了 K8s 调度器二次开发中几乎所有的经典坑。
走完这套流程,你拥有了:
- 一个可以任意魔改的自定义调度器 ,同时运行在集群里与官方
default-scheduler并行 - Filter + Score 双插件骨架,后续的扩展只需要在 Go 代码里实现你的业务逻辑
- 一套可复用的 go.mod 模板,以后再做 K8s 组件二开,依赖问题直接复制粘贴
下一步可以探索的方向:
- 基于实时资源的动态打分器 :通过
handle.SnapshotSharedLister()查询节点实际剩余 CPU/Memory,实现真正的动态负载均衡 - PreFilter 数据预计算 :在
PreFilter阶段把复杂计算的结果写入CycleState,在Filter/Score阶段直接读取,提升调度性能 - 自定义 Operator/Controller:深入 K8s 控制器模式,扩展自定义资源(CRD)
如果你在实验过程中遇到文中未覆盖的报错,欢迎评论区留言。本文所有 YAML 和 Go 代码均经过 Kind v1.35 环境实测验证。
觉得有用,点个赞再走 👍