GKEのカスタムコンピューティングクラスの機能を自作した

## カスタムコンピューティングクラスとは？
GKEの[カスタムコンピューティングクラス](https://cloud.google.com/kubernetes-engine/docs/concepts/about-custom-compute-classes?hl=ja)をご存知でしょうか？
カスタムリソースで使用したいインスタンスタイプを優先順位付けすることができ、スケールアウト時にその優先順位を守りながらスケールしてくれるというものです。スポットインスタンスを使用するか否かといった設定もできます

例えば、ノードプールをスケールする際n2とn2dのインスタンスファミリーが候補として挙げられる場合に、ClusterAutoScalerはデフォルトでより料金単価の低いn2dのインスタンスファミリーを選択します。ただ、Reserved Instancesを購入しているなど、場合によってはn2を優先的に利用したい場合もあるかと思います。通常はスポットインスタンスを起動して在庫不足の時にオンデマンドインスタンスを起動したい場合も多くあり、このようなユースケースにおいてGKEのカスタムコンピューティングクラスが有効な選択肢になり得ます。

以下のようなカスタムリソースを適用するとn2のmachineFamilyのNodeに優先的にスケジューリングを行い、スケジューリングできない場合にはn2dのmachineFamilyのNodeにスケジューリングを行うといった機能を提供します。
```yaml
apiVersion: cloud.google.com/v1
kind: ComputeClass
metadata:
  name: my-class
spec:
  priorities:
  - machineFamily: n2
  - machineFamily: n2d
  nodepoolAutoCreation:
    enabled: true
```
適用してみると、デフォルトではe2のNodeが起動されますがカスタムコンピューティングクラスの設定によりn2のNodeが起動していることが確認できます。
```bash
$ kubectl get nodes -o jsonpath='{range .items[*]}{.metadata.name}{"\t"}{.metadata.labels.cloud\.google\.com/gke-nodepool}{"\n"}{end}'
gke-tr-nap-e2-standard--9a93a93c-5llw     nap-e2-standard-2-1an6b1lg
gke-node-1e48c429-2x9q                    gke-nodepool-n2d
gke-node-24a75112-5wbh                    gke-nodepool-n2
gke-node-24a75112-gkh8                    gke-nodepool-n2
gke-node-24a75112-hhwb                    gke-nodepool-n2
gke-node-24a75112-kr5r                    gke-nodepool-n2
gke-node-24a75112-p78j                    gke-nodepool-n2
gke-node-24a75112-r6g8                    gke-nodepool-n2
gke-node-24a75112-v57j                    gke-nodepool-n2
gke-node-24a75112-vj9c                    gke-nodepool-n2
gke-node-24a75112-vsbv                    gke-nodepool-n2
```
本機能は、OSSではないので内部でどのように実装しているかは明示的にはなっていません。そこで、カスタムコンピューティングクラスの機能のを一部再現するようなKubernetesのOperatorを開発してみた、というのがこのブログの趣旨になります。Operatorについては、以下のブログで解説を行いました。

https://qiita.com/tryu___/items/8fff1edd57e8b0758512

## 定義した優先度に基づいてスケジューリングを行う機能の自作
### アーキテクチャ
以下のようなアーキテクチャになります。
具体的な説明は後述しますが、カスタムコントローラがカスタムリソースやPod、Nodeを監視し、必要に応じてNodeをプロビジョニングしたりTaints/TolerationsやNodeaffinity、NodeSelectorを適切に付与していくことで、カスタムリソースで定義したルールに基づくスケジューリングを実現します。また、GKEのCluster AutoScalerを組み合わせる形でのスケジューリングとしています。
以下の図は、n2ノードを優先的に利用するようなカスタムルールを作成し、実際に指定したラベルの付与されたPodはそのルールを満たすようにn2ノードにスケジューリングされることを表しています。
![mycomputeclass.drawio.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3839850/ada3c1d1-d8b4-e955-dd74-b254a9fb9257.png)

### カスタムリソースについて
CRDは、以下のように定義しました。
```yaml
spec:
    description: MyComputeClassSpec defines the desired state of MyComputeClass.
    properties:
      properties:
        items:
          properties:
            machineFamily:
              type: string
            priority:
              type: integer
            spot:
              type: boolean
          required:
          - machineFamily
          - priority
          type: object
        type: array
```
カスタムリソースの設定例は以下の通りです。
至ってシンプルで、ここではn2のmachineFamilyのNodeをスポットインスタンスで優先的に起動できるよう設定しています。n2のスポットNodeにスケジューリングできない場合はn2dのスポットNodeにフォールバックします。
```yaml
apiVersion: scaling.tryu.com/v1
kind: MyComputeClass
metadata:
  name: example
spec:
  properties:
    - machineFamily: n2
      priority: 1
      spot: true
    - machineFamily: n2d
      priority: 2
      spoy: true
```
また、ノードプールのオートスケールはGKEのClusterAutoScalerに任せ併用する形で実装を行い、Operator開発としてKubebuilderを使用しました。
### 実装方針
初めは以下のように、taints/tolerationsを適切に付与するだけで同じような機能が実現できるのではないかと考えました。
- カスタムリソースが適用された場合と新たにNodeが起動するような場合について、任意のNodeに対し**そのNodeのmachineFamilyをvalueとした**taintsを付与します。例えば、n2というmachineFamilyのNodeが起動する場合、以下のtaintsを付与します。
```json
{
  "effect": "NoSchedule",
  "key": "my-compute-class",
  "value": "n2"
}
```
- また、Podが新たに作成されたタイミングPodに対し**カスタムリソースで最優先として定義したmachineFamilyをvalueとしたtolerations**を付与します。具体的には以下のようなtolerationsを付与するようにしました。
```yaml
tolerations:
  - effect: NoSchedule
    key: my-compute-class
    operator: Equal
    value: n2 
```
- PodやNodeの起動時にtaints/tolerationsを付与する際、Mutating Admission Webhookを使用します。Mutating Admission Webhookについては以下の記事で解説しています。

https://qiita.com/tryu___/items/71060a11a4b5d9d91cae

- **カスタムリソースで最優先として定義した、n2のmachineFamilyに関するtolerationのみPodに付与している**ため、最も優先度を高く定義したmachine Familyのノードプールが優先的にスケールされスケジューリングできるようになると考えました。
(スケジューリングができない場合は2番目以降の優先度のmachineFamilyに関するtolerationsを付与する)
### 詰まった点
そう簡単にはいきませんでした。上記のようなtaints/tolerationsを自動で付与するように実装し、n2のNodeにのみスケジューリング可能な場合であってもなぜかn2のNodeが選択されず、GKEはe2のNodeを起動し続け、e2のNodeへのスケジューリングを試みようとしていたのでした。しかし、Mutating Admission Webhookにより新たに起動するe2 NodeにはTaintsが付与されスケジューリングされず、e2 Nodeを新たに起動するがスケジューリングできずまた新たに起動するという負の無限ループのような形になってしまいました。
理由はGKEのClusterAutoScalerの仕様にあり、GKEの[ドキュメント](https://cloud.google.com/kubernetes-engine/docs/concepts/node-auto-provisioning?hl=ja)には以下のような記載がありました:

> **次のいずれかの条件が適用されない限り** 、GKE はデフォルトで **E2 マシンシリーズ** を使用します。
> - ワークロードが、E2 マシンシリーズで利用できない機能をリクエストしている場合。たとえば、ワークロードで GPU がリクエストされた場合、新しいノードプールには N1 マシンシリーズが使用されます。
>  - ワークロードが TPU リソースをリクエストします。TPU の詳細については、Cloud TPU の概要をご覧ください。
>  - ワークロードが machine-family ラベルを使用する場合。詳細については、カスタムマシン ファミリーの使用をご覧ください。

つまり、この3つの条件のいずれかが適用されている状態でない限りGKEのClusterAutoScalerはe2のマシンシリーズを使用しようとし続けます。その際、n2のNodeにはtolerationsがありスケジューリングできるものの、ClusterAutoScalerはe2のNodeを新たに作成しそのNodeにスケジューリングを試みてしまっていたのでした。

### 追加実装
前述の通り、ドキュメントには**3つの条件のいずれかが適用されない限り** e2のマシンシリーズが使用されてしまいます。ただ、カスタムリソースで定義したn2やn2dのマシンを使用しないとカスタムコンピューティングクラスの機能の再現にはならないため、工夫が必要でした。
そこで、3つ目の「**ワークロードが machine-family ラベルを使用する場合** 」に注目しました。
つまり、カスタムマシンファミリーを設定することでデフォルトのe2マシンシリーズが起動してしまうことを防げるということです。
カスタムマシンファミリーにより`cloud.google.com/machine-family`をKeyとし、ValueをmachineFamilyとするNodeAffinityを設定することで、デフォルトのe2ではなく独自のmachineFamilyのNodeをスケジューリング対象として選択できるようになります。

:::note warn
この時、[preferredDuringSchedulingIgnoredDuringExecution](preferredDuringSchedulingIgnoredDuringExecution)を使用することでweightにより優先度を決めることもできるので、このようにカスタムファミリーを設定することで機能の再現をする上で一番楽だと考えました。
しかし、[ドキュメント](https://cloud.google.com/kubernetes-engine/docs/concepts/spot-vms?hl=ja#scheduling-workloads)を読むと、ClusterAutoScalerは自動スケール時にpreferredDuringSchedulingIgnoredDuringExecution の影響を受けないとの記述があったため、うまく動かなさそうと判断し、requiredDuringSchedulingIgnoredDuringExecutionを活用することにしました。
:::
[ドキュメント](https://cloud.google.com/kubernetes-engine/docs/how-to/node-auto-provisioning?hl=ja#custom_machine_family)を参考にしつつ、requiredDuringSchedulingIgnoredDuringExecutionを用いてデフォルトのe2ではなく、カスタムリソースで定義された優先度のマシンファミリーのノードプールをスケールするようにしました。
その上で、taints/tolerationsをうまく活用してフォールバック時には2番目以降の優先度のマシンファミリーを利用するという方針です。
そのため、まずはPod起動時にTolerationsに加えてNodeAffinityを付与します。
```yaml
spec:
  affinity:
    nodeAffinity:
      requiredDuringSchedulingIgnoredDuringExecution:
        nodeSelectorTerms:
        - matchExpressions:
          - key: cloud.google.com/machine-family
            operator: In
            values:
            - n2
            - n2d
```
こうすることで、n2またはn2dのmachineFamilyのNodeがPodのスケジューリング対象となり、最も優先度を高く定義しているn2についてPodにtolerationを付与しているため、n2dではなくn2にスケジューリングされるということです。
## カスタムリソースの設定に応じたスポットインスタンスの利用
自身のGKE環境では、スポットノードには`cloud.google.com/gke-preemptible: true` と `cloud.google.com/gke-provisioning: preemptible`というラベルが付与されていました。

カスタムリソースで`spot: true`の記述があった場合、**このラベルに関するNodeSelectorをPodに付与する**ことでスポットインスタンスのノードにスケジューリングされるようになります。
逆に、カスタムリソースで`spot: false`の記述があった場合には**NodeAffinity**を利用して、上記の二つのラベルが付与されていないノード(つまり、オンデマンドのノード)にPodがスケジューリングされるようにします。
以下のような実装により、`cloud.google.com/gke-preemptible=true`のラベルのついたNodeはスケジューリング対象から外れるため、オンデマンドのNodeへのスケジューリングができるようになります。
```go
pod.Spec.Affinity.NodeAffinity = &corev1.NodeAffinity{
		RequiredDuringSchedulingIgnoredDuringExecution: &corev1.NodeSelector{
			NodeSelectorTerms: []corev1.NodeSelectorTerm{
				{
					MatchExpressions: []corev1.NodeSelectorRequirement{
						{
							Key:      "cloud.google.com/gke-preemptible",
							Operator: corev1.NodeSelectorOpNotIn,
							Values:   []string{"true"},
						},
					},
				},
			},
		},
	}
```
なお、spotのオプションをカスタムリソースで定義しなかった場合にはGKEのClusterAutoScalerがスポットインスタンスを利用するか否かを決定します。
## 最優先ルールが適用できない状況の対応
### 方針1 - イベント駆動型の処理

例えば、n2のスポットノードを最優先ルールとして適用していたとします。何も問題がなければ、Podはn2のスポットノードへスケジューリングされますが、場合によっては途中でn2のスポットノードが中断したりしてしまい利用できなくなる可能性があります。この時、ノードに付与されているTaintsを許容するようなTolerationsがPodに存在しないので、スケジューリングが行われることなく`Pending`状態となってしまいます。

この状況を避けるために、Podの状態を監視し必要に応じて**2番目のルールに関するToleration**を付与する必要があります。
この方針は、Podの**状態の変化**に応じて処理を実行したいので、リソースの作成や削除時に理想状態を維持するために用いられるReconcile Loopはここでは向いていません。
そのため、Managerの[Runnable](https://pkg.go.dev/sigs.k8s.io/controller-runtime/pkg/manager#Runnable)と[Informer](https://pkg.go.dev/sigs.k8s.io/controller-runtime/pkg/cache#Informer)を使用しました。Reconcile Loop以外において、より柔軟に状態変化を検知して処理を実行するためです。
そして、以下のような処理を実装しました。
- ManagerにRunnableを登録し、バックグラウンドでInformerによる監視と状態変化に基づく処理を実行できるようにする
- Informerを用いて、常にPodの状態変化を検知
- Pending状態のPodがある場合には2番目の優先度ルールを満たせるようTolerationを付与する(その際、なんらかの別の理由で一瞬PendingとなっているだけのPodに対し処理をしないように、30秒以上Pending状態が続いたPodのみ対象とする)

このようにすることで、2番目の優先度を満たすようなスケジューリングを行うことができるようになります。
この時のイメージを以下に示します。N2を最優先、N2Dを2番目に利用するというルールを反映し、N2 Nodeが利用できなくなったとします。この時、初めはN2のTolerationのみでしたが、PodがPendingとなったのを検知して追加でN2DのTolerationを付与することで再度スケジューリングされるようになります。
![fallback.drawio (1).png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3839850/781cba72-e0ec-4c62-f176-e6357cdeb6e6.png)




### 方針2 - Batchを用いてPending Podsをまとめて処理
こちらは、Pending Podsを見つけたらそのタイミングで一つずつToleration追加の処理を行うのではなく、Pending Podsをまとめてバッチで処理するという考え形になります。そもそも、追加のTolerationが必要になるのは、Spot中断などにより最優先ルールが適用できない場合になります。つまり、TolerationのないことによるPending Podsは**まとまった時間帯**に発生する可能性が高くなります。そのため、Podを一つずつ処理するのではなく、バッチ処理でToleration追加をまとめて行うことが特徴です。
これは、[Karpenterの実装](https://github.com/kubernetes-sigs/karpenter/blob/af00eb443545048b99f1633bbecb8a12e9187c00/pkg/controllers/provisioning/batcher.go#L72)を参考にした考え方で、実装において**idleDuration**と**maxDuration**を使っていることがわかります。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3839850/d379d786-c209-7531-c45e-f172cd3d9d27.png)




バッチ処理(Toleration付与)を行う可能性があるタイミングは2種類あります。
一つ目がPending Podsが発生しない期間(idle状態)が一定期間続いた場合にバッチ処理を実行するというものです。Spot中断の例に例えるとSpotが中断するとそのノードに乗っていたPodはPending状態となり、Pending Podsの発生は一定期間内にまとめて起こります。そのため、一定のidle期間が経過した際に追加のPending Podsが発生する確率が低いということでバッチ処理を実行します。
二つ目は、最大のタイムアウトを設け、その時間内にidle状態が一定期間続かなかった場合でもバッチ処理を実行してしまうというものです。Spot中断等の影響が長引いてしまいPending Podsが長時間に渡って断続的に発生するような場合に実行されます。このタイムアウトがないとリアルタイム性を大きく損ねてしまうことになり、必要不可欠なものになります。

この方針は、一つ目に紹介したイベント駆動型の処理と比較するとリアルタイム性はやや欠けてしまいます。しかし、Karpenterの場合、一つのPodがPendingになった途端Nodeをプロビジョニングしたりすると効率が悪く、かつ1Podに対し1Nodeを起動するとコスト効率が悪いです。そのため、複数のまとまったPodに対し一つのNodeを起動することで効率化を図っており、これを実現するためにバッチを用いています。
しかし、今回はPending Podsに対してTolerationsを付与するだけでこの付与のタイミングによってパフォーマンスに大きな影響を与えたりすることはないため、方針1のイベント駆動型の処理を採用しました。


## その他の設定
### 特定のラベルが付与されたPodのみ監視対象とする
全てのPodに対して自作したコンピューティングクラスのルールが適用されてしまうと不便なので、`tryu.com/my-computeclass`をKeyとするような**特定のラベルのついたPodのみ**を対象としています。
具体的には、`tryu.com/my-computeclass`をKeyとするラベルが付与されていないPodに対してはカスタムルールの適用を行わずデフォルトのClusterAutoScalerの挙動が取れるようにします。
現在、カスタムリソース適用時に全てのNodeに対しmy-compute-classをKeyとしmachineFamilyをValueとするようなTaints:
```json
{
  "effect": "NoSchedule",
  "key": "my-compute-class",
  "value": "n2"
}
```
を付与しているので、my-compute-classをKeyとするTaintsについて、**特定のValueを指定しない**形で以下のようなTolerationを付与します。そのため、ノードに対し自動で付与した`my-compute-class`に関するTaintsを全て許容するようになります。
```yaml
Key: "my-compute-class"
Operator: "Exists"
Effect: "NoSchedule"
```
このようなTolerationを、ラベルの付与されていないPodに対し自動で付与することで、Taintsの影響を受けずにデフォルトの挙動を取ることができるようになります。
以下の画像は上記の流れを図示したものです。
`tryu.com/my-computeclass`をKeyとするラベルが付与されているPodに対しては以下のようなTolerationを付与しているため、N2ノードにのみスケジューリングされます。
```yaml
effect: NoSchedule
key: my-compute-class
operator: Equal
value: n2
```
一方、ラベルが付与されておらずカスタムルールを適用しないPodに対しては特定のvalueを指定しない形でTolerationを付与しており、全てのノードのTaintsを満たすためデフォルトのCluster AutoScalerによるスケジューリングルールが適用されます。

![mycomputeclass2.drawio.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3839850/98251f2a-5078-dd79-8865-98ec58cb48ff.png)


### 必要に応じてノードプールを自動作成
例えば、以下のようなカスタムリソースを適用したとします。
```yaml
apiVersion: scaling.tryu.com/v1
kind: MyComputeClass
metadata:
  name: test
spec:
  properties:
    - machineFamily: n2
      priority: 1
      spot: true
    - machineFamily: n2d
      priority: 2
      spot: true
```
ここでは、n2マシンファミリーのspotインスタンス利用を最優先ルールとし、その次にn2dマシンファミリーのspotインスタンスを利用するようにします。この時、GKEクラスタ上でn2のspotノードプールやn2dのスポットノードプールが存在しない場合、自動で作成するような処理を実装する必要があります。
そのため、カスタムリソースの内容に応じ、[containerpb](https://pkg.go.dev/cloud.google.com/go/container/apiv1/containerpb)パッケージを利用してGKEに対しノードプールの作成をリクエストするようにしました。上記の例では、カスタムリソース適用時にReconcile関数においてn2のspotノードプールとn2dのspotノードプールを自動で作成するような処理を実装しました。

## 実装のまとめ
簡単に実装について振り返ってみます。(処理を一部抜き出して記述しています)
MutatingAdmissionWebhooksにより、NodeやPodの起動時にラベル等の設定を追加します。こうすることで、ClusterAutoScalerによるスケジューリングを一定制御することができるようになり、カスタムリソースで定義した挙動を実現することができます。
```golang
func (d *MyComputeClassCustomDefaulter) Default(ctx context.Context, obj runtime.Object) error {
	switch obj := obj.(type) {
    // Podが起動した場合、tolerationsを含む様々な設定を追加
	case *corev1.Pod:
		return d.AddPodSettings(ctx, obj)
    // Nodeが起動した場合、taintsを付与
	case *corev1.Node:
		return d.AddTaints(ctx, obj)
	default:
		return fmt.Errorf("unsupported resource type: %T", obj)
	}
}
```
Nodeの起動時にはそのNodeのmachineFamilyをvalueとしたtaintsを付与し、スケジューリングに関する制限を加えます。
```go
func (d *MyComputeClassCustomDefaulter) AddTaints(ctx context.Context, node *corev1.Node) error {
	// ノードラベルからmachineFamilyを取得
	machineFamily, exists := node.Labels["cloud.google.com/machine-family"]

	// taintの作成
	taint := corev1.Taint{
		Key:    "my-compute-class",
		Value:  machineFamily,
		Effect: corev1.TaintEffectNoSchedule,
	}
	// taintを重複して付与しないようチェック
	for _, existingTaint := range node.Spec.Taints {
		if existingTaint.Key == taint.Key {
			mycomputeclasslog.Info("Taint already exists on Node, skipping addition", "nodeName", node.GetName(), "key", taint.Key)
			return nil
		}
	}
	// taint付与
	node.Spec.Taints = append(node.Spec.Taints, taint)
	return nil
}
```

Podの起動時にMutatingAdmissionWebhookにより、NodeAffinityやNodeSelector、Tolerationの付与といった処理を行います。
```golang
func (d *MyComputeClassCustomDefaulter) AddPodSettings(ctx context.Context, pod *corev1.Pod) error {
    // カスタムリソースのデータを加工し取得
    priorityList, topPriorityMachineFamily, err := d.getPriorityListAndTopMachineFamily(ctx)

    // 最も優先度の高いmachineFamilyに関するtolerationを付与
    d.addTolerations(pod, topPriorityMachineFamily)
    // e2へのスケジューリングを避けるためのNodeAffinityを設定
    d.addNodeAffinity(pod, priorityList)
    
    // カスタムリソースのスポットインスタンスに関する設定に応じたNodeSelector/NodeAffinityの付与
    if priorityList[0].Spot != nil && *priorityList[0].Spot {
        d.addSpotNodeSelector(pod)
    }
    if priorityList[0].Spot != nil && !*priorityList[0].Spot {
        d.addNonSpotNodeAffinity(pod)
    }
}
```
## NodeAffinityの注意点
初め、ここまでの実装を反映した後のNodeAffinityに関する設定は以下のようになっていました。
```yaml
nodeAffinity:
  requiredDuringSchedulingIgnoredDuringExecution:
    nodeSelectorTerms:
    - matchExpressions:
      - key: cloud.google.com/machine-family
        operator: In
        values:
        - n2
        - n2d
    - matchExpressions:
      - key: cloud.google.com/gke-preemptible
        operator: NotIn
        values:
        - "true"

```
一見良さそうでしたが、これではうまくいきません。
なぜなら、複数のmatchExpressionsがある場合、どれか一つでも満たしていれば良いという「OR」の処理が行われてしまいます。ここでは、「n2またはn2d」**または**「cloud.google.com/gke-preemptible NotIn true」という設定になってしまい、一つ目の条件を満たしていれば二つ目の条件をスルーしてしまいます。
そのため、以下のような設定になるように実装を変更しました。
```yaml
nodeAffinity:
  requiredDuringSchedulingIgnoredDuringExecution:
    nodeSelectorTerms:
    - matchExpressions:
      - key: cloud.google.com/machine-family
        operator: In
        values:
        - n2
        - n2d
      - key: cloud.google.com/gke-preemptible
        operator: NotIn
        values:
        - "true"
```
こうすることで一つのmatchExpressionsの中に複数の条件が記述されるため、「AND」で処理が行われ、「n2またはn2d」**かつ**「cloud.google.com/gke-preemptible NotIn true」という設定になり、うまくいきました。

## 動作確認
### 定義した優先度に応じたスケジューリングができているか確認
以下のようなカスタムリソースを反映したとします。
```yaml
apiVersion: scaling.tryu.com/v1
kind: MyComputeClass
metadata:
  name: instance-priority
spec:
  properties:
    - machineFamily: n2
      priority: 1
    - machineFamily: n2d
      priority: 2
```
最優先として定義しているn2のNodeにスケジューリングが行われていることがわかります。
```bash
$ kubectl get pods -o wide 
NAME    READY   STATUS    RESTARTS   AGE     IP           NODE                                                  NOMINATED NODE   READINESS GATES
nginx   1/1     Running   0          5m19s   172.17.2.2  gke-node-093ebee6-s4nq   <none>           <none>
```
また、n2のNodeが優先的にプロビジョニングされています。
```bash
$ kubectl get nodes -o jsonpath='{range .items[*]}{.metadata.name}{"\t"}{.metadata.labels.cloud\.google\.com/gke-nodepool}{"\n"}{end}'
gke-tr-nap-e2-standard--081369f5-l249     nap-e2-standard-2-7oucysnv
gke-node-093ebee6-s4nq                    gke-nodepool-n2
gke-node-093ebee6-t8w9                    gke-nodepool-n2
gke-node-093ebee6-vl64                    gke-nodepool-n2
gke-node-093ebee6-wsdc                    gke-nodepool-n2
gke-node-f45cc4a8-rfxr                    gke-nodepool-n2d
```
n2にスケジューリングできない場合、nodeAffinityによりn2dにスケジューリングが行われるため、自動的に2番目の優先度のmachine Familyが使用されることになります。そのため、カスタムリソースにおいて優先度を2つまで定義するような場合についてはGKEのカスタムコンピューティングクラスの機能の自作ができていると言えるかと思います。
まだ実装はできていませんが、優先度を3つ以上定義する場合についてはPodのEventsを監視してリソース不足を検知し、その際に2番目の優先度のmachine Familyに関するtolerationをPodに付与するといった処理が必要になるかと思います。
### スポットインスタンスの利用に関する動作確認
スポットインスタンスのノードプールについては、カスタムリソースの内容に基づいて自動で作成されているものとします。
以下のように、スポットインスタンスに関するオプションを付与しました。
```yaml
apiVersion: scaling.tryu.com/v1
kind: MyComputeClass
metadata:
  name: example
spec:
  properties:
    - machineFamily: n2
      priority: 1
      spot: false
    - machineFamily: n2d
      priority: 2
      spot: true
```
n2の`spot: false`を最も優先度を高くして設定しています。このspotのオプションがなかった場合、ClusterAutoScalerはn2のスポットインスタンスへのスケジューリングを行います。
ただ、今回はPod起動時にNodeAffinityの設定を行う処理を追加しているため、スポットノードにはスケジューリングされないようになっています。
この結果、n2のオンデマンドのノードプールがスケールしそのノードにスケジューリングされるようになりました。
```bash
$ kubectl get nodes -o jsonpath='{range .items[*]}{.metadata.name}{"\t"}{.metadata.labels.cloud\.google\.com/gke-nodepool}{"\n"}{end}'
gke-tr-nap-e2-standard--c0274342-xxx8     nap-e2-standard-2-8nzxogrs
gke-node-3ccd8768-g7jt                    gke-nodepool-n2d
gke-node-4ebcb34a-k42j                    gke-nodepool-n2-spot
gke-node-c6becea7-jv4p                    gke-nodepool-n2
gke-node-c6becea7-kg2f                    gke-nodepool-n2
gke-node-c6becea7-qhnn                    gke-nodepool-n2
gke-node-c6becea7-zd4v                    gke-nodepool-n2
gke-node-e45d9e1f-8xv8                    gke-nodepool-n2d-spot
```
また、以下のようにn2と`spot: true`を最優先として定義した場合にはn2のスポットインスタンスのノードプールがスケールしスケジューリングされることを確認できました。
```yaml
apiVersion: scaling.tryu.com/v1
kind: MyComputeClass
metadata:
  name: example
spec:
  properties:
    - machineFamily: n2
      priority: 1
      spot: true
    - machineFamily: n2d
      priority: 2
      spot: true
```
```bash
$ kubectl get nodes -o jsonpath='{range .items[*]}{.metadata.name}{"\t"}{.metadata.labels.cloud\.google\.com/gke-nodepool}{"\n"}{end}'
gke-tr-nap-e2-standard--c0274342-xxx8     nap-e2-standard-2-8nzxogrs
gke-node-3ccd8768-g7jt                    gke-nodepool-n2d
gke-node-4ebcb34a-k42j                    gke-nodepool-n2-spot
gke-node-4ebcb34a-m5mg                    gke-nodepool-n2-spot
gke-node-4ebcb34a-rpmb                    gke-nodepool-n2-spot
gke-node-4ebcb34a-slzn                    gke-nodepool-n2-spot
gke-node-c6becea7-jv4p                    gke-nodepool-n2
gke-node-e45d9e1f-8xv8                    gke-nodepool-n2d-spot
```
### フォールバックの動作確認
カスタムリソース適用時にノードプールを自動作成する処理がありますが、その際にノードプールの自動スケーリングをオフとしています。というのも、spot中断等のフォールバック状態再現のために、`gcloud`コマンドにより最優先ルール適用時にスケジューリングが行われるインスタンスタイプのノードプールを削除するようにしています。
この時、ノードプールを削除しているため最優先のルールは適用されず2番目の優先度のルールを適用せざるを得ない状況を作り出すことができます。
[スポットインスタンスの利用に関する動作確認](https://qiita.com/tryu___/items/3eedbf5498076b68be40#%E3%82%B9%E3%83%9D%E3%83%83%E3%83%88%E3%82%A4%E3%83%B3%E3%82%B9%E3%82%BF%E3%83%B3%E3%82%B9%E3%81%AE%E5%88%A9%E7%94%A8%E3%81%AB%E9%96%A2%E3%81%99%E3%82%8B%E5%8B%95%E4%BD%9C%E7%A2%BA%E8%AA%8D
)と同じカスタムリソースを適用し、Podの様子を確認してみます。
- 初めはn2に関するTolerationが付与されているためn2のノードにスケジューリングが行われます。
- n2のノードプールを手動削除したタイミングで、最優先ルールであったn2ノードへのスケジューリングができなくなり、他のノードにはTaintsが付与されておりどのノードにもスケジューリングされずにPendingとなります。
- Pending状態のPodをInformerによりリアルタイムで検知し、一定時間以上Pending状態が続いている場合、2番目のn2dに関するTolerationを付与します。そのため、n2dノードへのスケジューリングが行われるようになります。

以下は、元々n2 NodeにスケジューリングされていたPodが、手動でn2のNode Poolを削除しn2へスケジューリングできなくなった際の挙動の様子です。1分後にはRunningとなっており、2番目の優先度であるn2d Nodeへスケジューリングされることが確認できました。
```bash
$ k get pods -w
NAME                                READY   STATUS    RESTARTS   AGE
nginx-deployment-7685f84ddd-bg9zs   1/1     Running   0          69s
nginx-deployment-7685f84ddd-bg9zs   1/1     Terminating   0          88s
nginx-deployment-7685f84ddd-2hh85   0/1     Pending       0          0s
nginx-deployment-7685f84ddd-bg9zs   0/1     Terminating   0          88s
nginx-deployment-7685f84ddd-2hh85   0/1     Pending       0          0s
nginx-deployment-7685f84ddd-bg9zs   0/1     Terminating   0          89s
nginx-deployment-7685f84ddd-bg9zs   0/1     Terminating   0          89s
nginx-deployment-7685f84ddd-2hh85   0/1     Pending       0          1s
nginx-deployment-7685f84ddd-2hh85   0/1     Pending       0          36s
nginx-deployment-7685f84ddd-2hh85   0/1     Pending       0          36s
nginx-deployment-7685f84ddd-2hh85   0/1     ContainerCreating   0          36s
nginx-deployment-7685f84ddd-2hh85   1/1     Running             0          38s
```
```bash
$ k get pods -o wide
NAME                                READY   STATUS    RESTARTS   AGE    IP           NODE                                                  NOMINATED NODE   READINESS GATES
nginx-deployment-7685f84ddd-2hh85   1/1     Running   0          105s   172.17.5.7   gke-sreake-intern-tr-auto-nodepool-n2-d05274eb-86vt   <none>           <none>
$ k get nodes -o jsonpath='{range .items[*]}{.metadata.name}{"\t"}{.metadata.labels.cloud\.google\.com/gke-nodepool}{"\n"}{end}'
gke-sreake-intern-tr-auto-nodepool-n2-053f619d-g5t0     auto-nodepool-n2d
gke-sreake-intern-tr-auto-nodepool-n2-98877d40-3wfq     auto-nodepool-n2d
gke-sreake-intern-tr-auto-nodepool-n2-d05274eb-86vt     auto-nodepool-n2d
```
コントローラのログ:
```
2025-01-27T11:08:22Z    INFO    Pending Pod detected (second check, after 30 seconds)   {"PodName": "nginx-deployment-7685f84ddd-2hh85"}
2025-01-27T11:08:22Z    INFO    Second priority instance type   {"machineFamily": "n2d"}
2025-01-27T11:08:22Z    INFO    Toleration added        {"podName": "nginx-deployment-7685f84ddd-2hh85", "machineFamily": "n2d"}
```
ノードプールの削除前に、Podに付与されていたTolerations:
```json
{
  "name": "nginx-deployment-7685f84ddd-bg9zs",
  "tolerations": [
    {
      "effect": "NoExecute",
      "key": "node.kubernetes.io/not-ready",
      "operator": "Exists",
      "tolerationSeconds": 300
    },
    {
      "effect": "NoExecute",
      "key": "node.kubernetes.io/unreachable",
      "operator": "Exists",
      "tolerationSeconds": 300
    },
    {
      "effect": "NoSchedule",
      "key": "my-compute-class",
      "operator": "Equal",
      "value": "n2"
    }
  ]
}
```
ノードプールの削除後に付与されているTolerations:
(n2dに関するTolerationが追加されていることがわかる)
```json
{
  "name": "nginx-deployment-7685f84ddd-2hh85",
  "tolerations": [
    {
      "effect": "NoExecute",
      "key": "node.kubernetes.io/not-ready",
      "operator": "Exists",
      "tolerationSeconds": 300
    },
    {
      "effect": "NoExecute",
      "key": "node.kubernetes.io/unreachable",
      "operator": "Exists",
      "tolerationSeconds": 300
    },
    {
      "effect": "NoSchedule",
      "key": "my-compute-class",
      "operator": "Equal",
      "value": "n2"
    },
    {
      "effect": "NoSchedule",
      "key": "my-compute-class",
      "operator": "Equal",
      "value": "n2d"
    }
  ]
}
```
このように、n2とn2dに関するTolerationが付与されます。現在n2に関するNodeは存在しないので、2番目の優先度であるn2dのNodeにPodがスケジューリングされるようになります。
## 終わりに
一筋縄で行かないところがかなり多く、かなり大変だったところはありましたが最終的に少し形になってよかったなという印象です。まだまだ本家の機能の再現をする上で足りていない部分はたくさんあるので引き続き頑張っていきたいです。
実装を進めているGithubリポジトリは以下になります。ここまで読んでいただきありがとうございました。

https://github.com/tryuuu/my-computeclass