Pod Topology Spread, rolling update에서 skew가 박살나는 이유

요즘 멀티 AZ 운영하는 팀이라면 topologySpreadConstraints 한 번쯤은 다 써봤을 것이다. 근데 이거, 평상시엔 멀쩡한데 rolling update만 하면 한쪽 AZ로 쏠리는 경험 다들 있지 않나? 이게 사실 알고리즘적인 이유가 있다. 1.27부터 베타로 들어온 matchLabelKeys와 1.30에서 GA된 minDomains로 거의 해결된다. 우리 팀에서 한 달 전쯤 정리한 내용을 공유한다.
흔히 보는 증상
12노드, 3 AZ 클러스터에서 replicas 6짜리 Deployment를 돌린다고 치자. spec은 이렇게 들어가 있다.
topologySpreadConstraints:
- maxSkew: 1
topologyKey: topology.kubernetes.io/zone
whenUnsatisfiable: DoNotSchedule
labelSelector:
matchLabels:
app: payment-api
배포 직후엔 AZ당 2개씩, 깔끔하게 분산된다. 그런데 새 이미지로 rolling update를 한 번 돌리고 나면 AZ-a에 4개, AZ-b에 1개, AZ-c에 1개. 이런 식으로 묶여 있다.
왜 이렇게 될까. 답은 간단하다. labelSelector가 app: payment-api 하나만 보기 때문이다. rolling update 중에는 구버전 Pod와 신버전 Pod가 동시에 떠 있다. 둘 다 app: payment-api 라벨을 갖고 있으니 스케줄러는 "총합"으로 skew를 계산한다. 신버전 입장에서 보면 구버전이 이미 차지한 자리가 있어서, "이쪽 AZ는 비어있네" 같은 잘못된 판단을 한다. 그렇게 한쪽으로 쏠린다.
matchLabelKeys로 ReplicaSet 단위 분리
여기서 matchLabelKeys가 들어온다. ReplicaSet은 pod-template-hash라는 라벨을 자동으로 박아준다. 이걸 쓰면 신버전과 구버전을 다른 그룹으로 친다.
topologySpreadConstraints:
- maxSkew: 1
topologyKey: topology.kubernetes.io/zone
whenUnsatisfiable: DoNotSchedule
labelSelector:
matchLabels:
app: payment-api
matchLabelKeys:
- pod-template-hash
스케줄러가 신규 Pod를 만들 때 그 Pod의 pod-template-hash 값을 labelSelector에 동적으로 합쳐준다. 결과적으로 신버전 6개끼리만 분산 계산을 한다. 구버전이 어디 떠있든 상관 안 한다. rolling update 끝나면 자연스럽게 균등 분포로 수렴한다.
주의할 점 두 가지. 첫째, labelSelector가 비어있으면 matchLabelKeys는 못 쓴다. 둘째, 같은 키를 양쪽에 다 적으면 거부된다. 그리고 자주 바뀌는 라벨(예: version을 수동으로 갱신하는 식)은 추천하지 않는다. ReplicaSet의 pod-template-hash처럼 불변에 가까운 것만 써라.
minDomains, AZ 한 개 빠지면 일어나는 일
또 하나 자주 보는 케이스. 3 AZ 중 하나에 갑자기 capacity가 부족해서 새 Pod가 안 뜨는 상황. AZ-a, AZ-b만 남고 AZ-c가 사실상 0개일 때, 스케줄러는 "현재 도메인 2개"로 인지한다. 그러면 AZ-a 3개, AZ-b 3개도 maxSkew=1을 만족한다. 우리는 사실 3 AZ에 분산하려고 했던 건데 2 AZ에 몰아둔 채로 만족 처리되는 거다.
minDomains가 이 문제를 짚어준다.
topologySpreadConstraints:
- maxSkew: 1
topologyKey: topology.kubernetes.io/zone
whenUnsatisfiable: DoNotSchedule
minDomains: 3
labelSelector:
matchLabels:
app: payment-api
matchLabelKeys:
- pod-template-hash
minDomains: 3을 적으면, 실제 도메인이 3개 미만일 때 스케줄러는 "비어있는 도메인이 0개의 Pod를 가진 것처럼" 계산한다. 그러면 AZ-a, AZ-b만 살아있는 상황에서도 "AZ-c는 0이고 maxSkew=1을 어긴다"고 판단해서 Pending에 둔다. 멀쩡한 AZ가 다시 살아날 때까지 기다리는 거다.
이게 맞는 동작이냐는 팀 내에서도 의견이 갈렸다. AZ 장애 시점에는 일단 살아있는 곳에라도 띄우는 게 가용성에 낫지 않냐는 의견. 반대로 의도한 분산을 깨면서 쏟아붓는 게 더 위험하다는 의견. 결국 우리는 stateless 서비스는 whenUnsatisfiable: ScheduleAnyway로 풀어주고, stateful한 결제 시스템 같은 건 DoNotSchedule + minDomains: 3으로 엄격하게 묶었다. 정답은 없는 것 같다.
검증은 어떻게
배포 후에 진짜로 분산됐는지 확인하는 거, 의외로 까다롭다. 매번 노드 탑재 보고 카운트하기 귀찮아서 한 줄짜리 명령을 자주 쓴다.
kubectl get pod -l app=payment-api \
-o custom-columns=NAME:.metadata.name,ZONE:.spec.nodeName \
--no-headers \
| awk '{print $2}' \
| xargs -I{} kubectl get node {} -o jsonpath='{.metadata.labels.topology\.kubernetes\.io/zone}{"\n"}' \
| sort | uniq -c
이걸 rolling update 직후에 한 번씩 돌려보면 이상 분포 잡힌다. 더 본격적으로 보고 싶으면 kube-state-metrics의 kube_pod_info와 노드 라벨을 PromQL로 조인하는 게 낫다.
정리하면
matchLabelKeys: [pod-template-hash]— rolling update 중 skew 깨짐 방지. Deployment 쓰면 거의 무조건 추가하는 게 안전하다.minDomains— AZ 장애 시 의도한 분산을 보존. 단, 가용성 trade-off 고려.whenUnsatisfiable은 워크로드 성격에 맞게. stateless면 ScheduleAnyway가 무난하고, stateful이면 DoNotSchedule.
매번 새 클러스터 셋업할 때 까먹는 게 pod-template-hash 추가다. 우리 팀은 이걸 ArgoCD ApplicationSet 템플릿이나 Kyverno mutating policy로 강제하는 쪽으로 정리했다. 깜빡할 일이 없어졌다.
혹시 여러분 팀은 어떻게 처리하는지 궁금하다. StatefulSet에서는 또 다른 함정이 있던데 그건 다음에 따로 다뤄볼 생각이다.