topologySpreadConstraints, ScheduleAnyway를 너무 믿지 말자
오늘 알게 된 건데, 이거 모르는 분 꽤 많더라. topologySpreadConstraints에서 whenUnsatisfiable: ScheduleAnyway로 설정해두고 "AZ 골고루 퍼지겠지" 안심하다가 뒤통수 맞기 딱 좋다.
우리 팀에서도 이번 주에 비슷한 일이 있었다. 3-AZ EKS에 6개 replica를 배포하는데 특정 AZ에 4개가 몰려서 나머지 두 AZ는 각각 1개씩. 스펙에 maxSkew: 1, whenUnsatisfiable: ScheduleAnyway가 분명히 박혀 있는데도 그랬다.
왜 이런 일이 생기나
ScheduleAnyway는 이름 그대로 소프트 제약이다. 스케줄러가 최선을 다해서 스프레드를 지키려 하지만, 다른 스코어링(리소스 여유, 이미지 로컬리티, 노드 셀렉터 우선순위 등)이 강하게 작동하면 그냥 무시하고 뿌린다. 특히 maxSkew: 1처럼 빡빡한 값을 소프트 모드로 걸어두면 스케줄러가 "지킬 수 없네" 하고 포기하는 경우가 흔하다.
거기다 스케줄러는 스케줄링 시점의 클러스터 상태만 본다. 롤아웃 도중이나, 노드가 하나 빠졌다가 다시 붙는 상황에서 파드가 이미 배치되면 나중에 재조정하지 않는다. Descheduler를 따로 붙이지 않으면 계속 편중된 채로 남는다.
실제로 해본 것
우리는 이렇게 바꿨다.
topologySpreadConstraints:
- maxSkew: 1
topologyKey: topology.kubernetes.io/zone
whenUnsatisfiable: DoNotSchedule # 하드 제약으로
labelSelector:
matchLabels:
app: payment-api
matchLabelKeys:
- pod-template-hash
- maxSkew: 2
topologyKey: kubernetes.io/hostname
whenUnsatisfiable: ScheduleAnyway # 노드 레벨은 소프트로
labelSelector:
matchLabels:
app: payment-api
두 가지 포인트가 있다.
첫째, 존 레벨은 DoNotSchedule로 하드하게 잡았다. 대신 replica 개수가 존 개수의 배수가 되도록 관리하고 (3 AZ면 3, 6, 9…), PDB로 maxUnavailable: 1을 걸어서 롤아웃 중 한쪽만 다 죽는 케이스를 막았다.
둘째, 노드 레벨은 소프트로 남겼다. 존 안에서까지 하드하게 걸면 노드 하나 빠졌을 때 파드가 Pending에 걸린다. 여기는 maxSkew: 2로 조금 여유를 준다.
놓치기 쉬운 부분
labelSelector가 파드 자신의 라벨과 일치해야 한다. 이거 틀리면 스케줄러가 "이 도메인에 매칭되는 파드가 0개네" 하고 판단해서 아무 데나 뿌린다. 조용히 실패하는 케이스라 발견이 늦다. 배포 후 kubectl get pods -o wide | awk '{print $7}' | sort | uniq -c 정도로 실제 분포를 한번 찍어보는 걸 습관들이자.
그리고 matchLabelKeys: [pod-template-hash]도 잊지 말자. 이게 없으면 롤링 업데이트 중에 옛 ReplicaSet과 새 ReplicaSet의 파드가 같이 카운트되면서 스프레드가 어그러진다. 1.27부터 stable이니 그냥 넣자.
마무리
ScheduleAnyway가 나쁜 건 아니다. 노드 레벨처럼 유연성이 필요한 도메인에는 맞다. 다만 "가용성 보장"이 목적이라면 존 레벨은 하드로 잡는 게 안전하다. 소프트로 걸어두면 "될 것 같은" 안심이 되지만, 실제로는 안 될 때가 많다.
혹시 Descheduler로 재조정하시는 분 있으면 어떤 정책 쓰는지 궁금하다. 우리도 도입 검토 중인데 프로덕션에서 파드 evict하는 거라 아직 좀 무섭다.