Chapter 10

스케줄러 커스터마이징

  • 10.1 Priority Classes
  • 10.2 멀티 스케줄러
  • 10.3 스케줄러 프로필

앞 챕터에서 파드를 "어디에" 배치할지를 다뤘다면, 이번엔 "어떤 순서로, 누가" 스케줄링할지를 다뤄. 우선순위로 스케줄링 큐를 제어하고, 커스텀 스케줄러를 만들어서 기본 스케줄러와 함께 돌리고, 하나의 바이너리에서 여러 프로필로 스케줄러를 분리하는 것까지.

쿠버네티스 클러스터에서는 우선순위가 다른 다양한 워크로드가 파드로 돌아가잖아. 컨트롤 플레인 구성 요소 자체도 파드로 실행되는데 이건 어떤 상황에서도 반드시 돌아가야 하고, 중요한 데이터베이스도 있고, 백그라운드 작업처럼 우선순위가 낮은 것도 있어. 우선순위가 높은 워크로드가 낮은 것에 밀려서 스케줄링이 안 되면 안 되잖아. 여기서 **우선순위 클래스(Priority Class)**가 등장하는 거야.

우선순위 클래스는 다양한 워크로드의 우선순위를 정의해서, 중요한 워크로드가 항상 먼저 스케줄링되도록 해줘. 리소스가 부족할 때는 스케줄러가 우선순위가 낮은 워크로드를 죽여서 자리를 확보할 수도 있어.

우선순위는 숫자로 정의하는데, 일반 애플리케이션 워크로드는 -2억에서 +10억 사이 범위를 써. 숫자가 클수록 우선순위가 높아. 쿠버네티스 컨트롤 플레인 같은 시스템 내부 구성 요소는 별도 범위가 있어서 최대 20억까지 올라갈 수 있어, 항상 최우선 순위를 차지하게 돼.

기존 우선순위 클래스를 확인하려면 kubectl get priorityclass를 실행하면 돼. system-cluster-critical이나 system-node-critical 같은 시스템용 클래스가 이미 있는 걸 볼 수 있어. 값이 20억 가까이 돼.

새로운 우선순위 클래스를 만들려면 이렇게 해.

apiVersion: scheduling.k8s.io/v1
kind: PriorityClass
metadata:
  name: high-priority
value: 1000000
description: "This is a high priority class"

우선순위 클래스는 네임스페이스가 없는 오브젝트야. 특정 네임스페이스에 속하지 않으니까 한번 만들면 어떤 네임스페이스의 파드에서든 쓸 수 있어.

파드에 적용하려면 priorityClassName 필드를 쓰면 돼.

spec:
  priorityClassName: high-priority
  containers:
  - name: app
    image: app

파드에 priorityClassName을 안 넣으면 기본 우선순위 값은 0이야. 이걸 바꾸고 싶으면 새 우선순위 클래스를 만들면서 **globalDefault: true**를 설정해. 그러면 명시적으로 우선순위 클래스를 지정하지 않은 모든 파드가 이 기본값을 갖게 돼. globalDefault: true는 하나의 우선순위 클래스에서만 설정할 수 있어, 여러 개에 설정하면 안 돼.

이제 실제 동작을 보자. 우선순위 7인 중요한 앱과 우선순위 5인 작업 앱이 스케줄링 대기 중이라면, 중요한 앱이 먼저 배치돼. 리소스가 남으면 작업 앱도 배치되고. 근데 여기서 우선순위 6인 새 워크로드가 들어왔는데 클러스터에 리소스가 없다면 어떻게 될까?

이건 **선점 정책(preemption policy)**이 결정해. 선점 정책이 설정 안 되어 있으면 기본값이 **PreemptLowerPriority**야. 기존의 우선순위가 낮은 파드를 죽이고 그 자리를 차지하는 거지. 근데 기존 워크로드를 죽이지 않고 리소스가 확보될 때까지 기다리게 하고 싶으면 **preemptionPolicy: Never**로 설정하면 돼.

apiVersion: scheduling.k8s.io/v1
kind: PriorityClass
metadata:
  name: high-priority-no-preempt
value: 1000000
preemptionPolicy: Never

Never로 설정하면 해당 우선순위 클래스의 파드는 다른 파드를 선점하지 않고 리소스가 생길 때까지 대기해. 다만 스케줄링 큐에서는 여전히 우선순위가 낮은 파드보다 먼저 스케줄링 대상이 돼. 즉 선점은 안 하지만 순서는 앞에 서는 거야.

우선순위로 스케줄링 순서를 제어하는 걸 넘어서, 아예 스케줄러 자체를 여러 개 돌리는 것도 가능해.

쿠버네티스의 기본 스케줄러는 노드에 파드를 균등하게 분배하고, 테인트/톨러레이션이나 노드 어피니티 같은 조건을 고려해서 배치해. 근데 이것만으로 부족할 때가 있어. 특정 애플리케이션에 맞는 독자적인 스케줄링 알고리즘이 필요하다면, 자체 스케줄러를 만들어서 기본 스케줄러와 함께 돌릴 수 있어. 쿠버네티스는 여러 스케줄러를 동시에 실행할 수 있거든. 대부분의 앱은 기본 스케줄러를 쓰고, 특수한 앱만 커스텀 스케줄러를 쓰는 식이지.

여러 스케줄러가 있으면 서로 구분할 수 있게 이름이 달라야 해. 기본 스케줄러의 이름은 **default-scheduler**야. 이 이름은 kube 스케줄러 구성 파일에서 설정돼.

apiVersion: kubescheduler.config.k8s.io/v1
kind: KubeSchedulerConfiguration
profiles:
- schedulerName: default-scheduler

커스텀 스케줄러를 만들려면 별도의 구성 파일을 만들고 스케줄러 이름을 다르게 지정하면 돼.

apiVersion: kubescheduler.config.k8s.io/v1
kind: KubeSchedulerConfiguration
profiles:
- schedulerName: my-custom-scheduler

배포 방법은 여러 가지가 있어. 가장 단순한 건 동일한 kube-scheduler 바이너리를 사용하되 커스텀 구성 파일을 지정해서 서비스로 실행하는 거야. 근데 오늘날 99%의 경우에는 이렇게 안 해. kubeadm 배포에서는 모든 컨트롤 플레인 구성 요소가 파드나 디플로이먼트로 돌아가니까.

파드로 배포하는 경우에는 파드 정의 파일을 만들고, kube-scheduler 이미지를 쓰고, 커스텀 구성 파일을 --config 옵션으로 전달해. 구성 파일에 스케줄러 이름이 들어 있으니까 스케줄러가 그 이름을 알아서 가져가.

디플로이먼트로 배포하는 게 가장 일반적이야. 구성 파일은 보통 ConfigMap으로 만들어서 볼륨 마운트로 전달해. ConfigMap의 내용이 특정 경로에 파일로 마운트되고, 그 파일을 스케줄러가 읽는 방식이야. 디플로이먼트가 제대로 작동하려면 서비스 계정, 클러스터 역할 바인딩 같은 인증/권한 설정도 필요한데, 이건 인증 섹션에서 다루는 내용이니까 지금은 넘어가도 돼.

고가용성(HA) 설정에서 여러 마스터 노드가 있으면 스케줄러 복사본이 여러 개 돌아갈 수 있어. 이때 한 번에 하나만 활성화되어야 하는데, 그걸 결정하는 게 **리더 선출(Leader Election)**이야. 스케줄러 구성에서 leaderElection.leaderElect를 설정하면 돼. 레플리카가 하나뿐이면 false로, 여러 개면 true로.

kube-system 네임스페이스에서 kubectl get pods를 실행하면 새 커스텀 스케줄러가 돌고 있는 걸 볼 수 있어. 올바른 네임스페이스를 확인하고 있는지 꼭 확인해.

이제 커스텀 스케줄러를 배포했으면, 파드에서 그 스케줄러를 쓰도록 지정해야 해. schedulerName 필드를 추가하면 끝이야.

spec:
  schedulerName: my-custom-scheduler
  containers:
  - name: nginx
    image: nginx

schedulerName을 안 넣으면 기본 스케줄러가 담당해. 스케줄러가 제대로 구성 안 됐으면 파드가 계속 Pending 상태로 남아. 이럴 때는 kubectl describe pod로 이벤트를 확인하면 대부분 원인을 알 수 있어.

어떤 스케줄러가 특정 파드의 스케줄링을 담당했는지 확인하려면 kubectl get events -o wide를 실행해. 이벤트의 source 필드에 스케줄러 이름이 나와. 문제가 있으면 kubectl logs <스케줄러-파드-이름> -n kube-system으로 스케줄러 로그도 볼 수 있어.

별도 바이너리로 여러 스케줄러를 돌리는 것보다 더 깔끔한 방법이 있어. 바로 하나의 스케줄러에서 여러 프로필을 구성하는 거야.

스케줄러가 파드를 노드에 배치하는 과정은 네 단계 파이프라인으로 이루어져 있고, 각 단계마다 플러그인이 꽂혀서 동작해. 그리고 하나의 스케줄러 바이너리에서 여러 프로필을 돌릴 수 있어. 이 구조를 이해하면 스케줄러의 동작을 커스터마이즈할 수 있어.

예를 들어 CPU 10개가 필요한 파드가 4개 노드 중 하나에 스케줄링돼야 한다고 하자. 이 파드 말고도 다른 파드들도 대기 중이야. 가장 먼저 일어나는 일은 파드가 스케줄링 큐에 들어가는 거야. 여기서 파드들이 우선순위에 따라 정렬돼. PrioritySort 플러그인이 파드에 설정된 PriorityClass를 보고 순서를 매겨. 우선순위가 높은 파드가 대기열 앞에 서서 먼저 스케줄링돼.

그 다음이 필터 단계야. 파드를 실행할 수 없는 노드를 걸러내. NodeResourcesFit 플러그인이 리소스가 부족한 노드를 제거하고, NodeName 플러그인은 파드 스펙에 nodeName이 지정되어 있으면 그 이름과 일치하지 않는 노드를 전부 걸러내. NodeUnschedulable 플러그인은 Unschedulable 플래그가 true인 노드(cordon 처리된 노드)를 제거해.

필터를 통과한 노드들은 점수 매기기 단계로 가. NodeResourcesFit 플러그인이 각 노드에서 파드를 배치한 후 남는 여유 리소스에 따라 점수를 매겨. 여유가 많은 노드가 높은 점수를 받아. ImageLocality 플러그인은 파드가 쓰는 컨테이너 이미지가 이미 있는 노드에 더 높은 점수를 줘. 이 단계에서는 배치를 거부하지 않아, 그냥 점수만 매기는 거야. 이미지가 없는 노드라도 배치 가능해, 단지 점수가 낮을 뿐이지.

마지막으로 바인딩 단계에서 가장 높은 점수를 받은 노드에 파드가 바인딩돼. DefaultBinder 플러그인이 이걸 담당해.

각 단계마다 플러그인을 꽂을 수 있는 **확장 포인트(Extension Point)**가 있어. 큰 흐름은 Sort -> PreFilter -> Filter -> PostFilter -> PreScore -> Score -> Reserve -> Permit -> PreBind -> Bind -> PostBind 순서야. 자체 플러그인을 만들어서 원하는 확장 포인트에 꽂으면 커스텀 로직을 실행할 수 있어. 하나의 플러그인이 여러 확장 포인트에 걸칠 수도 있고, 특정 포인트에만 있을 수도 있어.

이전에 별도의 바이너리로 여러 스케줄러를 배포하는 방법을 봤잖아. 근데 이 방식에는 문제가 있어. 별도 프로세스를 유지보수해야 하는 부담도 있고, 더 중요한 건 **경쟁 조건(Race Condition)**이 생길 수 있다는 거야. 한 스케줄러가 같은 노드에 워크로드를 배치하려는 다른 스케줄러가 있다는 걸 모른 채 스케줄링할 수 있거든.

그래서 쿠버네티스 v1.18부터 단일 스케줄러 바이너리에서 여러 프로필을 구성하는 기능이 나왔어. 스케줄러 구성 파일에서 profiles 목록에 항목을 추가하면 돼.

apiVersion: kubescheduler.config.k8s.io/v1
kind: KubeSchedulerConfiguration
profiles:
- schedulerName: default-scheduler

- schedulerName: my-scheduler-2
  plugins:
    score:
      disabled:
      - name: TaintToleration

- schedulerName: my-scheduler-3
  plugins:
    preScore:
      disabled:
      - name: '*'
    score:
      disabled:
      - name: '*'

각 프로필마다 별도의 스케줄러 이름을 지정하고, 플러그인 구성도 다르게 할 수 있어. 첫 번째 프로필은 기본 스케줄러와 동일하게 동작하고, 두 번째는 TaintToleration 점수 매기기 플러그인을 비활성화했고, 세 번째는 모든 preScore와 score 플러그인을 꺼버렸어. plugins 섹션에서 확장 포인트를 지정하고, 그 안에서 이름이나 와일드카드(*)로 플러그인을 활성화하거나 비활성화하면 돼.

이렇게 하면 여러 스케줄러가 동일한 바이너리에서 실행되니까 유지보수도 쉽고 경쟁 조건도 없어.


정리

10장 읽고 기억할 거 세 가지:

  1. Priority Class로 스케줄링 순서를 제어. 숫자가 클수록 먼저 스케줄링되고, 리소스 부족 시 선점 정책에 따라 낮은 우선순위 파드를 밀어낼 수 있어. preemptionPolicy: Never로 선점 없이 순서만 앞세울 수도 있어
  2. 커스텀 스케줄러는 schedulerName으로 연결. 파드 스펙에 스케줄러 이름을 지정하면 해당 스케줄러가 담당하고, 안 넣으면 default-scheduler가 처리해
  3. 스케줄러 프로필이 멀티 바이너리를 대체. 별도 프로세스로 돌리면 경쟁 조건이 생길 수 있으니, v1.18부터는 단일 바이너리의 profiles 배열로 플러그인 구성을 다르게 한 여러 스케줄러를 운영하는 게 권장돼