Kubernetesのスケジューラを2つの方法で拡張する


# kube-schedulerについて

Kubernetesを構成するコンポーネントの1つにkube-schedulerというものがあります。
kube-schedulerは、登録されたPodをどのノードに配置するかを決定する役割を担っています。

Kubernetesのデフォルトスケジューラであるkube-schedulerは、細かい制御が可能であり基本はこのスケジューラを利用するだけでほとんどのケースは事足ります。

一方で、スケジューラは自作したり拡張することも可能です。
本エントリーでは、既存のスケジューラを拡張し挙動を変える方法紹介します。

今回の検証に利用したKubernetes環境は以下のになります。

Minikube v1.14.2
Kubernetes v1.19.4


# スケジューラの拡張方法

kube-schedulerの挙動を変える方法は複数ありますが今回は以下の2パターンを試してみます。

- Scheduler extenderを利用して拡張する
- Scheduling Frameworkを利用して拡張する

kube-scheduler本体をcloneしてハックする方法もありますが、バージョンアップの追従が困難になるため推奨はされていません。

その他、既存のスケジューラの一部のロジックを無効化したり、後述するスコアリングの係数を変更するといった調整が可能ですが、ここでは触れません。

# Scheduler Extender

スケジューラの挙動は大まかに分けると以下のステップに分かれます。

 - Podを配置するNodeを絞り込む
 - 絞り込まれたNodeをスコアリングする
 - 最も高いスコアがついたNodeにPodをバインドする

Scheduler Extenderでは、デフォルトスケジューラが上記のスケジューリングフロー中に用意した4箇所の拡張ポイントに任意のロジックを差し込むことができます。

差し込むロジックはwebhook経由で実行される仕組みになっており、webhookにより呼び出される外部のエンドポイントを用意することで、スケジューラの挙動を変えることができます。

https://github.com/kubernetes/community/blob/master/contributors/design-proposals/scheduling/scheduler_extender.md

### Scheduler Extenderを使ってスケジューラを拡張する

今回は、sampleという文字列が名前に含まれているpodのみNodeにスケジューリングされるようなロジックを既存のスケジューラに組み込んでみたいと思います。

extenderを利用するには、まずスケジューラに関する設定ファイルを作成する必要があります。

```
apiVersion: kubescheduler.config.k8s.io/v1beta1
kind: KubeSchedulerConfiguration
clientConnection:
   kubeconfig: "/etc/kubernetes/scheduler.conf"
extenders:
  - urlPrefix: "http://192.168.3.5:8080/api/scheduler"
    filterVerb: filter
```

上記の設定ファイルでは、KubeSchedulerConfigurationリソースとしてextendersにwebhookでアクセスするURL、拡張ポイントに対応したエンドポイントを指定しています。今回はノードのフィルタリング処理を拡張するためfilterVerbを指定していますが、例えばスコアリング処理を入れたい場合はprioritizeVerbを指定してください。

その他、extendersに指定できる設定を知りたい場合は、ソースを参照すると良いでしょう。
https://github.com/kubernetes/kubernetes/blob/v1.19.4/pkg/scheduler/apis/config/types.go#L339

次に、webhookで呼ばれる外部サーバーの処理を書いていきます。
指定したエンドポイントには、ExtenderArgsで定義されたデータがPOSTリクエストで送信されます。

```
type ExtenderArgs struct {
    Pod *v1.Pod
    Nodes *v1.NodeList
    NodeNames *[]string
}
```

フィルタリングを行うextenderでは、上記のデータを受けとって以下のようなデータをスケジューラに返す必要があります。

```
type ExtenderFilterResult struct {
    Nodes *v1.NodeList
    NodeNames *[]string
    FailedNodes FailedNodesMap
    Error string
}
```

かなり雑ですが、以下のような処理を行う外部サーバーを用意しました。

```
func main() {
    http.HandleFunc("/api/scheduler/filter", func(w http.ResponseWriter, r *http.Request) {
        if err := r.ParseForm(); err != nil {
            return
        }

        var extenderArgs extenderv1.ExtenderArgs
        if err := json.NewDecoder(r.Body).Decode(&extenderArgs); err == nil {
            var result extenderv1.ExtenderFilterResult
            if strings.Contains(extenderArgs.Pod.Name, "sample") {
                result = extenderv1.ExtenderFilterResult{
                    Nodes: extenderArgs.Nodes,
                }
            } else {
                failedNodes := make(extenderv1.FailedNodesMap)
                for _, node := range extenderArgs.Nodes.Items {
                    failedNodes[node.Name] = "Pod name does not contain 'sample'"
                }
                result = extenderv1.ExtenderFilterResult{
                    FailedNodes: failedNodes,
                }
            }

            body, _ := json.Marshal(result)
            w.Write(body)
        }

    })

    if err := http.ListenAndServe(":8080", nil); err != nil {
        log.Fatal(err)
    }
}
```

webhookで利用するエンドポイントが用意できたら、次はkube-schedulerのオプションを追加して起動します。

minikubeの場合は、`minikube ssh`でノードにログインし、/etc/kubernetes/manifest/kube-scheluder.yamlを編集します。kube-schedulerコマンドに`--config`を追加し、用意しておいた設定ファイルを指定します。

```
apiVersion: v1
kind: Pod
metadata:
  creationTimestamp: null
  labels:
    component: kube-scheduler
    tier: control-plane
  name: kube-scheduler
  namespace: kube-system
spec:
  containers:
  - command:
    - kube-scheduler
    - --authentication-kubeconfig=/etc/kubernetes/scheduler.conf
    - --authorization-kubeconfig=/etc/kubernetes/scheduler.conf
    - --config=/etc/kubernetes/kube-scheduler-configuration.yaml #ここを追加
    - --bind-address=127.0.0.1
    - --kubeconfig=/etc/kubernetes/scheduler.conf
    - --leader-elect=false
    - --port=0
   ...
```

省略していますが、`--config`で指定する設定ファイルをこのpodにマウントするのを忘れないでください。

`kube-scheduler.yaml`を編集すると、kube-schedulerが再起動します。

では、以下の2つのpodをデプロイしています。

```
$ kubectl run test --restart=Never --image=nginx:1.14.2
$ kubectl run test-sample --restart=Never --image=nginx:1.14.2
```

sampleという文字列が名前に含まれているpodのみスケジューリングされて、それ以外はステータスがpendingのままになっています。extenderが機能している事が確認できました。

```
$ kubectl get pods
NAME          READY   STATUS    RESTARTS   AGE
test          0/1     Pending   0          63s
test-sample   1/1     Running   0          60s
```

# Scheduling Framework

Scheduling Frameworkとは従来のkube-schedulerの問題点を解消し、より柔軟に拡張しやすく設計し直した仕組みです。
![scheduling-framework-extensions.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/544882/70d4473d-09b6-cba1-d06b-8a17d6295188.png)
https://github.com/kubernetes/enhancements/blob/master/keps/sig-scheduling/624-scheduling-framework/README.md

この図は、Podがスケジューリングされるまでのステップが書かれています。前述で書いたとおり、Podのスケジューリングは、Nodeの絞り込み、スコアリング、バインディングという流れは変わっていないのですが、Scheduling Frameworkでは、そこに差し込む拡張ポイントが11箇所も用意されています。そしてkube-schedulerの多くのロジックはプラグインという形で実装されています。

Scheduling Frameworkの仕組みを利用し、独自のプラグインを実装することで既存のスケジューラをより柔軟に拡張することが可能になっています。

### Scheduling Frameworkを使ってスケジューラを拡張する

では、extenderの例と同じく、名前にsampleという文字列が含まれているPodのみスケジューリングされるような挙動を、Scheduling Frameworkに沿ったプラグインを定義してスケジューラに組み込んでみます。

スケジューラのプラグインの作成自体は難しくなく、拡張したい任意の拡張ポイントを実装することで実現できます。１つのプラグインで、複数の拡張ポイントを実装することができますが、今回はFilterの拡張ポイントに対して処理を埋め込んでいきます。
Scheduling Frameworkのプラグインのインターフェースはこのファイルを参考にしました。
https://github.com/kubernetes/kubernetes/blob/v1.19.4/pkg/scheduler/framework/v1alpha1/interface.go

大まかな流れとしては、Nameメソッドと、拡張ポイントで呼び出されるメソッドを実装していくことになります。

```
type Plugin interface {
	Name() string
}

type FilterPlugin interface {
	Plugin
	Filter(ctx context.Context, state *CycleState, pod *v1.Pod, nodeInfo *NodeInfo) *Status
}
```

Filterのロジックを以下のように実装します。
実装のイメージは既存のプラグインのソースを参考にすると良いでしょう。
https://github.com/kubernetes/kubernetes/tree/v1.19.4/pkg/scheduler/framework/plugins

```
import (
    "context"
    "strings"

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

type SamplePlugin struct{}

var _ framework.FilterPlugin = &SamplePlugin{}

func (pl *SamplePlugin) Name() string {
    return "SamplePlugin"
}

func (pl *SamplePlugin) Filter(ctx context.Context, _ *framework.CycleState, pod *v1.Pod, nodeInfo *framework.NodeInfo) *framework.Status {
    if !strings.Contains(pod.Name, "sample") {
        return framework.NewStatus(framework.Error, "Pod name does not contain 'sample'")
    }

    return nil
}

func NewSamplePlugin(_ runtime.Object, _ framework.FrameworkHandle) (framework.Plugin, error) {
    return &SamplePlugin{}, nil
}
```

このプラグインをkube-schedulerに組み込むためには、kube-schedulerのmain関数でプラグインを追加してコンパイルし直す必要があります。

https://github.com/kubernetes/kubernetes/blob/v1.19.4/cmd/kube-scheduler/scheduler.go

このファイルのmain関数で呼び出している`app.NewSchedulerCommand()`の引数に自作したプラグインを渡します。ここで渡したプラグインはout-of-treeのプラグインとして、kube-schedulerに登録されます。

```
package main

import (
        "math/rand"
        "os"
        "time"

        "github.com/spf13/pflag"

        cliflag "k8s.io/component-base/cli/flag"
        "k8s.io/component-base/logs"
        _ "k8s.io/component-base/metrics/prometheus/clientgo"
        _ "k8s.io/component-base/metrics/prometheus/version" // for version metric registration
        "k8s.io/kubernetes/cmd/kube-scheduler/app"
)

func main() {
        rand.Seed(time.Now().UnixNano())

        command := app.NewSchedulerCommand(
                app.WithPlugin("SamplePlugin", NewSamplePlugin),
        )

        logs.InitLogs()
        defer logs.FlushLogs()

        if err := command.Execute(); err != nil {
                os.Exit(1)
        }
}
```

コンパイルを実行します。

```
$ GOOS=linux GOARCH=amd64 go build -o kube-scheduler
```

コンパイルが通ったら、以下のようなDockerfileを用意し

```
FROM busybox
ADD  ./kube-scheduler /usr/local/bin/kube-scheduler
```

imageをDockerhubに保存します。

```
docker build -t my-account/my-kube-scheduler:0.1 .
docker push my-account/my-kube-scheduler:0.1
```

これで拡張したスケジューラのimageが用意できました。しかし、これをただ利用するだけでは自作したプラグインは動作しませんでした。設定ファイルに登録したプラグインを有効化する定義を追記する必要があるようです。

というわけで、自作プラグインが有効になるように定義した設定ファイルを用意します。
extenderでも作成したKubeSchedulerConfigurationリソースですが、プラグインの挙動を変える設定としてprofilesを定義していきます。

```
apiVersion: kubescheduler.config.k8s.io/v1beta1
kind: KubeSchedulerConfiguration
clientConnection:
   kubeconfig: "/etc/kubernetes/scheduler.conf"
profiles:
  - plugins:
      filter:
        enabled:
        - name: SamplePlugin
```

最後にマスターノードの`/etc/kubernetes/manifests/kube-scheduler.yaml`で定義されているイメージを差し替えます。

```
apiVersion: v1
kind: Pod
metadata:
  creationTimestamp: null
  labels:
    component: kube-scheduler
    tier: control-plane
  name: kube-scheduler
  namespace: kube-system
spec:
  containers:
  - command:
    - kube-scheduler
    - --authentication-kubeconfig=/etc/kubernetes/scheduler.conf
    - --authorization-kubeconfig=/etc/kubernetes/scheduler.conf
    - --config=/etc/kubernetes/kube-scheduler-configuration.yaml
    - --bind-address=127.0.0.1
    - --kubeconfig=/etc/kubernetes/scheduler.conf
    - --leader-elect=false
    - --port=0
    image: my-account/my-kube-scheduler:0.1 #ここを差し替える
    imagePullPolicy: IfNotPresent
    ...
```

問題がなければmanifestを変更後、しばらくするとkube-schedulerが再起動します。
では、kube-schedulerが切り替わったことを確認します。

```
$ kubectl run test --restart=Never --image=nginx:1.14.2
$ kubectl run test-sample --restart=Never --image=nginx:1.14.2
```

```
$ kubectl get pods
NAME          READY   STATUS    RESTARTS   AGE
test          0/1     Pending   0          15s
test-sample   1/1     Running   0          11s
```
自作したプラグインの働きにより、sampleという文字列を名前に含むPodのみスケジューリングされていることが確認できました。

今回はsampleの文字列をソースコードにハードコーディングしていますが、設定ファイルから引数としてを渡すことも可能です。

# まとめ

kube-schedulerを拡張する2パターンの方法を検証してみました。
拡張できるポイントは少ないものの、webhookを利用したScheduler Extenderはそれほど手間がかからずschedulerを拡張できそうです。webhookを処理する外部サーバーがダウンした場合は、スケジューリングに不具合が発生するのでそこは気をつける必要がありそうです。

一方、Scheduling Frameworkでは、out-of-treeのプラグインを登録し、コンパイルし直したkube-schedulerのimageを差し替える手間が発生しますが、extenderよりも高速かつ柔軟にスケジューリングのロジックを定義できることはメリットとなるでしょう。

# 参考

https://github.com/kubernetes/community/blob/master/contributors/design-proposals/scheduling/scheduler_extender.md
https://github.com/kubernetes/enhancements/blob/master/keps/sig-scheduling/624-scheduling-framework/README.md