"이 Pod를 어느 노드에?" — 스케줄러가 매번 내리는 수십 개의 판단

GPU가 달린 노드가 3대 있다. 머신러닝 추론 Pod를 배포했다. 그런데 스케줄러가 그것을 일반 CPU 노드에 올렸다. Pod는 GPU 없이 돌다가 OOM이 났고, 운영자는 "왜 스케줄러가 GPU 노드를 안 골랐지?"를 추적했다. 답은 간단했다 — 스케줄러는 Pod가 GPU를 원한다는 것을 몰랐다. 아무 요구도 명시하지 않았으니 가장 자원 여유가 큰 노드(일반 CPU 노드)를 골랐을 뿐이다.

이 글이 푸는 것은: kube-scheduler가 "이 Pod를 어느 노드에?"를 어떻게 결정하고, 사용자가 그 결정을 어떻게 유도하는가다. 핵심은 스케줄링이 필터링 + 점수매기기의 두 단계라는 것과, 그 각 단계에 영향을 주는 네 가지 장치(nodeSelector, affinity, taint/toleration, topology spread)다.

스케줄링은 두 단계다 — Filter와 Score

Pod가 생성돼 노드가 아직 안 정해지면(Pending), 스케줄러가 동작한다. 스케줄러는 후보 노드 전체에 대해 두 단계를 거친다.

flowchart LR
    P["Pending Pod<br/>(아직 노드 미정)"] --> F["1. Filter (필터링)<br/>조건에 안 맞는 노드를 탈락"]
    F --> SC["2. Score (점수매기기)<br/>남은 노드에 점수 → 최고점 선택"]
    SC --> BIND["3. Bind<br/>'이 노드로' 기록"]
    BIND --> KL["해당 노드 kubelet이 실행"]

1단계 Filter(필터링): 절대적 조건으로 후보를 좁힌다.

  • 노드에 충분한 자원(CPU/메모리)이 있나? 없으면 탈락.
  • nodeSelector/affinity가 요구하는 라벨이 있나? 없으면 탈락.
  • 노드의 taint를 Pod가 견딜(toleration) 수 있나? 못하면 탈락.

2단계 Score(점수매기기): 살아남은 노드들에 점수를 매겨 최적을 고른다.

  • 자원 균형: 너무 빽빽한 노드는 감점.
  • nodeAffinity의 preferredDuringScheduling: 선호 노드는 가점.
  • topology spread: 가용영역/노드 간 분산 정도.

핵심 통찰: Filter를 통과하지 못하면 Pod는 영원히 Pending이다. Score는 통과한 노드들 중 최적을 고를 뿐, 후보를 만들어내지 못한다. 그래서 "왜 내 Pod가 안 뜨나"를 디버깅할 때 Filter(절대 조건)부터 봐야 한다.

# Kubernetes 1.36 — Pending Pod의 스케줄링 실패 이유 보기
kubectl describe pod <pending-pod> | grep -A15 Events

확인할 것: FailedScheduling 이벤트와 사유("0/3 nodes are available: 3 Insufficient cpu").

Events:
  Type     Reason            Age   From               Message
  ----     ------            ----  ----               -------
  Warning  FailedScheduling  3s    default-scheduler  0/3 nodes available: 3 Insufficient cpu.

nodeSelector — 가장 단순한 "이 라벨 있는 노드로"

가장 단순한 장치. Pod가 라벨 셀렉터로 노드를 고르게 한다.

# 노드에 라벨 붙이기
kubectl label node k8s-study-worker gpu=true
# Kubernetes 1.36
spec:
  nodeSelector:
    gpu: "true"        # gpu=true 라벨이 있는 노드에만

nodeSelector는 있거나 없거나 — Filter 단계에서 라벨이 없는 노드를 전부 탈락시킨다. Score에는 관여 안 한다. 단순하지만 유연성이 떨어져서 실무에선 다음의 nodeAffinity를 더 쓴다.

nodeAffinity — nodeSelector의 진화형 (필수 + 선호)

nodeAffinity는 두 모드를 갖는다:

  • requiredDuringScheduling (필수): Filter처럼 동작. 안 맞으면 스케줄 안 됨.
  • preferredDuringScheduling (선호): Score처럼 동작. 맞으면 가점, 안 맞아도 스케줄은 됨.
spec:
  affinity:
    nodeAffinity:
      requiredDuringSchedulingIgnoredDuringExecution:
        nodeSelectorTerms:
        - matchExpressions:
          - {key: gpu, operator: In, values: ["true"]}   # 필수: GPU 노드
      preferredDuringSchedulingIgnoredDuringExecution:
      - weight: 80
        preference:
          matchExpressions:
          - {key: zone, operator: In, values: ["us-east-1a"]}  # 선호: 1a에 가점 80

operator(In, NotIn, Exists, Gt, Lt 등)로 조건을 조합할 수 있어 nodeSelector보다 표현력이 크다.

podAffinity / podAntiAffinity — 다른 Pod와 함께/멀리

노드 라벨이 아니라 다른 Pod의 위치를 기준으로 스케줄하는 장치.

  • podAffinity(함께): "이런 라벨의 Pod가 있는 노드에 나도 달라" — 캐시 Pod와 웹 Pod를 같은 노드에 두어 localhost 통신.
  • podAntiAffinity(멀리): "이런 라벨의 Pod가 있는 노드엔 달라" — 같은 Deployment의 replica들을 서로 다른 노드에 분산.
affinity:
  podAntiAffinity:
    preferredDuringSchedulingIgnoredDuringExecution:
    - weight: 100
      podAffinityTerm:
        labelSelector:
          matchLabels: {app: web}
        topologyKey: kubernetes.io/hostname   # 같은 *호스트*에 겹치면 감점

이 설정은 "같은 app=web Pod가 이미 있는 호스트엔 피하라" — 단일 노드 장애에 대비한 분산의 기본 패턴.

podAntiAffinity는 클러스터가 클면 비용이 높다 — 모든 노드의 모든 Pod 라벨을 평가해야 하므로. 대규모에선 다음의 topology spread constraints가 더 효율적이다.

taint와 toleration — 노드가 Pod를 "밀어내는" 장치

지금까지는 Pod가 노드를 고르는 방향이었다. taint/toleration은 반대 방향 — 노드가 원치 않는 Pod를 밀어낸다.

taint는 노드에 붙이는 표식이다. "이 노드는 특별하니, 특별히 견딜 수 있는(toleration 있는) Pod만 받겠다"는 신호.

# 노드에 taint 부여
kubectl taint node k8s-study-worker dedicated=special:NoSchedule

taint는 key=value:effect 형태. effect의 세 종류:

  • NoSchedule: 견딜 수 없으면 아예 스케줄 안 함(강제).
  • PreferNoSchedule: 가급적 피하지만, 어쩔 수 없으면 스케줄.
  • NoExecute: 이미 떠 있는 Pod도 견디지 못하면 쫓아냄(퇴거).

toleration은 Pod 쪽에서 taint를 "견딘다"고 선언하는 것:

spec:
  tolerations:
  - key: "dedicated"
    operator: "Equal"
    value: "special"
    effect: "NoSchedule"

이 Pod만 taint를 견뎌 전용 노드에 들어갈 수 있다.

전형 용도: 컨트롤 플레인 노드는 기본 node-role.kubernetes.io/control-plane:NoSchedule taint를 갖는다. 그래서 일반 Pod가 거기에 안 스케줄된다. 특별한 인프라 Pod(DaemonSet)만 toleration으로 들어간다.

# 노드의 taint 보기
kubectl describe node k8s-study-control-plane | grep -i taint
Taints: node-role.kubernetes.io/control-plane:NoSchedule

taint/toleration과 nodeAffinity는 다른 축이다. taint는 노드가 밀어내는 것, nodeAffinity는 Pod가 끌어당기는 것. 둘 다 써야 정확히 원하는 노드로 간다 — "GPU 노드에만 가고 싶다"(affinity) + "GPU 노드는 일반 Pod를 밀어낸다"(taint)를 함께 쓰면 강제력이 생긴다.

topology spread constraints — 가용영역 단위 분산

가장 현대적인 분산 장치. topologyKey(예: topology.kubernetes.io/zone)를 기준으로 Pod를 고르게 펼친다.

spec:
  topologySpreadConstraints:
  - maxSkew: 1
    topologyKey: topology.kubernetes.io/zone
    whenUnsatisfiable: DoNotSchedule     # 또는 ScheduleAnyway
    labelSelector:
      matchLabels: {app: web}

의미: "가용영역(zone) 간 Pod 수 차이가 maxSkew(1)를 넘지 않게". zone-A에 3개, zone-B에 0개면 skew=3이어서 새 Pod는 반드시 zone-B로 간다. 단일 영역 장애에 대비한 분산의 표준.

whenUnsatisfiable: DoNotSchedule이면 Filter(강제), ScheduleAnyway이면 Score(선호).

자원 요청 — Filter의 가장 기본 조건

지금까지 장치들을 봤지만, 가장 기본 Filter는 자원이다. Pod가 resources.requests.cpu: 500m을 요구하면, 남은 CPU가 500m 이상인 노드만 후보가 된다.

containers:
- name: app
  image: myapp:1.0
  resources:
    requests: {cpu: "500m", memory: "256Mi"}
    limits:   {cpu: "1",    memory: "512Mi"}

자원 요청이 너무 크면 아무 노드도 Filter를 못 통과해 영원히 Pending. kubectl describeFailedScheduling: Insufficient cpu가 가장 흔한 스케줄 실패 사유다. (requests/limits와 QoS는 11-resource-qos에서 깊이.)

직접 확인하기

# Kubernetes 1.36 — nodeSelector로 특정 노드에 강제 스케줄
kubectl label node k8s-study-worker disktype=ssd
kubectl run targeted --image=nginx:1.27 --restart=Never \
  --overrides='{"spec":{"nodeSelector":{"disktype":"ssd"}}}'
kubectl get pod targeted -o wide

확인할 것: NODE 열이 k8s-study-worker로 고정된다.

# 스케줄 불가능한 Pod로 FailedScheduling 이벤트 유발
kubectl run huge --image=nginx:1.27 --restart=Never \
  --overrides='{"spec":{"containers":[{"name":"x","image":"nginx:1.27","resources":{"requests":{"cpu":"999"}}}]}}'
kubectl describe pod huge | grep -A8 Events

확인할 것: Insufficient cpu로 스케줄 실패. Filter 단계의 절대 조건 위반.

# 노드의 라벨/토폴로지 정보
kubectl get nodes --show-labels | tr ',' '\n' | grep -E 'zone|arch|os'

흔히 묻는 것, 흔히 틀리는 것

오해 정정
"스케줄러가 가장 빈 노드를 무조건 고른다" Score의 한 요소일 뿐. Filter 통과한 노드 중에서 점수매기기
"nodeSelector는 선호다" 필수(Filter). 안 맞으면 스케줄 안 됨
"taint는 노드에, toleration도 노드에" taint는 노드에, toleration은 Pod에. 서로 다른 객체
"affinity와 taint는 같다" 방향이 반대. affinity=Pod가 끌어당김, taint=노드가 밀어냄
"topology spread는 노드 단위만" topologyKey가 정하기 나름. zone, rack, hostname 뭐든
"Pending이면 자원 부족이다" 흔하지만 전부는 아님. taint, affinity, 노드 수 자체도 원인

요약 — 이 글의 결론

  • 스케줄링은 Filter(절대) + Score(선호)의 두 단계. Filter를 못 통과하면 영원히 Pending — 디버깅은 절대 조건부터.
  • 네 장치가 각기 다른 축: nodeSelector/nodeAffinity(Pod→노드 끌어당김), podAffinity/AntiAffinity(Pod→Pod 위치), taint/toleration(노드→Pod 밀어냄), topology spread(토폴로지 단위 분산).
  • affinity는 required/preferred로 필수와 선호를 구분. nodeSelector는 항상 필수.
  • taint/toleration은 반대 방향 — 컨트롤플레인 노드 보호, 전용 노드 강제에 핵심. NoSchedule/PreferNoSchedule/NoExecute 세 효과.
  • 자원 요청(requests)이 Filter의 가장 기본 조건. Insufficient cpu는 가장 흔한 스케줄 실패.
  • 대규모 분산엔 topology spread가 podAntiAffinity보다 효율적. zone/rack 단위 고른 분산의 표준.

생각해 볼 문제

  1. Filter를 통과한 노드가 0개인데 Pod를 띄워야 한다면, 무엇을 먼저 점검하겠는가? 우선순위를 매기라.
  2. nodeAffinity(required)와 taint(NoSchedule)가 충돌하는 설정을 만들면 Pod는 어떻게 되나?
  3. podAntiAffinity를 쓰면 클러스터가 클수록 비용이 높다. topology spread로 바꿨을 때 이득은?
  4. NoExecute taint를 추가하면 그 노드의 기존 Pod는 어떻게 되나? toleration이 없다면?
  5. maxSkew: 1, DoNotSchedule인데 한 zone의 노드가 전부 죽었다. 새 Pod는 스케줄되나?
  6. GPU 노드 3대에 ML Pod를 "반드시 + 골고루" 배치하려면 세 장치를 어떻게 조합하겠는가?

참고

'Tech Artifacts > Kubernetes' 카테고리의 다른 글

Kubernetes - 10. config secret  (0) 2026.07.13
Kubernetes - 09. storage  (0) 2026.07.13
Kubernetes - 07. daemonset job cronjob  (0) 2026.07.13
Kubernetes - 06. statefulset  (0) 2026.07.13
Kubernetes - 05. deployment  (0) 2026.07.13