IT/AWS

Karpenter WhenEmptyOrUnderutilized 켰다가 밤새운 이야기

gfrog 2026. 8. 4. 15:17

 

지난 화요일 밤이었다. 정확히는 수요일 새벽 2시 40분. 폰이 진동했고, 나는 다음날 반차를 쓸까 진지하게 고민 중이었다.

Karpenter를 도입한 지 4개월쯤 됐다. 처음에는 얌전하게 consolidationPolicy: WhenEmpty로 돌렸는데, 팀 내부에서 "이럴 거면 왜 Karpenter 썼냐"는 얘기가 슬슬 나왔다. 우리 EKS 클러스터는 노드 30대 규모, 대부분 m6i 계열이었고 야간에는 40% 정도 놀고 있었다. AWS 비용 리뷰에서 매번 얻어맞다 보니 결국 이번 분기에 WhenEmptyOrUnderutilized로 넘어가기로 했다. Karpenter 문서에 최근 이게 코스트 민감한 팀의 기본값이라고까지 적혀 있었으니 뭐 걱정할 게 있겠나 싶었다.

그날 새벽에 걸린 알람은 PaymentGateway였다. 결제 API 5xx 급증.

뭐가 일어났나

먼저 clarify. WhenEmptyOrUnderutilized는 노드가 완전히 비었을 때뿐만 아니라, 파드를 다른 노드로 옮겨서 더 작은 노드로 교체할 수 있으면 그렇게 하는 정책이다. Karpenter가 매 몇 분마다 클러스터 상태를 스캔하면서 "이 노드 지금 30%밖에 안 쓰네, 이거 없애고 남은 파드들 저기로 밀어넣자" 하고 판단한다.

우리 팀은 이걸 켜기 전에 나름 준비했다. graceful shutdown hook, terminationGracePeriodSeconds 60초, readiness probe 다시 정리. 여기까지는 좋았다. 문제는 PDB(PodDisruptionBudget)를 대충 설정한 거였다.

결제 서비스 PDB는 이렇게 되어 있었다.

apiVersion: policy/v1
kind: PodDisruptionBudget
metadata:
  name: payment-gateway-pdb
spec:
  maxUnavailable: 1
  selector:
    matchLabels:
      app: payment-gateway

replica가 6개니까 maxUnavailable: 1이면 충분하다고 생각했다. 실제로도 문서적으로는 그렇다.

근데 Karpenter가 새벽에 뭘 했냐면, 두 대의 노드를 거의 동시에 evict하기 시작한 거다. 각각의 노드에 결제 파드가 2개씩 있었고, Karpenter는 PDB를 존중해서 노드 A에서 파드 1개를 먼저 evict했다. 그런데 노드 A에서 파드가 종료되기도 전에 노드 B에서도 evict 요청이 들어갔다. Karpenter는 각 노드별로 PDB 체크를 하는데, 그 짧은 시간 동안 두 노드 모두 "지금 unavailable이 0이니까 evict 가능"이라고 판단한 거다.

결과: 결제 파드 6개 중 2개가 거의 동시에 SIGTERM을 받았고, 60초 뒤 강제 종료됐다. terminationGracePeriodSeconds가 60초였는데, 우리 결제 서비스는 커넥션 draining에 90초쯤 걸린다는 걸 그때 알았다.

인플라이트 요청 수천 건이 502를 뿌리며 죽었다.

첫 시도가 왜 실패했나

멘탈이 반쯤 나간 상태로 우선 WhenEmpty로 롤백했다. 그다음날 회고를 하면서 몇 가지가 겹쳤다는 걸 알았다.

첫째, PDB의 maxUnavailableeviction API 호출 시점에 체크된다. Karpenter가 병렬로 노드를 처리하면서 각각의 시점에서 "지금 unavailable 파드는 0" 이라고 봤고, 두 노드 모두 통과시켰다. K8s의 disruption controller는 원자적이지 않다.

둘째, terminationGracePeriodSeconds와 실제 draining 시간이 안 맞았다. 이건 순전히 우리 잘못이다. 결제 서비스 코드에는 이미 shutdown hook이 있었는데, 커넥션이 90초 이상 살아 있는 케이스를 아무도 측정 안 했다.

셋째, Karpenter의 disruption.budgets를 안 걸어놨다. 이게 뭐냐면, Karpenter 자체적으로 "한 번에 몇 대까지 disrupt할 수 있는지" 제한하는 설정이다. 문서에는 있는데 우리는 안 걸었다.

두 번째 시도

일주일 뒤에 다시 시도했다. 이번엔 조금 더 조심스럽게.

먼저 Karpenter의 NodePool에 disruption budget을 걸었다.

apiVersion: karpenter.sh/v1
kind: NodePool
metadata:
  name: default
spec:
  disruption:
    consolidationPolicy: WhenEmptyOrUnderutilized
    consolidateAfter: 5m
    budgets:
    - nodes: "10%"
    - nodes: "0"
      schedule: "0 9 * * mon-fri"
      duration: 10h
      reasons:
      - Underutilized

두 개의 budget이 겹쳐서 걸린다. 기본적으로 한 번에 10% 노드만 disrupt할 수 있고, 평일 업무시간(9시부터 10시간)에는 Underutilized 사유로는 아예 disrupt를 안 한다. Empty 노드는 여전히 정리된다.

이거 걸어두니까 대낮에 결제 트래픽이 몰릴 때 갑자기 노드가 사라지는 일은 없어졌다. 야간 배치 시간대에만 알아서 청소한다.

두 번째로 결제 서비스의 PDB를 좀 더 빡세게 바꿨다.

apiVersion: policy/v1
kind: PodDisruptionBudget
metadata:
  name: payment-gateway-pdb
spec:
  minAvailable: 80%
  selector:
    matchLabels:
      app: payment-gateway

maxUnavailable: 1에서 minAvailable: 80%로 바꿨다. replica가 6이면 항상 5개는 살아 있어야 한다. 사실상 한 번에 1개까지 evict 가능한 건 똑같은데, 표현 방식을 명확히 했다. 그리고 replica를 8로 늘려서 여유를 뒀다. 이 부분은 팀 내부에서 "그러면 비용 최적화 왜 해?" 소리 좀 들었다. 근데 결제는 그럴 만한 서비스다.

세 번째로 terminationGracePeriodSeconds를 120초로 늘렸다. draining이 90초 걸린다는 걸 실측했으니까.

지금은 어떤가

두 번째 시도 이후로 3주 정도 지났다. Karpenter가 야간에 알아서 노드를 정리해주고 있고, 낮에는 얌전하다. AWS 비용은 이전 대비 약 22% 줄었다. 40%는 원래 마케팅 문구고, 우리 워크로드 패턴에서는 22%가 현실적인 숫자였다.

한 가지 아직 애매한 건 스팟 인스턴스 섞어 쓸 때다. Karpenter가 스팟 회수를 만나면 그건 또 별개의 disruption path인데, 이건 아직 우리 팀이 완전히 검증 못 했다. 스팟은 지금 개발/스테이징에만 붙여놨고, 프로덕션 결제 쪽은 여전히 온디맨드다.

교훈 세 줄 요약이 아닌 것

이번 일로 배운 건 사실 새로운 게 아니다. Karpenter 문서에도 다 적혀 있다. 그런데 "PDB 잘 걸어라"는 말과 "PDB가 병렬 eviction을 원자적으로 막지는 않는다"는 실제 동작은 온도차가 있다. 문서를 백 번 읽어도 새벽 2시 40분에 폰이 울리기 전까지는 몸에 안 들어온다.

혹시 이번 분기에 WhenEmptyOrUnderutilized 켤 계획 있는 분들, disruption budget 먼저 걸어두시길. 하루아침에 40% 아끼는 것보다 밤에 자는 게 낫다.