カスタムコンピューティングクラスとは?
GKEのカスタムコンピューティングクラスをご存知でしょうか?
カスタムリソースで使用したいインスタンスタイプを優先順位付けすることができ、スケールアウト時にその優先順位を守りながらスケールしてくれるというものです。スポットインスタンスを使用するか否かといった設定もできます
例えば、ノードプールをスケールする際n2とn2dのインスタンスファミリーが候補として挙げられる場合に、ClusterAutoScalerはデフォルトでより料金単価の低いn2dのインスタンスファミリーを選択します。ただ、Reserved Instancesを購入しているなど、場合によってはn2を優先的に利用したい場合もあるかと思います。通常はスポットインスタンスを起動して在庫不足の時にオンデマンドインスタンスを起動したい場合も多くあり、このようなユースケースにおいてGKEのカスタムコンピューティングクラスが有効な選択肢になり得ます。
以下のようなカスタムリソースを適用するとn2のmachineFamilyのNodeに優先的にスケジューリングを行い、スケジューリングできない場合にはn2dのmachineFamilyのNodeにスケジューリングを行うといった機能を提供します。
apiVersion: cloud.google.com/v1
kind: ComputeClass
metadata:
name: my-class
spec:
priorities:
- machineFamily: n2
- machineFamily: n2d
nodepoolAutoCreation:
enabled: true
適用してみると、デフォルトではe2のNodeが起動されますがカスタムコンピューティングクラスの設定によりn2のNodeが起動していることが確認できます。
$ 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については、以下のブログで解説を行いました。
定義した優先度に基づいてスケジューリングを行う機能の自作
アーキテクチャ
以下のようなアーキテクチャになります。
具体的な説明は後述しますが、カスタムコントローラがカスタムリソースやPod、Nodeを監視し、必要に応じてNodeをプロビジョニングしたりTaints/TolerationsやNodeaffinity、NodeSelectorを適切に付与していくことで、カスタムリソースで定義したルールに基づくスケジューリングを実現します。また、GKEのCluster AutoScalerを組み合わせる形でのスケジューリングとしています。
以下の図は、n2ノードを優先的に利用するようなカスタムルールを作成し、実際に指定したラベルの付与されたPodはそのルールを満たすようにn2ノードにスケジューリングされることを表しています。
カスタムリソースについて
CRDは、以下のように定義しました。
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にフォールバックします。
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を付与します。
{
"effect": "NoSchedule",
"key": "my-compute-class",
"value": "n2"
}
- また、Podが新たに作成されたタイミングPodに対しカスタムリソースで最優先として定義したmachineFamilyをvalueとしたtolerationsを付与します。具体的には以下のようなtolerationsを付与するようにしました。
tolerations:
- effect: NoSchedule
key: my-compute-class
operator: Equal
value: n2
- PodやNodeの起動時にtaints/tolerationsを付与する際、Mutating Admission Webhookを使用します。Mutating Admission Webhookについては以下の記事で解説しています。
-
カスタムリソースで最優先として定義した、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のドキュメントには以下のような記載がありました:
次のいずれかの条件が適用されない限り 、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をスケジューリング対象として選択できるようになります。
この時、preferredDuringSchedulingIgnoredDuringExecutionを使用することでweightにより優先度を決めることもできるので、このようにカスタムファミリーを設定することで機能の再現をする上で一番楽だと考えました。
しかし、ドキュメントを読むと、ClusterAutoScalerは自動スケール時にpreferredDuringSchedulingIgnoredDuringExecution の影響を受けないとの記述があったため、うまく動かなさそうと判断し、requiredDuringSchedulingIgnoredDuringExecutionを活用することにしました。
ドキュメントを参考にしつつ、requiredDuringSchedulingIgnoredDuringExecutionを用いてデフォルトのe2ではなく、カスタムリソースで定義された優先度のマシンファミリーのノードプールをスケールするようにしました。
その上で、taints/tolerationsをうまく活用してフォールバック時には2番目以降の優先度のマシンファミリーを利用するという方針です。
そのため、まずはPod起動時にTolerationsに加えてNodeAffinityを付与します。
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へのスケジューリングができるようになります。
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と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を付与することで再度スケジューリングされるようになります。
方針2 - Batchを用いてPending Podsをまとめて処理
こちらは、Pending Podsを見つけたらそのタイミングで一つずつToleration追加の処理を行うのではなく、Pending Podsをまとめてバッチで処理するという考え形になります。そもそも、追加のTolerationが必要になるのは、Spot中断などにより最優先ルールが適用できない場合になります。つまり、TolerationのないことによるPending Podsはまとまった時間帯に発生する可能性が高くなります。そのため、Podを一つずつ処理するのではなく、バッチ処理でToleration追加をまとめて行うことが特徴です。
これは、Karpenterの実装を参考にした考え方で、実装においてidleDurationとmaxDurationを使っていることがわかります。
バッチ処理(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:
{
"effect": "NoSchedule",
"key": "my-compute-class",
"value": "n2"
}
を付与しているので、my-compute-classをKeyとするTaintsについて、特定のValueを指定しない形で以下のようなTolerationを付与します。そのため、ノードに対し自動で付与したmy-compute-class
に関するTaintsを全て許容するようになります。
Key: "my-compute-class"
Operator: "Exists"
Effect: "NoSchedule"
このようなTolerationを、ラベルの付与されていないPodに対し自動で付与することで、Taintsの影響を受けずにデフォルトの挙動を取ることができるようになります。
以下の画像は上記の流れを図示したものです。
tryu.com/my-computeclass
をKeyとするラベルが付与されているPodに対しては以下のようなTolerationを付与しているため、N2ノードにのみスケジューリングされます。
effect: NoSchedule
key: my-compute-class
operator: Equal
value: n2
一方、ラベルが付与されておらずカスタムルールを適用しないPodに対しては特定のvalueを指定しない形でTolerationを付与しており、全てのノードのTaintsを満たすためデフォルトのCluster AutoScalerによるスケジューリングルールが適用されます。
必要に応じてノードプールを自動作成
例えば、以下のようなカスタムリソースを適用したとします。
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パッケージを利用してGKEに対しノードプールの作成をリクエストするようにしました。上記の例では、カスタムリソース適用時にReconcile関数においてn2のspotノードプールとn2dのspotノードプールを自動で作成するような処理を実装しました。
実装のまとめ
簡単に実装について振り返ってみます。(処理を一部抜き出して記述しています)
MutatingAdmissionWebhooksにより、NodeやPodの起動時にラベル等の設定を追加します。こうすることで、ClusterAutoScalerによるスケジューリングを一定制御することができるようになり、カスタムリソースで定義した挙動を実現することができます。
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を付与し、スケジューリングに関する制限を加えます。
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の付与といった処理を行います。
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に関する設定は以下のようになっていました。
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」という設定になってしまい、一つ目の条件を満たしていれば二つ目の条件をスルーしてしまいます。
そのため、以下のような設定になるように実装を変更しました。
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」という設定になり、うまくいきました。
動作確認
定義した優先度に応じたスケジューリングができているか確認
以下のようなカスタムリソースを反映したとします。
apiVersion: scaling.tryu.com/v1
kind: MyComputeClass
metadata:
name: instance-priority
spec:
properties:
- machineFamily: n2
priority: 1
- machineFamily: n2d
priority: 2
最優先として定義しているn2のNodeにスケジューリングが行われていることがわかります。
$ 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が優先的にプロビジョニングされています。
$ 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に付与するといった処理が必要になるかと思います。
スポットインスタンスの利用に関する動作確認
スポットインスタンスのノードプールについては、カスタムリソースの内容に基づいて自動で作成されているものとします。
以下のように、スポットインスタンスに関するオプションを付与しました。
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のオンデマンドのノードプールがスケールしそのノードにスケジューリングされるようになりました。
$ 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のスポットインスタンスのノードプールがスケールしスケジューリングされることを確認できました。
apiVersion: scaling.tryu.com/v1
kind: MyComputeClass
metadata:
name: example
spec:
properties:
- machineFamily: n2
priority: 1
spot: true
- machineFamily: n2d
priority: 2
spot: true
$ 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番目の優先度のルールを適用せざるを得ない状況を作り出すことができます。
スポットインスタンスの利用に関する動作確認と同じカスタムリソースを適用し、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へスケジューリングされることが確認できました。
$ 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
$ 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:
{
"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が追加されていることがわかる)
{
"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リポジトリは以下になります。ここまで読んでいただきありがとうございました。