새벽 두 시에 깨운 Karpenter, disruption budget 시간 윈도우로 막은 이야기

지난주 화요일 새벽 두 시, 휴대폰이 울렸다. P0 알람. 결제 API의 P99 레이턴시가 1.2초를 넘기고 있었고, 50x 비율이 3분 동안 8%까지 올라갔다 떨어졌다. 잠이 확 깼다.
대시보드를 열어보니 노드가 한꺼번에 세 대가 빠지고 있었다. 우리 클러스터는 노드 24대 규모인데, Karpenter consolidation이 동시에 세 대를 cordoning + draining 중이었다. Pod Disruption Budget도 걸어뒀고, terminationGracePeriodSeconds도 충분히 잡아뒀는데도 결제 서비스 두 개 인스턴스가 같은 노드에 몰려 있던 게 문제였다. 새벽 2시는 트래픽이 낮긴 해도 결제는 0이 아니다. 일본/동남아 유저들이 깨어 있다.
왜 새벽에 한꺼번에 빠졌나
Karpenter v1로 올린 뒤로 consolidation이 좀 더 공격적으로 도는 느낌은 받고 있었다. v1부터 disruption budget 기본값이 nodes: 10% 다. 24대니까 올림하면 3대. 정확히 우리가 본 그 숫자다.
문제는 consolidation 트리거가 새벽에 몰린다는 거다. 낮 동안 트래픽 따라 노드가 22대 → 28대 → 30대로 스케일아웃 됐다가, 새벽이 되면 워크로드가 줄면서 "이 노드 비울 수 있는데?" 판정이 한꺼번에 들어온다. 그래서 24대로 줄어드는 와중에 동시에 셋이 빠진 거다.
10%면 안전하지 않냐고 할 수 있다. 평소엔 맞다. 근데 새벽 시간에 결제 같은 lat-sensitive 서비스가 한 노드에 두 인스턴스 겹쳐있으면, 그 노드 하나 빠지는 것도 reschedule 동안 한 자릿수 초 단위로 다운된다. 동시에 셋 빠지면 PDB가 막더라도 같은 zone에 같은 deployment의 pod가 어디로 갈지 결정되는 사이 콜드 스타트가 겹친다. 우리는 결제 인스턴스 readinessProbe initialDelay가 18초였다. 그 사이 트래픽이 다른 인스턴스로 몰리면서 P99가 튄 거다.
일단 막은 것
화 나서 일단 임시로 nodes: "0"을 박았다. consolidation 전부 멈춤. 다음날 출근해서 다시 풀었다. 이건 해결이 아니다.
진짜 해결책은 v1에서 stable로 들어온 schedule + duration 필드였다. 새벽 시간엔 disruption을 아예 막거나 1대만 허용하도록 윈도우를 끊는 것.
apiVersion: karpenter.sh/v1
kind: NodePool
metadata:
name: default
spec:
disruption:
consolidationPolicy: WhenEmptyOrUnderutilized
consolidateAfter: 5m
budgets:
- nodes: "20%" # 평소엔 좀 더 공격적으로
- nodes: "1" # 새벽 시간엔 1대만
schedule: "0 1 * * *"
duration: 5h
- nodes: "0" # 결제 마감 윈도우는 아예 정지
schedule: "55 23 * * *"
duration: 10m
cron 포맷이고 timezone은 controller env의 TZ를 따른다. 우리는 Asia/Seoul로 박아뒀다. 새벽 1시부터 5시간 동안은 동시 disruption 1대로 제한, 결제 일 마감 시점은 0대.
여러 budget이 동시에 매칭되면 가장 빡빡한 게 이긴다. 그래서 평소 20%와 새벽 1대를 같이 두면 새벽엔 1대가 적용된다. 이게 의외로 직관적으로 안 와닿아서 처음엔 헷갈렸다.
추가로 손 본 것
disruption budget만 가지고는 부족했다. 같은 deployment의 pod가 같은 노드에 두 개 떨어지는 게 근본 원인이었다. topologySpreadConstraints에 maxSkew: 1, topologyKey: kubernetes.io/hostname, whenUnsatisfiable: DoNotSchedule을 걸었다. ScheduleAnyway로 두면 결국 스케줄러가 양보해서 같은 노드로 떨어뜨릴 수 있다는 걸 늦게 알았다.
그리고 결제 서비스 readinessProbe는 따로 PR을 올려서 startup probe로 분리했다. initialDelay를 18초까지 잡고 있던 이유는 JVM 워밍업 때문이었는데, startupProbe로 분리하고 readinessProbe는 짧게 가져가니 노드 reschedule 시 다른 pod로 트래픽 전환이 더 빨라졌다.
아직 고민 중인 것
consolidateAfter를 늘리는 것도 고민했다. 기본 0s에서 5분으로 늘렸는데, 이러면 단기 트래픽 변동에는 노드가 안 빠진다. 비용은 약간 손해다. 한 달 돌려보고 결제팀이랑 비용 vs 안정성 트레이드오프 다시 얘기하기로 했다.
또 하나 — 시간 기반 budget이 모든 케이스를 막진 못한다. drift나 expiration으로 인한 강제 disruption은 별개 트랙으로 도는데, 우리는 AMI 자동 업데이트를 켜놨던 게 한 번 더 사고를 만들 뻔했다. expireAfter는 720h로 일단 늘려놨고, 노드 롤링은 별도 메인티넌스 윈도우에 수동으로 트리거하는 쪽으로 옮기는 중이다.
교훈이라면
기본값을 그대로 두는 게 안전한 건 아니다. 10% 동시 disruption이 24대 클러스터에서는 의미가 다르고, 200대 클러스터에서는 또 다르다. 그리고 "트래픽 낮은 시간에 정리"라는 직관은 결제처럼 글로벌 트래픽이 있는 서비스에는 잘 안 맞는다.
혹시 비슷한 상황 겪으신 분 있으면 댓글로 셋업 공유해주시면 감사하겠다. 특히 multi-NodePool로 stable/cost-optimized 분리하신 분들 의견 궁금하다.