Karpenter consolidation 때문에 노드가 5분에 한 번씩 죽던 이야기
처음에 의심한 것들
지난주 새벽 2시 반에 알람이 울렸다. P99 레이턴시 알람이었는데, 한두 번이면 그냥 무시하고 자고 다음 날 보겠지만 같은 알람이 5분 간격으로 계속 울렸다. 누워서 폰만 보다가 결국 노트북을 열었다.
원인은 Karpenter였다. v1.0으로 올리고 한 달 정도 됐는데, 이 시점에 처음으로 큰 사고가 터졌다. 자려고 누웠다가 새벽 5시까지 깨어 있었던 그 밤 이야기를 정리해두려고 한다.
알람이 P99 latency였으니까 당연히 애플리케이션을 먼저 봤다. 그런데 백엔드 트레이스를 까보니 응답 자체는 빠른데, 가끔 한 노드의 모든 파드가 동시에 사라지는 패턴이 보였다. terminated 이벤트가 5분에 한 번씩 떴다.
처음엔 spot interruption인 줄 알았다. 우리 서비스 노드의 70% 정도가 spot이라 충분히 의심할 만했다. 그런데 kubectl get events -A | grep -i interrupt 해봐도 깨끗했다. EC2 콘솔에서도 spot reclaim 이벤트는 없었다.
그 다음에 의심한 게 노드 lifecycle. kubectl get nodes -L karpenter.sh/nodepool -o wide로 보니 노드 나이가 죄다 10분 미만이었다. 12대 클러스터인데 매 5분마다 한 대씩 회전하고 있었다. 멘탈이 슬슬 나가기 시작했다.
Karpenter 로그를 까보다
Karpenter controller 로그를 tail로 걸었다.
{"level":"INFO","time":"...","message":"disrupting nodeclaim(s) via replace, terminating 1 nodes ... due to underutilized","commit":"..."}
{"level":"INFO","time":"...","message":"disrupting nodeclaim(s) via delete, terminating 1 nodes ... due to underutilized","commit":"..."}
줄줄이 underutilized다. 이게 정상인가 싶어서 NodePool을 확인했다.
spec:
disruption:
consolidationPolicy: WhenEmptyOrUnderutilized
consolidateAfter: 30s
consolidateAfter: 30s. v1.0 마이그레이션할 때 누가 이렇게 짧게 박아놨다. v0.x 시절엔 WhenUnderutilized라는 별도 정책이 있어서 consolidateAfter가 사실상 기본값으로 동작했는데, v1에서 WhenEmptyOrUnderutilized로 합쳐지면서 동작이 미묘하게 바뀌었다는 걸 그 시점엔 몰랐다.
consolidateAfter가 뭔지 다시 읽어보다
Karpenter 공식 문서를 다시 정독했다. 핵심은 이거였다. WhenEmptyOrUnderutilized 정책에서 Karpenter는 노드가 비어있든 아니든 consolidateAfter가 지난 시점부터 통합 후보로 본다. 즉 consolidateAfter는 "이 시간 동안 disruption 후보로 보지 말고 기다려라"는 grace period인데, 30초로 박아두면 노드가 뜨자마자 30초 후엔 바로 평가 대상이 된다.
그리고 우리 클러스터처럼 워크로드 분포가 출렁대는 환경에서는, 아주 잠깐 노드 하나가 비효율적으로 보이는 순간이 생기고 — Karpenter가 그걸 잡아서 통합하려 들고 — 통합한 노드가 또 30초 후에 평가 대상이 되고 — 무한 루프.
문서의 권장값은 사실 1분 이상이고, 운영환경에서는 보통 1분 ~ 15분 사이를 권장한다. 30초는 너무 공격적이었던 것이다.
disruption budget도 같이 봐야 했다
근데 단순히 consolidateAfter만 늘린다고 끝나는 게 아니었다. budget이 없으면 기본값이 nodes: 10%인데, 12대 환경에서 10%는 1대다. 한 번에 한 대씩만 죽는 건 맞는데 그게 5분에 한 번씩 일어나니 누적 churn은 미친 수준이었다.
게다가 우리는 reasons를 분리하고 싶었다. drift(노드 spec 변경)는 빠르게 굴려도 되지만, underutilized는 보수적으로 가야 했다.
spec:
disruption:
consolidationPolicy: WhenEmptyOrUnderutilized
consolidateAfter: 5m
budgets:
- nodes: "20%"
reasons: ["Empty"]
- nodes: "1"
reasons: ["Underutilized"]
schedule: "0 9 * * mon-fri"
duration: 8h
- nodes: "0"
reasons: ["Underutilized"]
# drift는 budget 없음 → 기본값 적용
이게 우리가 결국 적용한 설정이다. 평일 업무시간(09:00~17:00) 동안만 underutilized consolidation을 1대씩 허용하고, 그 외 시간(특히 새벽)은 0으로 막아버렸다. Empty 노드는 빠르게 정리해도 무방하니 20%.
schedule이랑 duration은 v1에서 들어온 기능인데 cron 문법이라 익숙해서 좋다. 다만 timezone이 컨트롤러 파드의 TZ을 따른다는 점은 한 번 더 파봐야 했다.
적용하고 본 것
설정 적용하고 30분 정도 지켜봤다. 노드 회전이 거짓말처럼 멈췄다. P99도 정상 범위로 돌아왔다.
근데 한 가지 의문이 남았다. 왜 한 달 동안은 멀쩡하다가 그날 갑자기 터졌을까. 추측이지만, 그날 저녁에 다른 팀이 배포한 워크로드의 resource request가 좀 특이했다 — CPU는 작은데 메모리는 컸다. 이게 들어오면서 bin-packing이 까다로워져서 Karpenter의 시뮬레이션이 자꾸 "이 노드 빼면 더 효율적인데?"라는 결론을 내고, 그러다 실제로 빼면 다음 노드가 또 같은 처지가 되고 — 그런 식이었던 것 같다. 추측이고 검증은 못 했다.
교훈이라기보다는
- consolidateAfter를 30초로 박아두지 마라. 이건 진짜 너무 짧다.
- WhenEmptyOrUnderutilized 정책은 편하긴 한데 Empty와 Underutilized를 budget의 reasons로 분리해서 관리하는 게 안전하다.
- 새벽시간 같은 저트래픽 윈도우에 underutilized consolidation이 한 번 폭주하면 다음 날 출근해서 멘붕한다. schedule + duration으로 시간대 제어 권장.
근본적으로는 disruption budget 없이 default로 두지 말고, 처음부터 reasons를 명시적으로 나눠 budget을 짜두면 이런 사고는 안 나는 것 같다. 우리 팀에서는 NodePool 템플릿에 이 budget 블록을 박아두고 PR 리뷰 시 강제로 보게 했다.
혹시 비슷한 증상 겪고 계신 분, consolidateAfter랑 budget의 reasons 두 개부터 보세요. 99% 그쪽입니다.