# K8s 自定义调度器 Part1：通过 Scheduler Extender 实现自定义调度逻辑


![scheduler-extender.jpg](https://img.lixueduan.com/kubernetes/cover/scheduler-extender.jpg)

本文主要分享如何通过 Scheduler Extender 扩展调度器从而实现自定义调度策略。

<!--more-->



## 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]( https://github.com/kubernetes-sigs/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 配置

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



以下是一个完整 KubeSchedulerConfiguration 的 yaml：

```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: false
      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 如下：

```yaml
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 配置如下：

```yaml
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: false
```

核心部分为

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

几个核心参数含义如下：

* `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 内容如下

```yaml
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 部分的配置

```yaml
      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。

```go
// 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 Extender 规范

Scheduler Extender 通过 HTTP 请求的方式，将调度框架阶段中的调度决策委托给外部的调度器，然后将调度结果返回给调度框架。

我们只需要实现一个 HTTP 服务，然后通过配置文件将其注册到调度器中，就可以实现自定义调度器。

通过 Scheduler Extender 扩展原有调度器一般分为以下两步：

* 1）创建一个 HTTP 服务，实现对应接口
* 2）修改调度器配置 KubeSchedulerConfiguration，增加 extenders 相关配置



外置调度器可以影响到三个阶段：

* Filter：调度框架将调用 Filter 函数，过滤掉不适合被调度的节点。

* Priority：调度框架将调用 Priority 函数，为每个节点计算一个优先级，优先级越高，节点越适合被调度。

* Bind：调度框架将调用 Bind 函数，将 Pod 绑定到一个节点上。

Filter、Priority、Bind 三个阶段分别对应三个 HTTP 接口，三个接口都接收 POST 请求，各自的请求、响应结构定义在这里：[#kubernetes/kube-scheduler/extender/v1/types.go](https://github.com/kubernetes/kube-scheduler/blob/master/extender/v1/types.go)

在这个 HTTP 服务中，我们可以实现上述阶段中的**任意一个或多个阶段的接口**，来定制我们的调度需求。



每个接口的请求和响应值请求如下。

### Filter

请求参数

```go
// ExtenderArgs represents the arguments needed by the extender to filter/prioritize
// nodes for a pod.
type ExtenderArgs struct {
    // Pod being scheduled
    Pod *v1.Pod
    // List of candidate nodes where the pod can be scheduled; to be populated
    // only if Extender.NodeCacheCapable == false
    Nodes *v1.NodeList
    // List of candidate node names where the pod can be scheduled; to be
    // populated only if Extender.NodeCacheCapable == true
    NodeNames *[]string
}
```

响应结果

```go
// ExtenderFilterResult represents the results of a filter call to an extender
type ExtenderFilterResult struct {
    // Filtered set of nodes where the pod can be scheduled; to be populated
    // only if Extender.NodeCacheCapable == false
    Nodes *v1.NodeList
    // Filtered set of nodes where the pod can be scheduled; to be populated
    // only if Extender.NodeCacheCapable == true
    NodeNames *[]string
    // Filtered out nodes where the pod can't be scheduled and the failure messages
    FailedNodes FailedNodesMap
    // Filtered out nodes where the pod can't be scheduled and preemption would
    // not change anything. The value is the failure message same as FailedNodes.
    // Nodes specified here takes precedence over FailedNodes.
    FailedAndUnresolvableNodes FailedNodesMap
    // Error message indicating failure
    Error string
}
```



### Prioritize

请求参数

```go
// ExtenderArgs represents the arguments needed by the extender to filter/prioritize
// nodes for a pod.
type ExtenderArgs struct {
    // Pod being scheduled
    Pod *v1.Pod
    // List of candidate nodes where the pod can be scheduled; to be populated
    // only if Extender.NodeCacheCapable == false
    Nodes *v1.NodeList
    // List of candidate node names where the pod can be scheduled; to be
    // populated only if Extender.NodeCacheCapable == true
    NodeNames *[]string
}
```

响应结果

```go
// HostPriority represents the priority of scheduling to a particular host, higher priority is better.
type HostPriority struct {
    // Name of the host
    Host string
    // Score associated with the host
    Score int64
}

// HostPriorityList declares a []HostPriority type.
type HostPriorityList []HostPriority
```



### Bind

请求参数

```go
// ExtenderBindingArgs represents the arguments to an extender for binding a pod to a node.
type ExtenderBindingArgs struct {
    // PodName is the name of the pod being bound
    PodName string
    // PodNamespace is the namespace of the pod being bound
    PodNamespace string
    // PodUID is the UID of the pod being bound
    PodUID types.UID
    // Node selected by the scheduler
    Node string
}
```

响应结果

```go
// ExtenderBindingResult represents the result of binding of a pod to a node from an extender.
type ExtenderBindingResult struct {
    // Error message indicating failure
    Error string
}
```



## 4. Demo

这部分则是手把手实现一个简单的扩展调度器,完整代码见：[lixd/i-scheduler-extender](https://github.com/lixd/i-scheduler-extender)

功能如下：

* 1）过滤阶段：仅调度到带有 Label `priority.lixueduan.com` 的节点上
* 2）打分阶段：直接将节点上  Label `priority.lixueduan.com` 的值作为得分
  * 比如某节点携带 Label `priority.lixueduan.com=50` 则打分阶段该节点则是 50 分



### 代码实现



#### main.go

比较简单，直接通过内置的 net.http 包启动一个 http 服务即可。

```go
var h *server.Handler

func init() {
    h = server.NewHandler(extender.NewExtender())
}

func main() {
    http.HandleFunc("/filter", h.Filter)
    http.HandleFunc("/filter_onlyone", h.FilterOnlyOne) // Filter 接口的一个额外实现
    http.HandleFunc("/priority", h.Prioritize)
    http.HandleFunc("/bind", h.Bind)
    http.ListenAndServe(":8080", nil)
}
```

由于 Priority 阶段返回的得分 kube-Scheduler 会自行汇总后重新计算，因此扩展调度器的 priority 接口并不能安全控制最终调度结果，因此额外实现了一个 filter_onlyone 接口。



#### Filter 实现

filter 接口用于过滤掉不满足条件的接口，这里直接过滤掉没有指定 label 的节点即可。

```go
// Filter 过滤掉不满足条件的节点
func (ex *Extender) Filter(args extenderv1.ExtenderArgs) *extenderv1.ExtenderFilterResult {
	nodes := make([]v1.Node, 0)
	nodeNames := make([]string, 0)

	for _, node := range args.Nodes.Items {
		_, ok := node.Labels[Label]
		if !ok { // 排除掉不带指定标签的节点
			continue
		}
		nodes = append(nodes, node)
		nodeNames = append(nodeNames, node.Name)
	}

	// 没有满足条件的节点就报错
	if len(nodes) == 0 {
		return &extenderv1.ExtenderFilterResult{Error: fmt.Errorf("all node do not have label %s", Label).Error()}
	}

	args.Nodes.Items = nodes

	return &extenderv1.ExtenderFilterResult{
		Nodes:     args.Nodes, // 当 NodeCacheCapable 设置为 false 时会使用这个值
		NodeNames: &nodeNames, // 当 NodeCacheCapable 设置为 true 时会使用这个值
	}
}
```



具体返回 Nodes 还是 NodeNames 决定了后续 Scheduler 部署的 NodeCacheCapable 参数的配置，二者对于即可。



#### Prioritize 实现

Prioritize 对应的就是 Score 阶段，给 Filter 之后留下来的节点打分，选择一个最适合的节点进行调度。

这里的逻辑就是之前说的：解析 Node 上的 label ，直接将其 value 作为节点分数。

```go
// Prioritize 给 Pod 打分
func (ex *Extender) Prioritize(args extenderv1.ExtenderArgs) *extenderv1.HostPriorityList {
    var result extenderv1.HostPriorityList
    for _, node := range args.Nodes.Items {
       // 获取 Node 上的 Label 作为分数
       priorityStr, ok := node.Labels[Label]
       if !ok {
          klog.Errorf("node %q does not have label %s", node.Name, Label)
          continue
       }

       priority, err := strconv.Atoi(priorityStr)
       if err != nil {
          klog.Errorf("node %q has priority %s are invalid", node.Name, priorityStr)
          continue
       }

       result = append(result, extenderv1.HostPriority{
          Host:  node.Name,
          Score: int64(priority),
       })
    }

    return &result
}
```



#### Bind 实现

就是通过 clientset 创建一个 Binding 对象，指定 Pod 和 Node 信息。

```go
// Bind 将 Pod 绑定到指定节点
func (ex *Extender) Bind(args extenderv1.ExtenderBindingArgs) *extenderv1.ExtenderBindingResult {
    log.Printf("bind pod: %s/%s to node:%s", args.PodNamespace, args.PodName, args.Node)

    // 创建绑定关系
    binding := &corev1.Binding{
       ObjectMeta: metav1.ObjectMeta{Name: args.PodName, Namespace: args.PodNamespace, UID: args.PodUID},
       Target:     corev1.ObjectReference{Kind: "Node", APIVersion: "v1", Name: args.Node},
    }

    result := new(extenderv1.ExtenderBindingResult)
    err := ex.ClientSet.CoreV1().Pods(args.PodNamespace).Bind(context.Background(), binding, metav1.CreateOptions{})
    if err != nil {
       klog.ErrorS(err, "Failed to bind pod", "pod", args.PodName, "namespace", args.PodNamespace, "podUID", args.PodUID, "node", args.Node)
       result.Error = err.Error()
    }

    return result
}
```



#### Filter OnlyOne 实现

Extender 仅作为一个额外的调度插件接入，  接口返回得分最终 Scheduler 会将其和其他插件打分合并之后再选出最终节点，因此 extender 中无法通过 prioritie 接口的分数完全控制调度结果。

*不过也不是没有办法！*

**想要完全控制调度结果，我们可以在 Filter 接口中特殊处理。**

Filter 接口先过滤掉不满足条件的节点，然后对剩余节点进行打分，最终只返回得分最高的那个节点，这样就一定会调度到该接口，从而实现完全控制调度结果。

具体实现如下：

```go
// FilterOnlyOne 过滤掉不满足条件的节点,并将其余节点打分排序，最终只返回得分最高的节点以实现完全控制调度结果
func (ex *Extender) FilterOnlyOne(args extenderv1.ExtenderArgs) *extenderv1.ExtenderFilterResult {
	// 过滤掉不满足条件的节点
	nodeScores := &NodeScoreList{NodeList: make([]*NodeScore, 0)}

	for _, node := range args.Nodes.Items {
		_, ok := node.Labels[Label]
		if !ok { // 排除掉不带指定标签的节点
			continue
		}
		// 对剩余节点打分
		score := ComputeScore(node)
		nodeScores.NodeList = append(nodeScores.NodeList, &NodeScore{Node: node, Score: score})
	}
	// 没有满足条件的节点就报错
	if len(nodeScores.NodeList) == 0 {
		return &extenderv1.ExtenderFilterResult{Error: fmt.Errorf("all node do not have label %s", Label).Error()}
	}
	// 排序
	sort.Sort(nodeScores)
	// 然后取最后一个，即得分最高的节点，这样由于 Filter 只返回了一个节点，因此最终肯定会调度到该节点上
	m := (*nodeScores).NodeList[len((*nodeScores).NodeList)-1]

	// 组装一下返回结果
	args.Nodes.Items = []v1.Node{m.Node}

	return &extenderv1.ExtenderFilterResult{
		Nodes:     args.Nodes,
		NodeNames: &[]string{m.Node.Name},
	}
}
```



### 部署

#### 构建镜像

Dockerfile 如下：

```dockerfile
# syntax=docker/dockerfile:1

# Build the manager binary
FROM golang:1.22.5 as builder
ARG TARGETOS
ARG TARGETARCH

ENV GOPROXY=https://goproxy.cn

WORKDIR /workspace
# Copy the go source
COPY . /workspace
# cache deps before building and copying source so that we don't need to re-download as much
# and so that source changes don't invalidate our downloaded layer
RUN go mod download

# Build
# the GOARCH has not a default value to allow the binary be built according to the host where the command
# was called. For example, if we call make docker-build in a local env which has the Apple Silicon M1 SO
# the docker BUILDPLATFORM arg will be linux/arm64 when for Apple x86 it will be linux/amd64. Therefore,
# by leaving it empty we can ensure that the container and binary shipped on it will have the same platform.
RUN CGO_ENABLED=0 GOOS=${TARGETOS:-linux} GOARCH=${TARGETARCH} go build -a -o extender main.go

# Use distroless as minimal base image to package the manager binary
# Refer to https://github.com/GoogleContainerTools/distroless for more details
#FROM gcr.io/distroless/static:nonroot
FROM busybox:1.36
WORKDIR /
COPY --from=builder /workspace/extender .
USER 65532:65532

ENTRYPOINT ["/extender"]
```



#### 部署到集群

部分也是分为两步：

* 1）部署 Extender：由于 Extender 只是一个 HTTP 服务器，只需要使用 Deployment 将其部署到集群即可。

* 2）配置 Extender：修改调度器的 KubeSchedulerConfiguration 配置，在其中 extender 部分指定 url 以及对应的 path，进行接入。



这里为了不影响到默认的 kube-scheduler，我们使用 kube-scheduler 镜像单独启动一个 Scheduler，然后为该调度器配置上 Extender，同时为了降低网络请求的影响，直接将 kube-scheduler 和 Extender 直接运行在同一个 Pod 里，通过 localhost 进行访问。



完整 yaml 如下：

```yaml
apiVersion: v1
kind: ServiceAccount
metadata:
  name: i-scheduler-extender
  namespace: kube-system
---
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRoleBinding
metadata:
  name: i-scheduler-extender
subjects:
  - kind: ServiceAccount
    name: i-scheduler-extender
    namespace: kube-system
roleRef:
  kind: ClusterRole
  name: cluster-admin
  apiGroup: rbac.authorization.k8s.io
---
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: false
---
apiVersion: apps/v1
kind: Deployment
metadata:
  labels:
    component: i-scheduler-extender
    tier: control-plane
  name: i-scheduler-extender
  namespace: kube-system
spec:
  replicas: 1
  selector:
    matchLabels:
      component: i-scheduler-extender
      tier: control-plane
  template:
    metadata:
      labels:
        component: i-scheduler-extender
        tier: control-plane
    spec:
      serviceAccountName: i-scheduler-extender
      containers:
        - name: kube-scheduler
          image: registry.k8s.io/kube-scheduler:v1.29.0
          command:
            - kube-scheduler
            - --config=/etc/kubernetes/i-scheduler-extender.yaml
          livenessProbe:
            httpGet:
              path: /healthz
              port: 10259
              scheme: HTTPS
            initialDelaySeconds: 15
          readinessProbe:
            httpGet:
              path: /healthz
              port: 10259
              scheme: HTTPS
          resources:
            requests:
              cpu: '0.1'
          volumeMounts:
            - name: config-volume
              mountPath: /etc/kubernetes
        - name: i-scheduler-extender
          image: lixd96/i-scheduler-extender:v1
          ports:
            - containerPort: 8080
      volumes:
        - name: config-volume
          configMap:
            name: i-scheduler-extender

```



```bash
kubectl apply -f deploy
```



确认服务正常运行

```bash
[root@scheduler-1 ~]# kubectl -n kube-system get po|grep i-scheduler-extender
i-scheduler-extender-f9cff954c-dkwz2   2/2     Running   0          1m
```



接下来就可以开始测试了。



### 测试

#### 创建 Pod

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

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

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

```bash
[root@scheduler-1 lixd]# k get po
NAME                    READY   STATUS    RESTARTS   AGE
test-58794bff9f-ljxbs   0/1     Pending   0          17s

```

查看具体情况

```bash
[root@scheduler-1]# k describe po test-58794bff9f-ljxbs
Events:
  Type     Reason            Age                From                  Message
  ----     ------            ----               ----                  -------
  Warning  FailedScheduling  99s                i-scheduler-extender  all node do not have label priority.lixueduan.com
  Warning  FailedScheduling  95s (x2 over 97s)  i-scheduler-extender  all node do not have label priority.lixueduan.com
```

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



#### 添加 Label

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



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

```bash
[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 状态

```bash
[root@scheduler-1 lixd]# k get po -owide
NAME                    READY   STATUS    RESTARTS   AGE    IP               NODE          NOMINATED NODE   READINESS GATES
test-58794bff9f-ljxbs   1/1     Running   0          104s   172.25.123.201   scheduler-1   <none>           <none>
```

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

```bash
[root@scheduler-1 install]# k describe po test-7f7bb8f449-w6wvv
Events:
  Type     Reason            Age                  From                  Message
  ----     ------            ----                 ----                  -------
  Warning  FailedScheduling  116s                 i-scheduler-extender  0/2 nodes are available: preemption: 0/2 nodes are available: 2 No preemption victims found for incoming pod.
  Warning  FailedScheduling  112s (x2 over 115s)  i-scheduler-extender  0/2 nodes are available: preemption: 0/2 nodes are available: 2 No preemption victims found for incoming pod.
  Normal   Scheduled         26s                  i-scheduler-extender  Successfully assigned default/test-58794bff9f-ljxbs to scheduler-1
```

可以看到，确实是 i-scheduler-extender 这个调度器在处理，调度到了 node1.



#### 多节点排序

我们实现的 Score 是根据 Node 上的 `priority.lixueduan.com` 对应的 Value 作为得分的，因此调度器会**优先考虑**调度到 Value 比较大的一个节点。

> 因为 Score 阶段也有很多调度插件，Scheduler 会汇总所有得分，最终才会选出结果，因此这里的分数也是仅供参考，不能完全控制调度结果。



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

```bash
[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 ，测试调度逻辑。

```bash
[root@scheduler-1 lixd]# kubectl rollout restart deploy test
deployment.apps/test restarted
```

因为 Node2 上的 priority 为 20，node1 上为 10，那么理论上会调度到 node2 上。

```bash
[root@scheduler-1 lixd]# k get po -owide
NAME                    READY   STATUS    RESTARTS   AGE   IP             NODE          NOMINATED NODE   READINESS GATES
test-84fdbbd8c7-47mtr   1/1     Running   0          38s   172.25.0.162   scheduler-1   <none>           <none>
```

***结果还是调度到了 node1，为什么呢？***

这就是前面提到的：因为 Extender 仅作为一个额外的调度插件接入，**Prioritize 接口返回得分最终 Scheduler 会将其和其他插件打分合并之后再选出最终节点**，因此 Extender 想要完全控制调度结果，只能在 Filter 接口中实现，过滤掉不满足条件的节点，并对剩余节点进行打分，最终 Filter 接口只返回得分最高的那个节点，从而实现完全控制调度结果。

> ps：即之前的 Filter OnlyOne 实现，可以在 KubeSchedulerConfiguration 中配置不同的 path 来调用不同接口进行测试。



修改 KubeSchedulerConfiguration 配置，

```yaml
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_onlyone"
      prioritizeVerb: "prioritize"
      bindVerb: "bind"
      weight: 1
      nodeCacheCapable: false
```

修改点：

```yaml
filterVerb: "filter_onlyone"
```

Path 从 filter 修改成了 filter\_onlyone，这里的 path 和前面注册服务时的路径对应：

```go
    http.HandleFunc("/filter", h.Filter)
    http.HandleFunc("/filter_onlyone", h.FilterOnlyOne) // Filter 接口的一个额外实现
```

修改后重启一下 Scheduler

```bash
kubectl -n kube-system rollout restart deploy i-scheduler-extender
```

再次更新 Deployment 触发调度

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

这样应该是调度到 node2 了，确认一下

```bash
[root@scheduler-1 lixd]# k get po -owide
NAME                    READY   STATUS    RESTARTS   AGE   IP             NODE          NOMINATED NODE   READINESS GATES
test-849f549d5b-pbrml   1/1     Running       0          12s   172.25.0.166     scheduler-2   <none>           <none>
```

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

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

再次更新 Deployment 触发调度

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

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

```bash
[root@scheduler-1 lixd]# k get po -owide
NAME                    READY   STATUS        RESTARTS   AGE   IP               NODE          NOMINATED NODE   READINESS GATES
test-69d9ccb877-9fb6t   1/1     Running       0          5s    172.25.123.203   scheduler-1   <none>           <none>
```

说明修改 Filter 方法实现之后，确实可以直接控制调度结果。



## 5. 小结

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

Extender 具体为一个 http 服务器，可以实现 Filter、Prioritize(调度中的 Score 阶段)、Bind 三个接口。

通过修改 KubeSchedulerConfiguration 配置，为 Scheduler 指定了一个外部的调度器插件。 

因此通过 Scheduler Extender 扩展原有调度器一般分为以下两步：

* 1）创建一个 HTTP 服务，实现对应接口
* 2）修改调度器配置 KubeSchedulerConfiguration，增加 extenders 相关配置

此外还可以通过 ManagedResources 配置实现只让部分 Pod 走 Extender 调度逻辑。



需要注意的是：Extender 仅作为一个额外的调度插件接入，Prioritize 接口返回得分最终 Scheduler 会将其和其他插件打分合并之后再选出最终节点，**因此 Extender 想要完全控制调度结果，只能在 Filter 接口中实现**，过滤掉不满足条件的节点，并对剩余节点进行打分，最终 Filter 接口只返回得分最高的那个节点，从而实现完全控制调度结果。

最佳实践：

* 如果只是要实现普通调度插件：可以正常实现  Filter、Prioritize、Bind 三个接口。

* 如果要由 Extender 完全控制调度结果：只需要实现Filter、Bind 接口，且 Filter 结果只能返回一个得分最高的节点作为最终选定的节点，即：将 FIlter 和 Score 逻辑都合并到 Filter 接口中。



> 完整代码见：[lixd/i-scheduler-extender](https://github.com/lixd/i-scheduler-extender)


---

> 作者: [意琦行](https://github.com/lixd)  
> URL: https://www.lixueduan.com/posts/kubernetes/30-custom-scheduker-by-scheduler-extender/  

