# 파드를 흩뿌리는 두 가지 방법 - podAntiAffinity와 topologySpreadConstraints

레플리카가 한 노드에 몰려 있다가 그 노드가 빠지면서 서비스가 통째로 끊기는 사고는 흔합니다. 대응으로 podAntiAffinity를 넣는 것도 흔합니다. 그런데 이 설정은 "고르게 퍼뜨려라"가 아니라 "같은 도메인에 두 개를 두지 마라" 입니다. 의미가 다르고, 그 차이는 노드가 부족한 순간 Pending으로 나타납니다.

이 글에서는 두 방식이 실제로 표현하는 제약이 어떻게 다른지, 언제 어느 쪽이 맞는지, 그리고 스케줄링이 막혔을 때 원인을 어떻게 좁히는지 정리합니다.

# 1. 두 제약은 표현력이 다르다

podAntiAffinityrequiredDuringSchedulingIgnoredDuringExecution은 이렇게 동작합니다.

The scheduler can't schedule the Pod unless the rule is met.

topologyKey: kubernetes.io/hostname으로 자기 자신과의 안티어피니티를 걸면, 결과적으로 노드 하나당 파드 하나가 됩니다. 여기서 곧바로 함정이 생깁니다. 레플리카가 노드 수보다 많으면 남는 파드는 영원히 Pending입니다. 노드 3대에 레플리카 5개를 요구하면 2개는 뜨지 않습니다.

topologySpreadConstraints는 같은 목적을 정도의 문제로 표현합니다. maxSkew는 도메인 간 파드 수 차이의 상한이고, 위반 시 동작을 고를 수 있습니다.

  • DoNotSchedule (default): The scheduler will not schedule the Pod if it violates the topology spread constraint.
  • ScheduleAnyway: The scheduler will still schedule the Pod while prioritizing nodes that minimize the skew.

ScheduleAnyway"최대한 퍼뜨리되, 안 되면 몰아서라도 띄운다" 를 표현합니다. 안티어피니티에는 이 중간 상태가 없습니다.

표현하고 싶은 것 맞는 도구
절대 같은 노드에 두면 안 된다 podAntiAffinity + required
가능하면 퍼뜨리되 가용성이 우선 topologySpreadConstraints + ScheduleAnyway
균형이 깨지면 차라리 대기 topologySpreadConstraints + DoNotSchedule
존별로 고르게 topologySpreadConstraints (topologyKey: topology.kubernetes.io/zone)

# 2. maxSkew를 어떻게 읽는가

DoNotSchedule에서 maxSkew대상 도메인의 파드 수와 전역 최솟값의 차이 상한입니다. 문서 예시가 명확합니다. 세 존에 각각 2, 2, 1개가 있고 maxSkew: 1이면 전역 최솟값은 1이므로, 다음 파드는 파드가 1개인 존에만 들어갈 수 있습니다.

minDomains가 여기에 미묘한 영향을 줍니다.

When eligible domains < minDomains, global minimum is treated as 0

존이 3개여야 하는데 2개만 살아 있다면 전역 최솟값을 0으로 간주합니다. 결과적으로 살아 있는 존에 파드를 더 넣지 못하고 대기하게 됩니다. 이 동작은 의도적입니다. 존 하나가 빠진 상태에서 남은 존에 전부 몰아넣으면, 복구 후에도 불균형이 그대로 남기 때문입니다. 다만 가용성보다 균형을 우선한다는 선택이므로, 그 트레이드오프를 받아들일 수 있을 때만 켜야 합니다.

topologySpreadConstraints:
  - maxSkew: 1
    topologyKey: topology.kubernetes.io/zone
    whenUnsatisfiable: DoNotSchedule
    minDomains: 3               # 존 3개가 살아 있어야 정상 배치
    labelSelector:
      matchLabels:
        app: inference
  - maxSkew: 1
    topologyKey: kubernetes.io/hostname
    whenUnsatisfiable: ScheduleAnyway    # 노드 단위는 강제하지 않는다
    labelSelector:
      matchLabels:
        app: inference

두 제약을 겹쳐 쓰는 이 조합이 실무에서 자주 맞습니다. 존은 엄격하게, 노드는 느슨하게. 존 장애는 실제 위험이고, 노드 단위 완전 분산은 자원을 낭비하는 경우가 많기 때문입니다.

# 3. 계산 대상에서 노드가 빠지는 조건

스프레드 계산에 어떤 노드를 포함할지 정하는 두 필드가 있습니다. 둘 다 v1.26 베타, v1.33 GA입니다.

필드 Honor Ignore 기본
nodeAffinityPolicy nodeAffinity/nodeSelector에 맞는 노드만 계산 전체 노드 계산 Honor
nodeTaintsPolicy 테인트 없는 노드 + 톨러레이션 있는 노드만 계산 전체 노드 계산 Ignore

nodeTaintsPolicy의 기본값이 Ignore라는 점이 문제를 만듭니다. GPU 노드에 테인트를 걸어 두고 일반 워크로드를 노드 단위로 스프레드하면, 파드가 절대 갈 수 없는 GPU 노드까지 도메인에 포함해서 계산합니다. 갈 수 없는 도메인의 파드 수는 0이므로 전역 최솟값이 0이 되고, DoNotSchedule과 만나면 배치가 막힙니다.

테인트를 쓰는 클러스터라면 명시하는 편이 안전합니다.

    nodeTaintsPolicy: Honor

# 4. 어느 쪽을 고를 것인가

판단 기준을 세 가지로 좁힐 수 있습니다.

첫째, 위반했을 때 어떻게 되기를 원하는가. 파드가 안 뜨는 게 나은지, 몰려서라도 뜨는 게 나은지입니다. 같은 노드에 두 개가 뜨면 데이터가 깨지는 상태 저장 워크로드라면 안티어피니티가 맞습니다. 무상태 서비스라면 대개 몰려서라도 뜨는 편이 낫습니다.

둘째, 레플리카 수와 도메인 수의 관계. 레플리카가 노드 수를 넘길 가능성이 있다면 required 안티어피니티는 시한폭탄입니다. 오토스케일링으로 레플리카가 늘어나는 워크로드라면 특히 그렇습니다.

셋째, 균형을 유지하고 싶은 축이 여러 개인가. 존과 노드를 동시에 고려해야 한다면 스프레드 제약이 자연스럽습니다. 안티어피니티로 두 축을 표현하려면 규칙이 금세 복잡해집니다.

한 가지 공통점도 기억해야 합니다. 안티어피니티는 필드 이름(requiredDuringSchedulingIgnoredDuringExecution)에 IgnoredDuringExecution이 붙어 있고, 그 의미는 (노드 어피니티 설명에서) 명시돼 있습니다.

IgnoredDuringExecution means that if the node labels change after Kubernetes schedules the Pod, the Pod continues to run.

topologySpreadConstraints에는 이런 접미사가 없지만, 문서의 알려진 제약(Known limitations)에 같은 성질이 적혀 있습니다.

There's no guarantee that the constraints remain satisfied when Pods are removed.

배치 시점에만 평가하고, 이후에는 강제하지 않습니다. 노드가 빠졌다가 복구되면서 파드가 한쪽으로 몰려도 스케줄러가 알아서 재배치하지 않습니다. 시간이 지나면 균형이 무너지는 것이 정상 동작입니다. 재조정하려면 별도의 도구나 롤링 재시작이 필요합니다.

# 5. Pending일 때 원인 좁히기

# 스케줄러가 남긴 실패 사유가 가장 정확하다
kubectl describe pod <pending-pod> | sed -n '/Events/,$p'

메시지로 원인을 구분할 수 있습니다.

이벤트 메시지 원인
didn't match pod anti-affinity rules 안티어피니티 위반. 도메인 수 부족
didn't match pod topology spread constraints maxSkew 초과
node(s) had untolerated taint 테인트. 스프레드 계산 대상 문제일 수도 있음
Insufficient cpu/memory 스프레드와 무관한 자원 부족

현재 분포를 직접 세어 보면 판단이 빨라집니다.

kubectl get pods -l app=inference -o json \
  | jq -r '.items[] | .spec.nodeName' | sort | uniq -c | sort -rn

# 존 단위 분포
kubectl get pods -l app=inference -o json | jq -r '.items[].spec.nodeName' \
  | while read n; do kubectl get node "$n" -o jsonpath='{.metadata.labels.topology\.kubernetes\.io/zone}{"\n"}'; done \
  | sort | uniq -c

노드가 실제로 몇 개나 후보인지도 함께 봐야 합니다. 안티어피니티에서는 후보 노드 수가 곧 배치 가능한 레플리카 수의 상한이기 때문입니다.

# 6. 마무리

  • podAntiAffinity는 "고르게"가 아니라 "같은 도메인에 둘은 안 된다" 입니다. 레플리카가 도메인 수를 넘으면 Pending으로 남습니다.
  • topologySpreadConstraints는 불균형의 정도를 다룹니다. ScheduleAnyway는 안티어피니티에 없는 중간 지점을 제공합니다.
  • 존은 DoNotSchedule, 노드는 ScheduleAnyway로 겹쳐 쓰는 조합이 실무에서 자주 맞습니다.
  • 테인트를 쓰는 클러스터라면 nodeTaintsPolicy: Honor를 명시해야 합니다. 기본값 Ignore는 갈 수 없는 노드까지 계산에 넣습니다.
  • 두 기능 모두 배치 시점에만 평가합니다. 시간이 지나면 분포가 무너지는 것을 전제로 운영해야 합니다.

# 참고