Karpenter를 1년 넘게 운영하다 보니 "왜 이 인스턴스 타입을 골랐을까" 하는 순간이 종종 생긴다. m5.2xlarge면 충분해 보이는데 c6i.4xlarge를 띄운다거나, 분명히 비슷한 spec인데 어떤 Pod는 한 노드에 몰리고 어떤 Pod는 새 노드를 띄운다. 처음엔 그냥 "알아서 잘 해주겠지" 하고 넘어갔는데, 최근 Karpenter 1.11에서 Application Recovery Controller(ARC) zonal shift 통합이 들어오면서 zone 단위 회복 시나리오를 검토할 일이 생겼다. 이 김에 스케줄러 내부를 한 번 제대로 들여다봤다.
사실 Karpenter의 스케줄링 로직은 pkg/controllers/provisioning/scheduling/scheduler.go 한 파일에 핵심이 거의 다 들어 있다. 코드를 따라가면서 "내가 운영하면서 본 동작이 어디서 결정되는지"를 매칭해 보니, 평소에 이해 못 했던 동작 몇 개가 명확해졌다.
Provisioner Loop과 Pod 수집 단계
Karpenter의 provisioning controller는 unschedulable 상태(PodScheduled=False)인 Pod 목록을 매 sync마다 모은다. 이때 묘하게 헷갈리는 부분이 있는데, Karpenter는 kube-scheduler를 대체하지 않는다. kube-scheduler가 먼저 Pod를 어딘가에 스케줄링하려고 시도하다 실패한 결과물(Unschedulable=True)을 보고 Karpenter가 움직인다. 그래서 노드가 생긴 뒤 실제 Pod 바인딩은 여전히 kube-scheduler가 한다.
이 분리 구조 때문에 가끔 곤란해진다. Pod의 affinity가 너무 빡빡하면 Karpenter가 노드를 띄워도 kube-scheduler가 "그 노드에 안 맞아"라며 또 거부할 수 있다. 우리 팀에서 한 번 GPU Pod의 requiredDuringScheduling rule을 잘못 걸어서 Karpenter가 G5 노드를 띄웠는데 kube-scheduler가 거부해서 노드만 둥둥 떠 있는 상황을 만든 적 있다. Karpenter의 emptiness 감지가 들어오기 전까지 약 30분간 빈 GPU 노드가 시간당 1달러씩 태우고 있었다. 이건 알고리즘 자체의 문제는 아니지만, 두 컴포넌트가 같은 결정을 따로 하기 때문에 발생하는 구조적 함정이다.
후보 NodeClaim 생성: Bin-Packing이라기엔 좀 다른
스케줄러가 가장 처음 하는 일은 unschedulable Pod 리스트를 받아서 "이 Pod들을 담을 가상의 NodeClaim들"을 만드는 것이다. 코드를 보면 Scheduler.Solve() 함수가 Pod를 하나씩 순회하며 다음 순서로 처리한다.
// 의사 코드 — 실제 코드는 더 복잡함
for _, pod := range podsToSchedule {
// 1. 기존 노드(in-flight 포함)에 들어갈 수 있는지 시도
if existingNode.tryAdd(pod) == ok { continue }
// 2. 기존 NodeClaim 후보에 들어갈 수 있는지 시도
for _, nc := range pendingNodeClaims {
if nc.tryAdd(pod) == ok { continue outer }
}
// 3. 새 NodeClaim 생성
newNC := NewNodeClaim(pod.requirements)
pendingNodeClaims = append(pendingNodeClaims, newNC)
}
여기서 핵심은 tryAdd다. tryAdd는 단순히 "리소스가 남느냐"만 보지 않는다. Pod의 nodeSelector, taints/tolerations, topology spread, pod affinity/anti-affinity, volume topology를 NodeClaim에 누적된 requirements와 교집합 연산을 한다. 이 교집합이 비어버리면 그 NodeClaim에는 못 들어간다.
처음에 이걸 보고 의아했던 부분이 있다. "그러면 Pod 순서에 따라 결과가 달라지지 않나?" 맞다, 어느 정도는 그렇다. Karpenter는 Pod를 우선순위 → 리소스 요청량 내림차순으로 정렬해서 처리한다. 큰 Pod를 먼저 배치해야 fragmentation이 줄어드는 first-fit decreasing의 변형이다. 이게 완벽한 최적해를 보장하진 않는다. 그래서 실제 운영에서 "왜 노드 두 개로 충분한데 세 개를 띄웠지" 같은 상황이 가끔 나온다. 대부분은 cost 측면에서 무시할 수준이지만, 대규모 batch 워크로드에서는 신경 쓰이는 부분이다.
InstanceType 선정: 800개 후보를 어떻게 좁히나
AWS provider 기준으로 Karpenter는 약 800개의 인스턴스 타입 카탈로그를 메모리에 들고 있다. NodeClaim의 requirements가 결정되면 이 카탈로그를 필터링한다. 필터링은 대략 이런 흐름이다.
먼저 NodePool의 requirements로 카테고리/family/CPU/메모리 범위를 자른다. 보통 이 단계에서 800개가 50~200개로 줄어든다. 그 다음 Pod의 요청 리소스를 만족하지 못하는 타입을 떨군다. 마지막으로 capacity-type(spot/on-demand)과 zone 제약을 적용한다.
남은 후보 중에서 가격이 가장 싼 것 하나를 고르는 게 아니다. Karpenter는 EC2 Fleet API에 한 번에 여러 인스턴스 타입을 넘긴다. 기본적으로 60개까지. 이게 spot의 가용성과 가격 안정성을 위한 설계다. spot 요청 시 EC2 측에서 그 시점에 가장 저렴하고 가용한 타입을 골라 띄운다. on-demand는 Karpenter가 우선순위 리스트의 첫 번째를 시도한다.
이 부분이 운영하다 보면 가끔 의도와 어긋난다. 예를 들어 우리 팀에서 "비싼 인스턴스는 안 띄웠으면" 하고 karpenter.k8s.aws/instance-cpu에 32 이하 제약을 걸었는데, 그래도 가끔 24 vCPU짜리가 떴다. 이유는 단순했다 — Pod의 합산 CPU 요청이 22 vCPU여서 16 vCPU 타입에 안 들어갔고, 24 vCPU 타입이 그 spot 풀에서 그 순간 가장 쌌던 거다. 알고리즘 자체는 합리적이다. 다만 "비용 최적"이 항상 "가장 작은 인스턴스"를 의미하진 않는다는 걸 보여준다.
Topology와 Volume Awareness — 의외로 깊은 부분
내가 가장 인상 깊었던 부분은 PVC topology 처리다. Pod가 이미 존재하는 PVC를 참조하고, 그 PV가 특정 zone에 있다면 Karpenter는 NodeClaim의 zone requirement를 그 zone으로 좁힌다. 이건 PV가 WaitForFirstConsumer mode가 아닌 경우 특히 중요한데, 그렇지 않으면 다른 zone에 노드를 띄워놓고 PVC가 attach 안 되는 사태가 생긴다.
흥미로운 점은 PVC가 WaitForFirstConsumer인 경우다. 이 경우 PV가 아직 없으므로 Karpenter는 자유롭게 zone을 고른다. 그런데 같은 StatefulSet에서 PVC가 여러 개고 각각 다른 zone에 attach될 수 있는 상황이면, Karpenter는 topology spread constraint를 가져와 zone 분산을 시도한다. 이 부분이 1.x에서 한 번 더 정교해졌다. capacity-spread label을 활용해 spot/on-demand 비율을 zone과 같은 방식으로 분산시킨다.
ARC zonal shift와 Karpenter 1.11의 변화
최근에 본 Karpenter 1.11 릴리즈에서 추가된 ARC zonal shift 통합이 이 topology 결정 로직과 맞물린다. zonal shift가 활성화된 상태에서 특정 zone이 격리되면, Karpenter는 해당 zone을 candidate에서 자동으로 제외한다. 이전에는 NodePool requirements를 수동으로 바꿔야 했고, 그 사이에 새 노드가 격리된 zone에 떠 버리는 race가 있었다. 이제는 controller 레벨에서 candidate 풀이 좁혀진다.
실제로 우리 팀에서는 이걸 game day 시나리오에서 검증해 봤다. zone us-east-1c를 shift 상태로 만들었더니 Karpenter가 1~2분 내에 그 zone 신규 provisioning을 멈췄다. 다만 이미 떠 있던 노드는 그대로 두는데, 이건 disruption budget과 별개 정책으로 관리해야 한다. 이 부분에서 한 번 헷갈렸다. zonal shift가 자동으로 기존 노드를 빼주리라 기대했는데, 그건 별도 메커니즘이 필요했다.
정리하다 보니
코드를 따라가다 보면 "Karpenter는 똑똑한 게 아니라 빠른 거구나" 하는 인상을 받는다. 거창한 ML도 없고, OR-tools 같은 정통 최적화도 없다. 잘 설계된 first-fit decreasing + EC2 Fleet의 가용성 보장 + 풍부한 메타데이터 매칭이 전부다. 그런데 그게 실무에선 충분히 잘 작동한다. Cluster Autoscaler가 ASG 단위로 의사결정하느라 5분씩 걸리던 시절을 생각하면, 단순함이 곧 속도라는 걸 새삼 느낀다.
다음에는 Karpenter의 disruption controller — consolidation과 drift detection 쪽 — 도 비슷한 방식으로 한 번 정리해 볼 생각이다. 그쪽이 사실 운영 사고가 더 자주 나는 영역이라.
'IT > Kubernets' 카테고리의 다른 글
| startupProbe 모르고 슬로우 스타트 앱 운영하지 마세요 (0) | 2026.05.13 |
|---|---|
| kube-proxy 내부 동작 - iptables, IPVS, nftables 모드는 패킷을 어떻게 처리하나 (0) | 2026.05.12 |
| 배포할 때마다 503이 잠깐씩 튀던 이유 — Pod 종료 흐름 삽질 노트 (0) | 2026.05.12 |
| KEDA SQS scaler에서 새벽에 만난 함정 (0) | 2026.05.11 |
| Bottlerocket vs Talos, 워커노드 OS는 뭘 쓸까 (0) | 2026.05.11 |