IT/Kubernets

matchLabelKeys 안 썼다가 롤링 업데이트 중 한 노드에 트래픽 70% 쏠린 사건

gfrog 2026. 5. 23. 21:28
반응형

지난주 화요일 새벽 2시. 슬랙 알림 한 통에 잠이 깼다. P99 레이턴시가 800ms를 찍었고, 한 가용영역의 한 노드만 CPU가 95%를 치고 있었다. 다른 두 노드는 30%. 분명히 우리는 topologySpreadConstraints 를 걸어뒀는데, 왜 한 쪽으로만 쏠렸을까.

결론부터 말하면, matchLabelKeys 를 안 써서 그렇다. 그게 무슨 소리인지 정리해보겠다.

우리 환경

EKS 1.32, 노드 12대(3 AZ × 4대), Deployment replicas 18. 매니페스트의 spread 설정은 이렇게 생겼었다.

topologySpreadConstraints:
  - maxSkew: 1
    topologyKey: topology.kubernetes.io/zone
    whenUnsatisfiable: DoNotSchedule
    labelSelector:
      matchLabels:
        app: payment-api

평소엔 잘 동작했다. AZ당 6개씩 떨어졌고, 노드 간에도 적당히 분산됐다. 문제는 배포가 트리거된 새벽 1시 50분. CD 파이프라인이 이미지 태그만 바뀐 새 ReplicaSet을 만들면서 시작됐다.

무슨 일이 일어났나

롤링 업데이트가 진행 중인 순간을 끊어서 보자. 새 RS는 pod을 6~7개 띄운 상태였고, 구 RS는 11~12개가 살아 있었다. 둘 다 app: payment-api 라벨이 동일하다. spread constraint의 labelSelector는 그 라벨 하나만 보고 있었다.

그 말은 스케줄러가 "현재 18개쯤 떠 있는 pod 전체"를 한 그룹으로 보고 skew를 계산했다는 뜻이다. 새 pod 입장에서는 "구 pod이 이미 AZ A에 4개, B에 4개, C에 4개 있으니, 나는 어디든 가도 maxSkew=1을 만족한다"는 결론이 나온다. 결과적으로 새 RS의 pod이 우연히 AZ A의 한 노드에 4개나 몰렸다.

여기까지는 그래도 문제가 안 됐다. 진짜 사고는 구 RS pod이 종료되는 시점에 터졌다. AZ A의 다른 노드에 있던 구 pod 두 개가 종료되면서, ALB target group이 새 pod 쪽으로 트래픽을 옮겼다. 그런데 새 pod이 한 노드에 뭉쳐 있으니, 그 노드 한 대가 트래픽의 70%를 받게 된 것이다.

새벽 1시 56분 ~ 2시 4분 사이의 8분이 가장 안 좋았다. 단일 노드 CPU throttling, conntrack 슬롯 부족 워닝, 같은 노드 위의 다른 워크로드까지 같이 느려졌다.

왜 spread는 무력했는가

스케줄러 입장에서 spread 계산이 잘못된 건 아니다. 설정이 시키는 대로 한 거다. "label app=payment-api 인 pod 전체"를 도메인별로 균등하게 spread 한다는 명령에 충실했을 뿐이다.

근데 우리가 진짜로 원했던 건 그게 아니었다. "같은 ReplicaSet에 속한 pod끼리만 spread를 따로 계산해라" 가 진짜 의도였다. 즉, 구 RS와 새 RS는 서로 다른 그룹으로 봐야 했다.

쿠버네티스 1.27에서 베타로 들어와서 1.32에서 GA가 된 matchLabelKeys 가 정확히 이 문제를 푼다. 라벨 키를 명시하면, pod의 그 라벨 값을 selector에 자동으로 AND 해준다.

topologySpreadConstraints:
  - maxSkew: 1
    topologyKey: topology.kubernetes.io/zone
    whenUnsatisfiable: DoNotSchedule
    labelSelector:
      matchLabels:
        app: payment-api
    matchLabelKeys:
      - pod-template-hash

pod-template-hash 는 Deployment가 만든 ReplicaSet의 pod에 자동으로 붙는 라벨이다. RS가 바뀌면 이 값도 바뀐다. 그래서 spread 계산이 RS 단위로 분리된다. 새 RS는 새 RS pod끼리만, 구 RS는 구 RS pod끼리만 본다. 롤링 중에도 새 pod들이 한 쪽으로 쏠리지 않는다.

그래서 어떻게 고쳤나

당장은 spread constraint에 matchLabelKeys: [pod-template-hash] 한 줄을 추가했다. 그리고 노드 레벨 spread도 추가했다.

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
    matchLabelKeys:
      - pod-template-hash

두 번째 constraint는 노드 단위. ScheduleAnyway 로 둔 건, hostname 단위에서 너무 엄격하게 묶으면 노드가 부족할 때 pod이 아예 안 뜨는 케이스가 생기기 때문이다. zone은 DoNotSchedule로 강하게, hostname은 best-effort로. 이게 우리 팀이 1주 정도 굴려보고 정한 조합이다.

재현 테스트를 짰다

이런 류의 사고는 한 번 더 당하기 싫어서, kind 클러스터에서 재현 시나리오를 만들었다. 3노드 클러스터, label만 zone처럼 흉내내고, Deployment 12개 replica를 띄운 뒤 rolling restart를 돌린다. matchLabelKeys 가 없는 매니페스트로 돌리면 새 RS의 pod이 평균 ~50% 확률로 한 노드에 4개 이상 몰린다. 추가하면 깔끔하게 4-4-4로 떨어진다.

테스트 코드는 사내 레포에 올려놨다. 이런 거 하나씩 쌓아두면 신입 온보딩할 때 "이거 한번 돌려보면 왜 필요한지 감 잡힌다" 식으로 쓸 수 있어서 좋다.

남은 의문 하나

matchLabelKeys 가 GA이긴 한데, 사실 우리는 Deployment만 쓴다. StatefulSet에서는 어떻게 동작하는지 아직 안 봤다. StatefulSet은 controller-revision-hash 라벨이 자동으로 붙는다고 들었는데, 그게 진짜 같은 의미로 동작하는지는 확인이 더 필요하다. 이건 다음 주에 한번 파볼 생각이다.

혹시 운영 중에 비슷한 케이스 겪으신 분 있으면 어떻게 해결했는지 댓글 남겨주세요. 특히 노드 풀이 자주 바뀌는(Karpenter 같은) 환경에서 spread constraint를 어떻게 안정적으로 거는지가 궁금하다.

반응형