誰がPodを作り、誰がノードを選ぶのか
一言でいうと
DeploymentはPodを作りません。ReplicaSetを作り、ReplicaSetがPodを作り、スケジューラーがノードを選び、kubeletが実行します。各層は、自分の1つ下の層だけを見ます。この連鎖を知っていれば、ロールアウトとスケジューリングの問題を、層ごとに切り分けて診断できます。
なぜ必要なのか
なぜDeploymentはPodを直接作らないのでしょうか。ローリングアップデートのためです。
イメージを変更すると、Deploymentは新しいReplicaSetを1つ追加で作ります。そして、新しいものを増やしながら、古いものを減らします。このとき、どれくらいの速さで増減させるかが、maxSurgeとmaxUnavailableです。
| パラメーター | 意味 | replicas=4のとき |
|---|---|---|
maxSurge: 2 |
目標値より、いくつ多く起動できるか | ローリング中は最大6個 |
maxUnavailable: 0 |
目標値より、いくつまで不足してよいか | 常に4個はReady |
両方とも0にすると、何も動かせません。maxUnavailable: 0は無停止を意味しますが、余裕のあるリソースが必要で、maxSurge: 0はリソースを節約する代わりに、一時的に容量が減ります。
元に戻せる理由も、ここにあります。古いReplicaSetは削除されず、レプリカ0のまま残っています。revisionHistoryLimitの個数だけです。rollout undoは、その古いReplicaSetを再び大きくするだけです。この値を0にすると、元に戻せません。
どう動くのか
スケジューラーに配置を指示するレバーは、大きく分けて7つです。
- nodeSelector: ラベルの完全一致です。最も単純で、最もよく使われます。
- nodeAffinity required: 式(In、NotIn、Exists、Gt、Lt)でノードを選びます。合わせられなければPendingになります。
- nodeAffinity preferred: weightを与えると、score段階で加点するだけです。合わせられなくても配置されます。
- podAntiAffinity: 同じtopologyKeyの中に、同じラベルのPodがあれば避けます。高可用性の配置の基本です。
- taint / toleration: ノードが掛ける拒否と、Podが差し出す通行証です。
NoScheduleは新しいPodだけを防ぎ、NoExecuteはすでに起動したPodも追い出します。 - PriorityClass: リソースが足りないとき、誰が先かを決めます。優先度の高いPodは、低いものをプリエンプションできます。
- topologySpreadConstraints: ゾーン/ノードごとのPod数の偏差を、
maxSkew以下に保ちます。
ここでよく間違える部分があります。topologySpreadの候補ドメインの集合は、PodのnodeAffinity/nodeSelectorを通過するノードたちです。一方、テイントは、デフォルトでは無視して数えます。そのため、cordonされたり、テイントされたりしたノードが、Pod0個のドメインとして残って偏差を大きくし、DoNotScheduleなら残りがPendingになることが起こります。
現場での姿
事例1: Pendingはエラーではないかもしれません。ホームラボをkubeadm 1.34 + Ciliumで再構築した直後、単一ノードの状態で、hubble-relayとhubble-uiがPendingのままになりました。
Warning FailedScheduling 0/1 nodes are available: 1 node(s) had untolerated taint(s).
コントロールプレーンノードには、node-role.kubernetes.io/control-plane:NoScheduleテイントが設定されており、hubbleの構成要素は、DaemonSetではなくDeploymentなので、これを許容しません。一方、CoreDNSは、デフォルトのトレラレーションを持っているため、問題なく起動しました。同じクラスター、同じノード、違う結果でした。違いは、トレラレーション1つでした。ワーカーが参加すると、すぐに解消しました。これはバグではなく、正常な動作です。
事例2: GPU 1個はGPU 1個ではありません。同じホームラボには、GPUが4枚付いています。RTX 3090 24GB、RTX 5090 32GB、RTX 4070 Laptop 8GBが2枚です。Podがnvidia.com/gpu: 1だけを要求すると、32GBが必要な学習が、8GBのノートPC用GPUに載ってしまうことがあります。スケジューラーにとって、拡張リソースは個数にすぎず、どちらも同じGPU 1個だからです。
GPU Feature Discoveryが付けてくれるgpu.memoryラベルは文字列なので、24GB以上のような比較セレクターが使えません。そこで、意味ベースのラベルを自分で載せました。
gpu.homelab/tier=xlarge gpu.homelab/vram=32g # 5090
gpu.homelab/tier=large gpu.homelab/vram=24g # 3090
gpu.homelab/tier=small gpu.homelab/vram=8g # 4070 Laptop x2
これで、ワークロードがnodeSelector: {gpu.homelab/tier: xlarge}で、自分の階級を選びます。スケジューリングとは、結局のところ、リソースの名前をどれだけ正確に付けたかの問題です。
付け加えると、配置対象の選択の例として、同じクラスターでGPU Operatorを導入したところ、NFD workerはノード5台すべてに、残りのGPU構成要素は、GPUノード4台にだけ起動しました。NFDが付けたラベルを、ほかのDaemonSetがnodeSelectorで見ているからです。
次のラボですること
最初のラボで、Deploymentを作成してスケールし、ローリングのパラメーターを調整したあと、実際にロールアウトして元に戻します。DaemonSetとStatefulSetも作成してみます。2つ目のラボでは、nodeSelectorからtopologySpreadConstraintsまで、7つのレバーを順番に使ってみます。最後の問題は、前にcordonしてテイントしたノードの状態を覚えていてはじめて解けます。