欢迎光临
我们一直在努力

K8s 调度器扩展:自定义优先级与亲和性策略深度解析

K8s 调度器扩展:自定义优先级与亲和性策略深度解析

一、默认调度器的能力天花板

K8s 默认调度器足够应付 80% 的场景。但当你的 Pod 对 GPU 型号敏感、需要跨可用区严格反亲和、或要求基于实时负载动态调度时,默认的 nodeSelector + affinity 组合就力不从心了。

典型痛点:集群里有 A100 和 V100 两种 GPU 节点,默认调度器只能按 label 匹配,无法感知哪个节点的 GPU 显存碎片率更低、当前温度更优。你要的是"优先 A100,但 A100 负载 > 80% 时 fallback 到 V100"——这种带有优先级的降级链。

还有更棘手的:多个 Deployment 之间的跨 Pod 反亲和。默认的 podAntiAffinity 基于 label selector,但如果你希望"同一个拓扑域内最多 2 个同类 Pod",直接用 requiredDuringScheduling 会导致集群碎片化,用 preferredDuringScheduling 权重又不够精确。

flowchart TD
A[Pod 创建事件] –> B[Scheduler 监听]
B –> C{默认调度阶段}
C –>|Filter| D[节点过滤]
C –>|Score| E[节点打分]
D –> F[Scheduler Extender]
E –> F
F –> G[自定义 Filter 插件]
F –> H[自定义 Score 插件]
G –> I{通过?}
I –>|否| J[节点剔除]
I –>|是| H
H –> K[最终排序]
K –> L[Bind Pod 到 Node]

二、Scheduler Extender 与 Scheduling Framework 的选型

两种扩展方式:

Scheduler Extender(HTTP Webhook 模式):调度器在 Filter/Score 阶段通过 HTTP 调用外部服务。优点是解耦,任意语言实现。缺点是每次调度都要额外 HTTP 调用,延迟高;Webhook 不可用时调度整体阻塞。

Scheduling Framework(插件模式):Go 语言写的编译时插件,和调度器同进程运行。延迟几乎为零,能访问调度器的内部状态。缺点是只能用 Go,且调度器升级时需要重新编译。

生产环境建议:Filter 阶段用 Framework 插件(高吞吐低延迟),Score 阶段用 Extender(业务逻辑灵活变更)。

三、自定义 Scoring 插件实现

package main

import (
"context"
"fmt"
"math"
"sync"
"time"

v1 "k8s.io/api/core/v1"
"k8s.io/apimachinery/pkg/runtime"
"k8s.io/kubernetes/pkg/scheduler/framework"
"k8s.io/kubernetes/pkg/scheduler/framework/plugins/feature"
"k8s.io/kubernetes/pkg/scheduler/framework/plugins/names"
)

const PluginName = "GPUWearLevel"

// GPUWearLevel 基于 GPU 利用率漂移节点打分。
// 设计决策:不使用实时指标(Prometheus 查询有延迟抖动),
// 而是读取 Node 上的 annotation,由外部 DaemonSet 定期写入。
// 这样调度器内部无 IO,保持确定性。

type GPUWearLevel struct {
handle framework.Handle
mu sync.RWMutex
}

var _ framework.ScorePlugin = &GPUWearLevel{}

func (p *GPUWearLevel) Name() string {
return PluginName
}

// Score 为节点打分。
// 分数 = (1 – GPU利用率) * 100,确保低负载节点获高分。
// 没有 GPU annotation 的节点给 0 分——不参与 GPU 工作负载调度。
func (p *GPUWearLevel) Score(
ctx context.Context,
state *framework.CycleState,
pod *v1.Pod,
nodeName string,
) (int64, *framework.Status) {
nodeInfo, err := p.handle.SnapshotSharedLister().NodeInfos().Get(nodeName)
if err != nil {
return 0, framework.NewStatus(framework.Error, fmt.Sprintf("node not found: %s", nodeName))
}

node := nodeInfo.Node()
if node == nil {
return 0, framework.NewStatus(framework.Error, "node object is nil")
}

// 读取外部 DaemonSet 写入的 annotation
gpuUtilStr, ok := node.Annotations["gpu-scheduler.example.com/utilization"]
if !ok {
return 0, framework.NewStatus(framework.Success, "no gpu annotation, skip")
}

var gpuUtil float64
if _, err := fmt.Sscanf(gpuUtilStr, "%f", &gpuUtil); err != nil {
return 0, framework.NewStatus(framework.Error, fmt.Sprintf("invalid gpu annotation: %v", err))
}

// 利用率越低的节点分越高,上限 100
score := int64((1.0 – gpuUtil) * 100)
if score < 0 {
score = 0
}
return score, framework.NewStatus(framework.Success, "")
}

// ScoreExtensions 返回空——NormalizeScore 不做归一化,保持原始分。
func (p *GPUWearLevel) ScoreExtensions() framework.ScoreExtensions {
return nil
}

四、边界分析与架构权衡

自定义调度的隐性成本

  • 调试复杂度:Pod 为什么没调度到预期节点?默认调度器有 Events。自定义插件需要自己实现诊断日志。
  • 升级风险:K8s 版本升级时,Framework 插件的 API 可能 breaking change。需要每次升级时重新编译。
  • annotation 滞后:Node annotation 更新有延迟(取决于 DaemonSet 的采集间隔),可能导致"过时的调度决策"。采集间隔取 5-10s,太短则 etcd 写压力大。
  • 禁用场景

    • 集群规模 < 10 节点,自定义调度纯属过度设计
    • 对延迟要求 > 99.9% 可用性的场景,HTTP Extender 的单点风险太高
    • 业务调度逻辑频繁变更的场景(改一次插件就要重启调度器)

    五、结语

    自定义调度不是炫技。只有当你明确知道默认调度器哪里不够用时才动手。优先用 Scheduling Framework 做 Filter/Sort,用 Node annotation 传指标——把复杂计算放在调度器外面,保持核心链路轻量。记住一件事:调度决策越快越好,100ms 的延迟放大到千级 Pod 就是 100 秒。

    赞(0)
    未经允许不得转载:171主机测评 » K8s 调度器扩展:自定义优先级与亲和性策略深度解析
    分享到: 更多 (0)

    评论 抢沙发

    • 昵称 (必填)
    • 邮箱 (必填)
    • 网址