IT/AWS

Karpenter v1.5로 올렸더니 새벽에 깨버린 이야기

gfrog 2026. 6. 13. 00:27
SMALL

지난주 화요일이었다. 새벽 3시 47분에 PagerDuty가 울렸다. P95 레이턴시 알람. 평소 같으면 일시적 스파이크겠거니 했을 텐데, 이번엔 5분 넘게 안 풀렸다.

원인은 Karpenter였다. 정확히는, 일주일 전에 우리 팀이 v1.4에서 v1.5로 올린 게 발단이었다. Release note에 "emptiness-first consolidation"이라는 게 있길래 "오, 빈 노드 더 잘 정리해주겠네" 정도로만 읽고 PR을 머지했었다. 그게 화근이었다.

무슨 일이 벌어졌나

새벽 트래픽이 줄면서 Karpenter가 underutilized 판정을 내린 노드들을 consolidation하기 시작했다. 여기까진 정상이다. 문제는 우리 클러스터에 있던 60대 노드 중 30대 가까이가 비슷한 시점에 동시에 drain 대상이 되었다는 것.

$ kubectl get nodeclaim -o wide | grep -c Terminating
27

27대가 동시에 빠지는 동안 새 노드가 그만큼 빠르게 안 떴다. EC2 RunInstances API에 throttling이 살짝 걸렸고, NodeClaim provisioning이 평소 35초쯤 걸리던 게 1분 넘게 걸리기 시작했다. 그 사이에 pod들은 다른 노드로 옮겨가야 하는데, 옮길 노드가 부족하니까 pending이 쌓였다. 일부 deployment는 readiness probe 통과까지 시간이 걸려서 P95가 튄 것.

왜 v1.5에서 이게 더 심해졌나

v1.5 release note를 다시 꼼꼼히 읽어봤다. emptiness-first가 단순히 "빈 노드부터 정리한다"가 아니었다. 정확히는 consolidation 평가 주기에서 emptiness 후보를 먼저 spot으로 처리하고, underutilized 후보 평가를 그 다음 cycle에서 batch로 한다는 의미였다. 평가는 더 빠르지만, 일단 한 cycle에 진입한 노드들은 거의 동시에 disruption 큐로 들어간다.

문제는 우리가 budgets 설정을 안 해뒀다는 것이었다. NodePool의 disruption budget이 비어있으면 기본값은 사실상 무제한이다 (10% 같은 안전한 기본값을 기대했는데 아니었다).

# 우리의 잘못된 설정
apiVersion: karpenter.sh/v1
kind: NodePool
metadata:
  name: default
spec:
  disruption:
    consolidationPolicy: WhenEmptyOrUnderutilized
    consolidateAfter: 30s
    # budgets 가 비어있음!

consolidateAfter: 30s도 v1.4 시절 우리가 박아둔 값이다. 그땐 consolidation이 좀 더 보수적이어서 문제가 없었다. v1.5에서는 같은 30s가 훨씬 더 공격적으로 작동한다.

새벽 4시의 응급조치

일단 출혈부터 막아야 했다. 잠결에 부랴부랴 NodePool에 budget을 박았다.

spec:
  disruption:
    consolidationPolicy: WhenEmptyOrUnderutilized
    consolidateAfter: 5m
    budgets:
      - nodes: "10%"
      - nodes: "0"
        schedule: "0 3-6 * * *"
        duration: 3h
        reasons: ["Underutilized"]

두 줄 의미는 이렇다. 첫 번째 budget은 평상시 "한 번에 최대 10% 노드만 disruption 허용". 두 번째는 "새벽 3시부터 6시까지는 underutilized 사유로는 아예 disruption 금지". reasons 필드를 지정하면 그 사유에 한해서만 budget이 적용되는데, 우리는 새벽 시간엔 비용 절감보다 안정성이 우선이라서 underutilized만 막았다. drift나 empty는 그대로 둔다.

apply하자마자 churn이 멈췄다. 새벽 4시 12분. 한숨 돌렸다.

다음날 회고에서 정리한 것

회고에서 팀이 합의한 게 몇 가지 있다.

첫째, NodePool에 budget이 비어있는 건 사실상 production-ready가 아니다. 우리는 그동안 운이 좋았던 거다. v1.4 시절에도 사실 위험은 있었지만 consolidation 알고리즘이 보수적이어서 안 터졌을 뿐.

둘째, Karpenter major/minor 업그레이드 전에는 staging 클러스터에서 새벽 트래픽 패턴을 24시간 이상 돌려봐야 한다. v1.4 → v1.5는 우리한테는 사실상 breaking change였다. 문서엔 그렇게 안 적혀있어도.

셋째, consolidateAfter는 짧을수록 좋은 게 아니다. 30초로 박아두면 일시적 트래픽 드롭에도 반응한다. 우리 케이스에선 5분이 적절했다. 트래픽이 5분 이상 죽어있으면 그땐 정말로 consolidation이 필요한 상황이다.

모니터링 측면에서 추가한 것

이번 일을 계기로 Prometheus alert를 두 개 추가했다.

- alert: KarpenterHighDisruptionRate
  expr: |
    sum(rate(karpenter_nodes_terminated_total[5m])) > 0.5
  for: 3m
  annotations:
    summary: "Karpenter가 5분간 분당 0.5대 이상 노드를 종료시키고 있음"

- alert: KarpenterPendingPodsSpike
  expr: |
    sum(karpenter_pods_state{phase="Pending"}) > 20
  for: 5m

분당 0.5대면 시간당 30대다. 우리 클러스터 규모에선 이게 churn 시그널이다. 더 큰 클러스터는 임계값을 다시 잡아야 한다.

마무리

솔직히 이번 incident는 우리 잘못이 90%다. budget을 안 박아둔 것, 업그레이드 전 staging에서 24시간 돌려보지 않은 것, release note를 대충 읽은 것. Karpenter 잘못은 아니다.

그래도 한 가지는 말하고 싶다. consolidation 같은 자동화 기능의 default 동작이 minor 버전에서 바뀌면, release note에 좀 더 굵게 적어줬으면 한다. "emptiness-first consolidation added"라는 한 줄로는 우리 같은 사람은 위험을 못 알아본다.

다음에는 disruption budget을 어떻게 더 세밀하게 잡았는지, schedule 기반 budget 운영 패턴을 따로 정리해보려고 한다. 혹시 비슷한 경험 있으신 분 있으면 댓글로 알려주시면 좋겠다.

BIG