18분 읽기

쿠버네티스에서 Pod 배치하기

Pod를 Deployment로 만들면 Kubernetes가 알아서 Node 하나를 골라 준다. 대부분은 이 기본 동작으로 충분하다. 하지만 운영 중인 클러스터에는 “아무 Node나”에 놓으면 곤란한 워크로드가 생긴다.

  • 이미지 빌드는 CPU와 디스크 I/O를 많이 쓰므로, 일반 API Pod와 분리하고 싶다.
  • 결제 API 복제본 두 개가 같은 Node에 놓이면 Node 하나의 장애가 곧 서비스 중단이 된다.
  • GPU Node는 비싸고 수가 적다. GPU를 쓰지 않는 Pod가 들어와 자리를 차지하면 안 된다.

이 글에서는 이런 상황에서 Pod 배치를 제어할 때 자주 쓰는 네 가지 도구를 정리한다.

질문주로 사용할 도구
어떤 Node를 후보로 삼을까?nodeSelector, node affinity
다른 Pod와 함께 또는 떨어져 배치할까?pod affinity, pod anti-affinity
이 Node에 아무 Pod나 들어오지 못하게 할까?taint
그 Node에 들어갈 수 있는 Pod는 무엇인가?toleration

스케줄러는 어떻게 Node를 고를까?

kube-scheduler는 아직 Node가 정해지지 않은 Pod를 보고 실행할 곳을 고른다. 먼저 조건에 맞지 않는 Node를 후보에서 빼고(filtering), 남은 Node에 점수를 매긴다(scoring). 리소스 요청, 하드웨어 조건, affinity 같은 설정이 이 과정에 반영된다. Kubernetes Scheduler 공식 문서를 보면, 아래 설정들은 결국 “어떤 Node를 후보로 남길지”와 “남은 후보 중 어디를 더 선호할지”를 정하는 도구다.

Pod 스케줄링에서 nodeSelector와 node affinity가 Node 후보를 고르고, taint와 toleration이 Node 진입을 통제하며, pod anti-affinity가 기존 Pod와의 거리를 정하는 흐름
선택(node labels), 거부(taint), Pod 간 관계(affinity)는 서로 대체 관계가 아니라 함께 작동한다.

그림은 각 설정의 역할을 쉽게 보기 위한 개념도다. 실제 scheduler plugin의 실행 순서를 그대로 보여 주는 것은 아니다. 리소스 요청과 여러 제약이 함께 후보 Node를 걸러 내고, 남은 Node를 점수화한다고 이해하면 된다.

이 글에서는 설명을 위해 클러스터를 다음처럼 단순화한다.

general-1, general-2     일반 API와 웹 워크로드
build-1, build-2         이미지 빌드 전용 Node
gpu-1                    추론 작업용 GPU Node

실제 환경에서는 Node 이름으로 정책을 걸기보다 일관된 label과 taint 규칙을 정해 두는 편이 낫다. 예시에서는 아래 label을 사용한다.

kubectl label node build-1 workload.example.com/pool=build
kubectl label node build-2 workload.example.com/pool=build
kubectl label node gpu-1 accelerator.example.com/type=nvidia

nodeSelector로 실행할 Node 고르기

nodeSelector는 Pod가 필요한 Node label을 가장 간단하게 적는 방법이다. 지정한 key/value가 모두 맞는 Node만 후보가 된다.

빌드 Pod를 전용 Node로 보내기

애플리케이션 이미지를 만드는 Job은 CPU와 디스크 I/O를 많이 쓴다. 빌드가 몰리는 시간에 일반 API와 같은 Node를 쓰면 API 응답 시간이 튈 수 있다. 이럴 때는 빌드 Pod를 build Node pool로만 보내면 된다.

apiVersion: batch/v1
kind: Job
metadata:
  name: image-build
spec:
  template:
    spec:
      nodeSelector:
        workload.example.com/pool: build
      restartPolicy: Never
      containers:
        - name: builder
          image: example.registry/buildkit-runner:1.0

이 설정은 이 Pod는 build Node에서만 실행한다는 뜻이다. 다만 이것만으로 일반 API Pod의 유입까지 막을 수는 없다. 일반 API Pod 입장에서는 build Node도 여전히 후보가 될 수 있다.

해당 label을 가진 Node가 없거나, 그런 Node에 여유 리소스가 없으면 Job Pod는 Pending으로 남는다. nodeSelector는 “반드시 이 Node에 넣어라”가 아니라 “이 조건에 맞는 Node 중 하나에 배치해라”에 가깝다.

node affinity로 필수 조건과 선호 조건 나누기

node affinity도 label로 Node를 고른다는 점에서는 nodeSelector와 같다. 차이는 조건을 더 다양하게 쓸 수 있고, “반드시 지켜야 하는 조건”과 “되도록 지키면 좋은 조건”을 나눌 수 있다는 점이다.

구분의미스케줄 결과
requiredDuringSchedulingIgnoredDuringExecution반드시 만족해야 하는 조건만족하는 Node가 없으면 Pending
preferredDuringSchedulingIgnoredDuringExecution가능하면 만족하고 싶은 조건만족하지 않아도 다른 조건이 맞으면 배치 가능

이름 끝의 IgnoredDuringExecution스케줄된 뒤 Node label이 바뀌어도 기존 Pod를 자동으로 쫓아내지는 않는다는 뜻이다. node affinity가 더 표현력이 있고, required/preferred를 함께 쓸 수 있다는 점은 Assigning Pods to Nodes 공식 문서에 정리돼 있다.

캐시가 큰 빌드 Node를 먼저 사용하기

빌드 Job은 반드시 build pool에서 돌리되, 그중에서도 대용량 캐시가 있는 Node를 먼저 쓰고 싶다고 해 보자.

apiVersion: batch/v1
kind: Job
metadata:
  name: image-build
spec:
  template:
    spec:
      affinity:
        nodeAffinity:
          requiredDuringSchedulingIgnoredDuringExecution:
            nodeSelectorTerms:
              - matchExpressions:
                  - key: workload.example.com/pool
                    operator: In
                    values: [build]
          preferredDuringSchedulingIgnoredDuringExecution:
            - weight: 80
              preference:
                matchExpressions:
                  - key: storage.example.com/cache
                    operator: In
                    values: [large]
      restartPolicy: Never
      containers:
        - name: builder
          image: example.registry/buildkit-runner:1.0

이 Job은 pool=build가 아닌 Node에는 배치되지 않는다. 그중 storage.example.com/cache=large를 가진 Node가 있으면 더 높은 점수를 받는다. 캐시가 큰 Node가 모두 바쁘더라도 Job이 계속 Pending으로 남지는 않고, 다른 build Node에 배치될 수 있다. 이게 선호 조건을 두는 이유다.

nodeSelector와 node affinity를 같이 쓰면 둘 다 만족해야 한다. affinity의 여러 nodeSelectorTerms는 OR, 하나의 term 안의 여러 matchExpressions는 AND로 평가된다. 조건을 많이 쌓을수록 교집합이 비어 Pending이 되기 쉽다. required에는 정말 양보할 수 없는 조건만 넣는 편이 안전하다.

pod anti-affinity로 복제본 떨어뜨리기

node affinity가 Node의 label을 보고 판단한다면, pod affinity와 anti-affinity는 이미 떠 있는 Pod의 label을 기준으로 판단한다.

  • pod affinity: 특정 Pod와 같은 topology domain에 함께 두고 싶을 때 쓴다.
  • pod anti-affinity: 특정 Pod가 있는 topology domain은 피하고 싶을 때 쓴다.

여기서 topology domain은 Pod를 나눌 범위를 뜻한다. kubernetes.io/hostname이면 Node 단위, topology.kubernetes.io/zone이면 가용 영역 단위로 나눈다.

결제 API 복제본을 다른 Node에 배치하기

복제본이 둘인 payments-api를 운영한다고 해 보자. 두 Pod가 우연히 같은 Node에 올라가면, 그 Node 하나가 죽는 순간 복제본을 둘 다 잃는다.

apiVersion: apps/v1
kind: Deployment
metadata:
  name: payments-api
spec:
  replicas: 2
  selector:
    matchLabels:
      app.kubernetes.io/name: payments-api
  template:
    metadata:
      labels:
        app.kubernetes.io/name: payments-api
    spec:
      affinity:
        podAntiAffinity:
          requiredDuringSchedulingIgnoredDuringExecution:
            - labelSelector:
                matchLabels:
                  app.kubernetes.io/name: payments-api
              topologyKey: kubernetes.io/hostname
      containers:
        - name: api
          image: example.registry/payments-api:1.0

두 번째 Pod는 첫 번째 Pod가 있는 hostname을 후보에서 뺀다. Node가 하나뿐이라면 두 번째 Pod가 Pending인 것은 오류가 아니다. 같은 Node에는 함께 올라가지 않겠다고 선언한 결과다.

반드시 지켜야 하는 anti-affinity는 강력하지만 조건을 만족할 Node가 없으면 Pod가 실행되지 못한다. 큰 클러스터에서는 Pod affinity/anti-affinity가 스케줄링 속도에 영향을 줄 수도 있다. 또 requiredDuringSchedulingIgnoredDuringExecution의 pod anti-affinity는 기본 admission controller에서 topologyKey: kubernetes.io/hostname만 허용하도록 제한될 수 있다. 자세한 제약은 공식 affinity 문서를 확인하자.

“한 Node에는 무조건 하나”까지는 아니고, 복제본을 가능한 한 고르게 퍼뜨리고 싶다면 topologySpreadConstraints가 더 잘 맞는 경우가 많다. 이 기능은 topology별 복제본 수 차이를 maxSkew로 제어한다. 다음 글에서 따로 다룰 만한 주제지만, Pod Topology Spread Constraints 공식 문서는 anti-affinity보다 세밀한 분산이 필요할 때 좋은 출발점이다.

taint와 toleration으로 Node 보호하기

지금까지 본 설정은 Pod가 “어디로 가고 싶은가”를 말하는 방식이었다. taint는 반대로 Node가 “어떤 Pod는 받지 않겠다”라고 선언하는 방식이다.

nodeSelector / node affinity  → Pod가 갈 Node를 고른다
taint                         → Node가 Pod의 진입을 막는다
toleration                    → Pod가 그 taint를 허용한다

빌드 전용 Node에는 빌드 Job만 받기

앞에서 nodeSelector만 적용한 build Node는 일반 Pod도 올라갈 수 있었다. build Node에 NoSchedule taint를 붙이면, 해당 toleration이 없는 Pod는 새로 배치될 수 없다.

kubectl taint nodes build-1 workload.example.com/pool=build:NoSchedule
kubectl taint nodes build-2 workload.example.com/pool=build:NoSchedule

이제 이미지 빌드 Job에는 selector와 toleration을 같이 넣는다.

apiVersion: batch/v1
kind: Job
metadata:
  name: image-build
spec:
  template:
    spec:
      nodeSelector:
        workload.example.com/pool: build
      tolerations:
        - key: workload.example.com/pool
          operator: Equal
          value: build
          effect: NoSchedule
      restartPolicy: Never
      containers:
        - name: builder
          image: example.registry/buildkit-runner:1.0

둘을 같이 쓰면 역할이 깔끔하게 나뉜다.

설정보장하는 것
Node label + nodeSelector빌드 Pod가 build Node만 고른다
Node taint + Pod toleration허가되지 않은 Pod가 build Node에 들어오지 못한다

toleration은 배치를 허용할 뿐, 그 Node를 꼭 선택하게 하지는 않는다. toleration만 있는 Pod는 다른 조건과 점수에 따라 일반 Node에 배치될 수도 있다. 전용 Node에 특정 워크로드를 보내고 싶다면 보통 label 선택과 taint/toleration을 같이 둔다. 이 차이는 Taints and Tolerations 공식 문서에도 명확히 나와 있다.

taint의 세 가지 효과 비교하기

effect새 Pod이미 실행 중인 Pod
NoSchedule일치하는 toleration 없이는 배치하지 않음그대로 둠
PreferNoSchedule가능하면 배치를 피함그대로 둠
NoExecute일치하는 toleration 없이는 배치하지 않음toleration이 없으면 eviction

NoExecute는 Node 상태가 나빠졌을 때 기존 Pod를 내보내는 데도 관여한다. tolerationSeconds를 함께 지정하면, 잠깐 생긴 Node 문제 때문에 Pod를 바로 내보내지 않고 일정 시간 기다릴 수 있다. 반대로 전용 GPU Node처럼 “새 일반 Pod는 받지 않는다”가 목적이라면 보통 NoSchedule부터 보면 된다.

GPU Node에 일반 Pod가 들어오지 못하게 하기

GPU Node는 비싸고 수가 적다. 일반 Pod가 올라가도 GPU를 쓰지 않는 경우가 많다. label로 GPU Node를 찾고, taint로 일반 Pod를 막자.

kubectl taint nodes gpu-1 accelerator.example.com/type=nvidia:NoSchedule
apiVersion: apps/v1
kind: Deployment
metadata:
  name: inference-api
spec:
  replicas: 2
  selector:
    matchLabels:
      app.kubernetes.io/name: inference-api
  template:
    metadata:
      labels:
        app.kubernetes.io/name: inference-api
    spec:
      nodeSelector:
        accelerator.example.com/type: nvidia
      tolerations:
        - key: accelerator.example.com/type
          operator: Equal
          value: nvidia
          effect: NoSchedule
      containers:
        - name: api
          image: example.registry/inference-api:1.0
          resources:
            limits:
              nvidia.com/gpu: "1"

여기서 label/taint는 배치 정책이고, nvidia.com/gpu: "1"실제 자원 요청이다. GPU를 써야 하는 워크로드라면 이 세 가지를 함께 맞춰야 한다. label만으로는 GPU 용량을 예약할 수 없고, GPU limit만으로는 일반 Pod의 GPU Node 진입을 막을 수 없다.

Pod가 Pending이면 어디부터 확인할까?

스케줄링 규칙을 추가한 뒤 Pod가 Pending에 머문다면, 가장 먼저 이벤트를 확인하자.

kubectl describe pod <pod-name>

아래 메시지는 단순한 오류가 아니라, 어떤 조건 때문에 후보 Node가 사라졌는지 보여 주는 힌트다.

이벤트에 흔히 보이는 이유먼저 확인할 것
didn't match Pod's node affinity/selectorNode label 오타, required 조건이 너무 빡빡하지 않은지
had taint ... that the pod didn't toleratetaint의 key/value/effect와 toleration 일치 여부
Insufficient cpu / Insufficient memoryresource request와 Node 여유 자원
anti-affinity 관련 조건복제본 수보다 topology domain 수가 충분한지

특히 required 조건, NoSchedule taint, resource request는 모두 filtering 단계에서 후보를 줄인다. 하나씩 보면 타당해도, 합치면 조건을 만족하는 Node가 하나도 없을 수 있다. 조건은 하나씩 추가하고, 매번 describe 이벤트로 결과를 확인하는 습관이 가장 빠른 디버깅 방법이다.

상황에 맞는 설정 고르기

의도권장 조합피해야 할 오해
특정 Node pool에서만 실행label + nodeSelector 또는 required node affinityselector만으로 Node를 전용으로 만들 수 있다고 생각하는 것
가능한 좋은 Node를 우선 사용preferred node affinitypreference가 반드시 지켜진다고 생각하는 것
같은 서비스 복제본을 Node 장애에서 분리pod anti-affinity 또는 topology spreadNode 수보다 많은 hard anti-affinity 복제본을 기대하는 것
비싼/특수 Node를 일반 Pod에서 보호taint NoSchedule + 필요한 Pod의 tolerationtoleration이 그 Node로의 배치를 보장한다고 생각하는 것
전용 Node에 허가된 워크로드만 배치label 선택 + taint/toleration둘 중 하나만으로 양방향 정책을 완성하려는 것

핵심 정리

스케줄링 설정은 YAML 한두 줄을 더 쓰는 일이 아니다. Node pool의 역할과 장애 경계를 정하는 운영 정책에 가깝다.

  • nodeSelector와 node affinity는 Pod가 갈 Node를 정한다.
  • pod affinity/anti-affinity는 다른 Pod와의 배치 관계를 정한다.
  • taint는 Node를 보호하고, toleration은 예외를 적는다.

전용 빌드 Node의 예시처럼 실행할 곳을 고르는 label과 다른 Pod를 막는 taint를 함께 쓰면 의도가 분명해진다. 반대로 반드시 지켜야 하는 조건을 많이 걸수록 장애나 증설 상황에서 Pending이 생길 가능성도 커진다. 다음 글에서는 Pod 분산, 자원 요청, 우선순위까지 이어서 더 현실적인 스케줄링 정책을 다뤄 보겠다.

참고 자료