Kubernetes Scheduler 自作入門

# はじめに
Kubernetesのスケジューラを自作するためのフレームワークであるscheduling frameworkが整備されてきています。
しかしまだalphaのステージということもあり、実際に作って試すには情報が少なく戸惑う状況です。
そこでこの記事では、簡単なscheduling frameworkのサンプルと動かし方を紹介します。
さらに応用例としてkubernetes scheduler sigsの開発しているCoschedulingプラグインの解説を行います

# そもそもスケジューラーとは？scheduling frameworkとは？仕様は？
ざっくりとだけ説明します。
詳細情報はすでに良い解説資料があるので、そちらをご参照ください

* [猫でもわかる Scheduling Framework #k8sjp](https://speakerdeck.com/ytaka23/kubernetes-meetup-tokyo-16th)
* [2020 年の秋、改めて学ぶ Scheduling Framework #k8sjp](https://speakerdeck.com/ytaka23/kubernetes-meetup-tokyo-35th)
* [公式マニュアル](https://kubernetes.io/docs/concepts/scheduling-eviction/scheduling-framework/)

#### スケジューラー
podをどのノードで起動するか制御するk8sの管理コンポーネント。デフォルトでdefault-schedulerというものが1つ動いています。

* default-schedulerの挙動はwebhookで拡張できます
* スケジューラーは1つのk8sに複数動かすことが出来ます。
    * デフォルトと自作のスケジューラーを使い分けたりできます
    * Podを作るときのyamlのschedulerNameプロパティで利用するスケジューラを指定できます

#### scheduling framework
default-schedulerのwebhook拡張だと物足りない人々がフルスクラッチでスケジューラ開発する事例がちらほらありました。
しかしdefault-schedulerと同じ挙動で済む部分もあるため、フルスクラッチは過剰なように思われます。
そこで、default-schedulerをベースに変えたい部分の挙動だけ記述することでスケジューラー開発できるフレームワークが作られました。
それがscheduling frameworkです。（たぶん）

#### scheduling frameworkの仕様

default schdeulerの動作は大まかに次のフェーズに分かれています

  * 割り当て待ちのPod群に対し、スケジューリングの優先度決め(QueueSort)
  * 割り当て待ちのPod群から最優先のpodに対してノードを決める（キューからPodを一つ取り出す）
      * 対象ポッドを実行できないノードを除外(Filter)
      * Filterで残ったNodeのうち、どれが最適かをスコアで出す(Scoring)
  * スコアを元にPodに割り当てるNodeを決定し、kubernetesのAPIサーバーに送る(bind)

これらについて差し替えたいフェーズ部分だけコーディングして
デフォルトスケジューラーと組み合わせてコンパイルすることで、
「変えたい部分だけ変えて他はデフォルトと同じ」、というスケジューラーを作ることができます。


なお、実際はもっと細かいフェーズがあります。Filterの前に実行するPreFilterなどの細かい拡張ポイントが用意されています。
詳細は[Kubernetes: kube-scheduler をソースコードレベルで理解する](https://ccvanishing.hateblo.jp/entry/2020/12/02/181155)や[公式マニュアル](https://kubernetes.io/docs/concepts/scheduling-eviction/scheduling-framework/)を確認してください

# 実際に作って動かしてみる
サンプルのソースは[github](https://github.com/ozota1/k8s-scheduling-framework-small-sample)に置いてあります。
なお、今回初めてGoを触ったのでおかしいところあればご指摘ください。

## 実装内容
#### 拡張プラグインの構造体を作成

```go:sample-scheduler.go
import(
    "k8s.io/kubernetes/cmd/kube-scheduler/app"
    //(略)
    framework "k8s.io/kubernetes/pkg/scheduler/framework/v1alpha1"
)

type SampleScheduler struct {
	framework.PreFilterPlugin
	framework.PostBindPlugin
}
```
* 今回はPreFilter、PostBindの部分に処理を入れてみます。そのための構造体の型を定義します。

#### 拡張プラグインのメソッドを作成
```go:sample-scheduler.go
func (cs *SampleScheduler) PostBind(ctx context.Context, state *framework.CycleState, pod *v1.Pod, nodeName string) {
	klog.Infof("pod %v is binded to %v", pod.Name, nodeName)
}
```
* 拡張ポイントに対応するメソッドを定義し、処理内容を記述します。今回は標準出力に書き出すだけです
* 細かいドキュメントはたぶんまだないので、型エラーや補完、インターフェースのコメントを見たりしながらメソッドを記述するしかなさそうです。
* postbindだけ抜粋してますが、prefilterも同様に作る必要があります。

#### プラグインの生成関数を作成
```go:sample-scheduler.go
func New(_ runtime.Object, _ framework.FrameworkHandle) (framework.Plugin, error) {
	return &SampleScheduler{}, nil
}
```

#### スケジューラーにプラグインを組み込んでエントリーポイントを作成
```go:sample-scheduler.go
func main() {
	command := app.NewSchedulerCommand(
		app.WithPlugin(Name, New),
	)

	logs.InitLogs()
	defer logs.FlushLogs()

	if err := command.Execute(); err != nil {
		os.Exit(1)
	}
}
```
* `app.NewSchedulerCommand`の引数に作成したプラグイン生成関数と名前を渡すことで、デフォルトスケジューラーに拡張プラグインが入った`command`が生成されます。

#### コンパイル
```bash
go build sample-scheduler.go
```
* 生成されたバイナリにはプラグインが入っていますが、実行されないのがデフォルトとなります。
* 後述の起動設定ファイル内でプラグインを有効にする設定を書いて起動時に渡します

#### スケジューラーの起動設定を用意
```yaml:scheduler-config.yaml
apiVersion: kubescheduler.config.k8s.io/v1beta1
kind: KubeSchedulerConfiguration
leaderElection:
  leaderElect: false
clientConnection:
  kubeconfig: "/etc/kubernetes/admin.conf"
profiles:
- schedulerName: sample-scheduler
  plugins:
    preFilter:
      enabled:
        - name: SampleScheduler
    postBind:
      enabled:
        - name: SampleScheduler
```
* `plugins.<拡張ポイント>.enabled`に作成したプラグインを入れます
* `schedulerName: sample-scheduler`でこのスケジューラーの名前を設定します。Pod作成時にスケジューラーを指定するのに使います。


### スケジューラーの起動
```bash
sudo ./sample-scheduler --authentication-kubeconfig=/etc/kubernetes/scheduler.conf --authorization-kubeconfig=/etc/kubernetes/scheduler.conf --config=scheduler-config.yaml --secure-port=10260
```
* masterで実行します
* `--config=`で先ほどの起動設定を渡します
* `--secure-port=10260`は待ち受けポートの設定です。なくてもデフォルトの10259で起動しますが、ポートの衝突が起きる場合はこのように指定することで回避します
* `--authentication-kubeconfig=`,`--authorization-kubeconfig=`はスケジューラーの使用するkubeconfigを指定します。今回は`/etc/kubernetes/scheduler.conf`を流用します。
* 実行後の状態は`default-scheduler`、`sample-scheduler`の2つが動いている状態です。

### 動作確認用Pod作成yaml
```yaml
apiVersion: v1
kind: Pod
metadata:
  name: sample-scheduler-pod
  labels:
    name: scheduler-example
spec:
  schedulerName: sample-scheduler
  containers:
  - name: container1
    image: k8s.gcr.io/pause:2.0
```
* `schedulerName: sample-scheduler`で作成したスケジューラを指定します
* 正常に動作すれば、スケジューラー実行したターミナルの標準出力に次が出力されます
    * 拡張ポイントに記述した`klog.Infof("pod %v is binded to %v", pod.Name, nodeName)`が実行されたことが分かります
    
```
I1225 07:09:30.512923   10010 sample-scheduler.go:27] pre filter called for pod sample-scheduler-pod
I1225 07:09:30.516019   10010 sample-scheduler.go:34] pod sample-scheduler-pod is binded to node-hoge-hoge
```

# より高度な例
上記のサンプルはあまりに処理が少なく、実用レベルとはギャップが大きいと思います。
そこで参考として、k8s公式の拡張プラグイン coschedulerがどのようにscheduling frameworkを使用しているかをかいつまんで紹介します
リポジトリは[kubernetes-sigs/scheduler-plugins](https://github.com/kubernetes-sigs/scheduler-plugins)です

## coschedulingとは？
デフォルトのスケジューラーはPodを1つづつバラバラにスケジューリングします。しかし、複数の連携するPodを起動したい場合には、
次のようなデッドロックが発生することがあります。

### デッドロックの発生する仕組み

#### 3つのPodが連携するPodのグループが2つPending状態になる
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/304012/72e6e20c-4eb8-b33f-0dac-bcb4c52d089a.png)

#### 青のグループからPod2つが起動される
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/304012/cc21a9be-2035-4530-53ef-9d3177729593.png)

#### 赤のグループからPod2つが起動される
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/304012/5d09b2b4-94a0-e2a4-d62f-12a1a3bf0779.png)

* Podのスケジューリングは1つずつばらばらのため、こういったことが起きえます

#### 赤も青もあと1つPodが起動すれば処理を始められるが、リソース満杯で起動できない
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/304012/c70b4358-ec47-3f56-4c04-e1ad777c5949.png)


* 結果、起動済みPodは残りの起動を待機し続け、PendingのPodはリソースが空くのを待ち続けるため、デッドロックとなる

### coschedulingによるデッドロックの回避
上記のデッドロックが発生するのはPodを1つずつバラバラに起動するせいです。なので、まとめて起動してやれば回避できます。
これをcoschedule(またはギャングスケジューリング)といいます。

#### Coschedulingプラグインの実装のおおざっぱな解説
* PodGroupというカスタムリソースを定義する。そこにPodGroup名と最小で必要なPodの数を記載させる
* Pod作成時のyamlのannotationに所属するPodGroupを記載させる
* CoschedulingプラグインはPodのPrefilter拡張で次を行う
  * スケジューリング要求されたPodのPodGroup名をannotationから取得。そのPodGroup名からPodGroupの設定をk8s clientライブラリを利用して取得
  * PodGroupの最小で必要なPodの数が起動できるかをチェックする。
      * k8sに投げられたPodの数が足りているか
      * 各ノードの空きは足りているか
  * もし起動不能ならスケジューリングを中断する。（prefilterメソッドでUnschedulableエラーを返す） 
* 他のスケジューラーとの競合でリソースがなくなって必要な数のPodが起動できなくなったりした場合はグループのPodすべてのスケジューリングをキャンセルすることでデッドロックを回避する（割愛しますが、Permitやunreserveといった拡張ポイントを使ってます）

このようにカスタムリソースとスケジューラーのプラグインを組み合わせて使うことで、高度なスケジューリングを実現しています。

# 備考
## 作ったスケジューラーをデフォルトのスケジューラーにするには？
* 今のところ、デフォルトのスケジューラーを削除して、自作スケジューラーをdefault-schedulerという名前で登録するしかなさそうです。