Pod Topology Spread Constraintsのすべて

December 21, 2021

こんにちは。最近口を開くたびに「卒論やばい」と呟いてしまいます。 @sanposhiho です。卒論やばい。

この記事はCAMPHOR- Advent Calenderの 22 日目、かつKubernetes scheduler pluginsの 1 日目(1 日目とは)の記事です。

この記事では Pod Topology Spread Constraints に関する基本情報から内部の仕組みまでの”全て”を紹介します。

Pod Topology Spread Constraints の基本情報

You can use topology spread constraints to control how Pods are spread across your cluster among failure-domains such as regions, zones, nodes, and other user-defined topology domains. This can help to achieve high availability as well as efficient resource utilization. https://kubernetes.io/docs/concepts/workloads/pods/pod-topology-spread-constraints/

Pod Topology Spread Constraints はユーザーが定義したドメインをもとにして、Pod をいい感じに分散させることを目指した機能です。 regions や zones、nodes など、Node にラベルをつけることでドメインを定義することができます。

ただし、region, zone, node に関しては well-known labels として label が用意されています。

https://kubernetes.io/docs/reference/labels-annotations-taints/

Pod Topology Spread Constraints を用いて、例えば「特定の Zone の Node に Pod が集まることを防いでおき、均等に散らばらせておくことで、一つの Zone の障害発生時に、サービスの Pod の一部のみに影響を抑える」などの要件を満たすことができます。

apiVersion: v1
kind: Pod
metadata:
  name: mypod
spec:
  topologySpreadConstraints:
    - maxSkew: <integer>
      topologyKey: <string>
      whenUnsatisfiable: <string>
      labelSelector: <object>

設定はこのような感じです。pod.spec.topologySpreadConstraintsに定義します。また、この例では一つの constraint しか定義していませんが、見ての通りリストになっているので、複数定義することも可能です。

whenUnsatisfiable

constraint に違反していた場合にどのように対応するかを指定します。二つの値をとります。

  • DoNotSchedule: 違反している Node には、Pod をスケジュールしません。具体的にいうと違反している Node は Filter で弾かれます。
  • ScheduleAnyway: 違反している Node には、Pod がスケジュールされにくくなります。具体的にいうと、違反している Node に与えるスコアが減ります。

labelSelector

constraint を適応する Pod を labelSelector を用いて決定します。

maxSkew

Pod の不均等をどの程度許容するかというのを示す値です。(default は 1) 例えば、以下のような constraint が定義されているとします

topologySpreadConstraints:
  - maxSkew: 2
    topologyKey: kubernetes.io/hostname
    whenUnsatisfiable: DoNotSchedule
    labelSelector:
      matchLabels:
        foo: bar

そして、Node が 3 つ存在し、NodeA には labelSelector にマッチする Pod が 1 つ、NodeB と NodeC には 3 つ存在しているとします。 この場合、次に作成された labelSelector にマッチする Pod は Pod Topology Spread Constraints によって、NodeB と NodeC にはスケジュールされません。

これはmaxSkewが 2、すなわち、「マッチする Pod の数の差を 2 つまでなら許容する」としているためです。 例えば、NodeB に新たな Pod がいってしまうと 1:4:3 となり、 ドメインのうち、最も Pod の数が少ない NodeA と最も Pod の数が多い NodeB の差が 3 となってしまうため、上記の constraint の規約に違反します。

デフォルトの Constraints

Scheduler の設定であるKubeSchedulerConfigurationにて、クラスターレベルでデフォルトの Constraint を定義しておくことができます。

https://kubernetes.io/docs/concepts/workloads/pods/pod-topology-spread-constraints/#cluster-level-default-constraints

Pod に.spec.topologySpreadConstraintsが定義されている場合はそちらが優先されます。

Pod Topology Spread Constraints の内部の仕組み

ここから先は内部実装でどのように Pod Topology Spread Constraints の動作を実現しているのかの概要を解説します。

大前提として Scheduler は Scheduling Framework という仕組みに沿ってそれぞれの機能が一つ一つ別のプラグインとして実装されています。

Scheduling Framework には拡張点として、プラグインの実行されるタイミングが定義されており、拡張点ごとに役割が異なっています。一つのプラグインは一つ以上の拡張点で動作します。

https://kubernetes.io/docs/concepts/scheduling-eviction/scheduling-framework/

こういった Scheduler 全体の仕組みを詳しく知りたい方は先日公開された以下の記事を参照してください。

自作して学ぶ Kubernetes Scheduler | メルカリエンジニアリング

さて、本題に移りましょう。Pod Topology Spread Constraints の実装は基本的に以下のフォルダに収まっています。

https://github.com/kubernetes/kubernetes/tree/0153febd9f0098d4b8d0d484927710eaf899ef40/pkg/scheduler/framework/plugins/podtopologyspread

DoNotSchedule

まずは、DoNotSchedule の実装を見ていきましょう。

DoNotScheduleの説明時に一瞬紹介しましたが、こちらは Filter プラグイン + PreFilter プラグインとして実装されています。

Filter 拡張点は要件に合わない Node を候補から弾く役割を持つプラグインが実行される拡張点です。 そして、PreFilter 拡張点は Filter 拡張点を実行するための準備(事前計算など)を行う拡張点です。

この PreFilter、Filter 拡張点の実装は/pkg/scheduler/framework/plugins/podtopologyspread/filtering.goにあります。Scheduling Framework において、「特定の拡張点で動作するプラグイン」というのは拡張点に対応する interface を実装することを意味します。

例えば、PreFilter と Filter の拡張点に対応する interface はこれらです。

type PreFilterPlugin interface {
	Plugin
	PreFilter(ctx context.Context, state *CycleState, p *v1.Pod) *Status
	PreFilterExtensions() PreFilterExtensions
}

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

https://github.com/kubernetes/kubernetes/blob/bf1c923502307687958b131561f9ec857b50d18e/pkg/scheduler/framework/interface.go#L335-L372

PreFilter の概要

では PreFilter の実装から見ていきましょう。 https://github.com/kubernetes/kubernetes/blob/master/pkg/scheduler/framework/plugins/podtopologyspread/filtering.go#L145

PreFilter では事前計算を行い、その結果を CycleState に保存しています。CycleState とはプラグイン間での情報の共有をすることができる構造体で、Pod のスケジュール開始時に毎回リセットされるものです。つまり、一つの Pod のスケジュールにおいてのみ有効なデータというイメージになります。

Pod Topology Spread に限らず、PreFilter などの事前計算を行う拡張点では CycleState にその結果が置かれる場合が多くあります。

Pod Topology Spread では以下のpreFilterStateと言う構造体が PreFilter の計算結果として CycleState の中に置かれます。

type preFilterState struct {
	Constraints []topologySpreadConstraint
	// We record 2 critical paths instead of all critical paths here.
	// criticalPaths[0].MatchNum always holds the minimum matching number.
	// criticalPaths[1].MatchNum is always greater or equal to criticalPaths[0].MatchNum, but
	// it's not guaranteed to be the 2nd minimum match number.
	TpKeyToCriticalPaths map[string]*criticalPaths
	// TpPairToMatchNum is keyed with topologyPair, and valued with the number of matching pods.
	TpPairToMatchNum map[topologyPair]*int32
}

Constraintsは “その Pod のスケジュール中に考慮すべき Constraint”が格納されています。

  • pod.Spec.TopologySpreadConstraintsがあり、尚且つ、labelSelectorにその Pod がマッチする場合、 それをpreFilterStateConstraints
  • なかった場合、デフォルトの Constraint を取得して、デフォルトの Constraint のうちlabelSelectorに Pod がマッチする Constraint を選び、preFilterStateConstraints

と言う二通りの処理で、“その Pod のスケジュール中に考慮すべき Constraint”を取得してpreFilterStateConstraintsに格納しています。

TpKeyToCriticalPathsにはそれぞれの Topology 毎に”ドメインが保持している「labelSelectorにマッチしている Pod の数」の最小値”が map で保存されます。

TpPairToMatchNumには ”topologyPairが示す Node 達に「labelSelectorにマッチしている Pod」がいくつ存在するか” が保持されます。

Filter の概要

Filter では基本的に PreFilter で計算したpreFilterStateをもとにフィルタリングを行います。

constraint 毎に条件を満たしているのかを確認して行って、Node を pass して OK かどうかを判断していきます。

Node を pass して OK かどうかは以下の基準で判断されます。

'existing matching num' + 'if self-match (1 or 0)' - 'global min matching num' <= 'maxSkew'

それぞれ

  • existing matching num: 現在ドメインに存在している 「labelSelectorにマッチしている Pod」 の数
  • if self-match: Pod がlabelSelectorにマッチしているかどうか
  • global min matching num Topology のドメインのうち、labelSelectorにマッチする Pod の数が最も少ないドメインの Pod の数

を表しています。これによって「もしスケジュール中の Pod が その Node に行った場合に いづれかの Constraint のmaxSkewに違反しないかどうか」を判断しています。

実際にここで計算が行われ、Node を pass するかどうかを判断しています。 https://github.com/kubernetes/kubernetes/blob/a8cb4e22bf982a39ae6f8a522bd2b74fdce04620/pkg/scheduler/framework/plugins/podtopologyspread/filtering.go#L315-L326

ScheduleAnyway

こちらは Score プラグイン + PreScore プラグイン + Normalized Score プラグインとして実装されています。

Score 拡張点は Node をスコアリングするための拡張点です。 そして、PreScore 拡張点は Score 拡張点を実行するための準備(事前計算など)を行う拡張点です。

実装は全てこのファイルに存在します。 https://github.com/kubernetes/kubernetes/blob/master/pkg/scheduler/framework/plugins/podtopologyspread/scoring.go

同様に PreScore からざっくり説明していきます。

PreScore の概要

PreScore では以下のpreScoreStateを計算します。

type preScoreState struct {
	Constraints []topologySpreadConstraint
	// IgnoredNodes is a set of node names which miss some Constraints[*].topologyKey.
	IgnoredNodes sets.String
	// TopologyPairToPodCounts is keyed with topologyPair, and valued with the number of matching pods.
	TopologyPairToPodCounts map[topologyPair]*int64
	// TopologyNormalizingWeight is the weight we give to the counts per topology.
	// This allows the pod counts of smaller topologies to not be watered down by
	// bigger ones.
	TopologyNormalizingWeight []float64
}

Constraintsは PreFilter と同様に”その Pod のスケジュール中に考慮すべき Constraint”が格納されています。

IgnoredNodesはその名の通り、無視すべき Node が格納されます。 現在スケジュールしている Pod において影響を与える全ての Constraints の topologyKey を持っていない Node がこのフィールドを通して無視されます。

TopologyPairToPodCountstopologyPair毎にtopologyPairが示す Node 上に存在する「labelSelectorにマッチしている Pod の合計」が map で格納されます。

TopologyNormalizingWeightは Constraint 毎の点数に差がつくのを防ぐために計算されるものです。この重み付けによって、Constraint 間の点数が正規化されています。

Score の概要

Pod Topology Spread ではドメイン間の Pod の数の差を常に縮めるようなスコアリングをします。

つまり、”labelSelectorにマッチしている Pod” の数が少ないドメインに属する Node に高い点数をつけるわけですね。

そして、Score では以下によってスコアが計算されます。

'existing matching num' * 'topology weight' + 'maxSkew' - 1

existing matching numは 現在ドメインに存在している 「labelSelectorにマッチしている Pod」 の数です。

さて、気がつきましたか?この計算だと「labelSelectorにマッチしている Pod」の数」が多い Node に高い点数がついてしまいます。

Score では「Score では本来低い Score をつけなければいけない Node に高い Score を、高い Score をつけなければいけない Node に低い Score を」つけているというわけですね。

Normalized Score の概要

そして Normalized Score です。この Normalized Score の拡張点は全ての Node に対する Score 拡張点の結果を踏まえて、Score 全てを修正することができる拡張点です。

Pod Topology Spread では Score では「Score では本来低い Score をつけなければいけない Node に高い Score を、高い Score をつけなければいけない Node に低い Score を」つけていました。

それをこの Normalized Score で各 Node へのスコアsを以下の式で正規化します。これによって先程の逆転現象も解決されます。

framework.MaxNodeScore * (maxScore + minScore - s) / maxScore

https://github.com/kubernetes/kubernetes/blob/813671d1a0bb90a48c220916b4d2e9fc8e8c9b9a/pkg/scheduler/framework/plugins/podtopologyspread/scoring.go#L252

終わりに

ということで、今回は、実装をあまり交えずに、Pod Topology Spread Constraints の概要と仕組みを紹介しました。

何か質問や「これちがくね?」などあればTwitterまでお願いします。

このエントリーをはてなブックマークに追加