
지난주에 HPA behavior 필드를 손댄 적이 있다. 정확히 말하면 손댄 게 아니라, 누군가 친절하게 PR로 올려준 "스케일 다운 빠르게 하자"는 변경을 별생각 없이 머지한 게 시작이었다. 그날 오후부터 P99 레이턴시가 평소 80ms에서 320ms를 찍었고, 새벽 1시쯤 알람이 한 번 더 울리고 나서야 우리 팀은 이게 비용 최적화가 아니라 자해였다는 걸 인정했다.
이 글은 그 삽질 회고다. 비슷한 PR 들어오면 한 번만 더 생각해 보시라는 의미에서 적어둔다.
사건의 시작
서비스는 API 트래픽이 출퇴근 시간대에 몰리는 전형적인 패턴이다. 노드 12대짜리 EKS 클러스터에서 Deployment 3개가 HPA로 묶여 있었고, 평소 replica가 8~30 사이를 왔다 갔다 했다. 비용을 줄이려면 트래픽이 빠질 때 빨리 replica를 줄여야 한다, 이건 누구나 동의하는 얘기다.
문제가 된 PR은 이렇게 생겼었다.
behavior:
scaleDown:
stabilizationWindowSeconds: 30
policies:
- type: Percent
value: 50
periodSeconds: 30
이전엔 디폴트(300초 안정화 윈도우)를 그대로 쓰고 있었다. PR 설명에는 "스케일 다운이 너무 느려서 비용 낭비. 30초로 줄이고 50%씩 줄이는 정책 추가"라고 적혀 있었고, 스테이징에서 1시간 돌려본 스크린샷도 첨부돼 있었다. 솔직히 그날 일정이 빡빡해서 깊게 안 봤다.
그날 오후, 알람이 울리기 시작했다
머지하고 ArgoCD가 동기화한 게 14시쯤. 16시 반부터 P99가 천천히 올라왔다. 처음엔 다운스트림 DB 슬로 쿼리를 의심했는데, RDS Performance Insights 보니까 멀쩡했다. CloudWatch 메트릭 펼쳐놓고 한참 보다가 한 가지가 눈에 들어왔다.
replica 수가 4분 간격으로 톱니처럼 출렁이고 있었다.
12 → 6 → 11 → 5 → 10 → 5 → 11.
처음엔 그래프 버그인 줄 알았다. 새로고침해도 똑같았다. 진짜로 HPA가 매 4분마다 절반씩 줄였다가 다시 늘렸다가를 반복하는 중이었다.
왜 이게 P99를 망가뜨렸나
여기서 좀 생각을 정리해야 한다. 스케일 다운이 빠르면 비용은 줄지만, 그 자체로 P99를 망가뜨리진 않는다. 진짜 문제는 스케일 다운이 빠르고 스케일 업이 따라가지 못할 때 발생한다.
우리 워크로드는 JVM 기반이라 새 pod이 readiness probe를 통과하는 데 평균 45초가 걸린다. 그런데 30초 안정화 윈도우는 다음과 같이 동작한다.
- 14:00 — 트래픽 잠깐 빠짐. 평균 CPU 30%까지 떨어짐
- 14:00:30 — HPA가 "50% 줄이자" 결정. replica 12 → 6
- 14:01:30 — 남은 6개에 트래픽 몰림. CPU 85% 도달
- 14:02:00 — HPA가 "scale up 하자" 결정. replica 6 → 11
- 14:02:45 — 새 pod 5개 ready. 그 사이 트래픽은 6개 pod이 받아냄
- 14:03:30 — 다시 평균 CPU 떨어짐. "또 50% 줄이자"
이 사이클이 반복되는 동안, 6개 pod이 트래픽을 받아내던 45초 구간이 매 사이클마다 발생했다. 그 45초 동안 P99가 튀고, 일부 요청은 큐에 쌓이고, 다음 사이클에 다시 부하가 몰린다. 결과적으로 평균 CPU 사용률 자체는 멀쩡해 보이는데 — 그래서 사람도 HPA도 다 속았다 — P99만 일관되게 망가졌다.
kubectl describe hpa로 이벤트 로그를 봤더니 그날 오후에만 스케일 이벤트가 60번 넘게 찍혀 있었다.
디폴트로 되돌리고 나서
일단 PR을 revert 했다. 그러자 안정화 윈도우가 300초로 돌아오면서 톱니가 사라졌다. P99도 80ms대로 복귀했다. 비용은 다시 약간 올라갔지만 그래도 평소 대비 +7% 수준이라 견딜 만하다.
문제를 진단하고 나서 팀 내부에서 논의 끝에 결국 이런 형태로 합의했다.
behavior:
scaleDown:
stabilizationWindowSeconds: 600 # 10분. 디폴트보다 더 보수적으로.
policies:
- type: Percent
value: 20 # 한 번에 20%만
periodSeconds: 60
- type: Pods
value: 2 # 또는 2개. 둘 중 작은 쪽 (selectPolicy: Min)
periodSeconds: 60
selectPolicy: Min
scaleUp:
stabilizationWindowSeconds: 0 # 스케일 업은 즉시
policies:
- type: Percent
value: 100
periodSeconds: 30
- type: Pods
value: 4
periodSeconds: 30
selectPolicy: Max
핵심은 두 가지였다. 스케일 다운은 안정화 윈도우를 디폴트보다 더 길게, 스케일 업은 즉시. 비대칭적으로 가는 게 안전하다. 우리 워크로드는 비용보다 P99가 중요하니까.
교훈이라기엔 좀 부끄러운 것들
비용 최적화 PR은 SLO를 깰 수 있다는 걸 머리로는 알고 있었는데, 그날 그 PR을 머지하면서는 안 떠올랐다. 스테이징은 트래픽 패턴이 다르니까 1시간 짜리 검증으론 부족했다. 사실 출퇴근 패턴은 스테이징에서 절대 재현이 안 된다.
그리고 HPA 메트릭으로 평균 CPU만 보는 한, 톱니 패턴은 영원히 안 보인다. 평균은 멀쩡하니까. 그날 이후로 HPA replicas 메트릭의 분산(stddev)을 같이 보는 알람을 하나 추가했다. 5분 동안 replica 변동폭이 30%를 넘으면 슬랙으로 핑.
Kubernetes HPA 공식 KEP-853 문서를 다시 읽었는데, behavior 필드의 의도 자체가 "scale velocity를 제한하는 것"이라고 명시돼 있다. 빠르게 줄이라고 만든 게 아니라, 너무 빠르게 줄지 못하게 하라고 만든 거다. 디폴트 300초도 그래서 보수적인 값으로 잡혀 있는 거고, 줄이는 건 신중해야 한다.
마무리
HPA behavior 필드 만지는 PR이 들어오면, 일단 "왜 디폴트로는 안 되나"를 먼저 물어봐야 한다. 디폴트는 대체로 합리적인 이유로 그렇게 잡혀 있다. 비용 절감을 핑계로 안정화 윈도우 줄이자는 얘기는, 사실은 SLO를 깎아도 된다는 얘기다. 그 거래를 명시적으로 합의했다면 괜찮은데, 우리 팀처럼 별생각 없이 머지하면 새벽에 알람을 받게 된다.
혹시 비슷한 형태로 HPA behavior 튜닝하고 계신 분 있으면, replica 변동폭을 한 번 봐주시길. 평균 CPU만 보면 절대 안 보인다.
'IT > Kubernets' 카테고리의 다른 글
| CronJob 실패 로그가 증발한 사건 (0) | 2026.05.15 |
|---|---|
| Kubernetes graceful node shutdown, 안에서 도는 것들 (1) | 2026.05.15 |
| Karpenter 스케줄러는 어떻게 노드를 결정하는가 — 내부 동작 분석 (0) | 2026.05.14 |
| startupProbe 모르고 슬로우 스타트 앱 운영하지 마세요 (0) | 2026.05.13 |
| kube-proxy 내부 동작 - iptables, IPVS, nftables 모드는 패킷을 어떻게 처리하나 (0) | 2026.05.12 |