2
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

kube-scheduler が Pod のスケジュール先を決める仕組み

2
Last updated at Posted at 2026-10-17

はじめに

Pod の配置を考えるうえで、まず大切なのは、CPU・メモリ・ephemeral-storage の需要に見合った resources.requests を設定することです。スケジューラーは、この Request と配置条件をもとにノードを選びます。主な仕組みを押さえると、次のように整理できます。

  • リソースに応じた配置:NodeResourcesFit / NodeResourcesBalancedAllocation が、Request をもとに空き容量と CPU・メモリの配分バランスを評価します。実使用量や Pod 数を均等にするものではありません。
  • 分散・配置条件の追加:PodTopologySpread でノード・ゾーン間の分散条件を、NodeAffinity でノードのラベルに応じた配置条件を指定します。
  • 専用ノードやメンテナンス時の制御:Taint と Toleration で配置を制限・許可し、TaintToleration がその条件を評価します。専用ノードへ配置先を限定するには、NodeAffinity なども組み合わせます。
  • 見落としやすい評価:ImageLocality による、ノードが保持するイメージに応じた点数も配置先に影響します。Request や明示した配置条件だけでは、選択理由を説明できないことがあります。

普段の運用では、Request を適切に設定し、必要な配置条件を追加することで対応できるケースも多いと思います。一方、細かなチューニングや「なぜこのノードが選ばれたのか」の原因究明には、Filter/Score の動作や各プラグインの計算式・重みの理解が必要になります。この記事では、これらの仕組みと、よくある要件に応じた設定例を順番に整理します。

対象コンポーネントとバージョン

コンポーネント バージョン リポジトリ 本記事での役割
Kubernetes v1.37.1 kubernetes/kubernetes kube-scheduler のデフォルト Filter/Score プラグイン (pkg/scheduler/framework/plugins/)
kind v0.33.0 kubernetes-sigs/kind 検証環境。ノードイメージは kindest/node:v1.37.0

本記事は Kubernetes v1.37.1 のデフォルトの feature gate・プラグイン構成・重みを前提にします。後半ではカスタマイズ例も扱います。数式の代入例は説明用の仮定で、ログの検証記録は kind の v1.37.0 です。プロファイルや重みを変更した環境では、その設定を確認する必要があります。

TL;DR

  • Filter は配置可能なノードを絞り、Score は候補間の優先順位を決めます。候補が1台なら Score を省略し、複数なら最終スコアが最も高いノードを選びます。
  • 最終スコアは各プラグインの点数と重みの積の合計です。重みを掛ける前の点数は 0〜100 です。
  • リソースの評価には Request を使い、CPU・メモリの実使用量は使いません。
  • 配置理由を調べるには、その時点の状態が必要です。デバッグログや simulator を使えますが、現在の状態だけで過去の判断を確定することはできません。

第1章: スケジューラーの基本的な動作

Filter と Score の位置づけ、デフォルトのプラグイン構成を整理します。

1.1 配置先を決める流れ

通常は .spec.nodeName が未設定で、spec.schedulerName が担当プロファイルと一致する Pod を kube-scheduler が処理します。SchedulingGates などで待機する Pod もあるため、未配置の Pod が直ちに処理されるとは限りません。ここでは通常の単一 Pod のスケジューリングを扱います。

  1. Filter — 配置条件を満たさないノードを候補から除外します。候補がない場合は PostFilter のプリエンプション検討などに進み、ただちに配置できなければ再試行を待ちます。
  2. Score — 複数の候補ノードに点数を付け、最終スコアが最も高いノードを選びます。最終スコアが同じノード間にはランダムな選択が入ります。候補が1台なら Score は実行しません。
  3. Bind — Reserve・Permit・PreBind などを経て、選んだノードへの割り当てを API に反映します。途中で失敗した場合は、選択しただけでは配置完了になりません。

(参考: schedule_one.go#L587-L625、Kubernetes Scheduler)

判断を行う部品が スケジューリングプラグイン、処理を差し込む段階が 拡張点 です。ノードを決める scheduling cycle と、割り当てを反映する binding cycle に分かれます。Filter は失敗時に後続を打ち切ることがあり、PreFilter / PreScore が対象なしとして処理をスキップする場合もあります。

Scheduling framework extension points

scheduling cycle と binding cycle の主な拡張点。Filter は PreFilter の後、Score は PreScore の後にあります。

(参考: Scheduling Framework)

1.2 デフォルトの Score プラグインと重み

v1.37.1 のデフォルトでは、次の9プラグインが Score に登録されます。登録されていても、Pod の条件によって処理がスキップされたり、0 点になったりします。

スケジューリングプラグイン Filter Score 重み Pod 側の設定項目
NodeResourcesFit ✅ ✅ 1 spec.containers[].resources.requests
NodeResourcesBalancedAllocation ✅ 1 (同上)
ImageLocality ✅ 1 (設定不要。イメージとノードの保持状況から判定)
VolumeBinding ✅ ✅ 1 PVC / PV / StorageClass / CSIStorageCapacity
DynamicResources ✅ ✅ 2 spec.resourceClaims など DRA に関する設定
NodeAffinity ✅ ✅ 2 spec.affinity.nodeAffinity、spec.nodeSelector
InterPodAffinity ✅ ✅ 2 spec.affinity.podAffinity、spec.affinity.podAntiAffinity
PodTopologySpread ✅ ✅ 2 spec.topologySpreadConstraints
TaintToleration ✅ ✅ 3 spec.tolerations

(参考: default_plugins.go#L31-L101)

第2章: Filter

この章では、配置できないノードを候補から除外する条件を整理します。

v1.37.1 のデフォルトでは、Filter に14個のプラグインが登録されます。基本の12個に、デフォルト有効の feature gate による DynamicResources と NodeDeclaredFeatures が加わります。これらは AND 条件で動作し、1 つでも「配置できない」と判定すると、そのノードは Score の対象から除外されます。

プラグイン 概要 Pod/リソース側の主な関係項目
NodeName ノード名の制約がある場合に、一致しないノードを除外する。通常、nodeName を指定した Pod は scheduler を経由しない spec.nodeName
NodeUnschedulable .spec.unschedulable: true のノードを除外する(node.kubernetes.io/unschedulable Taint を許容していれば通る) -
NodePorts Pod が要求する hostPort が空いていないノードを除外する spec.containers[].ports[].hostPort
NodeVolumeLimits CSI ドライバごとに決まる、ノードあたりのアタッチ可能ボリューム数の上限を超えるノードを除外する spec.volumes
VolumeRestrictions 同じ PV を複数 Pod が条件に反してマウントしようとしている場合など、Volume の制約に違反するノードを除外する spec.volumes
VolumeBinding バインド済み PV の NodeAffinity や、未バインド PVC の PV 選択・動的プロビジョニング条件に合わないノードを除外する spec.volumes (PVC)
VolumeZone PV に Zone のラベルが付いている場合、別の Zone のノードを除外する spec.volumes (PV)
NodeResourcesFit Pod の Request を満たせないノードを除外する(Score にも関与。詳細は次章) spec.containers[].resources.requests
NodeAffinity nodeSelector・requiredDuringSchedulingIgnoredDuringExecution を満たさないノードを除外する(Score にも関与。詳細は次章) spec.nodeSelector、spec.affinity.nodeAffinity
TaintToleration 許容できない NoSchedule・NoExecute Taint を持つノードを除外する(Score にも関与。詳細は次章) spec.tolerations
InterPodAffinity requiredDuringSchedulingIgnoredDuringExecution の Pod (Anti-)Affinity を満たさないノードを除外する(Score にも関与。詳細は次章) spec.affinity.podAffinity/podAntiAffinity
PodTopologySpread whenUnsatisfiable: DoNotSchedule を満たさないノードを除外する(Score にも関与。詳細は次章) spec.topologySpreadConstraints
DynamicResources ResourceClaim などで要求するデバイスの割り当て条件を満たさないノードを除外する spec.resourceClaims など
NodeDeclaredFeatures Pod が必要とする宣言済みのノード機能を満たさないノードを除外する ノードの宣言機能と Pod の要求機能

(参考: node_name.go#L30-L31、node_unschedulable.go#L33-L35、node_ports.go#L33-L34、csi.go#L60-L61、volume_restrictions.go#L38-L39、volume_binding.go#L71-L74、volume_zone.go#L42-L43、fit.go#L92-L93、node_affinity.go#L39-L40、taint_toleration.go#L35-L36、interpodaffinity/plugin.go#L46-L47、podtopologyspread/plugin.go#L59-L60、default_plugins.go#L31-L101)

このうち7プラグインは Score にも登録されます。次章では、一般的な CPU・メモリ、ラベル、Pod の分布、イメージに関わる7つの Score プラグインを中心に説明します。VolumeBinding と DynamicResources の点数計算は対象外とし、代入例では寄与を0とします。

(参考: kube_features.go#L1579-L1585、kube_features.go#L1892-L1896)

これらのプラグインは MultiPoint という 1 つの登録リストに列挙されており、各プラグインがどの拡張点 (Filter・Score・PreFilter など) で動作するかは、プラグインが実装している Go のインターフェースによって決まります。1 つのプラグインが複数の拡張点に登録されることもあります。

第3章: Score

この章では、各プラグインの点数に重みを掛け、ノードの最終スコアを求める流れを説明します。

以降は、重みを掛ける前の値を「点数」、点数に重みを掛けた値を「重み付きスコア」、それらをノードごとに合計した値を「最終スコア」と呼びます。Score / NormalizeScore は拡張点・メソッドの名前として表記します。

Score の戻り値は、プラグインによっては Pod 数などの中間値です。NormalizeScore を実装するプラグインは候補全体を使って点数を変換し、実装しないプラグインは Score だけで点数を返します。重みを掛ける直前の点数が 0〜100 であることを framework が検査し、重みを掛けて合計します。

(参考: framework.go#L1530-L1579)

\text{ノードの最終スコア} = \sum_{\text{プラグイン}} \left(\text{点数} \times \text{重み}\right)

例えば重みが 3 のプラグインが 100 点を付けると重み付きスコアは 300、重みが 1 のプラグインが 100 点を付けると 100 が加算されます。同じ 100 点でも、重みが大きいプラグインの影響は大きくなります。

そのため、重みの小さいプラグインが満点を付けていても、重みの大きい別のプラグインの点数差によって結果が覆ることがあります。 重みは Score のみに影響し、Filter は「除外するかしないか」の判定なので重みは関係ありません。

それぞれのスケジューリングプラグインの役割と、Score の計算式は次のとおりです。計算結果はいずれも 0〜100 点の範囲に収まり、上記の重みが掛けられます。

以降の代入例は、ソースコードの計算式を当てはめる説明用のシナリオです。実際に起動したワークロードの測定値ではありません。対象は3台だけで、すべてが Filter を通過し、Score の候補になるものとします。既存 Pod に Affinity はなく、Volume・DRA の加点もありません。

Worker ノードが 3 台 (node1〜node3) あり、いずれも同じ Zone に属しています。Allocatable は cpu 4000m / memory 8000Mi。配置する Pod は Deployment の 2 つ目のレプリカで、Request は cpu 500m / memory 2000Mi、myapp:stage イメージ (ノードの報告値を 8192 MiB と仮定) を使います。

今回スケジュールする Pod を生む Deployment は、マニフェストにすると次のようなイメージです(replicas: 2 の 2 つ目の Pod が、これから配置先を決める対象です)。

apiVersion: apps/v1
kind: Deployment
metadata:
  name: myapp
spec:
  replicas: 2
  selector:
    matchLabels: {app: myapp}
  template:
    metadata:
      labels: {app: myapp}
    spec:
      topologySpreadConstraints:
      - maxSkew: 3
        topologyKey: kubernetes.io/hostname
        whenUnsatisfiable: ScheduleAnyway
        labelSelector:
          matchLabels: {app: myapp}
      containers:
      - name: myapp
        image: myapp:stage
        resources:
          requests:
            cpu: "500m"
            memory: "2000Mi"
ノード 既存 Pod の Request 合計 myapp:stage イメージ 同じ Deployment の Pod
node1 cpu 2000m / mem 4000Mi 保持している 1
node2 cpu 1000m / mem 2000Mi 保持していない 0
node3 cpu 3000m / mem 2000Mi 保持していない 0

3.1 NodeResourcesFit (重み 1)

Pod の Request と、ノードの Allocatable を比較します。Filter では Request を満たせないノードを除外し、Score ではデフォルトの LeastAllocated 戦略で「配置後に空きが多いノード」を優先します。

\mathrm{fitScore}_r =
\begin{cases}
\dfrac{(\mathrm{allocatable}_r - \mathrm{requested}_r) \times 100}{\mathrm{allocatable}_r} & (\mathrm{requested}_r \le \mathrm{allocatable}_r) \\[4pt]
0 & (\mathrm{requested}_r > \mathrm{allocatable}_r)
\end{cases}
\mathrm{score} = \dfrac{\displaystyle\sum_{r} \mathrm{fitScore}_r \times w_r}{\displaystyle\sum_{r} w_r}
  • $\mathrm{requested}_r$: そのノードの既存 Pod と今回の Pod を合わせた、点数計算上の Request 合計
  • $\mathrm{allocatable}_r$: ノードの Allocatable
  • $w_r$: リソースごとの重み。デフォルトは CPU・Memory ともに 1 (NodeResourcesFitArgs.ScoringStrategy.Resources で変更可能)。デフォルトでは CPU と Memory だけが計算対象で、ephemeral-storage は Filter では効きますが Score には数えません

ここでの使用量は実測値ではなく Request です。実際の Pod のリソース要求には init コンテナ、Pod overhead、Pod レベルの設定なども関わります。また、NodeResourcesFit の Score は CPU・メモリの Request が未指定のコンテナに CPU 100m・メモリ 200Mi を補って計算します。この補完は API の Request を変更するものでも、Filter の予約量でもありません。代入例ではすべて明示済みとします。

(参考: resource_allocation.go#L204-L255、pod_resources.go#L28-L31)

重みが CPU・Memory とも 1 なので、デフォルトでは 2 つの点数の単純平均と同じ結果になります。Request が Allocatable を超えるリソースは 0 点です。Allocatable が0のリソースは平均の分母から除外します。

(参考: least_allocated.go#L30-L61、デフォルトのリソースは defaults.go)

node1〜node3 に代入する

配置後の Request 合計(既存 + 今回の Pod)は次のとおりです。

cpu (配置後) mem (配置後)
node1 2500m 6000Mi
node2 1500m 4000Mi
node3 3500m 4000Mi
\begin{aligned}
\text{node1:}\quad
\mathrm{fitScore}_{\mathrm{cpu}} &= \frac{(4000-2500)\times100}{4000} = 37 \\
\mathrm{fitScore}_{\mathrm{mem}} &= \frac{(8000-6000)\times100}{8000} = 25 \\
\mathrm{score} &= \frac{37\times1 + 25\times1}{1+1} = \frac{62}{2} = 31
\end{aligned}
\begin{aligned}
\text{node2:}\quad
\mathrm{fitScore}_{\mathrm{cpu}} &= \frac{(4000-1500)\times100}{4000} = 62 \\
\mathrm{fitScore}_{\mathrm{mem}} &= \frac{(8000-4000)\times100}{8000} = 50 \\
\mathrm{score} &= \frac{62+50}{2} = 56
\end{aligned}
\begin{aligned}
\text{node3:}\quad
\mathrm{fitScore}_{\mathrm{cpu}} &= \frac{(4000-3500)\times100}{4000} = 12 \\
\mathrm{fitScore}_{\mathrm{mem}} &= \frac{(8000-4000)\times100}{8000} = 50 \\
\mathrm{score} &= \frac{12+50}{2} = 31
\end{aligned}

(小数点以下は Go の整数演算により切り捨てられます)

node1 = 31、node2 = 56、node3 = 31

3.2 NodeResourcesBalancedAllocation (重み 1)

CPU と Memory の Request / Allocatable の比率が釣り合う方向の配置を評価する、補助的なスコアリングプラグインです。片方だけが偏って埋まる状態を避けます。デフォルトの計算対象リソースについて Request がすべて0の Pod は、PreScore でスキップします。

まず、リソースの Request 比率のばらつき(標準偏差)から「バランスの点数」を計算する関数を定義します。

\mathrm{fraction}_r = \min\!\left(\dfrac{\mathrm{requested}_r}{\mathrm{allocatable}_r},\ 1\right)
\mathrm{std} = \dfrac{|\mathrm{fraction}_{\mathrm{cpu}} - \mathrm{fraction}_{\mathrm{mem}}|}{2}
\mathrm{balanced}(\mathrm{requested}) = (1 - \mathrm{std}) \times 100

(リソースが 3 種類以上ある場合は $\mathrm{std} = \sqrt{\frac{1}{n}\sum_i (\mathrm{fraction}_i - \overline{\mathrm{fraction}})^2}$ という一般の標準偏差の式になります)

このプラグインが返す点数は、balanced 関数を配置前と配置後で計算し、その改善度から求めます。

\mathrm{score} = 50 + \dfrac{50 + \mathrm{balanced}(\mathrm{requested}_{\text{配置後}}) - \mathrm{balanced}(\mathrm{requested}_{\text{配置前}})}{2}
  • $\mathrm{requested}_{\text{配置前}}$: 既存 Pod だけの Request 合計
  • $\mathrm{requested}_{\text{配置後}}$: 既存 Pod + 今回スケジュールする Pod の Request 合計

デフォルトの CPU・メモリの2リソースでは、この式により点数は 50〜100 点の範囲に収まります。バランスの変化が小さい場合は 75 前後、Pod を置くことでバランスが改善する場合は 100 へ、悪化する場合は 50 へ近づきます。

(参考: balanced_allocation.go#L204-L254)

node1〜node3 に代入する
\begin{aligned}
\text{node1 (配置前 cpu 2000m/4000m, mem 4000Mi/8000Mi):}\quad
\mathrm{std}_{\text{前}} &= \frac{|0.50-0.50|}{2} = 0 \Rightarrow \mathrm{balanced}_{\text{前}} = 100 \\
\text{(配置後 cpu 2500m/4000m, mem 6000Mi/8000Mi):}\quad
\mathrm{std}_{\text{後}} &= \frac{|0.625-0.75|}{2} = 0.0625 \Rightarrow \mathrm{balanced}_{\text{後}} = 93 \\
\mathrm{score} &= 50 + \frac{50 + 93 - 100}{2} = 50 + 21 = 71
\end{aligned}
\begin{aligned}
\text{node2 (配置前 1000m/4000m, 2000Mi/8000Mi):}\quad
\mathrm{balanced}_{\text{前}} &= 100\ (\text{差 0}) \\
\text{(配置後 1500m/4000m, 4000Mi/8000Mi):}\quad
\mathrm{std}_{\text{後}} &= \frac{|0.375-0.5|}{2}=0.0625 \Rightarrow \mathrm{balanced}_{\text{後}} = 93 \\
\mathrm{score} &= 50 + \frac{50+93-100}{2} = 71
\end{aligned}
\begin{aligned}
\text{node3 (配置前 3000m/4000m, 2000Mi/8000Mi):}\quad
\mathrm{std}_{\text{前}} &= \frac{|0.75-0.25|}{2}=0.25 \Rightarrow \mathrm{balanced}_{\text{前}} = 75 \\
\text{(配置後 3500m/4000m, 4000Mi/8000Mi):}\quad
\mathrm{std}_{\text{後}} &= \frac{|0.875-0.5|}{2}=0.1875 \Rightarrow \mathrm{balanced}_{\text{後}} = 81 \\
\mathrm{score} &= 50 + \frac{50+81-75}{2} = 50+28=78
\end{aligned}

node1 = 71、node2 = 71、node3 = 78(node3 は cpu と mem の使用率の差が縮まるため高くなります)

3.3 ImageLocality (重み 1)

Pod が要求するコンテナイメージを既に持っているノードを優先します。イメージの再ダウンロードを避けて Pod の起動を速くするための仕組みで、設定は不要です。

\mathrm{sumScore} = \sum_{\text{Podのイメージのうちノードが保持しているもの}} \mathrm{size}_{\text{image}} \times \dfrac{\mathrm{nodesWithImage}}{\mathrm{totalNodes}}
\mathrm{score} = 100 \times \dfrac{\mathrm{clip}(\mathrm{sumScore},\ \mathrm{minThreshold},\ \mathrm{maxThreshold}) - \mathrm{minThreshold}}{\mathrm{maxThreshold} - \mathrm{minThreshold}}
  • $\mathrm{size}_{\text{image}}$: そのイメージのサイズ
  • $\mathrm{nodesWithImage}$ / $\mathrm{totalNodes}$: そのイメージを持っているノード数 / 全ノード数。この比の分母は Filter 後の候補数ではなく、scheduler のスナップショットにある全ノード数です。この比を掛けているのは、イメージを持つ少数のノードに Pod が集まり続ける状態 (node heating) を避けるためです
  • $\mathrm{minThreshold} = 23\ \mathrm{MiB}$ (固定値)
  • $\mathrm{maxThreshold} = 1000\ \mathrm{MiB} \times \mathrm{imageCount}$ (Pod が要求するイメージ数。通常のコンテナ・init コンテナに加え、image volume も数えます)

保持イメージのサイズにノード間の普及率を掛けた合計が 23MiB 以下の場合は 0 点です。イメージを持っていないノードも 0 点になります。逆に大きい場合は上限に張り付きます。

(参考: image_locality.go#L30-L147)

node1〜node3 に代入する

myapp:stage (8192 MiB) を保持しているのは node1 のみ、Pod のイメージ数は 1 つとします。

\begin{aligned}
\text{node1:}\quad
\mathrm{sumScore} &= 8192\ \mathrm{MiB} \times \frac{1}{3} \approx 2730.7\ \mathrm{MiB} \\
&\quad (\mathrm{maxThreshold}=1000\ \mathrm{MiB}\ \text{を超えるため}\ 1000\ \mathrm{MiB}\ \text{に丸められる}) \\
\mathrm{score} &= 100 \times \frac{1000-23}{1000-23} = 100
\end{aligned}
\text{node2, node3:}\quad \mathrm{sumScore} = 0\ (\text{イメージなし}) \Rightarrow \mathrm{score} = 100\times\frac{23-23}{1000-23}=0

node1 = 100、node2 = 0、node3 = 0

3.4 NodeAffinity (重み 2)

ノードのラベルで配置先を制御します。nodeSelector と requiredDuringSchedulingIgnoredDuringExecution は Filter で条件を満たさないノードを除外します。preferredDuringSchedulingIgnoredDuringExecution は Score で効きます。

\mathrm{count} = \sum_{\text{一致した preferred term}} \mathrm{weight}
\mathrm{score} = 100 \times \dfrac{\mathrm{count}}{\max(\mathrm{count}_{\text{候補ノード}})}
  • $\mathrm{weight}$: 各 preferred term に指定した weight (1〜100)
  • 候補ノードの中で相対的に評価されるため、最も条件に合致したノードが 100 点になります。どのノードも条件に一致しない場合、あるいは preferred term を設定していない場合はすべてのノードが 0 点です

(参考: node_affinity.go#L269-L301、Assigning Pods to Nodes#node-affinity)

今回の共通シナリオ(node1〜node3)では nodeAffinity を設定していないため、すべて0点です。計算のイメージをつかむために、別の設定を仮定した代入例を見てみます。

参考: nodeAffinity を設定した場合の代入例

次の preferred NodeAffinity を設定し、各ノードに表のラベルが付いているとします。

affinity:
  nodeAffinity:
    preferredDuringSchedulingIgnoredDuringExecution:
    - weight: 80
      preference:
        matchExpressions:
        - {key: disktype, operator: In, values: [ssd]}
    - weight: 20
      preference:
        matchExpressions:
        - {key: gpu, operator: In, values: ["true"]}
ノード disktype=ssd gpu=true
node1 ✅
node2 ✅
node3 ✅ ✅
\begin{aligned}
\text{node1:}\quad \mathrm{count} &= 80 \\
\text{node2:}\quad \mathrm{count} &= 20 \\
\text{node3:}\quad \mathrm{count} &= 80+20=100\ (\max)
\end{aligned}
\begin{aligned}
\text{node1:}\quad \mathrm{score} &= 100 \times \frac{80}{100} = 80 \\
\text{node2:}\quad \mathrm{score} &= 100 \times \frac{20}{100} = 20 \\
\text{node3:}\quad \mathrm{score} &= 100 \times \frac{100}{100} = 100
\end{aligned}

node1 = 80、node2 = 20、node3 = 100(両方の preferred term を満たす node3 の点数が最も高くなります)

3.5 InterPodAffinity (重み 2)

既存 Pod のラベルを条件に、同じ topology domain へ寄せる (podAffinity) または避ける (podAntiAffinity) ことを制御します。topologyKey が hostname ならノード単位、zone なら Zone 単位です。配置する Pod の required は Filter、preferred は Score に関わります。既存 Pod 側の Affinity も考慮されるため、以下は入ってくる Pod の preferred term だけが寄与する単純化した式です。

\mathrm{count} = \sum_{\text{一致した Pod}} (\pm\,\mathrm{weight})
\mathrm{score} = 100 \times \dfrac{\mathrm{count} - \min(\mathrm{count}_{\text{候補ノード}})}{\max(\mathrm{count}_{\text{候補ノード}}) - \min(\mathrm{count}_{\text{候補ノード}})}
  • 指定した namespace と selector に一致する既存 Pod ごとに、preferred term の weight を加算します。podAffinity は +weight、podAntiAffinity は -weight として加算されるため、count は負の値になり得ます
  • Score の候補中の最小値〜最大値の幅で正規化されるため、最も条件に合致したノードが 100 点、最も合致しないノードが 0 点になります。全ノードで count が同じ場合は全ノードが 0 点です

実際の計算では、既存 Pod 側の preferred Affinity / Anti-affinity と、既存 Pod の required PodAffinity に対する hardPodAffinityWeight(デフォルト1)も寄与します。対象 Pod が Affinity を指定していなくても、必ず0 点になるとは限りません。

(参考: interpodaffinity/scoring.go#L80-L132)

Inter-pod affinity/anti-affinity は計算コストが高く、公式ドキュメントでも「数百ノードを超えるクラスタでは非推奨」とされています。大規模クラスタでの分散には、後述の PodTopologySpread を優先してください。

(参考: interpodaffinity/scoring.go#L242-L292)

共通シナリオでは、配置する Pod と既存 Pod のいずれにも Pod (Anti-)Affinity がないため、すべて0点です。計算のイメージをつかむために、別の設定を仮定した代入例を見てみます。

参考: podAffinity を設定した場合の代入例

次の preferred PodAffinity を設定し、各ノードに role=cache ラベルを持つ既存 Pod が表の数だけ配置されているとします(topologyKey: kubernetes.io/hostname)。

affinity:
  podAffinity:
    preferredDuringSchedulingIgnoredDuringExecution:
    - weight: 50
      podAffinityTerm:
        labelSelector:
          matchExpressions:
          - {key: role, operator: In, values: [cache]}
        topologyKey: kubernetes.io/hostname
ノード role=cache の既存 Pod 数
node1 2
node2 1
node3 0
\begin{aligned}
\text{node1:}\quad \mathrm{count} &= 2 \times 50 = 100 \\
\text{node2:}\quad \mathrm{count} &= 1 \times 50 = 50 \\
\text{node3:}\quad \mathrm{count} &= 0 \times 50 = 0
\end{aligned}
\begin{aligned}
\text{node1:}\quad \mathrm{score} &= 100 \times \frac{100-0}{100-0} = 100 \\
\text{node2:}\quad \mathrm{score} &= 100 \times \frac{50-0}{100-0} = 50 \\
\text{node3:}\quad \mathrm{score} &= 100 \times \frac{0-0}{100-0} = 0
\end{aligned}

node1 = 100、node2 = 50、node3 = 0(role=cache の Pod が多いノードほど点数が高くなります。podAntiAffinity ならこの逆で、該当する既存 Pod が多いノードほど点数が低くなります)

3.6 PodTopologySpread (重み 2)

同じ selector に一致する Pod を、ノードや Zone に分散させるためのプラグインです。whenUnsatisfiable: DoNotSchedule は Filter、ScheduleAnyway は Score です。

\mathrm{raw} = \sum_{\text{制約ごと}} \Big( \mathrm{cnt} \times \ln(\mathrm{size}+2) + (\mathrm{maxSkew}-1) \Big)
\mathrm{score} = 100 \times \dfrac{\max(\mathrm{raw}_{\text{候補ノード}}) + \min(\mathrm{raw}_{\text{候補ノード}}) - \mathrm{raw}}{\max(\mathrm{raw}_{\text{候補ノード}})}
  • $\mathrm{cnt}$: このノードが属する topology domain (例: 同じホスト、同じ Zone) にいる、制約の labelSelector に一致する Pod 数
  • $\mathrm{size}$: Score の候補ノードから求める、その topologyKey の値の種類数 (cardinality)。$\ln(\mathrm{size}+2)$ は、ノード数の少ない topology と多い topology を公平に扱うための内部的な正規化重みで、ユーザーが指定する値ではありません
  • $\mathrm{maxSkew}$: 制約に指定した maxSkew
  • raw は制約ごとの値を合計してから四捨五入します。raw が小さいノードほど点数が高くなり、候補すべての raw が0なら全候補が 100 点です。必要な topology ラベルがなく集計対象から外れるノードは、別途 0 点になります

このプラグインは spec.topologySpreadConstraints を設定していなくても動作します。自身で制約を指定せず、同じ namespace の Service の selector や、所属する ReplicaSet・StatefulSet・ReplicationController から Pod の集計用 selector を作れる場合、次の組み込みのデフォルト制約が自動的に適用されます (stable, v1.24〜)。

defaultConstraints:
  - maxSkew: 3
    topologyKey: "kubernetes.io/hostname"
    whenUnsatisfiable: ScheduleAnyway
  - maxSkew: 5
    topologyKey: "topology.kubernetes.io/zone"
    whenUnsatisfiable: ScheduleAnyway

そのため同じ Deployment のレプリカは、何も設定しなくてもノードと Zone にある程度分散されやすくなっています。ただし ScheduleAnyway (Score) なので保証はされません。確実に分散させたい場合は後述の要件別設定例を参照してください。

(参考: podtopologyspread/scoring.go#L196-L268、デフォルト制約は podtopologyspread/plugin.go#L46-L56、Pod Topology Spread Constraints)

node1〜node3 に代入する(明示した hostname 制約)

共通シナリオの YAML は hostname 制約を1つだけ明示しているため、組み込みの Zone 制約は加わりません。$\mathrm{size}=3,\ \mathrm{maxSkew}=3$ とし、node1 にだけ同じ Deployment の Pod が1つある条件で計算します。

\begin{aligned}
\text{node1:}\quad \mathrm{raw} &= 1 \times \ln(3+2) + (3-1) = 1.609 + 2 = 3.609 \to 4\\
\text{node2, node3:}\quad \mathrm{raw} &= 0 \times \ln(5) + 2 = 2
\end{aligned}
\begin{aligned}
\text{node1:}\quad \mathrm{score} &= 100 \times \frac{4+2-4}{4} = 50 \\
\text{node2, node3:}\quad \mathrm{score} &= 100 \times \frac{4+2-2}{4} = 100
\end{aligned}

node1 = 50、node2 = 100、node3 = 100(既に同じ Deployment の Pod がいる node1 は低くなります)

3.7 TaintToleration (重み 3)

ノードの Taint と Pod の Toleration を突き合わせます。Filter では NoSchedule と NoExecute の Taint を許容できないノードを除外します。Score では PreferNoSchedule の Taint を評価します。

\mathrm{count} = \text{許容できない PreferNoSchedule Taint の数}
\mathrm{score} = 100 - \left\lfloor 100 \times \dfrac{\mathrm{count}}{\max(\mathrm{count}_{\text{候補ノード}})} \right\rfloor

許容できない Taint が少ないノードほど点数が高くなります。どのノードにも該当する Taint がない場合(全ノードで count = 0)は、全ノードが 100 点です。デフォルトの Score プラグインでは最大の重み3です。

(参考: taint_toleration.go#L182-L219、Taints and Tolerations)

NoSchedule Taint の代表例として、ノードがディスク逼迫 (Disk Pressure) 状態になった際に NodeLifecycleController が自動的に付与する node.kubernetes.io/disk-pressure があります。全ノードにこの Taint があり、Pod が許容していなければ、他の条件に関係なく通常のスケジューリング候補はなくなります。Disk Pressure の発生条件や既存 Pod の退避 (Eviction) 条件は、別記事で詳しく整理しています。

参考: Kubernetes ノードのディスク領域と Disk Pressure Eviction について

今回の共通シナリオ(node1〜node3)では Taint を付与していないため、すべて100点です。計算のイメージをつかむために、別の設定を仮定した代入例を見てみます。

参考: Taint がある場合の代入例

各ノードに次の PreferNoSchedule Taint が付いており、Pod 側には Toleration を何も設定していないとします。

ノード Taint
node1 dedicated=batch:PreferNoSchedule
node2 (なし)
node3 dedicated=batch:PreferNoSchedule、special=true:PreferNoSchedule
\begin{aligned}
\text{node1:}\quad \mathrm{count} &= 1 \\
\text{node2:}\quad \mathrm{count} &= 0 \\
\text{node3:}\quad \mathrm{count} &= 2\ (\max)
\end{aligned}
\begin{aligned}
\text{node1:}\quad \mathrm{score} &= 100 - 100 \times \frac{1}{2} = 50 \\
\text{node2:}\quad \mathrm{score} &= 100 - 100 \times \frac{0}{2} = 100 \\
\text{node3:}\quad \mathrm{score} &= 100 - 100 \times \frac{2}{2} = 0
\end{aligned}

node1 = 50、node2 = 100、node3 = 0(許容できない Taint が多いノードほど点数が低くなります)

3.8 例: 3 台のノードからどのノードが選ばれるか

ここまでの計算結果をまとめ、各プラグインの点数からノードの最終スコアを求めます。

スケジューリングプラグインごとの点数

スケジューリングプラグイン node1 node2 node3
NodeResourcesFit 31 56 31
NodeResourcesBalancedAllocation 71 71 78
ImageLocality 100 0 0
NodeAffinity 0 0 0
InterPodAffinity 0 0 0
PodTopologySpread 50 100 100
TaintToleration 100 100 100

各プラグインの重み付きスコアと、ノードの最終スコア

スケジューリングプラグイン 重み node1 node2 node3
NodeResourcesFit 1 31 56 31
NodeResourcesBalancedAllocation 1 71 71 78
ImageLocality 1 100 0 0
NodeAffinity 2 0 0 0
InterPodAffinity 2 0 0 0
PodTopologySpread 2 100 200 200
TaintToleration 3 300 300 300
最終スコア 602 627 609

選ばれるのは node2 です。

注目すべきは node1 の結果です。ImageLocality で唯一 100 点を得ていますが、最終スコアでは node2 を 25 下回っています。理由は 2 つあります。

  1. ImageLocality の重みは 1 なので、100 点でも重み付きスコアは 100 にとどまる
  2. PodTopologySpread は重みが 2 で、node1 は 50 点 (重み付きスコアは 100)、node2 は 100 点 (重み付きスコアは 200)。この差だけで ImageLocality の優位が消える

つまりイメージを持っているノードが必ず選ばれるわけではありません。 重みの大きいスケジューリングプラグインの点数差によって、結果は容易に覆ります。イメージの事前取得で起動を速めたい場合は、候補ノードへの事前配布やレジストリの近接化も検討できます。NodeAffinity はラベルで配置先を制御しますが、イメージの保持や再取得の不要性までは保証しません。

比較: 明示的な制約を外し、組み込みデフォルトを使った場合

同じ条件でも、hostname と Zone のデフォルト制約を両方使うと結果が変わります。1つの Zone に既存の一致 Pod が1つあるため、Zone 制約は全候補に $\ln(1+2)+(5-1)$ を加えます。

\begin{aligned}
\mathrm{raw}_{\mathrm{node1}} &= \operatorname{round}(\ln 5+2+\ln 3+4)=9 \\
\mathrm{raw}_{\mathrm{node2,node3}} &= \operatorname{round}(2+\ln 3+4)=7 \\
\mathrm{score}_{\mathrm{node1}} &= \left\lfloor 100\times\frac{9+7-9}{9}\right\rfloor=77 \\
\mathrm{score}_{\mathrm{node2,node3}} &= 100
\end{aligned}

PodTopologySpread の差が縮まり、最終スコアは node1=656、node2=627、node3=609 となって node1 が選ばれます。全候補に同じ値を加えても、正規化後の点数差は保存されません。そのため、上の主計算では hostname 制約だけを YAML に明示しています。

第4章: スケジュール要件別の設定例

ここまでの計算式を踏まえて、よくある要件ごとに実際の設定例を見ていきます。

基本のサンプルは、preferred / ScheduleAnyway による優先条件です。その条件を満たせなくても、それだけを理由に配置不可にはなりませんが、Request 不足など別の条件で Pending になることはあります。折りたたみには required / DoNotSchedule による必須条件の例を載せています。

4.1 信頼性向上のために AZ を分散させたい

Service/ReplicaSet/StatefulSet に属する Pod は、何も設定しなくても組み込みのデフォルト制約 (maxSkew: 5, topology.kubernetes.io/zone, ScheduleAnyway) である程度 Zone に分散されます。Zone の分散方針を明示したい場合は、次のように設定できます。明示的な制約があると組み込みのデフォルト制約は追加されないため、hostname 方向の分散も必要なら、その制約を併記します。

apiVersion: apps/v1
kind: Deployment
metadata:
  name: myapp
spec:
  replicas: 6
  selector:
    matchLabels: {app: myapp}
  template:
    metadata:
      labels: {app: myapp}
    spec:
      topologySpreadConstraints:
      - maxSkew: 1
        topologyKey: topology.kubernetes.io/zone
        whenUnsatisfiable: ScheduleAnyway   # この分散条件だけでは配置を拒否しない
        labelSelector:
          matchLabels: {app: myapp}
      containers:
      - name: myapp
        image: myapp:1.0

この設定では Score で Zone の偏りを評価します。分散条件だけで配置を拒否しませんが、容量不足などがあれば Pending になります。すでに動いている Pod を自動で別の Zone へ移す機能でもありません。

参考: 確実に分散させたい場合(Pending のリスクあり)

「1 つの AZ 障害でワークロード全体が落ちる」ことをどうしても避けたい場合は、whenUnsatisfiable: DoNotSchedule にすると Filter レベルで強制できます。

      topologySpreadConstraints:
      - maxSkew: 1
        topologyKey: topology.kubernetes.io/zone
        whenUnsatisfiable: DoNotSchedule   # 守れないなら Pending にする
        labelSelector:
          matchLabels: {app: myapp}

DoNotSchedule は「守れなければ Pending」を意味します。AZ 障害でノード数が減った状態でスケールアウトすると、スケジュールできない Pod が出ることがあります。可用性と Pending 許容のトレードオフを踏まえて maxSkew を決めます。障害後に対象となる domain が1つだけになれば、その中で配置できる場合もあります。最低限必要な domain 数も制約したい場合は minDomains を合わせて検討します。

4.2 レイテンシを下げるために同じノードに配置したい

「頻繁に通信する 2 つのワークロードを同じノードに置いてネットワークホップを減らしたい」というケースです。まずは Score だけに効く preferred で試します。

# Web サーバー側: なるべく store (Redis) と同居する
      affinity:
        podAffinity:
          preferredDuringSchedulingIgnoredDuringExecution:
          - weight: 100
            podAffinityTerm:
              labelSelector:
                matchExpressions:
                - {key: app, operator: In, values: [store]}
              topologyKey: "kubernetes.io/hostname"

weight: 100 は1つの preferred term に指定できる最大値です。点数は他の term や既存 Pod の条件も含めて正規化されるため、その term だけで最終配置を固定するものではありません。以下の条件だけを考えると、他に候補を絞る要素がなければ store と同居しているノードが最も高く評価されます。ただし他のプラグイン(重みの大きい PodTopologySpread や TaintToleration など)の点数差で覆る可能性は残ります。

参考: 確実に同居させたい場合(Pending のリスクあり)

「必ず同居させたい」場合は requiredDuringSchedulingIgnoredDuringExecution を使います。次は公式ドキュメントにも載っている、Redis キャッシュと Web サーバーを組み合わせた例です。

# キャッシュ (Redis) 側: レプリカ同士は同居させない
apiVersion: apps/v1
kind: Deployment
metadata:
  name: redis-cache
spec:
  replicas: 3
  selector:
    matchLabels: {app: store}
  template:
    metadata:
      labels: {app: store}
    spec:
      affinity:
        podAntiAffinity:
          requiredDuringSchedulingIgnoredDuringExecution:
          - labelSelector:
              matchExpressions:
              - {key: app, operator: In, values: [store]}
            topologyKey: "kubernetes.io/hostname"
      containers:
      - name: redis-server
        image: redis:3.2-alpine
---
# Web サーバー側: store (Redis) と同居し、web-store 同士は同居させない
apiVersion: apps/v1
kind: Deployment
metadata:
  name: web-server
spec:
  replicas: 3
  selector:
    matchLabels: {app: web-store}
  template:
    metadata:
      labels: {app: web-store}
    spec:
      affinity:
        podAntiAffinity:
          requiredDuringSchedulingIgnoredDuringExecution:
          - labelSelector:
              matchExpressions:
              - {key: app, operator: In, values: [web-store]}
            topologyKey: "kubernetes.io/hostname"
        podAffinity:
          requiredDuringSchedulingIgnoredDuringExecution:
          - labelSelector:
              matchExpressions:
              - {key: app, operator: In, values: [store]}
            topologyKey: "kubernetes.io/hostname"
      containers:
      - name: web-app
        image: nginx:1.16-alpine

この 2 つの Deployment を作ると、web-store の Pod は必ず store (Redis) の Pod がいるノードに配置され、かつ同種の Pod 同士は別ノードに分散されるため、次のような配置になります。

node-1 node-2 node-3
webserver-1 webserver-2 webserver-3
cache-1 cache-2 cache-3

この設定が制御するのは配置です。同じノードのキャッシュだけに接続させるには、アプリケーションの接続先や Service のトラフィック設定も別途必要です。

required の Affinity/Anti-affinity は Filter に効くため確実ですが、条件を満たすノードが 1 台もないと Pod は Pending のままになります。 例えば Redis 側のレプリカ数がノード数より少ない、あるいは何らかの理由で Redis Pod が一時的に存在しない状態で Web サーバー側がスケジュールされようとすると Pending になり得ます。まずは preferred で試して、本当に確実性が必要な箇所だけ required に昇格させることをおすすめします。

4.3 逆に、別のノードに分散させたい

同じ種類の Pod をノードへ分散させたい場合、podAntiAffinity の required も使えますが、次の 2 つの制約があります。

  1. 大規模クラスタで重い。 公式ドキュメントでも数百ノードを超えるクラスタでは非推奨とされています
  2. デフォルト有効の LimitPodHardAntiAffinityTopology がある場合、required PodAntiAffinity の topologyKey は kubernetes.io/hostname に制限されます。 これは LimitPodHardAntiAffinityTopology という Admission Controller による制限で、この Admission Controller の設定を変えずに Zone 単位の required Anti-affinity を指定することはできません

そのため、ノード間の Pod 数の偏りを抑える分散であれば PodTopologySpread の方が軽量で、Zone 単位でも使えます。 まずは Score だけに効く ScheduleAnyway で試します。

      topologySpreadConstraints:
      - maxSkew: 1
        topologyKey: kubernetes.io/hostname
        whenUnsatisfiable: ScheduleAnyway   # この分散条件だけでは配置を拒否しない
        labelSelector:
          matchLabels: {app: myapp}

ScheduleAnyway の maxSkew は点数計算のパラメータであり、差を1以下に制限する上限ではありません。DoNotSchedule では、対象 domain の Pod 数と global minimum の差に対する配置条件になります。1ノードあたりの Pod 数の絶対上限でもありません。

参考: 確実に分散させたい場合(Pending のリスクあり)

whenUnsatisfiable: DoNotSchedule にすると、指定した maxSkew を守れないノードには配置されなくなります(守れるノードが 1 つもなければ Pending です)。

      topologySpreadConstraints:
      - maxSkew: 1
        topologyKey: kubernetes.io/hostname
        whenUnsatisfiable: DoNotSchedule
        labelSelector:
          matchLabels: {app: myapp}

podAntiAffinity の required で「同一ノードへの同居を絶対に避ける」ことも可能ですが、前述のとおり大規模クラスタでは非推奨、デフォルトの Admission Controller 設定では topologyKey が kubernetes.io/hostname に制限されます。

      affinity:
        podAntiAffinity:
          requiredDuringSchedulingIgnoredDuringExecution:
          - labelSelector:
              matchExpressions:
              - {key: app, operator: In, values: [myapp]}
            topologyKey: "kubernetes.io/hostname"

4.4 同じ Pod のグループを同じノード、もしくは同じ AZ に配置したい

グループ内の Pod を同じノードや AZ に寄せたいケースです。以下は集約だけの例で、異なるグループ間の分散までは設定していません。複数グループを分ける場合はグループ識別ラベルと別の Anti-affinity / TopologySpread などが必要です。自分自身のラベルに対して Affinity を張る (self-affinity) ことで実現できます。まずは preferred で試します。

      affinity:
        podAffinity:
          preferredDuringSchedulingIgnoredDuringExecution:
          - weight: 100
            podAffinityTerm:
              labelSelector:
                matchExpressions:
                - {key: app, operator: In, values: [worker-group]}
              topologyKey: topology.kubernetes.io/zone   # 同じ AZ に集める場合
              # topologyKey: kubernetes.io/hostname       # 同じノードに集める場合
参考: 確実に集めたい場合(Pending のリスクあり)

requiredDuringSchedulingIgnoredDuringExecution にすると確実にグループを集約できます。

      affinity:
        podAffinity:
          requiredDuringSchedulingIgnoredDuringExecution:
          - labelSelector:
              matchExpressions:
              - {key: app, operator: In, values: [worker-group]}
            topologyKey: topology.kubernetes.io/zone   # 同じ AZ に集める場合
            # topologyKey: kubernetes.io/hostname       # 同じノードに集める場合

self-affinity の最初の Pod にはデッドロックを避ける例外があります。既存の一致 Pod がなく、配置対象の Pod 自身が条件に一致し、候補ノードが必要な topology ラベルを持つ場合に配置を許可します。最初の Pod なら無条件でルールを無視する、という意味ではありません。ただし、2 個目以降は「1 個目がいるドメイン」以外には配置できなくなるため、そのドメインの空き容量が足りない場合は Pending になり得ます。

AZ 内には集めつつ、同じ AZ の中ではノードを散らしたい(1ノード故障の影響を抑えたい)場合は、self-affinity (required) と topologySpreadConstraints (ScheduleAnyway) を組み合わせます。こちらは同じ AZ への集約が required で、ノード単位の分散は優先条件の ScheduleAnyway です。

      affinity:
        podAffinity:
          requiredDuringSchedulingIgnoredDuringExecution:
          - labelSelector:
              matchExpressions:
              - {key: app, operator: In, values: [worker-group]}
            topologyKey: topology.kubernetes.io/zone
      topologySpreadConstraints:
      - maxSkew: 1
        topologyKey: kubernetes.io/hostname
        whenUnsatisfiable: ScheduleAnyway
        labelSelector:
          matchLabels: {app: worker-group}

上の明示的な selector は app ラベルだけを見るため、Deployment の新旧レプリカが同じ集計対象に入ります。同じロールアウト世代だけを対象にする場合は、Affinity の term や topologySpreadConstraint に matchLabelKeys: [pod-template-hash] を指定します。組み込みデフォルト制約が ReplicaSet の selector から作られる場合とは区別します。

(参考: Assigning Pods to Nodes、Pod Topology Spread Constraints)

第5章: スケジューラープロファイルの設定

ここまでは Pod 側の設定 (spec.affinity など) で調整する方法を見てきました。kube-scheduler 自体の設定 (KubeSchedulerConfiguration) を変更して、デフォルトプラグインの重みや挙動をプロファイルごとに変える方法もあります。KubeSchedulerConfiguration で変更できる内容は、大きく分けて次の 3 つです。

設定項目 変更できる内容 書き方
profiles[].plugins.<拡張点>.enabled[].weight 既存プラグインの重み (例: PodTopologySpread を 2→5 に) 拡張点ごとに同じプラグイン名を再度 enabled へ列挙し weight を指定する
profiles[].pluginConfig[].args プラグインごとの内部的な戦略・パラメータ (例: NodeResourcesFit の scoringStrategy) pluginConfig にプラグイン名と args を指定する
percentageOfNodesToScore Score の対象にするノードの割合 (レイテンシと精度のトレードオフ) トップレベルのフィールドとして指定する

以降、それぞれの書き方を順番に説明します。

5.1 デフォルトプラグインの重みを上書きする

デフォルトプラグインはすべて MultiPoint という仕組みでまとめて登録されています。特定の拡張点 (例えば score) で同じ名前のプラグインを改めて enabled に列挙し、weight を指定するだけで、その拡張点だけデフォルトの重みを上書きできます。disabled にしてから再度 enabled にする必要はありません。拡張点ごとの個別設定は、常に MultiPoint 側のデフォルト設定より優先されるためです。

例えば PodTopologySpread の重みを 2 から 5 に引き上げたい場合は、次のように書きます。

apiVersion: kubescheduler.config.k8s.io/v1
kind: KubeSchedulerConfiguration
profiles:
- schedulerName: default-scheduler
  plugins:
    score:
      enabled:
      - name: PodTopologySpread
        weight: 5

(参考: Scheduler Configuration#multipoint)

5.2 プラグインの挙動自体を変える (pluginConfig)

重みだけでなく、プラグインの内部的な戦略を切り替えられるものもあります。例えば NodeResourcesFit は、デフォルトでは LeastAllocated 戦略 (空きが多いノードを優先) ですが、pluginConfig で MostAllocated 戦略に切り替えると、逆に Request / Allocatable の比率が高いノードを優先します。実使用量ではありません。

\mathrm{fitScore}_r = \dfrac{\mathrm{requested}_r \times 100}{\mathrm{allocatable}_r}

LeastAllocated の式の分子を allocatable - requested から requested に置き換えただけの、ちょうど逆の式です(参考: most_allocated.go#L30-L65)。空いているノードを極力減らして台数を絞りたい(ビンパッキングしたい)場合などに使います。

apiVersion: kubescheduler.config.k8s.io/v1
kind: KubeSchedulerConfiguration
profiles:
- schedulerName: default-scheduler
  plugins:
    score:
      enabled:
      - name: PodTopologySpread
        weight: 5
  pluginConfig:
  - name: NodeResourcesFit
    args:
      scoringStrategy:
        type: MostAllocated
        resources:
        - name: cpu
          weight: 1
        - name: memory
          weight: 1

この例は、前節で説明した PodTopologySpread の重み変更と、この節の pluginConfig による戦略変更を、同じ 1 つのファイルに併記したものです。NodeResourcesFitArgs など各プラグインの args に指定できるフィールドの一覧は、KubeSchedulerConfiguration (v1) リファレンスで確認できます。

5.3 別の軸のクラスタレベル設定: percentageOfNodesToScore

重みや点数の計算方法とは別に、percentageOfNodesToScore もあります。これは見つける実行可能ノード数の目標を、全ノード数に対する割合で決めるもので、十分な数の実行可能なノードが見つかった時点で残りのノードのチェックを打ち切ります(チェックされなかったノードは Score の対象にもなりません)。各プラグインの計算式は同じでも、候補集合と正規化の基準が変わるため、各プラグインの点数や最終スコアにも影響する場合があります。大規模クラスタでのスケジューリングのレイテンシと精度のトレードオフを調整できます。同じ KubeSchedulerConfiguration ファイルに書けます。

apiVersion: kubescheduler.config.k8s.io/v1
kind: KubeSchedulerConfiguration
percentageOfNodesToScore: 50

未指定時の設定値は0で、実際の割合は max(5, 50 - ノード数 / 125)(整数除算)で求めます。実行可能ノード数の目標には100台の下限があり、総ノード数が100台未満なら全台が対象です。したがって、設定値50が「Filter を必ず全体の半分で止める」という意味ではありません。

(参考: schedule_one.go#L864-L892)

また、スケジューラーはノードを配列のように見立ててラウンドロビンで走査しており、複数 Zone がある場合は Zone をまたいで交互に走査します(例: Zone1 に 4 台、Zone2 に 2 台なら Node1 → Node5 → Node2 → Node6 → Node3 → Node4 の順)。さらに、前の Pod の走査を止めた位置から次の Pod の走査を再開するため、特定の Zone のノードだけが継続的にチェック対象から漏れる、という偏りを避けています。

(参考: Scheduler Performance Tuning)

5.4 設定ファイルの適用方法

作成した設定ファイルは kube-scheduler --config <ファイル> で読み込ませます。kube-scheduler をどう起動しているか (静的 Pod のマニフェスト、Deployment など) はクラスタの構築方法によって異なるので、実際にファイルを渡す手順は使っている構築ツールのドキュメントを確認してください。

重みの変更は、その scheduler プロファイルを使う Pod に影響します。複数プロファイルがある場合は、Pod の spec.schedulerName との対応も確認します。後述する kube-scheduler-simulator は Web UI 上で KubeSchedulerConfiguration を変更して結果を確認できるので、本番に適用する前に試しておくと安全です。

第6章: Pod の配置理由を調べる

計算式がわかっても、すでに起きた配置結果から逆算するのは簡単ではありません。実務でよく使う調査手段と、それぞれの限界を整理します。

6.1 方法 1: kube-scheduler のデバッグログ

kube-scheduler の --v=10 ログには、実行した Score プラグインごと・ノードごとの重み付きスコアと最終スコアが出ます。以下は control-plane 1台・worker 2台で取得した既存の検証記録(Kubernetes v1.37.0)です。再現用コマンドは Pod YAML を使う形に整理しています。3章の説明用シナリオとは別の条件です。

専用クラスタ名 sched-verify が既存環境と重複しないことを確認してから実行します。

cat <<'EOF' | kind create cluster --image kindest/node:v1.37.0 --config -
kind: Cluster
apiVersion: kind.x-k8s.io/v1alpha4
name: sched-verify
nodes:
- role: control-plane
  kubeadmConfigPatches:
  - |
    apiVersion: kubeadm.k8s.io/v1beta4
    kind: ClusterConfiguration
    scheduler:
      extraArgs:
      - name: v
        value: "10"
- role: worker
- role: worker
EOF

# worker2 にだけ先行 Pod を置き、Request 合計に差を作る
kubectl apply -f - <<'EOF'
apiVersion: v1
kind: Pod
metadata:
  name: filler
spec:
  nodeName: sched-verify-worker2
  containers:
  - name: filler
    image: registry.k8s.io/pause:3.10
    resources:
      requests:
        cpu: "2"
        memory: 4Gi
EOF
kubectl wait pod/filler --for=condition=Ready --timeout=120s

# 配置を観察する Pod は nodeName を指定しない
kubectl apply -f - <<'EOF'
apiVersion: v1
kind: Pod
metadata:
  name: test-pod
spec:
  containers:
  - name: test-pod
    image: registry.k8s.io/pause:3.10
    resources:
      requests:
        cpu: 500m
        memory: 512Mi
EOF
kubectl wait pod/test-pod --for=condition=PodScheduled --timeout=120s
kubectl -n kube-system logs kube-scheduler-sched-verify-control-plane --since=5m \
  | rg 'pod="default/test-pod"'

以下は既存ログの抜粋です。ノードの Allocatable、システム Pod の Request、イメージの保持状況などで値は変わります。

I0925 07:57:22.022676       1 batch.go:85] "OpportunisticBatch no node hint available for pod" pod="default/test-pod" profile="default-scheduler" cycleCount=13
I0925 07:57:22.022763       1 taint_toleration.go:132] "node had untolerated taints" logger="FilterWithNominatedPods.Filter.TaintToleration" pod="default/test-pod" node="sched-verify-control-plane" untoleratedTaint={"key":"node-role.kubernetes.io/control-plane","effect":"NoSchedule"}
I0925 07:57:22.023105       1 schedule_one.go:984] "Plugin scored node for pod" pod="default/test-pod" plugin="TaintToleration" node="sched-verify-worker" score=300
I0925 07:57:22.023133       1 schedule_one.go:984] "Plugin scored node for pod" pod="default/test-pod" plugin="NodeResourcesFit" node="sched-verify-worker" score=93
I0925 07:57:22.023141       1 schedule_one.go:984] "Plugin scored node for pod" pod="default/test-pod" plugin="VolumeBinding" node="sched-verify-worker" score=0
I0925 07:57:22.023147       1 schedule_one.go:984] "Plugin scored node for pod" pod="default/test-pod" plugin="DynamicResources" node="sched-verify-worker" score=0
I0925 07:57:22.023151       1 schedule_one.go:984] "Plugin scored node for pod" pod="default/test-pod" plugin="NodeResourcesBalancedAllocation" node="sched-verify-worker" score=74
I0925 07:57:22.023155       1 schedule_one.go:984] "Plugin scored node for pod" pod="default/test-pod" plugin="ImageLocality" node="sched-verify-worker" score=0
I0925 07:57:22.023159       1 schedule_one.go:984] "Plugin scored node for pod" pod="default/test-pod" plugin="TaintToleration" node="sched-verify-worker2" score=300
I0925 07:57:22.023162       1 schedule_one.go:984] "Plugin scored node for pod" pod="default/test-pod" plugin="NodeResourcesFit" node="sched-verify-worker2" score=67
I0925 07:57:22.023166       1 schedule_one.go:984] "Plugin scored node for pod" pod="default/test-pod" plugin="VolumeBinding" node="sched-verify-worker2" score=0
I0925 07:57:22.023169       1 schedule_one.go:984] "Plugin scored node for pod" pod="default/test-pod" plugin="DynamicResources" node="sched-verify-worker2" score=0
I0925 07:57:22.023173       1 schedule_one.go:984] "Plugin scored node for pod" pod="default/test-pod" plugin="NodeResourcesBalancedAllocation" node="sched-verify-worker2" score=74
I0925 07:57:22.023176       1 schedule_one.go:984] "Plugin scored node for pod" pod="default/test-pod" plugin="ImageLocality" node="sched-verify-worker2" score=0
I0925 07:57:22.023182       1 schedule_one.go:1052] "Calculated node's final score for pod" pod="default/test-pod" node="sched-verify-worker" score=467
I0925 07:57:22.023186       1 schedule_one.go:1052] "Calculated node's final score for pod" pod="default/test-pod" node="sched-verify-worker2" score=441
I0925 07:57:22.023201       1 batch.go:122] "OpportunisticBatch set cycle state" pod="default/test-pod" profile="default-scheduler" cycleCount=13 hintedNode="" chosenNode="sched-verify-worker"
I0925 07:57:22.023213       1 batch.go:172] "OpportunisticBatch found unusable state" pod="default/test-pod" profile="default-scheduler" cycleCount=13 reason="pod_not_batchable"
I0925 07:57:22.023381       1 default_binder.go:70] "Attempting to bind pod to node" logger="Bind.DefaultBinder" pod="default/test-pod" node="sched-verify-worker"
I0925 07:57:22.034438       1 schedule_one.go:483] "Successfully bound pod to node" pod="default/test-pod" node="sched-verify-worker" evaluatedNodes=3 feasibleNodes=2

(参考: schedule_one.go#L980-L1052)

この実際のログから読み取れることがいくつかあります。

  • evaluatedNodes=3 feasibleNodes=2: 3 台のうち 1 台(sched-verify-control-plane)は Filter で落ちています。理由は "node had untolerated taints" の行で、control-plane にデフォルトで付いている node-role.kubernetes.io/control-plane:NoSchedule Taint を許容できなかったためです
  • Score のログに出ているのは6プラグインです。VolumeBinding と DynamicResources も登録されており、この Pod では 0 点です。NodeAffinity は preferred 条件がなく、InterPodAffinity は評価対象となる Affinity がないためスキップされています。PodTopologySpread は、この Pod に明示的な制約がなく、Service / 所属コントローラーからデフォルトの集計用 selector も得られないためスキップされています。既存 Pod の Affinity や Service があれば、素の Pod でも同じ結果になるとは限りません。
  • worker2 の NodeResourcesFit だけ 93 → 67 と低くなっています。 事前に配置した filler Pod (cpu 2 / memory 4Gi) の分だけ空きが減っているためで、前述の NodeResourcesFit の式の通りです
  • ログに出る score はすでに重みを掛けた後の値です。 例えば TaintToleration は score=300 ですが、重み 3 を掛ける前の点数は 100 点 (満点) です。実際、worker の最終スコア 467 は 300 + 93 + 0 + 0 + 74 + 0 の合計と一致します
  • OpportunisticBatching のログも出ています。v1.35 以降デフォルト有効の Beta 機能で、同じ署名を持つ連続した Pod など、条件を満たす場合に直前の結果を利用して処理を短縮します。キャッシュの最大有効時間は500msですが、すべての類似 Pod でそのまま再利用するわけではありません。このログでは hint が使われず、通常の Score が実行されています。(参考: batch.go#L62-L110、Scheduler Performance Tuning)

ただし実務ではこの方法は使いにくいことが多いです。

  • --v=10 は非常に高い verbosity で、デフォルトでは出力されません。 常時有効にすると、稼働中のクラスタではログ量が膨大になります
  • 事後に有効化しても、すでにスケジュールされた Pod の判断は追えません。 「なぜ先週このノードに配置されたか」を後から調べる手段としては使えません

検証が終わったら専用クラスタを削除します。

kind delete cluster --name sched-verify

6.2 方法 2: クラスタの状態から推測する

ログがない場合、ノードの Allocatable・既存 Pod の Request・Taint・Affinity 設定などクラスタの状態(または当時に近い状態)から、この記事の計算式を当てはめて推測することになります。

ただしこれにも限界があります。

  • ノード数が数十〜100 台を超えると、全ノードの Filter/Score を手計算するのは現実的ではありません。 極端に Request が少ない、Taint が付いている、といった特徴的なノードを拾って「あたりをつける」程度にしかなりません
  • 正確に推測するには、この記事の計算式レベルの知識が必要で、属人化しやすい

6.3 補助手段: kube-scheduler-simulator

上記の限界を補う手段として、kube-scheduler-simulator が使えます。Kubernetes SIG Scheduling が開発している、kube-scheduler の判断を可視化するためのツールです(参考: Introducing kube-scheduler-simulator)。

Simulator の内部では「デバッグ可能なスケジューラー」が動作し、Pod をスケジュールするたびに、Filter/Score/最終スコアの結果を Pod の annotation として出力します。

metadata:
  annotations:
    kube-scheduler-simulator.sigs.k8s.io/filter-result: '{"node-a":{"NodeResourcesFit":"passed","TaintToleration":"passed"}, ...}'
    kube-scheduler-simulator.sigs.k8s.io/score-result: '{"node-a":{"NodeResourcesFit":"47","TaintToleration":"0"}, ...}'
    kube-scheduler-simulator.sigs.k8s.io/finalscore-result: '{"node-a":{"NodeResourcesFit":"47","TaintToleration":"300"}, ...}'
    kube-scheduler-simulator.sigs.k8s.io/selected-node: node-b

score-result は各プラグインの正規化前の値、finalscore-result は正規化後の点数に重みを掛けた値です。名前に final が含まれますが、ノードごとの合計ではなく、プラグイン別の重み付きスコアです。上の JSON は形式を示す抜粋で、... を含むためそのまま再現用データとしては使えません。Web UI でノードや Pod の YAML を編集して比較できます。ただし、simulator が組み込む scheduler のバージョン・プラグイン・設定が調査対象と同じか確認が必要です。

さらに、実クラスタの Node/Pod/PersistentVolume/PersistentVolumeClaim/StorageClass を Simulator へインポートする機能(beta)があるため、手計算で状態を再現する代わりに、実際に近い状態で Pod をスケジュールさせて結果を確認できます。

  1. 調査対象のクラスタの kubeconfig を指定して、Node・既存 Pod などを Simulator に一括インポートする(one-shot import)
  2. 調査対象と同じ Pod スペックを Simulator に投入する
  3. 各ノードの filter-result・score-result・finalscore-result annotation を確認する

インポートできるのは今のクラスタ状態のスナップショットです。過去の一時点を自動的に遡れるわけではありません。過去の事象を調べたい場合は、当時のノード状態や Pod の Request をメトリクス・Event から推測し、その状態を Simulator 上に手動で再現する必要があります(この再現作業自体は調査者が行います)。継続的に取り込む sync 機能や、変更を録って再生する record/replay 機能(いずれも beta)もありますが、いずれも事前に有効化しておく必要がある点はデバッグログと同じです

(参考: kube-scheduler-simulator README、import-cluster-resources.md、record-and-replay-cluster-changes.md)

6.4 学習教材としても有用

調査目的だけでなく、kube-scheduler-simulator はこの記事で説明した計算式が実際にどう働くかを手を動かして確認できる教材でもあります。リポジトリには Docker を使う make docker_up の手順があります。対応する環境と scheduler のバージョンを確認し、ノードや Pod の条件を1つずつ変えて比較すると、各プラグインの影響を確認しやすくなります。

6.5 期待通りにスケジュールさせるために

ここまで見てきたように、配置結果を完全に予測するのは簡単ではありませんが、ある程度期待通りにスケジュールさせる方法はあります。実際の消費量に見合った resources.requests を設定し、必須条件と優先条件を分けることが出発点になります。Request の設定だけで、実使用量の均等化や配置先を保証するわけではありません。

期待と異なる場合は、まず候補から外れた理由と、各プラグインの点数や重み付きスコアの差を確認するのが良いと思います。ImageLocality だけを原因と決めず、分散条件、Request、候補ノード数、適用プロファイルを合わせて確認します。

第7章: よく使うノードラベル

topologyKey や nodeSelector/nodeAffinity でよく使う、組み込みの (kubernetes.io/・node.kubernetes.io/・topology.kubernetes.io/ 配下の) ラベルです。付与するコンポーネントや有無は環境によって異なります。

ラベル 主な用途 値の例 備考
kubernetes.io/hostname ノード単位の分散・同居。topologyKey の定番 ip-172-20-114-199.ec2.internal kubelet がホスト名で自動設定。--hostname-override で変わる場合あり
topology.kubernetes.io/zone AZ 単位の分散・同居。topologyKey の定番 ap-northeast-1a クラウドプロバイダーを使っている場合のみ自動設定される
topology.kubernetes.io/region リージョン単位の分散・同居。topologyKey ap-northeast-1 同上
node.kubernetes.io/instance-type インスタンスタイプの指定。nodeSelector/nodeAffinity m5.xlarge GPU の割り当ては Extended Resource / DRA などで要求する。インスタンスタイプの指定だけではデバイス割り当てにならない
kubernetes.io/arch CPU アーキテクチャの指定。nodeSelector/nodeAffinity amd64、arm64 イメージが対応する CPU アーキテクチャへの配置に使う
kubernetes.io/os OS の指定。nodeSelector/nodeAffinity linux、windows Linux/Windows 混在クラスタで使用
node-role.kubernetes.io/control-plane control-plane ノードの判定。nodeSelector/nodeAffinity (値なし。ラベルの有無で判定) kubeadm などの構築ツールが付与する。ラベルだけでは配置を拒否せず、同名の NoSchedule Taint とは別の設定

failure-domain.beta.kubernetes.io/zone・failure-domain.beta.kubernetes.io/region・beta.kubernetes.io/arch・beta.kubernetes.io/os は非推奨です。古い記事やマニフェストで見かけますが、上表の topology.kubernetes.io/*・kubernetes.io/* を使ってください。

クラウドプロバイダーによっては、上記に加えて独自のラベル(サブゾーンや配置グループなど、より細かい topology を表すもの)が付いていることがあります。実際にどのラベルが付与されているかは kubectl get nodes --show-labels や kubectl describe node <ノード名> で確認してください。

おわりに

各プラグインの計算式だけでなく、候補集合と正規化の関係まで見ないと、配置結果を説明できないことが分かりました。特に、全ノードへ同じ値を加えても、正規化後の点数差が変わる点は印象に残りました。

過去の判断を調べるには、その時点の設定や状態も必要です。必須条件と優先条件を分け、ログや simulator で確認できる範囲を意識しながら使いたいと思います。

参考

参考リンク

本文で登場する順に並べています。

2
2
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
2
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?