새벽 3시 47분. 핸드폰이 미친 듯이 울렸다.
PagerDuty가 동시에 네 건. P99 레이턴시, 5xx 비율, 큐 적체, 그리고 결제 워커 alive 체크 실패까지 줄줄이 빨간색. 잠이 깰 새도 없었다. 노트북을 열고 Grafana부터 켰는데, 노드 수가 떡 하니 23대에서 6대로 떨어져 있었다. 누가 그랬을까. 범인은 나였다. 정확히는, 일주일 전 내가 만진 Karpenter 설정이.
무슨 짓을 했길래
배경부터. 우리 팀은 EKS에서 Karpenter로 노드를 굴린다. NodePool 두 개 — 일반 워크로드용 default, 그리고 결제/주문 같은 stability-sensitive 워크로드용 payments. 한 달 전쯤 FinOps 압박이 들어왔다. "비용 너무 많이 나온다, 야간에 트래픽 적은 시간대 노드 좀 정리해라."
답은 뻔했다. Karpenter consolidation을 켜는 거. 정확히 말하면 consolidationPolicy: WhenEmptyOrUnderutilized 로 바꾸고, 일주일 평가 후 비용이 23% 떨어지는 걸 보고 자신감을 얻었다.
그래서 그 일주일 뒤, 한 발 더 나갔다. disruption.budgets 를 풀어버렸다. 기본값이 nodes: 10% 였는데, "어차피 야간엔 트래픽 거의 없으니 더 빠르게 정리해도 되지 않나?" 라는 생각으로 야간 시간대(02:00~05:00 UTC)에 nodes: 100% 로 덮어쓴 것이다. 코드만 보면 이렇게.
disruption:
consolidationPolicy: WhenEmptyOrUnderutilized
consolidateAfter: 30s
budgets:
- nodes: "10%"
- schedule: "0 2 * * *"
duration: 3h
nodes: "100%"
근데 이거, 지금 다시 보면 한숨이 나온다. 야간에 트래픽이 "적다"는 건 0이 아니라는 뜻이다. 그리고 우리는 KST 기준으로 운영 중인데 cron은 UTC다. KST 02:00이 아니라 KST 11:00에 적용된다는 걸 그 새벽까지 몰랐다.
진짜 문제는 따로 있었다
UTC 02:00은 KST 11:00. 오전 11시는 우리 서비스 입장에서 점점 트래픽이 올라가는 시간대다. 그 시간에 budget이 100%로 풀리니까 Karpenter가 신나서 노드를 통째로 정리하기 시작했다. 일부 워크로드는 PDB가 있었지만, 정말 충격적이게도 결제 워커 deployment에는 PDB가 빠져 있었다. 이건 별도의 부끄러운 사연이 있다. 3개월 전 누군가 manifests 정리하면서 "이거 안 쓰는 것 같아서" 라며 제거한 PR이 머지된 거였다. 코드 리뷰는 내가 했다.
그래서 어떻게 됐냐. Karpenter는 노드 23개를 "underutilized" 라고 판단하고, budget이 100%였으니 한꺼번에 cordon + drain을 시작했다. PDB가 없는 결제 워커들은 그냥 evict됐고, 새 노드는 EC2 API 호출 → ENI 붙이고 → kubelet 부팅까지 1분 30초쯤 걸린다. 그 사이 결제 큐가 적체되고, retry storm이 돌고, 멘탈이 나갔다.
그래서 새벽에 뭘 했냐
일단 budget 부분만 빠르게 떼어냈다.
kubectl patch nodepool default --type=json \
-p='[{"op": "remove", "path": "/spec/disruption/budgets/1"}]'
그 다음 결제 워커 deployment에 PDB를 응급으로 다시 붙였다.
apiVersion: policy/v1
kind: PodDisruptionBudget
metadata:
name: payment-worker
spec:
minAvailable: 80%
selector:
matchLabels:
app: payment-worker
이제 노드는 다시 올라오기 시작했는데, 노드 24대를 한꺼번에 띄우려니 EC2 측에서 InsufficientInstanceCapacity가 떨어졌다. 우리가 쓰던 인스턴스 타입이 c7i.2xlarge 하나로 고정돼 있었던 게 문제였다. 새벽 4시 30분쯤 인스턴스 타입을 추가하는 PR을 머지해서 NodePool requirements 에서 c7i, m7i, c6i 를 다 받아들이도록 풀었다. 그제서야 노드가 정상 수로 회복됐고, 결제 큐도 비워졌다.
복구된 시간은 새벽 5시 12분. 1시간 25분짜리 P1.
사후에 정리한 것들
다음 날 회고를 했다. 항목이 많았다.
budget을 100%로 두는 건 어떤 상황에서도 하지 마라. 정 굳이 야간에 빠르게 정리하고 싶다면 50% 이내로. Karpenter 공식 문서도 기본값 10%가 권장이라고 못 박는다. 그리고 schedule 의 cron은 UTC 기준이라는 걸 주석으로 라도 NodePool YAML에 박아두기로 했다.
PDB는 운영 워크로드의 필수품이다. 우리는 admission policy로 PDB 없는 deployment의 prod 배포를 막기로 했다. Kyverno 정책 한 줄이면 된다.
match:
any:
- resources:
kinds: ["Deployment"]
namespaces: ["prod"]
validate:
message: "prod의 Deployment는 PDB가 있어야 한다"
pattern:
spec:
template:
metadata:
labels:
requires-pdb: "true"
이건 좀 거친 방법이고, 더 깔끔한 건 Deployment 와 매칭되는 PDB 가 같은 네임스페이스에 존재하는지 확인하는 정책이지만, 일단 첫 단계로는 충분했다.
NodePool 분리를 진지하게 하자. stability-sensitive 워크로드는 consolidation 정책을 WhenEmpty 로만 두고, FinOps용 노드만 WhenEmptyOrUnderutilized 를 켜는 식으로. 사실 처음부터 이렇게 했어야 했다. 검색해보니 비슷한 사례가 꽤 있고, 다들 같은 결론에 도달하더라.
인스턴스 타입은 무조건 다양화. 하나로 고정하는 건 capacity 리스크다. 같은 세대에서 c/m 패밀리는 다 받는 정도가 적당했다.
아직도 마음에 걸리는 것
consolidateAfter: 30s 가 너무 짧다는 건 알겠는데, 그럼 어느 정도가 적정한지는 솔직히 아직 답을 못 내렸다. 어떤 글에서는 15분(900s) 정도가 적당하다고 하던데, 그건 그쪽 워크로드 특성이고. 우리는 일단 5분(300s)으로 올려놨는데, 다음 주에 비용 영향이 어떻게 나오는지 보고 다시 판단할 생각이다.
그리고 솔직히 — disruption budget 의 schedule 문법, 익숙해지기 전까지 한 번씩 헷갈린다. UTC 라는 걸 깜빡하기 좋고, duration 단위도 명확하지 않다. 다음에 또 만진다면 staging 에서 같은 시각으로 한 사이클 돌려보고 prod 에 반영할 것 같다.
비슷한 새벽을 보낸 분 계시면 어떻게 안정화하셨는지 댓글로 들려주세요. 진심으로.
'IT > Kubernets' 카테고리의 다른 글
| matchLabelKeys 안 썼다가 롤링 업데이트 중 한 노드에 트래픽 70% 쏠린 사건 (0) | 2026.05.23 |
|---|---|
| Pod resize, kubelet은 사실 어떻게 하는가 — 1.35 GA 내부 동작 (0) | 2026.05.23 |
| kubectl debug --copy-to + --share-processes, 프로덕션 Pod 안 건드리고 진짜 디버깅하기 (0) | 2026.05.22 |
| KEDA Kafka 스케일러, 운영하면서 챙겨야 하는 설정 가이드 (0) | 2026.05.22 |
| ServiceAccount projected token 만료로 새벽 호출 — 1년 짜리 토큰을 캐싱한 SDK 이야기 (0) | 2026.05.19 |