목차
- 비용이 새는 지점 — kubectl top이 보여준 현실
- Spot 인스턴스를 프로덕션에 바로 넣으면 생기는 일
- Cluster Autoscaler에서 Karpenter로 — 갈아탄 이유
- HPA — CPU 기준만으로는 부족하다
- PDB — 짧지만 빠뜨리면 장애 난다
- Request와 Limit — 쿠버네티스 비용 최적화의 숨은 변수
- 적용 순서를 지켜야 한다
- 3주 후 청구서
5인 팀에서 EKS 비용이 월 $4,200(약 420만원)까지 올라갔다. Spot 인스턴스, HPA, Karpenter를 조합해서 33%를 줄인 과정을 다룬다.
정리하면 3주에 걸쳐 Spot 인스턴스, HPA, Karpenter를 조합해서 월 $2,800까지 낮췄다. 약 33% 절감. 그 과정에서 Spot 노드 회수로 40분짜리 장애도 한 번 냈다.
비용이 새는 지점 — kubectl top이 보여준 현실
클러스터 비용을 줄이겠다고 마음먹으면 제일 먼저 해야 하는 건 현재 상태를 정확히 파악하는 거다. 감으로 "좀 비싼 것 같은데"에서 출발하면 어디를 건드려야 할지 모른다.
kubectl top nodes를 찍어봤다. 노드 6대가 전부 떠 있었고, CPU 사용률 평균이 18%였다. 메모리는 그나마 나아서 42% 정도. 노드 그룹 3개가 전부 m5.xlarge On-Demand로 구성돼 있었다. vCPU 4개, 메모리 16GiB짜리가 6대면 시간당 약 $1.15가 나간다. 월로 환산하면 노드 비용만 $830쯤. 거기에 ALB, EBS, 데이터 전송료까지 합쳐져서 $4,200이 된 거다.
문제는 크게 세 가지였다:
- 오버프로비저닝: 새벽 2시에도 노드 6대가 풀로 돌고 있었다. 트래픽은 사실상 0인데.
- HPA 미적용: Deployment마다 replicas를 3으로 고정해놓고 오토스케일링이 없었다.
- 인스턴스 타입 단일화: m5.xlarge 하나만 쓰고 있어서 Spot 전환도, 크기 최적화도 안 된 상태.
AWS Cost Explorer에서 EKS 관련 비용을 태그로 필터링해보니, EC2 인스턴스 비용이 전체의 68%를 차지했다. 나머지는 NAT Gateway(15%), EBS(10%), 기타(7%). EC2 비용을 줄이는 게 가장 임팩트가 크다는 건 명확했다.
Spot 인스턴스를 프로덕션에 바로 넣으면 생기는 일
Spot 인스턴스가 On-Demand 대비 60~70% 저렴하다는 건 이미 알고 있었다. m5.xlarge 기준 On-Demand가 시간당 $0.192인데, Spot은 $0.06~0.08 수준이다(2026년 3월 기준, us-east-1 리전). 계산기를 두드려보니 노드 비용만 월 $500 이상 줄일 수 있었다.
문제는 실행이었다. 좀 급했다. CTO한테 "2주 안에 결과 보여드리겠습니다"라고 말해버린 상태라 스테이징에서 충분히 테스트하지 않고 기존 노드 그룹 하나를 Spot으로 바꿔버렸다.
Slack에 알림이 쏟아졌다.
Warning Killing pod/api-server-7b4d9f8c6-x2k9m Stopping container api-server
Warning NodeNotReady node/ip-10-0-1-47.ec2.internal Node status is now: NodeNotReady
Warning Killing pod/api-server-7b4d9f8c6-lp3nq Stopping container api-server
Warning NodeNotReady node/ip-10-0-2-83.ec2.internal Node status is now: NodeNotReady
Spot 인스턴스 2대가 동시에 회수됐다. AWS는 회수 2분 전에 알림을 주긴 하는데, Pod가 다른 노드로 옮겨갈 시간이 부족했다. 새 노드가 뜨는 데 3~4분, 컨테이너 이미지 풀 받는 데 1분, readiness probe 통과까지 또 30초. 그 사이에 API 서버 Pod 10개 중 6개가 Pending 상태로 빠졌다.
약 40분간 서비스 일부에서 5xx 에러가 발생했다. 식은땀이 났다. PodDisruptionBudget(PDB)을 설정하지 않은 게 직접적인 원인이었고, 인스턴스 타입을 m5.xlarge 하나만 지정한 것도 문제였다. 한 인스턴스 타입의 Spot 용량이 부족해지면 다른 타입으로 대체할 수가 없으니까.
여기서 배운 건 세 가지다:
- Spot은 반드시 인스턴스 다양화와 함께 — m5.xlarge만 쓰지 말고 m5a.xlarge, m5d.xlarge, m6i.xlarge 등 최소 4개 타입을 지정해야 한다. 용량 풀이 넓어질수록 회수 확률이 낮아진다.
- PDB는 Spot 전환 전에 먼저 적용 — 이건 순서 문제다. Spot을 넣기 전에 PDB부터 걸어놓아야 한다.
- Spot 회수 시뮬레이션을 미리 돌려야 한다 — AWS FIS(Fault Injection Service)로 Spot 중단을 시뮬레이션할 수 있다(출처: AWS FIS 공식 문서). 프로덕션에 넣기 전에 스테이징에서 이걸 먼저 했어야 했다.
Cluster Autoscaler에서 Karpenter로 — 갈아탄 이유
기존에는 Cluster Autoscaler(CA)를 쓰고 있었다. 정확히는 "쓰고 있었다"기보다 "설치되어 있었다"에 가깝다. 노드 그룹의 min/max만 설정해놓고 거의 신경 쓰지 않았다. Spot 장애를 겪고 나서 노드 프로비저닝 자체를 재검토하게 됐고, Karpenter가 대안으로 올라왔다.
| 항목 | Cluster Autoscaler | Karpenter |
|---|---|---|
| 노드 프로비저닝 속도 | 3~5분 (ASG 기반) | 30초~1분 (직접 EC2 API 호출) |
| 인스턴스 타입 선택 | 노드 그룹별 고정 | 워크로드에 맞게 자동 선택 |
| Spot 처리 | 별도 노드 그룹 필요 | NodePool에서 통합 관리 |
| 빈 노드 정리 | 느림 (10분+ 대기) | 빠름 (consolidation 정책) |
| 설정 복잡도 | 낮음 | 중간 |
| AWS 외 클라우드 지원 | 멀티 클라우드 | AWS 전용 (Azure 프리뷰) |
Karpenter를 선택한 결정적 이유는 프로비저닝 속도였다. CA는 ASG(Auto Scaling Group)를 통해 노드를 추가하기 때문에 3~5분이 걸린다. Karpenter는 EC2 API를 직접 호출해서 30초~1분이면 노드가 뜬다. Spot 회수 상황에서 이 차이가 장애 시간에 직결된다. 40분 장애를 겪어본 입장에서 이건 타협할 수 없는 기준이었다.
Karpenter v1.0이 2024년 8월에 GA로 릴리즈됐고(출처: Karpenter GitHub Releases), 2026년 4월 현재 v1.4.x까지 나와 있다. 1.0 이전에는 alpha/beta API가 자주 바뀌어서 도입을 꺼렸는데, GA 이후로는 API가 안정적이다. 보수적인 성격상 GA가 아닌 건 프로덕션에 안 넣는 편인데, 이건 충분히 성숙했다고 판단했다.
설치는 Helm으로 한다:
# Karpenter v1.4 설치
helm upgrade --install karpenter oci://public.ecr.aws/karpenter/karpenter \
--version "1.4.0" \
--namespace kube-system \
--set "settings.clusterName=${CLUSTER_NAME}" \
--set "settings.interruptionQueueName=${CLUSTER_NAME}" \
--set controller.resources.requests.cpu=1 \
--set controller.resources.requests.memory=1Gi
interruptionQueueName을 설정하는 게 핵심이다. 이걸 넣어야 Spot 회수 알림을 SQS로 받아서 graceful하게 Pod를 옮길 수 있다. 처음에 이 옵션을 빼먹었다가 Spot 회수 시 Pod가 SIGKILL로 죽는 걸 보고 다시 설정했다. 공식 문서에도 나와 있긴 한데, 설치 가이드의 "Optional" 섹션에 묻혀 있어서 놓치기 쉽다.
NodePool 설정에서 핵심은 인스턴스 다양화와 consolidation 정책이다:
apiVersion: karpenter.sh/v1
kind: NodePool
metadata:
name: default
spec:
template:
spec:
requirements:
- key: karpenter.sh/capacity-type
operator: In
values: ["spot", "on-demand"] # Spot 우선, 불가 시 On-Demand 폴백
- key: node.kubernetes.io/instance-type
operator: In
values: # 최소 4개 이상 지정해야 Spot 안정성 확보
- m5.xlarge
- m5a.xlarge
- m5d.xlarge
- m6i.xlarge
- m6a.xlarge
- c5.xlarge
- c6i.xlarge
- key: topology.kubernetes.io/zone
operator: In
values: ["us-east-1a", "us-east-1b", "us-east-1c"]
nodeClassRef:
group: karpenter.k8s.aws
kind: EC2NodeClass
name: default
disruption:
consolidationPolicy: WhenEmptyOrUnderutilized
consolidateAfter: 30s # 빈 노드 30초 후 정리
limits:
cpu: "40"
memory: 160Gi
consolidationPolicy: WhenEmptyOrUnderutilized가 비용 절감의 핵심이다. 사용률이 낮은 노드의 Pod를 다른 노드로 합치고, 빈 노드를 자동으로 내린다. CA에서는 이 동작이 느리고 보수적이었는데, Karpenter는 consolidateAfter: 30s로 설정하면 30초 만에 처리한다. 새벽 시간대에 노드가 6대에서 1~2대로 줄어드는 걸 처음 보고 좀 불안했는데, 아침에 트래픽이 올라오면 30초 만에 노드가 추가되니까 실제로 문제가 없었다.
HPA — CPU 기준만으로는 부족하다
HPA(Horizontal Pod Autoscaler)를 적용하면서 처음에는 CPU 사용률 70% 기준으로 전부 통일했다. 모든 Deployment에 똑같이 붙였다.
apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
metadata:
name: api-server
spec:
scaleTargetRef:
apiVersion: apps/v1
kind: Deployment
name: api-server
minReplicas: 2
maxReplicas: 10
metrics:
- type: Resource
resource:
name: cpu
target:
type: Utilization
averageUtilization: 70
behavior:
scaleDown:
stabilizationWindowSeconds: 300 # 스케일 다운 5분 대기
policies:
- type: Pods
value: 1
periodSeconds: 60 # 1분에 1개씩만 줄이기
scaleUp:
stabilizationWindowSeconds: 0 # 스케일 업은 즉시
policies:
- type: Percent
value: 100
periodSeconds: 15
이게 대부분의 서비스에서는 잘 동작했다. 그런데 CPU만 보면 안 되는 서비스가 있었다. 우리 팀의 이미지 처리 워커가 그랬다. CPU는 40%밖에 안 쓰는데 메모리를 2.5GiB까지 먹는 경우가 있었다. CPU 기준 HPA만 걸어두니 스케일 아웃이 안 되고, Pod가 OOMKilled(Exit Code 137)로 죽었다.
이 워커에는 메모리 기준을 추가했다. metrics 섹션에 메모리 Utilization 75% 조건을 하나 더 넣으면 된다. CPU든 메모리든 둘 중 하나라도 기준을 넘으면 스케일 아웃이 트리거되기 때문에, 메모리 집약 워크로드에서도 자동 확장이 동작한다.
서비스 특성에 따라 HPA 기준이 달라야 한다는 걸 이때 알았다. API 서버는 CPU 기준이 맞고, 이미지 처리 같은 메모리 집약 워크로드는 메모리 기준을 같이 걸어야 한다. RPS(Requests Per Second) 기반 커스텀 메트릭을 쓰는 게 가장 정확하긴 한데, Prometheus + Prometheus Adapter 세팅이 필요해서 이번에는 여기까지만 했다. 나중에 할 일 목록에 넣어둔 상태다.
behavior 섹션도 신경 써야 한다. 스케일 다운을 너무 급하게 하면 트래픽이 다시 올라왔을 때 Pod가 부족해진다. stabilizationWindowSeconds: 300으로 5분 정도 여유를 두는 게 안전하다. 반대로 스케일 업은 빠를수록 좋으니 0으로 설정했다. 이 비대칭 설정이 의외로 체감 안정성에 큰 차이를 만든다.
PDB — 짧지만 빠뜨리면 장애 난다
PodDisruptionBudget 설정 자체는 간단하다. minAvailable: "50%"이나 minAvailable: 1을 걸어두면 된다. replicas가 적은 서비스(2~3개)는 절대값으로, 많은 서비스(5개 이상)는 퍼센트로 거는 게 낫다. Spot 인스턴스 섹션에서 겪은 40분 장애의 직접적인 원인이 PDB 미적용이었다. 이것만 미리 걸어뒀어도 그 장애는 없었다.
Request와 Limit — 쿠버네티스 비용 최적화의 숨은 변수
쿠버네티스 비용 최적화 방법을 검색하면 Spot이나 오토스케일링 얘기가 먼저 나오는데, 사실 Request/Limit 설정이 기반이 안 되면 나머지가 다 소용없다. 결론부터 말하면, Request를 실제 사용량보다 너무 높게 잡으면 노드가 낭비되고, 너무 낮게 잡으면 Pod가 예고 없이 죽는다.
기존 설정을 확인해보니 모든 Deployment에 Request CPU 500m, 메모리 512Mi가 걸려 있었다. kubectl top pod로 실제 사용량을 2주간 모니터링했더니, API 서버의 평균 CPU 사용량은 120m, 피크가 380m이었다. 500m을 Request로 잡아놓으니 노드 입장에서는 "이 Pod한테 500m 예약해둬야 해"로 계산하는 거다. 실제로는 120m밖에 안 쓰면서 380m이 허공에 묶여 있었다.
이걸 서비스별로 조정했다. 방법은 단순하다:
- Request = P95 사용량 (상위 5% 피크 기준)
- Limit = P99 사용량의 1.5배
API 서버는 Request를 250m으로 낮추고, Limit은 600m으로 조정했다. 이렇게 하면 노드에 더 많은 Pod를 배치할 수 있어서 필요한 노드 수 자체가 줄어든다. 실제로 이것만으로 노드 1~2대가 절약됐다.
Kubernetes VPA(Vertical Pod Autoscaler)를 recommendation 모드로 돌리면 적정 Request/Limit 값을 제안해준다(출처: VPA GitHub 저장소). 자동 적용(auto 모드)은 아직 좀 불안정해서 쓰지 않았고, recommendation 값을 참고해서 수동으로 조정하는 방식을 택했다. VPA와 HPA를 동시에 쓸 때 CPU 기준이 충돌하는 문제가 있는데, VPA는 메모리만 자동 조정하고 CPU는 HPA에 맡기는 구성이 현재 권장되는 패턴이다.
Limit 없이 Request만 거는 "Burstable" 전략을 추천하는 글도 꽤 있다. 노드 리소스를 최대한 활용할 수 있다는 논리인데, 프로덕션에서는 위험하다. 한 Pod가 노드의 CPU를 전부 점유해버리면 같은 노드의 다른 Pod가 throttling에 걸린다. 특히 여러 팀이 하나의 클러스터를 공유하는 환경이라면 Limit은 걸어두는 게 안전하다.
적용 순서를 지켜야 한다
이걸 한꺼번에 다 적용하면 안 된다. 어디서 문제가 생겼는지 추적이 불가능해지기 때문이다. 내가 했던 순서를 정리하면 이렇다.
1주차 — Request/Limit 조정과 PDB 적용. 이건 기존 인프라를 건드리지 않으니 위험이 낮다. 기존 노드 그룹, 기존 Cluster Autoscaler 그대로 두고 Pod 설정만 바꾸는 거다. 이것만으로도 노드 1대가 줄었다. Karpenter consolidation이 아니라 단순히 Request를 줄여서 스케줄링 가능한 여유가 생긴 결과였다.
2주차 — HPA 적용. 서비스별로 CPU/메모리 기준을 다르게 설정하고, 스케일 다운 안정화 기간을 넉넉히 잡았다. 이 단계에서 야간에 Pod 수가 자동으로 줄어드는 걸 확인했다. replicas 고정으로 3개씩 돌던 서비스들이 새벽에는 2개까지 내려갔다. Pod가 줄어도 노드가 안 줄어드는 건 CA의 한계. 빈 노드를 10분 이상 기다려야 내리니까 실질적인 비용 절감 효과는 제한적이었다.
3주차 — Karpenter 전환과 Spot 인스턴스 적용. 이게 가장 임팩트가 크지만 위험도 크다. 스테이징에서 AWS FIS로 Spot 회수 시뮬레이션을 먼저 돌렸다. 노드 하나를 강제로 종료시키고 Pod가 다른 노드로 옮겨가는 시간을 측정했더니, Karpenter 환경에서 약 45초 만에 새 노드가 Ready 상태가 됐다. PDB 덕분에 동시에 죽는 Pod 수도 제한되고. 스테이징에서 3일 정도 안정적으로 돌아가는 걸 확인한 뒤 프로덕션에 적용했다. 한 서비스씩 옮기는 데 3일이 더 걸렸다.
이 순서를 지키면 각 단계의 효과를 개별적으로 측정할 수 있다. Request 조정으로 얼마, HPA로 얼마, Spot+Karpenter로 얼마 줄었는지 분리해서 파악해야 나중에 보고할 때도 설득력이 생긴다.
3주 후 청구서
최종 비용 변화다.
| 항목 | 변경 전 | 변경 후 | 절감률 |
|---|---|---|---|
| EC2 인스턴스 (월) | $2,856 | $1,680 | -41% |
| NAT Gateway | $630 | $630 | 0% |
| EBS | $420 | $350 | -17% |
| 기타 (ALB, 데이터 전송 등) | $294 | $280 | -5% |
| 합계 | $4,200 | $2,940 | -30% |
첫 달 청구서가 $2,940이었고, 두 번째 달에 Karpenter consolidation이 안정화되면서 $2,800 선까지 내려왔다. EC2 비용이 41% 줄어든 게 제일 크다. 평균 노드 수가 6대에서 3.5대로 줄었고, 그 3.5대도 대부분 Spot이다.
NAT Gateway 비용은 건드리지 않았다. VPC 엔드포인트(S3, ECR, STS용)를 추가하면 줄일 수 있는데, 네트워크 구조를 바꾸는 거라 이번 스코프에서는 뺐다. EBS는 사용하지 않는 PersistentVolume을 정리하면서 약간 줄어든 정도다.
Karpenter consolidation의 효과가 실측 기준으로 가장 컸다. 새벽에는 노드가 1~2대까지 줄어들고, 오전 9시쯤 트래픽이 올라오면 30초 만에 노드가 추가된다. CA 시절에는 노드 추가에 3~5분 걸렸으니, 이 속도 차이가 비용뿐 아니라 서비스 안정성에도 영향을 준다. 다음에는 NAT Gateway 비용 줄이기와 Graviton(ARM) 인스턴스 전환을 실험해볼 생각이다.
관련 글
- Terraform 상태 파일 충돌 해결 — S3 백엔드 락 설정부터 force-unlock까지 – 팀에서 동시에 terraform apply를 실행해 상태 파일이 꼬인 경험에서 출발한 글이다. S3 백엔드 + DynamoDB 락 설정, …
- GitHub Actions 자체 호스팅 러너
- LLM 비용 최적화