上一篇文章介绍了 通过 Webhook 扩展 k8s 的方法,在这篇文章中我们来探索 k8s 核心组件「调度器」的扩展方法,实现一个将 Pod 优先调度到网速较快的节点上的调度器。

引用一段官方文档对调度器的描述:

调度器通过 Kubernetes 的监测(Watch)机制来发现集群中新创建且尚未被调度到节点上的 Pod。调度器会将所发现的每一个未调度的 Pod 调度到一个合适的节点上来运行。调度器会依据下文的调度原则来做出调度选择。

根据文档介绍可以将调度器的工作流程可大致分为三步:

  1. 监听未调度的 Pod
  2. 选择合适的节点
  3. 将 Pod 调度到节点

如何定义未调度的 Pod 呢?只要 PodSpec 中 nodeName 字段为空就可以认为这个 Pod 就是未调度的。至于哪个节点才是合适的节点,这个问题就比较复杂,调度器需要考虑非常多的因素,例如:节点上的资源是否足够?是否违反亲和性设置?这些因素都会影响调度器的选择,在介绍 k8s 默认调度器章节会详细讲解这个过程。选出合适的节点后调度器就会将 Pod 调度到该节点上运行,这个过程称为「绑定」。

了解完调度器的工作流程后再来看一个 Pod 从创建到在节点上运行的过程,以及调度器是如何参与这个过程的:
20240131094849

从图中可以看出各组件之间都是相互独立的,仅通过 API Server 进行通信与交互,这样的设计使得 k8s 的组件可以非常容易的扩展。步骤 4 结束后调度就算完成了,接着对应节点上的 kubelet 就会在节点上运行 Pod。因此想要实现一个最简单的调度器只需要完成 Watch PodBind Pod 两个动作即可,熟悉 k8s.io/client-go 的同学可能已经马上脑补出了代码的实现。接下来我们结合上面所讲的内容实现一个 Demo 级别的调度器来加深对调度器工作流程的认识,这个调度器会将 Pod 随机调度到节点上,完整的代码放在 Github 仓库

const SchedulerName = "random-scheduler"

func main() {
    clientset, err := makeKubeClient()
    if err != nil {
        panic(err)
    }

    _, ctr := cache.NewInformer(&cache.ListWatch{
        ListFunc: func(options metav1.ListOptions) (runtime.Object, error) {
            // 仅获取 `schedulerName` 为 `random-scheduler` 的 Pod
            options.FieldSelector = "spec.schedulerName=" + SchedulerName
            return clientset.CoreV1().Pods(corev1.NamespaceAll).List(context.Background(), options)
        },
        WatchFunc: func(options metav1.ListOptions) (watch.Interface, error) {
            // 仅监听 `schedulerName` 为 `random-scheduler` 的 Pod
            options.FieldSelector = "spec.schedulerName=" + SchedulerName
            return clientset.CoreV1().Pods(corev1.NamespaceAll).Watch(context.Background(), options)
        },
    }, &corev1.Pod{}, 0, cache.ResourceEventHandlerFuncs{
        AddFunc: func(obj interface{}) {
            pod := obj.(*corev1.Pod)

            if pod.Spec.NodeName != "" {
                return
            }

            nodes := getNodes(clientset) // 获取节点
            availableNodes := filterNodes(nodes) // 过滤节点
            nodename := selectNode(availableNodes) // 选择节点

            if nodename == "" { // 无可用节点
                slog.Error("no available node", "pod", cache.MetaObjectToName(pod))
                return
            }

            binding := &corev1.Binding{ // 创建 `binding` SubResource
                ObjectMeta: pod.ObjectMeta,
                Target:     corev1.ObjectReference{Kind: "Node", Name: nodename},
            }

            // 绑定
            err := clientset.
                CoreV1().
                Pods(pod.Namespace).
                Bind(context.Background(), binding, metav1.CreateOptions{})

            if err != nil {
                slog.Error(
                    "bind pod error",
                    "error", err,
                    "pod", cache.MetaObjectToName(pod),
                )
            }
        },
    })

    ctr.Run(wait.NeverStop)
}

func getNodes(clientset *kubernetes.Clientset) []*corev1.Node {}

func filterNodes(nodes []*corev1.Node) []*corev1.Node {}

func selectNode(nodes []*corev1.Node) (nodename string) {}

random-scheduler 它的基本逻辑是监听所有 schedulerName 为 random-scheduler 的 Pod 并对这些 Pod 进行调度。调度逻辑集中在 AddFunc 函数中,这个函数会在 Pod 创建时被调用,它的工作流程如下:

  1. 判断 Pod 是否已经调度或指定节点
  2. 调用 getNodes 函数获取集群节点列表
  3. 调用 filterNodes 函数过滤出可用节点
  4. 调用 selectNode 函数选择节点
  5. 调用 Bind 函数将 Pod 绑定到节点

下面来测试 random-scheduler 的功能,通过一个 Job 来批量创建 Pod:

apiVersion: batch/v1
kind: Job
metadata:
  generateName: random-scheduler-test-
spec:
  backoffLimit: 0
  parallelism: 10 # 创建 10 个 Pod
  ttlSecondsAfterFinished: 600
  template:
    spec:
      containers:
        - name: busybox
          image: busybox
          imagePullPolicy: IfNotPresent
          command:
            - sleep
          args:
            - 10s
      restartPolicy: Never
      schedulerName: random-scheduler # 指定调度器

Job 创建好后接着来看看 Pod 的调度情况:

$ kubectl get pods --field-selector=spec.schedulerName=random-scheduler -o wide
NAME                                READY   STATUS    RESTARTS   AGE   IP           NODE          
random-scheduler-test-d99vn-2c7gt   1/1     Running   0          17s   10.42.0.58   macmini      
random-scheduler-test-d99vn-7gfxt   1/1     Running   0          17s   10.42.1.41   ecs          
random-scheduler-test-d99vn-8lls9   1/1     Running   0          17s   10.42.0.55   macmini      
random-scheduler-test-d99vn-dhm5w   1/1     Running   0          17s   10.42.1.39   ecs          
random-scheduler-test-d99vn-ktnrt   1/1     Running   0          17s   10.42.2.29   raspberrypi  
random-scheduler-test-d99vn-p5ffz   1/1     Running   0          17s   10.42.1.40   ecs          
random-scheduler-test-d99vn-p9cb7   1/1     Running   0          17s   10.42.2.28   raspberrypi  
random-scheduler-test-d99vn-pvff4   1/1     Running   0          17s   10.42.1.38   ecs          
random-scheduler-test-d99vn-sf7ch   1/1     Running   0          17s   10.42.0.56   macmini      
random-scheduler-test-d99vn-xd85r   1/1     Running   0          17s   10.42.0.57   macmini      

NODE 列可以看到这些 Pod 被 random-scheduler 调度到了不同的节点上,至此我们已经实现了一个最简单的调度器,但它和「正儿八经」的调度器之间还有很大的差距,首当其冲的是它不具备选择「合适节点」的功能,在整个调度环节中这是重中之重;此外它也没有队列功能,这意味着 Pod 创建时如果没有合适的节点那么这个 Pod 将不会被再次调度。接下来我们来看看 k8s 的默认调度器 kube-scheduler 是怎么做的。

kube-scheduler

kube-scheduler 是 k8s 的默认调度器,在早期版本中,kube-scheduler 的设计相对简单,主要基于预设的调度算法和策略进行工作。随着 k8s 的发展,用户需求的多样化推动了 kube-scheduler 的演进。在 1.0 到 1.12 版本中,kube-scheduler 主要通过预设的调度策略和优先级函数进行调度。这些策略和函数是硬编码在 kube-scheduler 中的,用户无法添加新的策略或函数。这种设计虽然简单,但灵活性较差。为了提高 kube-scheduler 的灵活性和可扩展性,从 1.13 版本开始,kube-scheduler 引入了 调度框架,这是一个插件化的调度框架,核心是一系列的 扩展点,调度插件可以使用这些扩展点自定义调度与和绑定流程。

20240112161324

箭头下方的小格子就是扩展点,我们着重看 Scheduling Cycle 部分,前文说到选择节点是调度工作中的重中之重,kube-scheduler 的节点选择分为两个过程:

  1. 预选:预选过程是调度器决定哪些节点可以用于运行 Pod 的阶段。这个过程主要涉及到的扩展点是 Filter。在这个阶段,kube-scheduler 会遍历集群中的所有节点并判断节点是否满足运行新 Pod 的条件。
  2. 打分:打分过程是调度器决定最适合运行新 Pod 的节点的阶段。这个过程主要涉及到的扩展点是 Score。在这个阶段,kube-scheduler 为每个通过预选阶段的节点打分。

在所有的节点分数都计算出来之后,kube-scheduler 会选择分数最高的节点来运行新的 Pod。如果有多个节点的分数相同,kube-scheduler 会随机选择一个。了解完扩展点后我们来了解一下扩展插件,扩展插件是实现扩展点功能的程序实体,kube-scheduler 的调度能力都是由 内置调度插件 提供的,例如 NodeAffinity 插件会根据 Pod 的亲和性设置判断节点是否适合运行 Pod;NodeResourcesFit 插件检查节点是否拥有 Pod 请求的所有资源。

kube-scheduler 不仅仅是 k8s 默认调度器它还是扩展调度器的「基座」,扩展调度器就是在 kube-scheduler 的基础上使用调度框架提供的扩展点实现 自定义调度插件,这么做的原因也很简单:如果从头开发新的调度器那么它需要实现所有 kube-scheduler 的内置调度插件的功能,否则这个调度器就是一个「残废」的调度器。下面我们正式进入主题:基于 kube-scheduler 开发一个自定义调度器,实现将 Pod 优先调度到网速较快的节点上的需求。

network-scheduler

调度框架提供了丰富的扩展点让我们能够在调度流程的各个阶段插入自定义逻辑,那该如何使用这些扩展点呢?kube-scheduler 是用 Go 语言编写的因此扩展点还有一个非常「亲切」的名字:接口。熟悉 Go 语言的同学可能已经猜到了,调度器插件其实就是实现了扩展点接口的结构体。扩展点接口的定义可以在 go.dev 中查看。

调度插件可以选择实现一个或多个扩展点接口以满足各类复杂的需求,要实现将 Pod 优先调度到网速较快的节点上毫无疑问 Score 扩展点是最佳选择,根据节点的网络状况给节点打分,下面开始实现 NetworkSpeedPlugin 调度插件:

package networkspeed

const PluginName = "NetworkSpeed"

type ProberConfig {
    Selector  map[string]string `json:"selector"`
    Namespace string            `json:"namespace"` 
    Port      int               `json:"port"`
    Module    string            `json:"module"`
    Target    string            `json:"target"`
    Timeout   int64             `json:"timeout"`
}

type Config struct {
    Prober ProberConfig `json:"prober"` // 拨测器配置
}

type NetworkSpeedPlugin struct {
    config *Config
    handle framework.Handle
}

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

func (p *NetworkSpeedPlugin) Score(ctx context.Context, state *framework.CycleState, pod *v1.Pod, nodeName string) (int64, *framework.Status) {}

func (n *NetworkSpeedPlugin) ScoreExtensions() framework.ScoreExtensions {}

func New(ctx context.Context, configuration runtime.Object, handle framework.Handle) (framework.Plugin, error) {}

到这里插件的骨架就写好了,NetworkSpeedPlugin 是插件的主体实现了 ScorePlugin 接口,New 函数作为构造函数,下面先来完成入口文件的编写:

// main.go
package main

import (
    "k8s-network-scheduler/plugins/networkspeed"
    "k8s.io/component-base/cli"
    "k8s.io/kubernetes/cmd/kube-scheduler/app"
    "os"
)

func main() {
    cmd := app.NewSchedulerCommand(
        app.WithPlugin(networkspeed.PluginName, networkspeed.New), // 注册调度插件
        // Other plugins...
    )

    code := cli.Run(cmd)
    os.Exit(code)
}

app.NewSchedulerCommand 函数用于创建一个调度器命令,app.WithPlugin 函数用于注册调度插件,networkspeed.PluginName 是插件的名称,networkspeed.New 是插件的构造函数。接下来实现插件的构造函数:

func New(ctx context.Context, configuration runtime.Object, handle framework.Handle) (framework.Plugin, error) {
    // 解析配置
    var config Config
    if err := frameworkruntime.DecodeInto(configuration, &config); err != nil {
        return nil, err
    }

    return &NetworkSpeedPlugin{
        config:        &config,
        handle:        handle,
    }
}

frameworkruntime.DecodeInto 函数用于解析配置,配置如何传递给插件稍后会讲到,配置结构体需要用 json tag 标记字段。framework.Handle 是非常有用的参数,它提供了一系列的方法用于获取调度器的状态、获取 Pod 信息、获取 kubernetes clientset 等。

在完善 Score 方法之前先简单介绍一下如何判断节点的网速,这里使用的是 Blackbox exporter 作为拨测器,它是一个支持使用 HTTP、HTTPS、DNS、TCP 和 ICMP 协议对目标进行拨测的工具,通过 HTTP 接口执行拨测,拨测结果以 Prometheus 指标形式返回,例如使用 HTTP 协议对 lin2ur.cn 进行拨测:

$ curl http://blackbox-exporter:9115/probe?module=http_2xx&target=lin2ur.cn
...
# HELP probe_duration_seconds Returns how long the probe took to complete in seconds
# TYPE probe_duration_seconds gauge
probe_duration_seconds 0.124378116
# HELP probe_http_duration_seconds Duration of http request by phase, summed over all redirects
# TYPE probe_http_duration_seconds gauge
probe_http_duration_seconds{phase="connect"} 0.029487528
probe_http_duration_seconds{phase="processing"} 0.029606199
probe_http_duration_seconds{phase="resolve"} 0.016701177999999997
probe_http_duration_seconds{phase="tls"} 0.032871766
probe_http_duration_seconds{phase="transfer"} 0.014863664
...

probe_duration_seconds 指标表示拨测的总耗时,调度插件就根据这个时间来判断节点的网速,耗时越短表示网速越快。辅助方法 doProbe 用于执行拨测:

func (n *NetworkSpeedPlugin) doProbe(ctx context.Context, nodename, target string) (duration float64, err error) {
    prober := n.getProber(nodename)
    if prober == nil {
        return 0, fmt.Errorf("no available prober")
    }

    probeUrl := fmt.Sprintf(
        "http://%s:%d/probe?module=%s&target=%s",
        prober.Status.PodIP,
        proberConf.Port,
        proberConf.Module,
        target,
    )

    res, err := http.Get(probeUrl)
    // ... 解析拨测数据 ...
    return duration, nil
}

func (n *NetworkSpeedPlugin) getProber(nodeName string) *v1.Pod {
    pods, err := n.handle.
        SharedInformerFactory().
        Core().
        V1().
        Pods().
        Lister().
        List(labels.SelectorFromSet(n.config.Prober.Selector))

        for _, pod := range pods {
            if pod.Spec.NodeName == nodeName && pod.Status.Phase == v1.PodRunning {
                return pod
            }
        }

        return nil
}

万事俱备接下来就可以实现节点打分逻辑了:

func (n *NetworkSpeedPlugin) Score(ctx context.Context, state *framework.CycleState, p *v1.Pod, nodeName string) (int64, *framework.Status) {
    duration, err := n.doProbe(ctx, nodeName, p.Annotations["network-scheduler/probe-target"])
    if err != nil {
        slog.Error("doProbe error", "err", err.Error())
        return 0, nil
    }

    // 将 duration 转换为毫秒整数
    return int64(duration * 1000), nil
}

// 规整分数
func (n *NetworkSpeedPlugin) NormalizeScore(ctx context.Context, state *framework.CycleState, p *v1.Pod, scores framework.NodeScoreList) *framework.Status {
    // 对拨测结果进行排序
    sortScores := scores[:]
    slices.SortFunc(sortScores, func(a, b framework.NodeScore) int {
        return int(a.Score - b.Score)
    })

    // 将排序结果转化为 0 ~ 100 之间的分数
    for i := range scores {
        scores[i].Score = int64((len(scores) - slices.Index(sortScores, scores[i])) * (100 / len(scores)))
    }

    return nil
}

func (n *NetworkSpeedPlugin) ScoreExtensions() framework.ScoreExtensions {
    return n
}

doProbe 返回的拨测结果 duration 是以秒为单位的浮点数,调度框架要求分数必须是 0 ~ 100 之间的 int64 整数,但 duration 可能是几十毫秒也有可能是几秒范围非常大,要转化为 0 ~ 100 之间的分数算法实现起来会比较复杂,因此这里采用了比较方便的做法,所有节点都拨测完后根据 duration 的值给节点排序,最后将排序结果作为分数。

在对所有节点都执行 打分(Score) 后,调度框架会调用 ScoreExtensions 方法尝试获取一个 framework.ScoreExtensions 对象,如果返回的是 nil 那么 Score 方法的返回值就是节点的最终分数;反之则调用 NormalizeScoreScore 的返回值进行规整。无论是否经过规整,节点的最终分数都要在 0 ~ 100 之间。

所有插件方法都有一个 *framework.Status 类型的返回值表示状态,可以使用辅助函数 framework.NewStatus(code Code, reasons ...string) *Status 创建;空值 nil 等效于 framework.NewStatus(framework.Success)code 参数是一个「枚举类型」用于表示状态,比较常用的 framework.Unschedulable 表示「不可调度」;reasons 参数是一个可变参数用于解释状态的原因。

相同的 code 在不同的插件方法返回会导致不同的行为,以 framework.Unschedulable 为例,在 Score 方法中返回会导致 Pod 被拒绝调度,这也是在 doProbe 发生错误时 Score 方法依然返回 nil 状态的原因。导致 doProbe 发生错误的其中一个原因是当前节点上没有可用的拨测器,我们可以实现FilterPlugin 接口来过滤没有可用拨测器的节点:

func (n *NetworkSpeedPlugin) Filter(ctx context.Context, state *framework.CycleState, pod *v1.Pod, nodeInfo *framework.NodeInfo) *framework.Status {
    if n.getProber(nodeInfo.Node().Name) != nil {
        return nil
    }

    return framework.NewStatus(framework.Unschedulable)
}

可以看到在 Filter 方法中 framework.Unschedulable 状态表示当前节点不可用,因此在开发时一定要仔细阅读文档,了解每个插件方法的用法以和状态码的含义。

到这里 network-scheduler 调度器就开发完成了,在默认调度器的基础上增加了一个 NetworkSpeed 插件,通过 FilterScore 扩展点实现了将 Pod 优先调度到网速较快的节点上的需求,接下来我们来部署运行这个调度器。

部署调度器

首先要准备一份配置文件用于配置调度器以及各调度插件的参数,配置文件通常是 YAML 格式,下面是一个简单的配置文件,更多细节可以参考 官方文档

apiVersion: kubescheduler.config.k8s.io/v1
kind: KubeSchedulerConfiguration
leaderElection:
  leaderElect: false
profiles:
  - schedulerName: network-scheduler
        plugins:
      multiPoint:
        enabled:
          - name: NetworkSpeed
#      filter:
#        enabled:
#          - name: NetworkSpeed
#      score:
#        enabled:
#          - name: NetworkSpeed
    pluginConfig:
      - name: NetworkSpeed
        args:
          prober:
            selector:
              app.kubernetes.io/instance: prometheus-blackbox-exporter

解释一下关键字段:

  • leaderElection:配置 leader 选举,不需要高可用部署可禁用选举。
  • profiles:调度器配置,里面每一项都可以理解为声明了一个名为 schedulerName 的调度器。
  • plugins:配置各个 扩展点 启用或禁用的调度插件,内置插件默认启用,自定义插件则需要在这里声明;multiPoint 是一个特殊 key,用于同时在多个扩展点启用插件。
  • plugins.enabled.name:调度插件名称,要和插件注册时的名称一致。
  • pluginConfig:调度插件配置,在初始化调度插件时会将 args 字段的配置传入,也就是插件构造函数中 configuration 参数的来源。

这份配置文件中声明了一个名为 network-scheduler 的调度器,启用了 NetworkSpeed 插件的所有扩展点;pluginConfig 中的 prober.selector 字段用于指定拨测器的标签选择器,这个标签选择器会被用于 getProber 方法筛选拨测器 Pod。

此外还可以通过配置多个 Profile 来实现不同的调度效果,内置插件也同样可以通过这种方式调整,kube-scheduler 配置文档 中对各个插件的配置都进行了详细说明这里就不展开了。


lin2ur
71 声望1 粉丝

while(!die) {