Contents

K8s 自定义调度器 Part2:通过 Scheduler Framework 实现自定义调度逻辑

https://img.lixueduan.com/kubernetes/cover/scheduler-framework.png

本文主要分享如何通过 Scheduler Framework 实现自定义调度策略。

1. 为什么需要自定义调度逻辑

什么是所谓的调度?

  • 所谓调度就是指给 Pod 对象的 spec.nodeName 赋值

  • 待调度对象则是所有 spec.nodeName 为空的 Pod

  • 调度过程则是从集群现有的 Node 中为当前 Pod 选择一个最合适的

实际上 Pod 上还有一个平时比较少关注的属性: spec.schedulerName,用于指定该 Pod 要交给哪个调度器进行调度。

那么问题来了,平时用的时候也没给 spec.schedulerName 赋值过,怎么也能调度呢?

因为默认的 kube-scheduler 可以兼容 spec.schedulerName 为空或者为 default 的 Pod。

为什么需要自定义调度逻辑

自定义调度逻辑可以解决特定应用场景和需求,使集群资源使用更高效,适应特殊的调度策略。

比如:

  • 不同的工作负载可能有特定的资源需求,比如 GPU 或 NPU,需要确保 Pod 只能调度到满足这些资源条件的节点上。
  • 某些集群可能需要均衡资源消耗,避免将多个负载集中到某些节点上。
  • 为了降低延迟,可能需要将Pod调度到特定地理位置的节点上。自定义调度器可以根据节点的地理位置标签进行调度决策。
  • 某些应用需要与其他应用隔离运行,以避免资源争抢。通过自定义调度器,可以将特定类型的任务或工作负载隔离到专用的节点上。

总之就是业务上有各种特殊的调度需求,因此我们需要通过实现自定义调度器来满足这些需求。

通过实现自定义调度器,可以根据具体的业务需求和集群环境,实现更灵活、更高效的资源管理和调度策略。

2.如何增加自定义调度逻辑

自定义调度器的几种方法

要增加自定义调度逻辑也并不复杂,K8s 整个调度流程都已经插件化了,我们并不需要重头开始实现一个调度器,而只需要实现一个调度插件,通过在调度过程中各个阶段加入我们的自定义逻辑,来控制最终的调度结果。

总体来说可以分为以下几个方向:

1)新增一个调度器

  • 直接修改 kube-scheduler 源码,编译替换
  • 使用 调度框架(Scheduling Framework),我们可以使用 scheduler-plugins 作为模板,简化自定义调度器的开发流程。
    • Kubernetes v1.15 版本中引入了可插拔架构的调度框架,使得定制调度器这个任务变得更加的容易。调库框架向现有的调度器中添加了一组插件化的 API,该 API 在保持调度程序“核心”简单且易于维护的同时,使得大部分的调度功能以插件的形式存在。

2)扩展原有调度器

  • 通过 Scheduler Extender 可以实现对已有调度器进行扩展。单独创建一个 HTTP 服务并实现对应接口,后续就可以将该服务作为外置调度器使用。通过配置 KubeSchedulerConfiguration原 Scheduler 会以 HTTP 调用方式和外置调度器交互,实现在不改动原有调度器基础上增加自定义逻辑。

3)其他非主流方案

  • 自定义 Webhook 直接修改未调度 Pod 的 spec.nodeName 字段,有点离谱但理论可行哈哈

二者都有自己的优缺点

优点缺点
新增调度器性能好:由于不依赖外部插件或 HTTP 调用,调度流程的延迟相对较低,适合对性能要求较高的场景复用性高:可复用现有的调度插件,如 scheduler-plugins,大大降低了开发难度,提升了开发效率。多个调度器可能会冲突:比如多个调度器同时调度了一个 Pod 到节点上,先启动的 Pod 把资源占用了,后续的 Pod 无法启动。
扩展调度器实现简单:无需重新编译调度器,通过配置 KubeSchedulerConfiguration 创建一个外部 HTTP 服务来实现自定义逻辑。零侵入性:不需要修改或重构调度器的核心代码,可快速上线新的调度逻辑。灵活性较高:原有调度器和自定义逻辑相对独立,方便维护与测试。性能差:调度请求需要经过 HTTP 调用,增加了调用延迟,对性能可能有影响。

一般在我们要改动的逻辑不多时,直接使用 Scheduler Extender 是比较简单的。

如果要实现多种调度策略,那么 Scheduler Framework 则是更好的选择。

Scheduler 配置

调度器的配置有一个单独的对象:KubeSchedulerConfiguration,不过并没有以 CRD 形式存在,而是存放到 Configmap 中的。

以下是一个完整 KubeSchedulerConfiguration 的 yaml:

apiVersion: v1
data:
  config.yaml: |
    apiVersion: kubescheduler.config.k8s.io/v1beta2
    kind: KubeSchedulerConfiguration
    leaderElection:
      leaderElect: false
    profiles:
    - schedulerName: hami-scheduler
    extenders:
    - urlPrefix: "https://127.0.0.1:443"
      filterVerb: filter
      bindVerb: bind
      nodeCacheCapable: true
      weight: 1
      httpTimeout: 30s
      enableHTTPS: true
      tlsConfig:
        insecure: true
      managedResources:
      - name: nvidia.com/gpu
        ignoredByScheduler: true
      - name: nvidia.com/gpumem
        ignoredByScheduler: true
      - name: nvidia.com/gpucores
        ignoredByScheduler: true    

一般分为基础配置和 extenders 配置两部分。

基础配置

基础配置一般就是配置调度器的名称,Demo 如下:

apiVersion: v1
kind: ConfigMap
metadata:
  name: my-scheduler-config
  namespace: kube-system
data:
  my-scheduler-config.yaml: |
    apiVersion: kubescheduler.config.k8s.io/v1beta2
    kind: KubeSchedulerConfiguration
    profiles:
      - schedulerName: my-scheduler
    leaderElection:
      leaderElect: false       

通过 schedulerName 来指定该调度器的名称,比如这里就是 my-scheduler

创建 Pod 时除非手动指定 spec.schedulerName 为 my-scheduler,否则不会由该调度器进行调度。

扩展调度器:extenders 配置

extenders 部分通过额外指定一个 http 服务器来实现外置的自定义的调度逻辑。

一个简单的 Scheduler Extender 配置如下:

apiVersion: v1
kind: ConfigMap
metadata:
  name: i-scheduler-extender
  namespace: kube-system
data:
  i-scheduler-extender.yaml: |
    apiVersion: kubescheduler.config.k8s.io/v1
    kind: KubeSchedulerConfiguration
    profiles:
      - schedulerName: i-scheduler-extender
    leaderElection:
      leaderElect: false
    extenders:
    - urlPrefix: "http://localhost:8080"
      enableHTTPS: false
      filterVerb: "filter"
      prioritizeVerb: "prioritize"
      bindVerb: "bind"
      weight: 1
      nodeCacheCapable: true    

核心部分为

    extenders:
    - urlPrefix: "http://localhost:8080"
      enableHTTPS: false
      filterVerb: "filter"
      prioritizeVerb: "prioritize"
      bindVerb: "bind"
      weight: 1
      nodeCacheCapable: true

几个核心参数含义如下:

  • urlPrefix: http://127.0.0.1:8080 用于指定外置的调度服务访问地址

  • filterVerb: "filter":表示 Filter 接口在外置服务中的访问地址为 filter,即完整地址为 http://127.0.0.1:8080/filter

  • prioritizeVerb: "prioritize":同上,Prioritize(Score) 接口的地址为 prioritize

  • bindVerb: "bind":同上,Bind 接口的地址为 bind

这样该调度器在执行 Filter 接口逻辑时,除了内置的调度器插件之外,还会通过 HTTP 方式调用外置的调度器。

这样我们只需要创建一个 HTTP 服务,实现对应接口即可实现自定义的调度逻辑,而不需要重头实现一个调度器。

ManagedResources 配置

在之前的配置中是所有 Pod 都会走 Extender 的调度逻辑,实际上 Extender 还有一个 ManagedResources 配置,用于限制只有申请使用指定资源的 Pod 才会走 Extender 调度逻辑,这样可以减少无意义的调度。

一个带 managedResources 的 KubeSchedulerConfiguration 内容如下

apiVersion: v1
data:
  config.yaml: |
    apiVersion: kubescheduler.config.k8s.io/v1
    kind: KubeSchedulerConfiguration
    leaderElection:
      leaderElect: false
    profiles:
    - schedulerName: hami-scheduler
    extenders:
    - urlPrefix: "https://127.0.0.1:443"
      filterVerb: filter
      bindVerb: bind
      nodeCacheCapable: false
      enableHTTPS: false
      managedResources:
      - name: nvidia.com/gpu
        ignoredByScheduler: true
      - name: nvidia.com/gpumem
        ignoredByScheduler: true
      - name: nvidia.com/gpucores
        ignoredByScheduler: true
      - name: nvidia.com/gpumem-percentage
        ignoredByScheduler: true
      - name: nvidia.com/priority
        ignoredByScheduler: true    

在原来的基础上增加了 managedResources 部分的配置

      managedResources:
      - name: nvidia.com/gpu
        ignoredByScheduler: true
      - name: nvidia.com/gpumem
        ignoredByScheduler: true
      - name: nvidia.com/gpucores
        ignoredByScheduler: true
      - name: nvidia.com/gpumem-percentage
        ignoredByScheduler: true

只有 Pod 申请这些特殊资源时才走 Extender 调度逻辑,否则使用原生的 Scheduler 调度即可。

ignoredByScheduler: true 的作用是告诉调度器忽略指定资源,避免将它们作为调度决策的依据。也就是说,虽然这些资源(如 GPU 或其他加速硬件)会被 Pod 请求,但调度器不会在选择节点时基于这些资源的可用性做出决定。

ps:因为这些资源可能是虚拟的,并不会真正的出现在 Node 上,因此调度时需要忽略掉,否则就没有任何节点满足条件了,但是这些虚拟资源则是我们的自定义调度逻辑需要考虑的事情。

Scheduler 中的判断逻辑如下,和之前说的一样,只有当 Pod 申请了这些指定的资源时,Scheduler 才会调用 Extender。

// IsInterested returns true if at least one extended resource requested by
// this pod is managed by this extender.
func (h *HTTPExtender) IsInterested(pod *v1.Pod) bool {
    if h.managedResources.Len() == 0 {
       return true
    }
    if h.hasManagedResources(pod.Spec.Containers) {
       return true
    }
    if h.hasManagedResources(pod.Spec.InitContainers) {
       return true
    }
    return false
}

func (h *HTTPExtender) hasManagedResources(containers []v1.Container) bool {
    for i := range containers {
       container := &containers[i]
       for resourceName := range container.Resources.Requests {
          if h.managedResources.Has(string(resourceName)) {
             return true
          }
       }
       for resourceName := range container.Resources.Limits {
          if h.managedResources.Has(string(resourceName)) {
             return true
          }
       }
    }
    return false
}

3. scheduler framework 扩展点

如下图所示,K8s 调度框架定义了一些扩展点(extension points),

https://dqrnax81jzj.feishu.cn/space/api/box/stream/download/asynccode/?code=NmRjNjBiMGUwNTBmZjk2YWJiYTc4MjliMTViOTZlY2JfNzBEYkhQem5lWnhmc3NLTmJJN1FZWjFveHpZZjBvdXNfVG9rZW46UnhoQWJ4aVlZb3V0R2F4Y1Y3S2NqdURObmFnXzE3MzMxODc2MTg6MTczMzE5MTIxOF9WNA

图源:scheduler-framework-extension-points

扩展点根据是否影响调度决策,可以分为两类:

  • 影响调度决策的扩展点

    • 这些插件实现的返回值中包括一个成功/失败字段,决定了是允许还是拒绝这个 pod 进入下一处理阶段;

    • 任何一个扩展点失败了,这个 pod 的调度就失败了;

  • 不影响调度决策的扩展点

    • 这些插件的实现没有返回值,一般只用于打印信息,更新 Pod、Node 信息,或执行清理操作

每一个扩展点都可以对应多个插件,各自可以有不同的实现,每个扩展点的作用如下:

  • PreEnqueue:这些插件在将 Pod 被添加到内部活动队列之前被调用,在此队列中 Pod 被标记为准备好进行调度。

    • 一般用于避免不满足调度要求的 Pod 进入调度阶段,从而降低调度工作负担。

    • 该插件如果返回失败,那么 Pod 不会进入活动队列,也就不会被调度。一般用来做一些预检测,当准备好时才让 Pod 进入调度队列,否则肯定会因为资源未准备好而调度失败,也就没必要进行调度了。

    • 例如:如果调度所需的 GPU 资源尚未准备好,可以通过 PreEnqueue 插件阻止相关 Pod 进入队列。

  • PreFilter:这些插件用于预处理 Pod 的相关信息,或者检查集群或 Pod 必须满足的某些条件。 如果 PreFilter 插件返回错误,则调度周期将终止。

  • Filter:这些插件用于过滤出不能运行该 Pod 的节点。对于每个节点, 调度器将按照其配置顺序调用这些过滤插件。如果任何过滤插件将节点标记为不可行, 则不会为该节点调用剩下的过滤插件。节点可以被同时进行评估。

    • 其主要作用是过滤掉不符合要求的节点,从而缩小可调度节点的范围
  • PostFilter:这些插件在 Filter 阶段后调用,但仅在该 Pod 没有可行的节点时调用。

    • 只有在 Filter 插件没有返回可用的节点 时才会调用 PostFilter 用于补救,以便为当前 Pod 找到或创造一个可行的节点。

    • 典型实现是 preemption 插件,它试图通过抢占其他 Pod 来使 Pod 可访问。

  • PreScore:这些插件用于执行“前置评分(pre-scoring)”工作,即生成一个可共享状态供 Score 插件使用。 如果 PreScore 插件返回错误,则调度周期将终止。

  • Score:这些插件用于对通过过滤阶段的节点打分并进行排序,帮助调度器选择最适合的节点,而不仅仅是选一个满足条件的节点

    • 调度器将为每个节点调用每个评分插件。 将有一个定义明确的整数范围,代表最小和最大分数。 在 NormalizeScore 阶段之后, 调度器将根据配置的插件权重合并所有插件的节点分数。

    • 例如:负载均衡:根据 CPU 或内存利用率进行评分,确保 Pod 分布均匀,避免资源集中于少数节点,或者按网络延迟、区域分布,将 Pod 调度到与数据位置接近的节点。

  • NormalizeScore:这些插件用于在调度器计算 Node 排名之前修改分数,对 Score 进行归一化处理,将得分映射到调度器规定的 0~100 范围内,便于对比。

  • ReserveReserve 阶段是 Kubernetes 调度框架中的一个关键阶段,主要用于在将 Pod 实际绑定到节点之前,临时为其保留必要的资源。这一设计旨在解决调度器可能遇到的资源竞争问题,保证资源在绑定之前不会被其他 Pod 抢占。

    • 实现了 Reserve 接口的插件,拥有两个方法,即 ReserveUnreserve,分别用于保留资源和释放资源。

    • 如果任意一个 Reserve 方法调用失败,后面的插件就不会被执行,Reserve 阶段被认为失败。 如果所有插件的 Reserve 方法都成功了,Reserve 阶段就被认为是成功的, 剩下的调度周期和绑定周期就会被执行。

    • 如果 Reserve 阶段或后续阶段失败了,则触发 Unreserve 阶段。 发生这种情况时,所有 Reserve 插件的 Unreserve 方法将按照 Reserve 方法调用的相反顺序执行。 这个阶段的存在是为了清理与保留的 Pod 相关的状态。

  • Permit:Permit 插件能够决定一个 Pod 是否可以被绑定到指定节点,这种控制是在调度周期的最后阶段进行的。调度器在此阶段会调用所有注册的 Permit 插件,以确认 Pod 的绑定是否符合某些条件,一般用于阻止或者延迟 Pod 的调度,每个 Permit 插件都可以做以下三件事:

    • 批准:一旦所有 Permit 插件批准 Pod 后,该 Pod 将被发送以进行绑定。

    • 拒绝:如果任何 Permit 插件拒绝 Pod,则该 Pod 将被返回到调度队列。 这将触发 Reserve 插件中的 Unreserve 阶段。

    • 等待(带有超时):如果一个 Permit 插件返回“等待”结果,则 Pod 将保持在一个内部的“等待中” 的 Pod 列表,同时该 Pod 的绑定周期启动时即直接阻塞直到得到批准。 如果超时发生,等待变成拒绝,并且 Pod 将返回调度队列,从而触发 Reserve 插件中的 Unreserve 阶段。

  • PreBind:这些插件用于执行 Pod 绑定前所需的所有工作。 例如,一个 PreBind 插件可能需要制备网络卷并且在允许 Pod 运行在该节点之前将其挂载到目标节点上。如果任何 PreBind 插件返回错误,则 Pod 将被拒绝并且退回到调度队列中。

  • Bind:Bind 插件用于将 Pod 绑定到节点上。直到所有的 PreBind 插件都完成,Bind 插件才会被调用。 各 Bind 插件按照配置顺序被调用。Bind 插件可以选择是否处理指定的 Pod。 如果某 Bind 插件选择处理某 Pod,剩余的 Bind 插件将被跳过

    • 真正执行调度的一个插件,更新 Pod 的 spec.nodeName 字段
  • PostBind:这是个信息传递性质的接口。 PostBind 插件在 Pod 成功绑定后被调用。这是绑定周期的结尾,可用于清理相关的资源。

4. 使用 Scheduler Framework 开发调度器

基于scheduler framework开发插件,本质上是实现接口。

我们实现一个简单的调度器,将 Pod 调度到带有指定 label 的节点上。

完整代码见:lixd/i-scheduler

初始化项目

mkdri i-scheduler
cd i-scheduler
#go mod init github.com/lixd96/i-scheduler

git clone -b release-1.29 https://github.com/kubernetes-sigs/scheduler-plugins.git
cd scheduler-plugins

创建自定义调度器

scheduler-plugins/pkg 目录下每一个目录都是一个调度插件,我们也创建一个 bylabel 目录来实现自己的调度逻辑。

/i-scheduler/scheduler-plugins# tree pkg -L 1     
├── capacityscheduling
├── controllers
├── coscheduling
├── crossnodepreemption
├── generated
├── networkaware
├── noderesources
├── noderesourcetopology
├── podstate
├── preemptiontoleration
├── pripority
├── qos
├── sysched
├── trimaran
└── util

pripority 目录下创建文件 pripority.go,内容如下:

package priority

import (
    "context"
    "fmt"
    "k8s.io/kubernetes/pkg/scheduler/framework/plugins/helper"
    "log"
    "strconv"

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

// Name is the name of the plugin used in the plugin registry and configurations.
const Name = "priority"

const Label = "priority.lixueduan.com"

type Priority struct {
    handle framework.Handle
}

var _ framework.FilterPlugin = &Priority{}
var _ framework.ScorePlugin = &Priority{}

// New initializes a new plugin and returns it.
func New(_ context.Context, _ runtime.Object, h framework.Handle) (framework.Plugin, error) {
    return &Priority{handle: h}, nil
}

// Name returns name of the plugin.
func (pl *Priority) Name() string {
    return Name
}

func (pl *Priority) Filter(ctx context.Context, state *framework.CycleState, pod *v1.Pod, nodeInfo *framework.NodeInfo) *framework.Status {
    log.Printf("filter pod: %v, node: %v", pod.Name, nodeInfo)
    log.Println(state)

    // 只调度到携带指定 Label 的节点上
    if _, ok := nodeInfo.Node().Labels[Label]; !ok {
       return framework.NewStatus(framework.Unschedulable, fmt.Sprintf("Node:%s does not have label %s", "Node: "+nodeInfo.Node().Name, Label))
    }
    return framework.NewStatus(framework.Success, "Node: "+nodeInfo.Node().Name)
}

// Score invoked at the score extension point.
func (pl *Priority) Score(ctx context.Context, state *framework.CycleState, pod *v1.Pod, nodeName string) (int64, *framework.Status) {
    nodeInfo, err := pl.handle.SnapshotSharedLister().NodeInfos().Get(nodeName)
    if err != nil {
       return 0, framework.NewStatus(framework.Error, fmt.Sprintf("getting node %q from Snapshot: %v", nodeName, err))
    }

    // 获取 Node 上的 Label 作为分数
    priorityStr, ok := nodeInfo.Node().Labels[Label]
    if !ok {
       return 0, framework.NewStatus(framework.Error, fmt.Sprintf("node %q does not have label %s", nodeName, Label))
    }

    priority, err := strconv.Atoi(priorityStr)
    if err != nil {
       return 0, framework.NewStatus(framework.Error, fmt.Sprintf("node %q has priority %s are invalid", nodeName, priorityStr))
    }

    return int64(priority), framework.NewStatus(framework.Success, "")
}

// ScoreExtensions of the Score plugin.
func (pl *Priority) ScoreExtensions() framework.ScoreExtensions {
    return pl
}

// NormalizeScore invoked after scoring all nodes.
func (pl *Priority) NormalizeScore(ctx context.Context, state *framework.CycleState, pod *v1.Pod, scores framework.NodeScoreList) *framework.Status {
    return helper.DefaultNormalizeScore(framework.MaxNodeScore, false, scores)
}

代码不多,实现也很简单,实现了framework.FilterPluginframework.ScorePlugin 接口,也就是实现了 Filter 和 Score 两个扩展点。

Filter 逻辑也是简单粗暴:

func (pl *Priority) Filter(ctx context.Context, state *framework.CycleState, pod *v1.Pod, nodeInfo *framework.NodeInfo) *framework.Status {
    log.Printf("filter pod: %v, node: %v", pod.Name, nodeInfo)
    log.Println(state)

    // 只调度到携带指定 Label 的节点上
    if _, ok := nodeInfo.Node().Labels[Label]; !ok {
       return framework.NewStatus(framework.Unschedulable, fmt.Sprintf("Node:%s does not have label %s", "Node: "+nodeInfo.Node().Name, Label))
    }
    return framework.NewStatus(framework.Success, "Node: "+nodeInfo.Node().Name)

如果 Node 上没有指定 Label 就返回 Unschedulable,有则返回 Success。

Score 逻辑同样很简单,取 Node 上的 Label 作为得分:

func (pl *Priority) Score(ctx context.Context, state *framework.CycleState, pod *v1.Pod, nodeName string) (int64, *framework.Status) {
    nodeInfo, err := pl.handle.SnapshotSharedLister().NodeInfos().Get(nodeName)
    if err != nil {
       return 0, framework.NewStatus(framework.Error, fmt.Sprintf("getting node %q from Snapshot: %v", nodeName, err))
    }

    // 获取 Node 上的 Label 作为分数
    priorityStr, ok := nodeInfo.Node().Labels[Label]
    if !ok {
       return 0, framework.NewStatus(framework.Error, fmt.Sprintf("node %q does not have label %s", nodeName, Label))
    }

    priority, err := strconv.Atoi(priorityStr)
    if err != nil {
       return 0, framework.NewStatus(framework.Error, fmt.Sprintf("node %q has priority %s are invalid", nodeName, priorityStr))
    }

    return int64(priority), framework.NewStatus(framework.Success, "")
}

注册自定义调度器

编辑 cmd/scheduler/main.go,将上面创建的调度器插件也注册上:

command := app.NewSchedulerCommand(
    app.WithPlugin(capacityscheduling.Name, capacityscheduling.New),
    app.WithPlugin(coscheduling.Name, coscheduling.New),
    app.WithPlugin(loadvariationriskbalancing.Name, loadvariationriskbalancing.New),
    app.WithPlugin(networkoverhead.Name, networkoverhead.New),
    app.WithPlugin(topologicalsort.Name, topologicalsort.New),
    app.WithPlugin(noderesources.AllocatableName, noderesources.NewAllocatable),
    app.WithPlugin(noderesourcetopology.Name, noderesourcetopology.New),
    app.WithPlugin(preemptiontoleration.Name, preemptiontoleration.New),
    app.WithPlugin(targetloadpacking.Name, targetloadpacking.New),
    app.WithPlugin(lowriskovercommitment.Name, lowriskovercommitment.New),
    app.WithPlugin(sysched.Name, sysched.New),
    // Sample plugins below.
    // app.WithPlugin(crossnodepreemption.Name, crossnodepreemption.New),
    app.WithPlugin(podstate.Name, podstate.New),
    app.WithPlugin(qos.Name, qos.New),
    app.WithPlugin(priority.Name, priority.New)
)

就是加了这一句:

app.WithPlugin(priority.Name, priority.New)

至此,我们的自定义调度器就开发完成了。

5.验证

部署

构建镜像

将自定义调度器打包成镜像

REGISTRY=docker.io/lixd96 RELEASE_VERSION=pripority PLATFORMS="linux/amd64,linux/arm64" DISTROLESS_BASE_IMAGE=busybox:1.36 make push-images

部署到集群

scheduler-plugins 项目下 manifest/install 目录中有部署的 chart,我们只需要修改下 image 参数就行了。

安装命令如下:

cd manifest/install 

helm upgrade --install i-scheduler charts/as-a-second-scheduler --namespace kube-system -f charts/as-a-second-scheduler/values-demo.yaml

完整 values-demo.yaml 内容如下:

# Default values for scheduler-plugins-as-a-second-scheduler.
# This is a YAML-formatted file.
# Declare variables to be passed into your templates.

scheduler:
  name: i-scheduler
  image: lixd96/kube-scheduler:pripority

controller:
  replicaCount: 0

# LoadVariationRiskBalancing and TargetLoadPacking are not enabled by default
# as they need extra RBAC privileges on metrics.k8s.io.

plugins:
  enabled: ["Priority","Coscheduling","CapacityScheduling","NodeResourceTopologyMatch","NodeResourcesAllocatable"]

deploy.yaml 完整内容如下:

apiVersion: v1
kind: ServiceAccount
metadata:
  name: i-scheduler
  namespace: kube-system
---
kind: ClusterRoleBinding
apiVersion: rbac.authorization.k8s.io/v1
metadata:
  name: i-scheduler-clusterrolebinding
  namespace: kube-system
roleRef:
  apiGroup: rbac.authorization.k8s.io
  kind: ClusterRole
  name: cluster-admin
subjects:
  - kind: ServiceAccount
    name: i-scheduler
    namespace: kube-system
---
apiVersion: v1
kind: ConfigMap
metadata:
  name: scheduler-config
  namespace: kube-system
data:
  scheduler-config.yaml: |
    apiVersion: kubescheduler.config.k8s.io/v1
    kind: KubeSchedulerConfiguration
    leaderElection:
      leaderElect: false
    profiles:
    - schedulerName: i-scheduler
      plugins:
        filter:
          enabled:
          - name: Priority
        score:
          enabled:
            - name: Priority
          disabled:
            - name: "*"    
---
apiVersion: apps/v1
kind: Deployment
metadata:
  name: i-scheduler
  namespace: kube-system
spec:
  replicas: 1
  selector:
    matchLabels:
      component: i-scheduler
  template:
    metadata:
      labels:
        component: i-scheduler
    spec:
      serviceAccount: i-scheduler
      priorityClassName: system-cluster-critical
      volumes:
        - name: scheduler-config
          configMap:
            name: scheduler-config
      containers:
        - name: i-scheduler
          image: lixd96/kube-scheduler:pripority
          args:
            - --config=/etc/kubernetes/scheduler-config.yaml
            - --v=3
          volumeMounts:
            - name: scheduler-config
              mountPath: /etc/kubernetes

部署到集群

kubectl apply -f deploy.yaml 

确认已经跑起来了

[root@scheduler-1 install]# kubectl -n kube-system get po|grep i-scheduler
i-scheduler-569bbd89bc-7p5v2                  1/1     Running            0          3m44s

测试

创建 Pod

创建一个 Deployment 并指定使用上一步中部署的 Scheduler,然后测试会调度到哪个节点上。

apiVersion: apps/v1
kind: Deployment
metadata:
  name: test
spec:
  replicas: 1
  selector:
    matchLabels:
      app: test
  template:
    metadata:
      labels:
        app: test
    spec:
      schedulerName: i-scheduler
      containers:
      - image: busybox:1.36
        name: nginx
        command: ["sleep"]         
        args: ["99999"]

创建之后 Pod 会一直处于 Pending 状态

[root@scheduler-1 install]# k get po
NAME                    READY   STATUS    RESTARTS   AGE
test-7f7bb8f449-w6wvv   0/1     Pending   0          4s

查看具体情况

[root@scheduler-1 install]# kubectl describe po test-7f7bb8f449-w6wvv

Events:
  Type     Reason            Age   From         Message
  ----     ------            ----  ----         -------
  Warning  FailedScheduling  8s    i-scheduler  0/2 nodes are available: 1 Node:Node: scheduler-1 does not have label priority.lixueduan.com, 1 Node:Node: scheduler-2 does not have label priority.lixueduan.com. preemption: 0/2 nodes are available: 2 No preemption victims found for incoming pod.

可以看到,是因为 Node 上没有我们定义的 Label,因此都不满足条件,最终 Pod 就一直 Pending 了。

添加 Label

由于我们实现的 Filter 逻辑是需要 Node 上有priority.lixueduan.com 才会用来调度,否则直接会忽略。

理论上,只要给任意一个 Node 打上 Label 就可以了。

[root@scheduler-1 install]# k get node
NAME          STATUS   ROLES           AGE     VERSION
scheduler-1   Ready    control-plane   4h34m   v1.27.4
scheduler-2   Ready    <none>          4h33m   v1.27.4
[root@scheduler-1 install]# k label node scheduler-1 priority.lixueduan.com=10
node/scheduler-1 labeled

再次查看 Pod 状态

[root@scheduler-1 install]# k get po -owide
NAME                    READY   STATUS    RESTARTS   AGE     IP               NODE          NOMINATED NODE   READINESS GATES
test-7f7bb8f449-w6wvv   1/1     Running   0          4m20s   172.25.123.199   scheduler-1   <none>           <none>

已经被调度到 node1 上了,查看详细日志

[root@scheduler-1 install]# k describe po test-7f7bb8f449-w6wvv
Events:
  Type     Reason            Age   From         Message
  ----     ------            ----  ----         -------
  Warning  FailedScheduling  4m8s  i-scheduler  0/2 nodes are available: 1 Node:Node: scheduler-1 does not have label priority.lixueduan.com, 1 Node:Node: scheduler-2 does not have label priority.lixueduan.com. preemption: 0/2 nodes are available: 2 No preemption victims found for incoming pod.
  Normal   Scheduled         33s   i-scheduler  Successfully assigned default/test-7f7bb8f449-w6wvv to scheduler-1

可以看到,也是 i-scheduler 在处理,调度到了 node1.

多节点排序

我们实现的 Score 是根据 Node 上的 priority.lixueduan.com 对应的 Value 作为得分的,因此肯定会调度到 Value 比较大的一个节点。

给 node2 也打上 label,value 设置为 20

[root@scheduler-1 install]# k get node
NAME          STATUS   ROLES           AGE     VERSION
scheduler-1   Ready    control-plane   4h34m   v1.27.4
scheduler-2   Ready    <none>          4h33m   v1.27.4
[root@scheduler-1 install]# k label node scheduler-2 priority.lixueduan.com=20
node/scheduler-2 labeled

然后更新 Deployment ,触发创建新 Pod ,测试调度逻辑。

因为 Node2 上的 priority 为 20,node1 上为 10,那么肯定会调度到 node2 上。

[root@scheduler-1 install]# k get po -owide
NAME                    READY   STATUS    RESTARTS   AGE   IP             NODE          NOMINATED NODE   READINESS GATES
test-7f7bb8f449-krvqj   1/1     Running   0          58s   172.25.0.150   scheduler-2   <none>           <none>

果然,被调度到了 Node2。

现在我们更新 Node1 的 label,改成 30

k label node scheduler-1 priority.lixueduan.com=30 --overwrite

再次更新 Deployment 触发调度

[root@scheduler-1 install]# k rollout restart deploy test
deployment.apps/test restarted

这样应该是调度到 node1 了,确认一下

[root@scheduler-1 install]# k get po -owide
NAME                   READY   STATUS    RESTARTS   AGE   IP               NODE          NOMINATED NODE   READINESS GATES
test-f7b597544-bbcb8   1/1     Running   0          65s   172.25.123.200   scheduler-1   <none>           <none>

果然在 node1,说明我们的 Scheduler 是能够正常工作的。

6. 小结

常见自定义调度器方式有两种:

  • Scheduler Extender
  • Scheduler Framework

本文主要分享了如何通过 Scheduler Framework 实现自定义调度逻辑。

K8s 调度框架定义了一些扩展点(extension points),我们只需要实现自己的调度插件,在具体扩展点接入即可。

  • 克隆 scheduler-plugins 项目
  • 实现对应的 framework plugin 接口并注册插件,例如framework.FilterPluginframework.ScorePlugin
  • 部署 Scheduler 时记得开启上一步中实现的 Plugin

完整代码见:lixd/i-scheduler

7. 参考