marp: true theme: default paginate: true backgroundColor: #1a1a2e color: #eaeaea style: | section { font-family: ‘Pretendard’, ‘Apple SD Gothic Neo’, sans-serif; } h1 { color: #00d4ff; } h2 { color: #00d4ff; } code { background-color: #16213e; } strong { color: #ff6b6b; } table { font-size: 0.8em; } .columns { display: grid; grid-template-columns: 1fr 1fr; gap: 1rem; }
K8s 스케줄러 Deep Dive
내부 동작 원리부터 Race Condition까지
🤔 질문
Pod를 생성하면 어떤 노드에 배치될까?
apiVersion: v1kind: Podmetadata: name: my-appspec: containers: - name: app image: nginx resources: requests: memory: "128Mi" cpu: "500m"목차
- 스케줄링 기본 구조
- Scheduling Framework
- Scheduling Queue
- Preemption Deep Dive
- 성능 튜닝
- 스케줄러의 한계
1. 스케줄링 기본 구조
3단계로 이해하기
┌─────────────────────────────────────────────────────────┐│ ││ Pending Pod ││ │ ││ ▼ ││ ┌───────────┐ ┌───────────┐ ┌───────────┐ ││ │ Filter │ ─▶ │ Score │ ─▶ │ Bind │ ││ │ (필터링) │ │ (점수화) │ │ (바인딩) │ ││ └───────────┘ └───────────┘ └───────────┘ ││ ││ 5개 노드 → 2개 각 노드 점수 최고 점수 노드 선택 ││ │└─────────────────────────────────────────────────────────┘Filter 단계
조건에 맞지 않는 노드를 제외
| Filter | 역할 |
|---|---|
NodeResourcesFit | CPU/Memory 충분한지 |
NodeAffinity | nodeSelector, nodeAffinity 만족하는지 |
TaintToleration | Taint를 Toleration하는지 |
PodTopologySpread | 토폴로지 분산 조건 만족하는지 |
Node A ✓ Node B ✗ Node C ✓ Node D ✗ Node E ✓ (taint) (리소스 부족)Score 단계
남은 노드에 점수 부여 (0~100)
Node A: 75점 ← LeastAllocated (여유 리소스 많음)Node C: 45점Node E: 60점NodeResourcesFit 스코어링 전략:
| 전략 | 설명 | 용도 |
|---|---|---|
LeastAllocated | 여유 많을수록 높은 점수 | 부하 분산 (기본값) |
MostAllocated | 꽉 찰수록 높은 점수 | 노드 수 최소화, 비용 절감 |
Bind 단계
최고 점수 노드에 바인딩
┌──────────────┐ ┌──────────────┐│ Scheduler │ ──────▶ │ API Server │└──────────────┘ └──────────────┘ │ pod.spec.nodeName = "node-a" │ ▼ ┌──────────────┐ │ Node A │ │ (kubelet) │ └──────────────┘2. Scheduling Framework
15개 Extension Point
PreEnqueue ─▶ [Queue] ─▶ PreFilter ─▶ Filter ─▶ PostFilter │ ▼ PreScore ─▶ Score ─▶ NormalizeScore │ ▼ Reserve ─▶ Permit ─▶ PreBind ─▶ Bind ─▶ PostBind왜 이렇게 많을까? → 커스텀 스케줄러 플러그인을 쉽게 끼워넣기 위해
Scheduling Cycle vs Binding Cycle
┌─────────────────────────────────────────────────────────────┐│ Scheduling Cycle (Serial) ││ ┌─────────┬─────────┬─────────┬─────────┬─────────┐ ││ │PreFilter│ Filter │PostFilter│PreScore │ Score │ ││ └─────────┴─────────┴─────────┴─────────┴─────────┘ ││ ↓ ││ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ││ ↓ ││ Binding Cycle (Parallel) ││ ┌─────────┬─────────┬─────────┬─────────┐ ││ │ Reserve │ Permit │ PreBind │ Bind │ ← goroutine ││ └─────────┴─────────┴─────────┴─────────┘ │└─────────────────────────────────────────────────────────────┘왜 분리? → Bind는 API Server 호출이라 느림. 병렬로 처리해야 throughput 확보
3. Scheduling Queue
3개의 큐
┌─────────────────┐ 새 Pod ────────▶ │ ActiveQ │ ────▶ 스케줄링 시도 │ (heap, 우선순위) │ └─────────────────┘ │ 스케줄링 실패 │ ▼ ┌─────────────────┐ │ BackoffQ │ ────▶ 대기 후 ActiveQ로 │ (지수 백오프) │ └─────────────────┘ │ 계속 실패 (임계값 초과) │ ▼ ┌─────────────────┐ │ Unschedulable │ ────▶ 클러스터 변경시 복귀 │ Pod Pool │ └─────────────────┘QueueingHint (v1.32 GA)
문제: Unschedulable Pod를 언제 다시 시도할까?
기존: 아무 이벤트나 발생하면 일단 ActiveQ로 이동 → 비효율
QueueingHint: 이벤트별로 “이 Pod가 스케줄 가능해질 수 있나?” 판단
// 반환값const ( QueueSkip // 이 이벤트로는 안 됨, 스킵 Queue // 가능성 있음, 큐로 이동 QueueImmediately // 확실함, 즉시 이동)4. Preemption Deep Dive
Preemption이란
높은 우선순위 Pod가 낮은 우선순위 Pod를 쫓아내고 자리 확보
┌────────────────────────────────────────┐│ Node A (capacity: 4 CPU) ││ ││ ┌──────────┐ ┌──────────┐ ││ │ Pod Low │ │ Pod Low │ ← 2 CPU ││ │ (pri:10) │ │ (pri:10) │ ││ └──────────┘ └──────────┘ ││ ││ High Priority Pod (pri:100) 요청: 3CPU ││ │ ││ ▼ ││ Low Pod 1개 evict → High Pod 배치 │└────────────────────────────────────────┘⚠️ 제한사항 1: Inter-Pod Affinity
높은 우선순위 Pod가 낮은 우선순위 Pod에 affinity가 있으면?
cache-pod (priority: 10) ←─── affinity ─── web-pod (priority: 100)문제:
- web-pod 스케줄링 시도
- preemption 시뮬레이션: cache-pod 제거하면 자리 생김
- 근데 cache-pod 제거하면 affinity 만족 불가
- 결과: web-pod는 영원히 Pending
해결책: affinity 대상 Pod의 우선순위를 같거나 높게 설정
⚠️ 제한사항 2: Cross-Node Preemption 불가
다른 노드의 Pod는 쫓아내지 않음
┌──────────────┐ ┌──────────────┐│ Node 1 │ │ Node 2 ││ │ │ ││ ┌────────┐ │ │ (빈 공간) ││ │ pod-a │ │ │ ││ │pri:10 │ │ │ ││ │anti- │ │ │ web-pod ││ │affinity│──┼─────┼─▶ 못 들어감 ││ │to web │ │ │ ││ └────────┘ │ │ │└──────────────┘ └──────────────┘ zone-a
pod-a를 쫓아내면 해결되는데, 스케줄러는 Node 1을 건드리지 않음⚠️ 제한사항 3: nominatedNodeName Race
Timeline────────────────────────────────────────────────────────────▶
T1: Pod P 스케줄링 실패, preemption 시작 │T2: │ 스케줄러가 Node N 선택, nominatedNodeName=N 설정 요청 │T3: │ 그 사이 Pod Q가 생성됨 │ │T4: │ └─▶ Pod Q가 Node N에 스케줄링됨 (자리 있었으니까) │T5: └─▶ Pod P의 nominatedNodeName=N 설정 완료 하지만 이미 자리 없음!
결과: Node N은 P를 위해 "예약"됐다고 생각해서 다른 작은 Pod들도 안 들어감 → 리소스 낭비⚠️ 제한사항 4: Graceful Termination 중 끼어들기
victim Pod 종료 대기 중 (기본 30초) 다른 Pod가 끼어듦
T1: Pod P (pri:100) preemption 시작, victim 종료 요청 │T2: │ victim 종료 중... (30초 대기) │T3: │ Pod Q (pri:200) 생성됨 │T4: │ victim 종료 완료, 자리 생김 │ │T5: │ └─▶ Q가 먼저 스케줄링됨 (더 높은 우선순위) │T6: └─▶ P는 다시 Pending 상태
완화책: 낮은 우선순위 Pod에 terminationGracePeriodSeconds: 0⚠️ 제한사항 5: PDB는 Best-Effort
PodDisruptionBudget을 존중하려고 노력하지만 보장 아님
Race Condition 시나리오:
PDB: minAvailable: 2현재 Pod: 3개
┌─────────────┐ ┌─────────────┐│ Scheduler A │ │ Scheduler B │ (HA 구성)│ │ │ ││ "1개 evict │ │ "1개 evict ││ 가능하네" │ │ 가능하네" ││ │ │ │ │ ││ ▼ │ │ ▼ ││ evict 1개 │ │ evict 1개 │└─────────────┘ └─────────────┘
결과: Pod 1개만 남음 (minAvailable: 2 위반!)ref: kubernetes/kubernetes#91492
⚠️ 제한사항 6: Priority Inflation
모든 Pod가 높은 우선순위면 preemption 무의미
해결책: ResourceQuota로 제한
apiVersion: v1kind: ResourceQuotametadata: name: high-priority-quotaspec: hard: pods: "10" # high-priority Pod는 최대 10개 scopeSelector: matchExpressions: - operator: In scopeName: PriorityClass values: ["high-priority"]5. 성능 튜닝
percentageOfNodesToScore
모든 노드를 스코어링하면 느림 → 일정 비율만 확인
┌──────────────────────────────────────────┐│ 클러스터 크기 │ 기본값 │├──────────────────────────────────────────┤│ 100 노드 │ 50% ││ 500 노드 │ 20% ││ 5000+ 노드 │ 10% ││ 최소값 │ 5% (하드코딩) │└──────────────────────────────────────────┘apiVersion: kubescheduler.config.k8s.io/v1kind: KubeSchedulerConfigurationpercentageOfNodesToScore: 50노드 순회 최적화
같은 노드만 선택되는 것 방지
// 시작 인덱스를 랜덤하게startIndex := rand.Intn(len(nodes))
// round-robin으로 순회for i := 0; i < numNodesToCheck; i++ { idx := (startIndex + i) % len(nodes) checkNode(nodes[idx])}50% 스코어링이어도 매번 다른 노드 집합 검사
6. 스케줄러의 한계
스케줄러가 모르는 것들
| 항목 | 설명 |
|---|---|
| NUMA 토폴로지 | kubelet의 Topology Manager가 처리. 스케줄러는 모름 |
| GPU 세부 정보 | nvidia.com/gpu: 1만 앎. 어떤 GPU인지 모름 |
| Ephemeral Storage | 요청량만 봄. 실제 사용량 모름 |
| 실시간 리소스 | 캐시된 정보 사용. 수 초 지연 있음 |
결과: 스케줄링 성공 → kubelet Admit 실패 가능
Scheduler: "Node A에 배치!"Kubelet: "NUMA 조건 안 맞는데요" → Pod 거부정리
-
3단계 기본 구조: Filter → Score → Bind
-
Scheduling Framework: 15개 extension point로 커스터마이징
-
Queue 구조: ActiveQ → BackoffQ → Unschedulable Pool
-
Preemption 주의사항:
- nominatedNodeName race condition
- PDB는 best-effort
- Cross-node preemption 안 됨
-
성능: percentageOfNodesToScore로 튜닝
-
한계: NUMA, GPU 상세 정보는 스케줄러가 모름