Karpenter + KEDA 동시 사용의 함정 — 소규모 클러스터에서 마주친 스케줄러 엣지 케이스 3가지
배경 / 문제 상황
EKS 클러스터를 운영하면서 가장 많이 받는 질문 중 하나가 “Karpenter랑 KEDA 같이 써도 돼요?“다. 공식 문서를 읽으면 둘 다 쿠버네티스 오토스케일링 생태계의 핵심 컴포넌트고, 역할이 명확히 분리되어 있어 함께 사용하는 것이 권장된다.
- Karpenter: 클러스터 노드 수준의 오토스케일링 (노드 추가/제거)
- KEDA: 이벤트 기반 Pod 수준의 오토스케일링 (큐 길이, 메시지 수 등 외부 메트릭)
- HPA: CPU/메모리 기반 Pod 수준의 오토스케일링
문제는 이 세 가지가 실제 운영 환경에서 동시에 작동할 때 발생하는 엣지 케이스다. 특히 업무 시간(노드 2대)과 업무 외 시간(노드 1대)을 구분해서 운영하는 소규모 클러스터에서 예상치 못한 세 가지 이슈를 마주쳤다.
이 글은 각 이슈의 현상, 근본 원인, 해결 방안을 기록한다. 단순한 설정 수정이 아니라 쿠버네티스 스케줄러와 Karpenter 내부 동작을 이해해야만 제대로 대응할 수 있는 케이스들이다.
운영 환경 개요
문제를 이해하려면 먼저 운영 환경의 특수성을 알아야 한다.
[업무 시간] 노드 A + 노드 B (2대 운영)
- KEDA ScaledObject → Pod 스케일 아웃
- topologySpreadConstraints로 양 노드 균등 분산
[업무 외 시간] 노드 A만 운영 (1대)
- KEDA ScaledObject → Pod 스케일 인
- Karpenter Consolidation → 노드 B 제거
이 구성에서 Karpenter의 Consolidation(통합) 기능이 핵심이다. Pod 사용량이 줄어들면 Karpenter가 비효율적인 노드를 감지하고 자동으로 제거한다.
기술 선택 배경
왜 Karpenter인가
EKS에서 노드 오토스케일링의 전통적인 선택은 Cluster Autoscaler(CA)다. CA와 Karpenter의 차이를 한 문장으로 표현하면 이렇다.
CA는 “미리 정의된 노드 그룹 중 어느 것을 늘릴까?” 를 결정하고, Karpenter는 “이 Pod를 스케줄링하려면 어떤 인스턴스 타입이 최적일까?” 를 직접 계산한다.
Karpenter는 NodePool 단위로 인스턴스 패밀리와 크기를 지정하면, 실제 필요한 리소스에 맞는 인스턴스를 EC2 API로 직접 프로비저닝한다. 과도하게 크거나 작은 노드 없이 빈 패킹(bin-packing) 효율이 높다.
왜 KEDA인가
KEDA(Kubernetes Event-driven Autoscaling)는 HPA를 확장하는 개념이다. HPA가 CPU/메모리만 볼 수 있는 반면, KEDA는 SQS 큐 길이, 메시지 수신 속도, 데이터베이스 레코드 수 등 외부 메트릭을 기반으로 Pod를 스케일한다. 배치 처리나 비동기 워크로드에서 특히 강력하다.
이슈 1: 중요 Pod 노드가 Consolidation 대상이 됨
현상
업무 외 시간으로 전환되어 노드를 2대 → 1대로 줄이는 Consolidation이 발생했다. 예상대로라면 상대적으로 가벼운 워크로드를 가진 노드가 제거되어야 했다. 그런데 Redis가 실행 중인 핵심 노드(노드 A)가 Consolidation 대상으로 선정되면서, 드레이닝 도중 Redis Pod가 잠시 다운되어 서비스 장애가 발생했다.
근본 원인 분석
Karpenter의 Consolidation 비용 계산 방식을 이해해야 한다.
Karpenter는 노드를 제거할 때 “이 노드를 비우는 비용”을 계산한다. 여기서 비용이란 재배치해야 할 Pod들의 리소스 총합이다. Pod 개수가 아닌 리소스 요청량(requests) 기준이다.
[상황]
- HPA가 서비스 Pod 수를 줄임 → 일부 서비스 Pod가 노드 A에서 먼저 제거됨
- 노드 A에 남은 것: Redis (request: CPU 100m, Memory 256Mi) → 리소스 총합 낮음
- 노드 B에 남은 것: 서비스 Pod 여럿 → 리소스 총합 높음
[Karpenter 판단]
- 노드 A 비우기: Redis 1개만 이동하면 됨 (비용 낮음)
- 노드 B 비우기: 서비스 Pod 다수 이동해야 함 (비용 높음)
- → 노드 A 선정
HPA가 노드 A의 서비스 Pod를 먼저 축소하는 스케줄링 순서가 Karpenter의 비용 계산을 왜곡한 것이다. 스케일러들 사이에 직접적인 조율 메커니즘이 없기 때문에 이런 연쇄 반응이 예상 밖의 결과를 낳는다.
해결 방안
이중 보호 장치를 적용했다.
① karpenter.sh/do-not-disrupt Annotation
Karpenter에게 “이 Pod가 실행 중인 노드는 Consolidation 대상에서 제외하라”고 명시적으로 지시한다.
apiVersion: apps/v1
kind: Deployment
metadata:
name: redis
spec:
template:
metadata:
annotations:
karpenter.sh/do-not-disrupt: "true" # 이 Pod가 있는 노드는 Consolidation 제외
② Pod Disruption Budget (PDB)
자발적 중단(Voluntary Disruption) 상황에서도 최소 가용성을 보장한다.
apiVersion: policy/v1
kind: PodDisruptionBudget
metadata:
name: redis-pdb
spec:
minAvailable: 1
selector:
matchLabels:
app: redis
do-not-disrupt Annotation이 1차 방어선이라면, PDB는 2차 방어선이다. Annotation이 없는 경우에도 PDB가 있으면 Karpenter는 Consolidation을 실행하기 전에 시뮬레이션하여 PDB 위반이 예상되면 해당 노드 제거를 포기한다.
Karpenter vs kubectl drain의 PDB 처리 차이
kubectl drain은 Pod를 하나씩 Evict 요청할 때 API 서버가 PDB를 실시간 검사한다. PDB에 막히면 그 자리에서 멈추고 기다린다.Karpenter는 더 지능적이다. 실제 Eviction 전에 “이 노드를 비웠을 때 PDB를 위반하는가?“를 미리 시뮬레이션한다. 위반이 예상되면 아예 그 노드의 Consolidation을 포기하고
DisruptionBlocked이벤트를 남긴다. 일단 행동하고 막히면 대기하는 drain과 달리, Karpenter는 가능성을 먼저 확인하고 행동한다.
결과: 이후 Scale-in 시 Redis가 위치한 노드 A는 안정적으로 유지되고, 다른 노드만 Consolidation 대상으로 선정되어 서비스 중단 문제가 완전히 해소됐다.
이슈 2: 신규 노드로 Pod가 분산되지 않음
현상
업무 시간 시작 시 노드가 1대 → 2대로 증가하는 시점에 문제가 발생했다. 신규 노드(노드 B)가 생성됐음에도 불구하고, 새로 스케일링되는 Pod들이 노드 B로 분산되지 않고 기존 노드 A에만 집중적으로 스케줄링됐다. 결과적으로 노드 A는 리소스 포화 상태, 노드 B는 텅 빈 상태가 됐다.
근본 원인 분석
이 문제는 두 가지 쿠버네티스 동작의 조합에서 비롯됐다.
① Karpenter는 Pending Pod가 생겨야 신규 노드를 프로비저닝한다
Karpenter의 기본 트리거는 스케줄링 실패(Pending) 상태의 Pod다. 노드 A가 아직 리소스 여유가 있으면 새 Pod가 노드 A에 스케줄링되고, Karpenter는 신규 노드를 만들 이유가 없다.
KEDA → Pod 스케일 아웃 → 노드 A에 스케줄링 성공 → 노드 B 생성 안 됨
→ 노드 A 포화 → Pod Pending → 그제야 노드 B 프로비저닝
즉, 노드 B는 노드 A가 포화된 이후에야 생성된다.
② topologySpreadConstraints는 비교 대상 노드가 없으면 동작하지 않는다
topologySpreadConstraints는 노드 간 Pod 분산을 위한 설정이다. 하지만 이 제약은 이미 존재하는 노드들 사이에서만 분산 효과를 발휘한다.
노드가 1대뿐인 상황에서는 비교 대상이 없으니 분산 로직이 동작하지 않는다. 노드 B가 생성되는 순간, 이미 많은 Pod가 노드 A에 몰려있는 상태라 topologySpreadConstraints가 있어도 재분산이 자동으로 이루어지지 않는다.
[실패 시나리오]
1. KEDA → 오전 9시 Pod 대거 스케일 아웃
2. 노드 A에 순차적으로 스케줄링 → topologySpreadConstraints 동작 안 함 (노드 1개)
3. 노드 A 포화 → Karpenter 노드 B 생성
4. 이미 노드 A에 다 몰린 상태 → 노드 B는 비어있음
해결 방안: Placeholder Pod로 선제 프로비저닝
핵심 아이디어는 실제 워크로드가 몰리기 전에 미리 노드 B를 준비해두는 것이다. 이를 위해 낮은 우선순위의 “자리 채우기(Placeholder)” Pod를 활용했다.
① PriorityClass 생성
apiVersion: scheduling.k8s.io/v1
kind: PriorityClass
metadata:
name: placeholder-priority
value: -1 # 일반 Pod보다 낮은 우선순위 (기본값 0보다 낮음)
globalDefault: false
preemptionPolicy: Never
description: "Placeholder Pod용 낮은 우선순위"
② Placeholder Pod 배포 (CronJob)
업무 시간 시작 직전에 Placeholder Pod를 배포하는 CronJob을 설정한다. 이 Pod는 의도적으로 Pending 상태를 만들어 Karpenter가 노드 B를 미리 생성하도록 유도한다.
apiVersion: batch/v1
kind: CronJob
metadata:
name: scale-out-placeholder
spec:
schedule: "50 8 * * 1-5" # 업무 시간 시작 10분 전
jobTemplate:
spec:
template:
spec:
priorityClassName: placeholder-priority
terminationGracePeriodSeconds: 0
containers:
- name: placeholder
image: pause:3.9
resources:
requests:
cpu: "500m" # 노드 B 생성을 유발하기에 충분한 리소스 요청
memory: "1Gi"
restartPolicy: Never
작동 원리:
- 오전 8시 50분: Placeholder Pod 배포 → 노드 A 여유 공간에 스케줄링
- 또는 Placeholder Pod의 리소스 요청이 노드 A의 여유를 초과 →
Pending상태 Pending감지 → Karpenter가 노드 B 프로비저닝- 오전 9시: KEDA가 실제 서비스 Pod 스케일 아웃
- 이때 노드 A + 노드 B가 이미 존재 →
topologySpreadConstraints정상 동작 → 균등 분산 - Placeholder Pod는 낮은 우선순위로 인해 실제 서비스 Pod에 의해 자연스럽게 퇴출(Preemption) 또는 완료
결과: Scale-out이 필요한 시점에 이미 2개의 노드가 준비되어 있어 topologySpreadConstraints가 정상 동작했다. 서비스 Pod가 두 노드에 균등하게 배포되어 초기 부하 쏠림 현상이 해소됐다.
이슈 3: Consolidation 중 Ready 비율 급락
현상
업무 외 시간으로 전환 시 노드 B의 모든 Pod가 노드 A로 재배치되는 과정에서, 전체 클러스터의 Ready 상태 Pod 비율이 순간적으로 50%까지 하락하는 현상이 관찰됐다. 직접적인 장애는 발생하지 않았지만, 고가용성 관점에서 개선이 필요한 지점이었다.
근본 원인 분석
Consolidation의 본질적인 동작 방식에서 비롯된 문제다. Karpenter는 노드를 통합할 때 대상 노드의 모든 Pod를 한꺼번에 Evict한다. 노드 B에 서비스 Pod가 10개 있다면, 10개가 동시에 Terminating 상태가 되고 새 노드(또는 노드 A)에서 재시작된다.
당시 PDB 설정은 minAvailable: 1이었다. 총 10개의 Pod 중 최소 1개만 살아있으면 되는 매우 느슨한 기준이었고, Karpenter의 사전 시뮬레이션에서 “위반 없음”으로 판정되어 Consolidation이 실행됐다.
[기존 설정: minAvailable: 1]
- 노드 B에 서비스 Pod 10개 → Consolidation 시뮬레이션
- "10개 중 1개만 남으면 되니까 이 노드 비워도 됩니다" → Consolidation 실행
- 10개 동시 Evict → Ready Pod 급락
해결 방안: PDB를 절대값에서 비율로 변경
minAvailable 값을 고정 숫자 1에서 상대적 비율 50%로 변경했다.
# 변경 전
apiVersion: policy/v1
kind: PodDisruptionBudget
metadata:
name: service-pdb
spec:
minAvailable: 1 # 절대값: Pod가 1개만 살아있어도 OK
selector:
matchLabels:
app: my-service
# 변경 후
apiVersion: policy/v1
kind: PodDisruptionBudget
metadata:
name: service-pdb
spec:
minAvailable: "50%" # 비율: 항상 전체의 절반 이상 유지
selector:
matchLabels:
app: my-service
50%로 설정 시 Karpenter의 동작 변화:
Karpenter는 Consolidation 전 시뮬레이션에서 “이 노드의 모든 Pod를 Evict하면 minAvailable(50%)를 위반하는가?“를 확인한다. 노드 B의 Pod 10개가 모두 사라지면 전체 Ready Pod가 50% 미만으로 떨어지므로, Karpenter는 한꺼번에 제거하는 대신 순차적으로 제거하게 된다. 새 Pod가 Ready 상태가 되면서 비율을 충족시킨 후 다음 Pod를 제거하는 방식으로 진행된다.
주의 사항:
minAvailable을 너무 높게 설정하면 Consolidation 자체가 불가능해질 수 있다. 예를 들어 replica가 2개인데minAvailable: 2를 설정하면, Consolidation 시 하나도 Evict할 수 없어 노드 통합이 영구적으로 차단된다. replica 수를 고려하여 적정 비율을 설정해야 한다.
결과: Consolidation 시에도 Ready Pod 비율이 안정적으로 유지되어 가용성 지표 급락 현상이 사라졌다.
ScheduleAnyway의 숨겨진 트레이드오프
이 섹션은 이슈 해결 과정에서 발견한 추가적인 함정이다.
topologySpreadConstraints에는 두 가지 모드가 있다.
DoNotSchedule: 분산 제약을 만족할 수 없으면 Pod를 스케줄링하지 않음 (Hard)ScheduleAnyway: 분산 제약을 만족하지 못해도 어딘가에 스케줄링함 (Soft)
일반적으로 “소프트 제약”은 가용성을 위해 느슨하게 동작한다고 이해한다. 하지만 Karpenter와 함께 사용할 때는 이중적인 성격을 보인다.
| 상황 | Karpenter의 ScheduleAnyway 처리 |
|---|---|
| 프로비저닝(Scale-out) 시 | 소프트 제약이지만 하드처럼 처리. 분산을 위해 필요 이상의 노드를 생성할 수 있음 |
| Consolidation(Scale-in) 시 | 소프트 제약을 하드 제약으로 취급. 제약 위반이 예상되면 Consolidation 포기 |
Consolidation에서 ScheduleAnyway를 하드처럼 처리하는 이유는 “프로비저닝-통합 루프” 방지다. 만약 Consolidation이 ScheduleAnyway를 무시하고 강제로 통합한다면:
- Karpenter가 분산을 위해 노드 B를 생성 (ScheduleAnyway 준수)
- 즉시 Consolidation에서 “노드 B가 비효율적” → 제거
- 다시 분산 위반 → 노드 B 생성 → 제거 루프
이를 막기 위해 Consolidation 시에는 소프트 제약도 하드처럼 취급한다. 결과적으로 ScheduleAnyway를 사용하는 워크로드가 있으면 Karpenter의 비용 최적화 Consolidation이 제한될 수 있다. “소프트 제약이니까 비용에 영향이 없겠지”라고 생각했다면 오산이다.
세 이슈의 공통 패턴과 교훈
세 가지 이슈를 돌아보면 공통된 패턴이 있다.
오토스케일러들은 서로를 모른다. KEDA, HPA, Karpenter는 각자의 책임 영역에서 독립적으로 동작한다. 이들 사이에는 직접적인 조율 메커니즘이 없다. 각자 최선의 결정을 내리지만, 조합 결과가 의도와 다를 수 있다.
이를 다루는 도구들이 쿠버네티스에 있다. 각 이슈의 해결 방안을 보면 Annotation, PDB, PriorityClass 등 모두 쿠버네티스 기본 기능이다. 오토스케일러의 동작을 제어하는 레버가 이미 시스템에 내장되어 있다. 핵심은 내부 동작을 이해하고 적절한 레버를 당기는 것이다.
| 이슈 | 원인 요약 | 해결 레버 |
|---|---|---|
| 중요 Pod 노드가 Consolidation 대상 선정 | Karpenter의 리소스 총합 기반 비용 계산 | do-not-disrupt Annotation + PDB |
| Scale-out 시 신규 노드로 Pod 미분산 | Karpenter 지연 프로비저닝 + topologySpreadConstraints 동작 조건 | Placeholder Pod (낮은 PriorityClass) |
| Consolidation 시 Ready Pod 급락 | 느슨한 PDB + Karpenter의 일괄 Eviction | PDB minAvailable 절대값 → 비율(50%) 변경 |
결과 및 개선 효과
세 가지 해결 방안을 모두 적용한 이후의 클러스터 운영 안정성은 명확히 개선됐다.
- 업무 외 시간 전환 시 Redis 등 중요 서비스의 다운타임: 0
- 업무 시간 시작 시 노드 간 Pod 분산 균형: 정상 (±10% 이내)
- Consolidation 시 Ready Pod 최저 비율: 50% 이상 유지
소규모 클러스터에서는 노드 한 대의 상태가 전체 클러스터에 미치는 영향이 크다. 대규모 클러스터에서는 쉽게 지나칠 수 있는 세부 동작이, 노드 2~3대 환경에서는 치명적인 장애로 이어질 수 있다. 제약 조건 자체가 시스템을 제대로 이해하도록 강제하는 훈련장이 됐다.
마무리 및 다음 스텝
Karpenter와 KEDA의 조합은 강력하지만, 두 시스템의 내부 동작을 충분히 이해하지 않으면 예상 밖의 동작을 마주치게 된다.
이후 개선 방향으로 고려 중인 것들:
- Karpenter NodePool 세분화: 중요 워크로드와 일반 워크로드를 다른 NodePool에 배치하여 Consolidation 범위를 명시적으로 분리
- KEDA CronScaler 도입: 업무 시간/외 시간 전환을 KEDA CronScaler로 관리하여 Placeholder Pod CronJob과 통합
- Disruption Budget at NodePool Level: Karpenter v1의 NodePool
disruption.budgets설정으로 시간대별 Consolidation 제한
돌아보며
- Karpenter Consolidation과 kube-scheduler(topologySpreadConstraints·PDB·PriorityClass)의 내부 동작을 시뮬레이션 단위까지 추적해 근본 원인을 규명하는 디버깅 능력
- 새 도구를 추가하는 대신 쿠버네티스 내장 레버(
do-not-disrupt, PDB 비율화, Placeholder Pod)로 재발을 차단하는 해결 방식의 판단 기준 - 소규모 클러스터라는 제약 조건에서 안정성 기준(중요 서비스 다운타임 0, Pod 분산 ±10%, Ready 비율 50% 이상)을 수치로 정의하고 달성한 운영 체계 구축 능력