Moment Note

쿠버네티스 파드 스케줄링 제어: node affinity·taint·toleration·topologySpread 실전

서버 & 인프라 ·

클러스터를 운영하다 보면 파드가 엉뚱한 곳에 뜨는 순간이 반드시 온다. GPU 노드에 CPU만 쓰는 배치 파드가 스케줄돼 비싼 자원을 놀리거나, 레플리카 3개가 하필 같은 노드에 몰려 그 노드가 죽는 순간 서비스 전체가 내려가거나, 특정 가용영역(AZ) 하나에 파드가 쏠려 존 장애에 무방비로 노출된다. 스케줄러는 기본적으로 “빈 자리에 적당히” 배치할 뿐, 우리가 원하는 배치 규칙을 알아서 지켜주지 않는다.

쿠버네티스는 이런 배치를 세밀하게 제어할 수 있는 여러 장치를 제공한다. nodeSelector와 node affinity로 어떤 노드에 붙일지 정하고, pod affinity/anti-affinity로 파드끼리 모으거나 떨어뜨리고, taint와 toleration으로 특정 노드를 격리하며, topologySpreadConstraints로 가용영역에 고르게 분산한다. 이 글에서는 각 장치를 언제 어떻게 쓰는지, 그리고 잘못 쓰면 어떤 함정에 빠지는지 실전 매니페스트 중심으로 정리한다.

nodeSelector vs nodeAffinity: 어떤 노드에 붙일까

가장 단순한 배치 제어는 nodeSelector다. 노드에 붙은 라벨과 정확히 일치하는 곳에만 파드를 스케줄한다. SSD가 달린 노드에만 특정 워크로드를 올리고 싶다면 이렇게 쓴다.

apiVersion: v1
kind: Pod
metadata:
  name: db-cache
spec:
  nodeSelector:
    disktype: ssd          # 이 라벨이 있는 노드에만 스케줄
  containers:
    - name: redis
      image: redis:7

nodeSelector는 직관적이지만 “정확히 일치”밖에 표현하지 못한다. “SSD면 좋지만 없으면 HDD라도”처럼 강제와 선호를 구분하거나, “존 A 또는 존 B”처럼 여러 값을 OR로 묶는 표현은 불가능하다. 이럴 때 nodeAffinity를 쓴다. 두 가지 모드가 있다.

  • requiredDuringSchedulingIgnoredDuringExecution: 반드시 만족해야 스케줄된다(하드 제약). 만족하는 노드가 없으면 파드는 Pending에 머문다.
  • preferredDuringSchedulingIgnoredDuringExecution: 만족하면 가점, 없어도 스케줄된다(소프트 선호). weight로 우선순위를 준다.
apiVersion: apps/v1
kind: Deployment
metadata:
  name: web
spec:
  replicas: 3
  selector:
    matchLabels: { app: web }
  template:
    metadata:
      labels: { app: web }
    spec:
      affinity:
        nodeAffinity:
          # 하드: 반드시 az-a 또는 az-b 존에
          requiredDuringSchedulingIgnoredDuringExecution:
            nodeSelectorTerms:
              - matchExpressions:
                  - key: topology.kubernetes.io/zone
                    operator: In
                    values: ["ap-northeast-2a", "ap-northeast-2b"]
          # 소프트: SSD 노드면 가점(없어도 뜸)
          preferredDuringSchedulingIgnoredDuringExecution:
            - weight: 80
              preference:
                matchExpressions:
                  - key: disktype
                    operator: In
                    values: ["ssd"]
      containers:
        - name: web
          image: nginx:1.27

이름 뒤의 IgnoredDuringExecution이 핵심 힌트다. 스케줄 시점에만 조건을 평가하고, 이미 뜬 파드는 나중에 노드 라벨이 바뀌어도 쫓아내지 않는다. 실행 중 조건 변화로 파드를 축출하는 기능은 taint의 NoExecute 쪽에서 담당한다.

podAffinity / podAntiAffinity: 파드끼리 모으고 떨어뜨리기

노드가 아니라 “다른 파드”를 기준으로 배치를 정할 때 pod affinity를 쓴다. 대표적으로 웹 파드와 캐시 파드를 같은 존에 붙여 지연을 줄이는 경우가 podAffinity, 같은 앱의 레플리카를 서로 다른 노드에 흩뿌려 단일 노드 장애를 견디게 하는 경우가 podAntiAffinity다.

핵심 필드는 topologyKey다. 이 라벨을 기준으로 “같은 범위”를 정의한다. kubernetes.io/hostname이면 노드 단위, topology.kubernetes.io/zone이면 존 단위로 모으거나 떨어뜨린다.

apiVersion: apps/v1
kind: Deployment
metadata:
  name: api
spec:
  replicas: 3
  selector:
    matchLabels: { app: api }
  template:
    metadata:
      labels: { app: api }
    spec:
      affinity:
        # 같은 app=api 파드끼리는 서로 다른 노드에 (하드 anti-affinity)
        podAntiAffinity:
          requiredDuringSchedulingIgnoredDuringExecution:
            - labelSelector:
                matchLabels: { app: api }
              topologyKey: kubernetes.io/hostname
        # 캐시(app=redis)와는 같은 존에 붙이면 가점 (소프트 affinity)
        podAffinity:
          preferredDuringSchedulingIgnoredDuringExecution:
            - weight: 50
              podAffinityTerm:
                labelSelector:
                  matchLabels: { app: redis }
                topologyKey: topology.kubernetes.io/zone
      containers:
        - name: api
          image: myorg/api:1.4

위처럼 하드 anti-affinity로 노드당 1개를 강제하면, replicas가 노드 수보다 많은 순간 나머지 파드는 붙을 노드가 없어 Pending에 빠진다. 레플리카를 3개로 늘렸는데 워커 노드가 2대뿐이면 1개가 영원히 안 뜨는 식이다. 이런 “노드당 최대 1개”가 실제로 필요한 경우는 드물고, 대개는 소프트(preferred)로 충분하다. 정말 고르게 흩뿌리는 것이 목적이라면 뒤에 나오는 topologySpreadConstraints가 더 적합하다.

taint & toleration: 전용 노드 격리

affinity가 “파드가 노드를 고르는” 관점이라면, taint는 반대로 “노드가 파드를 밀어내는” 관점이다. 노드에 taint를 걸면 그 taint를 견디는(tolerate) toleration을 가진 파드만 그 노드에 올라올 수 있다. GPU 노드, 모니터링 전용 노드, 특정 팀 전용 노드처럼 아무 파드나 올라오면 안 되는 노드를 격리할 때 쓴다.

taint에는 세 가지 effect가 있다.

  • NoSchedule: toleration 없는 파드는 스케줄되지 않는다. 이미 떠 있는 파드는 건드리지 않는다.
  • PreferNoSchedule: 가능하면 피하지만 자리가 없으면 올린다(소프트).
  • NoExecute: 스케줄을 막는 것은 물론, 이미 떠 있는데 toleration 없는 파드까지 축출한다.
# GPU 노드에 taint 부여 (전용 노드로 격리)
kubectl taint nodes gpu-node-1 dedicated=gpu:NoSchedule

# 특정 노드를 곧 내릴 예정이라 실행 중 파드까지 쫓아낼 때
kubectl taint nodes worker-7 maintenance=true:NoExecute

# taint 제거는 끝에 '-'
kubectl taint nodes gpu-node-1 dedicated=gpu:NoSchedule-

이제 GPU가 실제로 필요한 파드만 그 노드에 올라오게 하려면 toleration을 단다. 주의할 점은, toleration은 “올라올 수 있게 허용”할 뿐 “그 노드로 유인”하지는 않는다는 것이다. 전용 노드에 확실히 붙이려면 toleration과 nodeAffinity(또는 nodeSelector)를 함께 써야 한다.

apiVersion: apps/v1
kind: Deployment
metadata:
  name: ml-train
spec:
  replicas: 2
  selector:
    matchLabels: { app: ml-train }
  template:
    metadata:
      labels: { app: ml-train }
    spec:
      # 1) GPU taint를 견딘다
      tolerations:
        - key: dedicated
          operator: Equal
          value: gpu
          effect: NoSchedule
        # NoExecute를 최대 5분까지만 버티고 나가기
        - key: maintenance
          operator: Exists
          effect: NoExecute
          tolerationSeconds: 300
      # 2) 실제로 GPU 노드로 유인 (toleration만으로는 부족)
      nodeSelector:
        accelerator: nvidia-gpu
      containers:
        - name: trainer
          image: myorg/trainer:2.0
          resources:
            limits:
              nvidia.com/gpu: 1

topologySpreadConstraints: 가용영역 고르게 분산

레플리카를 여러 존과 노드에 균형 있게 흩뿌리는 데 가장 적합한 도구가 topologySpreadConstraints다. anti-affinity로도 비슷하게 흉내 낼 수 있지만, “얼마나 안 고르게 퍼져도 되는지”를 maxSkew로 정량 제어할 수 있다는 점이 다르다.

  • maxSkew: 토폴로지 도메인 간 파드 수 최대 차이. 1이면 어떤 두 존의 파드 개수 차이가 1을 넘지 않게 한다.
  • topologyKey: 분산 기준 라벨(존이면 topology.kubernetes.io/zone).
  • whenUnsatisfiable: 조건을 못 지킬 때 DoNotSchedule(하드)로 막을지, ScheduleAnyway(소프트)로 그냥 올릴지.
apiVersion: apps/v1
kind: Deployment
metadata:
  name: frontend
spec:
  replicas: 6
  selector:
    matchLabels: { app: frontend }
  template:
    metadata:
      labels: { app: frontend }
    spec:
      topologySpreadConstraints:
        # 존 단위: 차이 1 이하로 강제 분산
        - maxSkew: 1
          topologyKey: topology.kubernetes.io/zone
          whenUnsatisfiable: DoNotSchedule
          labelSelector:
            matchLabels: { app: frontend }
        # 노드 단위: 최대한 고르게, 안 되면 그냥 올림
        - maxSkew: 1
          topologyKey: kubernetes.io/hostname
          whenUnsatisfiable: ScheduleAnyway
          labelSelector:
            matchLabels: { app: frontend }
      containers:
        - name: frontend
          image: myorg/frontend:3.1

존이 3개고 replicas가 6이면 각 존에 2개씩 고르게 떨어진다. 존 하나가 통째로 죽어도 나머지 두 존의 4개가 살아 서비스가 유지된다. 존 분산은 DoNotSchedule로 강하게, 노드 분산은 ScheduleAnyway로 느슨하게 조합하는 것이 실무에서 흔한 패턴이다. 존은 반드시 지키되 노드 쏠림은 최선만 다하는 식이다.

우선순위와 프리엠션

노드 자원이 부족해 새 파드가 스케줄되지 못할 때, 덜 중요한 파드를 밀어내고 자리를 확보하는 것이 프리엠션(preemption)이다. PriorityClass로 파드에 우선순위 값을 부여하면, 스케줄러는 높은 우선순위 파드가 Pending일 때 낮은 우선순위 파드를 축출해 공간을 만든다.

apiVersion: scheduling.k8s.io/v1
kind: PriorityClass
metadata:
  name: high-priority
value: 1000000
preemptionPolicy: PreemptLowerPriority   # 낮은 우선순위 축출 허용
globalDefault: false
description: "결제 등 핵심 서비스용"
---
apiVersion: v1
kind: Pod
metadata:
  name: payment
spec:
  priorityClassName: high-priority
  containers:
    - name: payment
      image: myorg/payment:1.2

배치 작업이나 개발용 파드에는 낮은(음수 가능) 우선순위를, 핵심 서비스에는 높은 우선순위를 준다. 프리엠션이 필요 없는 저우선 파드는 preemptionPolicy: Never로 두면 다른 파드를 밀어내지 않고 자기가 대기한다. 다만 프리엠션이 잦으면 저우선 워크로드가 계속 죽고 재스케줄되며 불안정해지므로, 우선순위 등급은 몇 단계로 단순하게 유지하는 편이 좋다.

실무에서 자주 밟는 함정

이 장치들은 강력한 만큼 잘못 조합하면 파드가 안 뜨거나 클러스터가 비효율적으로 돌아간다. 현장에서 반복되는 실수를 정리한다.

  • over-constrained로 인한 Pending: nodeAffinity(required) + 노드당 1개 anti-affinity + taint toleration 누락이 겹치면, 조건을 모두 만족하는 노드가 0개가 돼 파드가 조용히 Pending에 머문다. kubectl describe pod의 Events에서 “0/N nodes are available” 사유를 먼저 확인하는 습관이 필요하다.
  • 하드 anti-affinity의 스케일 비용: required podAntiAffinity로 노드당 1개를 강제하면 레플리카 수가 노드 수에 묶인다. 오토스케일 상황에서 노드보다 파드가 많아지는 순간 초과분이 못 뜬다. 분산이 목적이면 topologySpreadConstraints를 우선 고려하라.
  • toleration만 달고 유인은 안 함: GPU 노드 taint를 견디는 toleration만 달면 그 파드는 GPU 노드에도 올 수 있을 뿐, 일반 노드에도 갈 수 있다. 전용 노드에 확실히 붙이려면 nodeAffinity/nodeSelector를 반드시 병행한다.
  • anti-affinity의 계산 비용: 대규모 파드 수에서 pod anti-affinity는 스케줄러가 모든 후보 노드에 대해 기존 파드를 대조해야 해 스케줄 지연이 커진다. topologyKey를 존 단위처럼 넓게 잡거나 spread constraint로 대체하면 부담이 줄어든다.
  • 존 라벨 부재: topologySpread나 존 affinity는 노드에 topology.kubernetes.io/zone 라벨이 있어야 동작한다. 온프레미스 클러스터에서 이 라벨을 안 붙여두면 분산이 전혀 되지 않으니, kubectl get nodes --show-labels로 먼저 확인하라.

정리하면, 무엇을 원하는지에 따라 도구를 고른다. 특정 하드웨어 노드로 유인은 nodeAffinity, 전용 노드 격리는 taint/toleration, 존/노드 분산은 topologySpreadConstraints, 파드 간 근접·격리는 pod affinity/anti-affinity다. 그리고 대부분의 경우 하드 제약보다 소프트(preferred/ScheduleAnyway)에서 시작해, Pending이 나지 않는 선에서 필요한 만큼만 강하게 조여가는 것이 안전하다.

마무리

파드 스케줄링 제어의 핵심은 “강제할 것과 선호할 것을 구분하는 감각”이다. 존 분산처럼 장애 대응에 직결되는 규칙은 하드로 강제하되, 나머지는 소프트로 두어 클러스터가 자원을 유연하게 쓰게 해야 한다. 모든 제약을 required로 걸어버리면 결국 파드가 안 뜨는 Pending 지옥에 빠진다. affinity·taint·topologySpread는 각각 다른 문제를 푸는 도구이며, 실제 운영에서는 이들을 조합해 “GPU 노드 격리 + 존 분산 + 캐시 근접” 같은 복합 정책을 만든다. 새 워크로드를 올릴 때마다 “이 파드는 어디에, 왜, 얼마나 강하게 붙어야 하는가”를 한 번씩 묻는 것만으로도 대부분의 배치 사고를 예방할 수 있다.

자주 묻는 질문

Q. nodeAffinity와 nodeSelector를 둘 다 쓰면 어떻게 동작하나요?
A. 두 조건은 AND로 결합됩니다. nodeSelector의 라벨 일치와 nodeAffinity의 required 조건을 모두 만족하는 노드에만 스케줄됩니다. 표현력이 더 풍부한 nodeAffinity로 통일하는 편이 관리하기 낫지만, 단순 라벨 매칭이면 nodeSelector가 더 읽기 쉽습니다. 다만 두 개를 섞으면 제약이 겹쳐 만족 노드가 0개가 되는 사고가 나기 쉬우니, 한쪽으로 몰아 쓰는 것을 권합니다.

Q. taint를 걸었더니 kube-system 파드까지 안 떠서 노드가 죽은 것처럼 보입니다.
A. CNI, kube-proxy 같은 시스템 데몬셋은 대개 모든 taint를 견디는 toleration(operator: Exists)을 갖고 있어야 정상입니다. 커스텀 taint를 걸 때는 이런 필수 데몬셋이 해당 taint에 대한 toleration을 갖고 있는지 먼저 확인하세요. NoExecute를 함부로 걸면 실행 중이던 시스템 파드까지 축출돼 노드 네트워킹이 끊길 수 있으니 특히 조심해야 합니다.

Q. topologySpreadConstraints와 podAntiAffinity 중 무엇을 써야 하나요?
A. 목적이 “고르게 분산”이면 topologySpreadConstraints가 거의 항상 낫습니다. maxSkew로 허용 편차를 정량 제어할 수 있고, 스케줄 비용도 anti-affinity보다 유리합니다. podAntiAffinity는 “특정 파드끼리 절대 같은 노드에 두면 안 된다” 같은 명확한 격리 요구가 있을 때만 제한적으로 쓰는 것이 좋습니다.