K8s 调度器黑盒全拆解:拓扑约束数学陷阱 + Go 插件二开实战(避坑全记录)

适合人群: 已掌握 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.modreplace 块强制重定向
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 调度器二次开发中几乎所有的经典坑。

走完这套流程,你拥有了:

  1. 一个可以任意魔改的自定义调度器 ,同时运行在集群里与官方 default-scheduler 并行
  2. Filter + Score 双插件骨架,后续的扩展只需要在 Go 代码里实现你的业务逻辑
  3. 一套可复用的 go.mod 模板,以后再做 K8s 组件二开,依赖问题直接复制粘贴

下一步可以探索的方向:

  • 基于实时资源的动态打分器 :通过 handle.SnapshotSharedLister() 查询节点实际剩余 CPU/Memory,实现真正的动态负载均衡
  • PreFilter 数据预计算 :在 PreFilter 阶段把复杂计算的结果写入 CycleState,在 Filter/Score 阶段直接读取,提升调度性能
  • 自定义 Operator/Controller:深入 K8s 控制器模式,扩展自定义资源(CRD)

如果你在实验过程中遇到文中未覆盖的报错,欢迎评论区留言。本文所有 YAML 和 Go 代码均经过 Kind v1.35 环境实测验证。

觉得有用,点个赞再走 👍

相关推荐
叶~小兮9 小时前
K8s常用组件学习笔记
笔记·学习·kubernetes
IT策士9 小时前
Docker 网络进阶:容器间通信与 DNS 解析
网络·docker·容器
热爱Liunx的丘丘人11 小时前
Docker
运维·docker·容器
sszdzq11 小时前
docker 安装 rocketmq + dashboard
docker·容器·rocketmq
齐潇宇12 小时前
Jenkins 自动化部署 Tomcat + PHP
linux·运维·容器·tomcat·jenkins
IT策士13 小时前
docker 实战:将一个多组件应用完整容器化
运维·docker·容器
IT策士13 小时前
Docker 数据管理:Volume 与 Bind Mount
运维·docker·容器
IT策士13 小时前
Docker Compose 入门:一条命令启动多服务
运维·docker·容器
IT策士14 小时前
Docker Compose 文件详解:服务、网络与卷
网络·docker·容器