PodAffinity と Topology Spread Constraints(以降、TSC)を組み合わせると、通常時は問題なく動いている Deployment のローリングアップデートが Pending になることがあります。
この記事では、その再現条件と原因を確認したうえで、matchLabelKeys: [pod-template-hash] を使って解決する方法を紹介します。
今回の PodAffinity は、TSC と競合する状況を意図的に作るために厳しく設定しています。常にこのような設定が必要という意味ではありません。
検証環境
3 台のノードで検証します。
$ kubectl get nodes
NAME STATUS ROLES AGE VERSION
ip-10-0-0-4 Ready control-plane 23m v1.36.5
ip-10-0-0-5 Ready <none> 16m v1.36.5
ip-10-0-0-6 Ready <none> 20m v1.36.5
以降の図では、これらをそれぞれ Node 1、Node 2、Node 3 と表記します。
deployment-a と deployment-b を配置する
まず、2 レプリカの deployment-b をデプロイします。kubernetes.io/hostname をトポロジードメインとして、同じ Deployment の Pod ができるだけ別ノードに配置されるよう TSC を設定します。
apiVersion: apps/v1
kind: Deployment
metadata:
name: deployment-b
spec:
replicas: 2
selector:
matchLabels:
app: deployment-b
template:
metadata:
labels:
app: deployment-b
spec:
topologySpreadConstraints:
- maxSkew: 1
topologyKey: kubernetes.io/hostname
whenUnsatisfiable: DoNotSchedule
labelSelector:
matchLabels:
app: deployment-b
containers:
- name: nginx
image: nginx:1.29
続いて、deployment-a をデプロイします。こちらにも同じ TSC を設定し、さらに deployment-b の Pod があるノードにしか配置できない PodAffinity を設定します。
apiVersion: apps/v1
kind: Deployment
metadata:
name: deployment-a
spec:
replicas: 2
selector:
matchLabels:
app: deployment-a
template:
metadata:
labels:
app: deployment-a
spec:
topologySpreadConstraints:
- maxSkew: 1
topologyKey: kubernetes.io/hostname
whenUnsatisfiable: DoNotSchedule
labelSelector:
matchLabels:
app: deployment-a
affinity:
podAffinity:
requiredDuringSchedulingIgnoredDuringExecution:
- labelSelector:
matchLabels:
app: deployment-b
topologyKey: kubernetes.io/hostname
containers:
- name: nginx
image: nginx:1.29
適用します。
$ kubectl apply -f deployment-b.yaml
deployment.apps/deployment-b created
$ kubectl apply -f deployment-a.yaml
deployment.apps/deployment-a created
deployment-b は Node 2 と Node 3 に 1 Pod ずつ配置されます。deployment-a は PodAffinity により、その Pod がある同じ 2 ノードに配置されます。
$ kubectl get pods -o wide
NAME READY STATUS RESTARTS AGE IP NODE
deployment-a-84d676876d-fb7h4 1/1 Running 0 20s 10.42.1.15 ip-10-0-0-5
deployment-a-84d676876d-rrt8r 1/1 Running 0 20s 10.42.2.14 ip-10-0-0-6
deployment-b-5f77d6ff9f-bfdw8 1/1 Running 0 31s 10.42.1.14 ip-10-0-0-5
deployment-b-5f77d6ff9f-hgq7s 1/1 Running 0 31s 10.42.2.13 ip-10-0-0-6
ローリングアップデートで新しい Pod が Pending になる
この状態で deployment-a をローリングアップデートします。ここでは挙動を確認するため、rollout restart を実行します。
$ kubectl rollout restart deployment/deployment-a
deployment.apps/deployment-a restarted
新しい ReplicaSet の Pod が 1 つ作成されますが、Pending のままになります。
$ kubectl get pods -o wide
NAME READY STATUS RESTARTS AGE IP NODE
deployment-a-68b887d5f6-d5qqv 0/1 Pending 0 12s <none> <none>
deployment-a-84d676876d-fb7h4 1/1 Running 0 11m 10.42.1.15 ip-10-0-0-5
deployment-a-84d676876d-rrt8r 1/1 Running 0 11m 10.42.2.14 ip-10-0-0-6
deployment-b-5f77d6ff9f-bfdw8 1/1 Running 0 12m 10.42.1.14 ip-10-0-0-5
deployment-b-5f77d6ff9f-hgq7s 1/1 Running 0 12m 10.42.2.13 ip-10-0-0-6
イベントを見ると、Node 1 は PodAffinity を満たさず、Node 2 と Node 3 は TSC を満たさないため、どのノードにも配置できないことが分かります。
Warning FailedScheduling default-scheduler 0/3 nodes are available: 1 node(s) didn't match pod affinity rules, 2 node(s) didn't match pod topology spread constraints.
なぜ TSC を満たさないのでしょうか。
deployment-a の TSC の labelSelector は app: deployment-a です。そのため、更新前の Pod と更新後の Pod を区別せず、どちらも偏りの計算対象になります。
新しい Pod を Node 2 または Node 3 に配置すると、deployment-a の分布は 0 / 2 / 1 になります。最小の 0 と最大の 2 の差は maxSkew: 1 を超えるため、whenUnsatisfiable: DoNotSchedule によってスケジュールできません。
一方、Node 1 には deployment-b の Pod がないため、必須の PodAffinity を満たせません。結果として、新しい Pod は Pending のままになり、ローリングアップデートが進みません。
matchLabelKeys で更新前後の Pod を分けて数える
このケースでは、deployment-a.yaml のマニフェストで、TSC の matchLabelKeys に pod-template-hash を指定します。
topologySpreadConstraints:
- maxSkew: 1
topologyKey: kubernetes.io/hostname
whenUnsatisfiable: DoNotSchedule
labelSelector:
matchLabels:
app: deployment-a
matchLabelKeys: # 追加
- pod-template-hash # 追加
pod-template-hash は Deployment が ReplicaSet ごとに Pod へ付与するラベルです。matchLabelKeys に指定すると、スケジューリング対象の Pod と同じ値を持つ Pod だけが TSC の偏り計算に含まれます。
つまり、更新中の新しい Pod は更新前の Pod と別のグループとして扱われます。最初の新しい Pod を Node 2 に配置する場合、新しい ReplicaSet だけで見た分布は 0 / 1 / 0 です。差は 1 なので maxSkew: 1 を満たし、配置できます。
この設定を含むマニフェストを適用します。
$ kubectl apply -f deployment-a.yaml
deployment.apps/deployment-a configured
その後、もう一度ローリングアップデートします。
$ kubectl rollout restart deployment/deployment-a
deployment.apps/deployment-a restarted
$ kubectl rollout status deployment/deployment-a
deployment "deployment-a" successfully rolled out
更新後の Pod は Node 2 と Node 3 に 1 Pod ずつ配置され、更新は完了します。
$ kubectl get pods -o wide
NAME READY STATUS RESTARTS AGE IP NODE
deployment-a-6dcdfdc889-kzbrx 1/1 Running 0 26s 10.42.1.18 ip-10-0-0-5
deployment-a-6dcdfdc889-xz62p 1/1 Running 0 22s 10.42.2.17 ip-10-0-0-6
deployment-b-5f77d6ff9f-bfdw8 1/1 Running 0 26m 10.42.1.14 ip-10-0-0-5
deployment-b-5f77d6ff9f-hgq7s 1/1 Running 0 26m 10.42.2.13 ip-10-0-0-6
以下の図の上段は更新途中、下段は更新完了後の状態です。更新途中では、旧 ReplicaSet と新 ReplicaSet がそれぞれの pod-template-hash ごとに TSC を満たします。そのため、新しい Pod を起動してから古い Pod を終了するローリングアップデートを進められます。
まとめ
PodAffinity と whenUnsatisfiable: DoNotSchedule の TSC を併用すると、更新前後の Pod をまとめて数えることで、ローリングアップデート時だけスケジューリング不能になる場合があります。
Deployment の更新前後で Pod を区別して TSC を評価したい場合は、matchLabelKeys: [pod-template-hash] を指定できます。これにより、更新中の新しい Pod も TSC を満たして配置でき、ローリング更新を進められます。


