1. 事象 — 定期メンテナンスでノードをdrainしたら、Podが戻ってこなかった

K3sクラスタで月次のOSパッチ適用のため、いつもどおりノードを1台ずつcordon/drainしていたときのことです。対象ノードのPodは他ノードへ無事退避し、ほとんどのワークロードは数秒で復旧しました。ところが、あるDeploymentだけ1つのPodがPending状態のまま動かなくなりました。
kubectl get pods を見る限りPodDisruptionBudgetは正しく設定されており、削除自体は許可されているはずでした。にもかかわらず、新しいPodはどのノードにもスケジュールされません。「PDBの設定ミスでは」という声が上がりましたが、結論から言うと原因は別の場所にありました。本記事では、Topology Spread ConstraintsとPodDisruptionBudgetがそれぞれ正しく動作しているにもかかわらずPodが戻ってこなくなる仕組みを、公式ドキュメントベースで検証します。こうした設定差分の切り分けを毎回手作業で追うのは地味に負荷が高く、KuboのCaptain UIのようにクラスタ状態を可視化できる環境のありがたみを実感する場面です。
2. 原因調査 — PodDisruptionBudgetは正常、問題はスケジューラ側だった

まず疑ったのはPDBです。Kubernetes公式ドキュメントによれば、Eviction APIはPDBを評価し、削除が許可されない場合は429 Too Many Requestsを返す仕様です(Kubernetes公式: API-initiated Eviction)。しかしkubectl get pdbではdisruptionsAllowedは1以上で、eviction自体は通っていました。Podは一度正しく退避し、新しいPodがスケジュールされないだけだったのです。
kubectl get events --sort-by='.lastTimestamp'を確認すると、スケジューラがトポロジースキューの超過を理由にPodを配置できていないイベントが記録されていました。犯人はPDBではなく、同じDeploymentに設定していたTopology Spread Constraintsでした。
3. 仕組み解説 — maxSkewとDoNotScheduleが「ハード制約」になる条件

Kubernetes公式ドキュメントによると、maxSkewは「対象トポロジ内の最小マッチングPod数との許容される最大差分」を定義するパラメータです。whenUnsatisfiableには2値あります。
DoNotSchedule(ハード制約): 条件を満たせない場合、PodはスケジュールされずPendingのままになるScheduleAnyway(ソフト制約): スキューを減らす配置を優先しつつ必ずスケジュールされる
今回の設定はtopologyKey: kubernetes.io/hostname、maxSkew: 1、whenUnsatisfiable: DoNotScheduleでした。AWS Prescriptive Guidanceも、DoNotScheduleはmaxSkewやminDomainsでハード要件を作る設定だと説明しています。問題は、このハード要件がドレイン中にどう崩れるかでした。
4. 核心 — drainが「ノード数不足」を自ら作り出していた

技術解説サイトStackHarborは、この種の事象を「Pending-Forever Trap(永遠にPendingのままになる罠)」と呼んでいます。maxSkew:1のhostname制約をDoNotScheduleで設定した状態でレプリカ数がノード数を上回ると、スケジューラにとって構造的に解決不可能な配置要求になり、Podは無期限にPendingのままになるという指摘です(StackHarbor: Kubernetes Topology Spread Constraints)。
今回のケースはまさにこれでした。平常時はノード3台にレプリカ3つで、各ノードに1つずつという完璧な分散が成立していました。ところがメンテナンスでノードを1台cordonした瞬間、スケジュール対象のノードは実質2台に減ります。レプリカ数(3)がノード数(2)を上回るためmaxSkew:1を満たす配置は存在しなくなり、退避してきたPodは行き場を失ってPendingのまま待ち続けていたのです。PodDisruptionBudgetは「削除してよいか」だけを判断し、「削除後どこに置くか」までは関知しません。ドレインという運用操作そのものが、Topology Spread Constraintsの前提条件を内側から崩していたわけです。
5. PodDisruptionBudgetだけでは守れない理由と、見落としがちな落とし穴

この一件から見えてくるのは、PodDisruptionBudgetとTopology Spread Constraintsが別の問題を解決する独立した機能だという点です。CAST AIのブログでも、スケジューラは既定でクラスタの全ゾーンを把握しておらず、ノードプールがゼロにスケールダウンするとトポロジドメイン自体が消失し制約を満たすノードがなくなるリスクが指摘されています(CAST AI: Topology Spread Constraints)。ノードのdrainも一時的に同じ状況を引き起こします。
さらにScheduleAnyway(ソフト制約)に緩めても油断はできません。スケジュール時にしか評価されないため、分散が崩れても稼働中のPodは自動では再配置されないからです。公式DeschedulerプロジェクトのRemovePodsViolatingTopologySpreadConstraint戦略は違反Podを事後的に退避させますが、これも「PDBを侵害する削除はしない」原則に従うため、3つの機能を互いの役割を理解したうえで組み合わせる必要があります(kubernetes-sigs/descheduler)。元のKEP(Kubernetes Enhancement Proposal)でも、Pod Anti-Affinityの「1ドメインにつき最大1Pod」という厳格ルールがローリングアップデート時に配置不可を招きやすいことが提案動機だったとされています(KEP-895: Pod Topology Spread)。Anti-Affinityの問題を避けた機能が、drainと組み合わさると同種の問題を再現する皮肉な構図です。
6. K3s本番でのメンテナンス手順への落とし込み

K3s公式ドキュメントでは、APIサーバーの短時間停止に敏感なワークロードがある場合、アップグレード時に手動でノードをdrain・cordonすることが推奨され、サーバーノードを1台ずつ先に、その後エージェントノードを行う順序厳守も明記されています(K3s公式: Manual Upgrade)。この「1台ずつ」という単位こそ、ノード数が一時的に減る瞬間を生み出す原因です。
Longhorn併用時はさらに注意が必要です。公式ドキュメントではkubectl drain --timeoutはレプリカ再構築に十分な値(または無制限)にし、複数ノードを同時アップグレードする場合は同時台数を各ボリュームのレプリカ数より少なくする必要があるとされています(Longhorn公式: Node Maintenance)。
実務上の対策は次の3点が有効です。
- メンテナンス直前だけ対象Deploymentの
whenUnsatisfiableをDoNotScheduleからScheduleAnywayへ一時的に緩和し、メンテナンス後に戻す maxSkewをレプリカ数とノード数の比率に応じて現実的な値に見直す- メンテナンス手順にTopology Spread ConstraintsとPodDisruptionBudgetの双方を加味したチェックリストを組み込む
こうした手順の標準化は運用チームの勘に頼りがちな領域です。オンプレミスやエアギャップ環境で再現性高く運用したい場合は、Kubo On-Premiseのような完全自社管理型のマネージドK3s基盤も選択肢になります。
7. まとめ
PodDisruptionBudgetとTopology Spread Constraintsは、どちらも可用性を守る機能ですが、守る対象が異なります。PDBは「削除してよいPod数」を管理し、Topology Spread Constraintsは「Podをどこに置けるか」を管理します。ノードのdrainのようにクラスタの母数そのものを一時的に変える操作は、この2つの前提を同時に揺さぶります。
今回のポイントは、PDBの設定ミスを疑う前に「drain中は一時的にノード数が減る」という事実とmaxSkew計算を結びつけて考えることでした。この視点があれば、kubectl get eventsから原因にたどり着くまでの時間を大きく短縮できます。
K3sベースの本番クラスタでこうした検証や運用手順の標準化にかかる工数を減らしたい場合は、KuboのAI-Driven DeploymentとCaptain UIによる可視化が助けになります。気になる方はお問い合わせから相談してみてください。