Создание кастомного Kubernetes Scheduler для StatefulSet

В этой статье мы рассмотрим процесс создания кастомного scheduler’а для Kubernetes, ориентируясь на Kubernetes Scheduling Framework.

Обычно для назначения подов на вычислительные узлы используется стандартный планировщик, который, проанализировав различные параметры, автоматически выполнит оптимальное размещение (например, распределит поды таким образом, чтобы не размещать их на вычислительных узлах с недостаточными ресурсами).

В одном из наших проектов, где мы использовали оператор Strimzi для развёртывания кластеров Kafka, заказчик выдвинул специфические требования по размещению данных, резервному копированию и восстановлению. Одним из ключевых пунктов стал вопрос строгой привязки экземпляров приложения к вычислительным узлам. Для этого нам пришлось создать кастомный scheduler, учитывая нюансы инфраструктуры, особенности приложения и требуемые правила размещения.

Визуализация работы k8s-sts-scheduler

Визуализация работы k8s-sts-scheduler

Что такое Kubernetes Scheduler?

Если вы хотите глубже погрузиться в работу Kubernetes Scheduler, понять его механику и возможности гибкой настройки, рекомендую отличные статьи:

Здесь я хочу пойти немного дальше и рассказать о процессе создания кастомного scheduler’a и интегрирации его в существующий кластер Kubernetes.

Почему кастомный Kubernetes Scheduler?

Оператор Strimzi предлагает использовать встроенные механизмы вроде affinity и tolerations для управления размещением подов Kafka на узлах:

Affinity — один из методов, позволяющих контролировать размешение подов на узлах, состоит их двух типов:

  • Node Affinity: задаёт правила, на каких узлах могут размещаться поды, используя метки узлов. Например, можно указать, что под должен запускаться только на узлах с меткой node-type=fast-network.

  • Pod Affinity и Anti-Affinity: позволяют управлять тем, как поды располагаются относительно друг друга на уровне размещения. Можно задать правила, по которым поды должны быть размещены рядом (affinity) или, наоборот, разнести их по разным узлам (anti-affinity).

Tolerations — это метод, который определяет, какие поды могут быть размещены на узлах с определёнными taints. Taints, в свою очередь, используются для того, чтобы ограничить запуск подов на узле, и только поды с соответствующими tolerations могут там запускаться.

На первый взгляд, использование разных комбинаций affinity и tolerations является логичным решением, чтобы достичь нужного поведения. Однако, на практике такие механизмы не всегда давали 100% гарантии, что поды будут назначены именно на те узлы, которые нам необходимы. В некоторых случаях они помогали приблизиться к нужному результату, но не решали задачу полностью.

Стандартный Kubernetes Scheduler — универсальный инструмент, который отлично справляется с широким спектром задач по распределению подов, имеющих типовые потребности в планировании. Но что делать, если вашей системе требуется более точечный контроль за тем, как и где размещать поды?

Когда стандартные механизмы вроде affinity и tolerations не дают желаемой гибкости, и возникает необходимость в своеобразной логике планирования, на помощь приходит кастомномный scheduler, который позволит:

  • Реализовать специфическую логику планирования, учитывающую особые требования приложений или корпоративные политики.

  • Получить больший контроль над тем, где и как будут размещаться поды, что позволяет более точно управлять жизненным циклом приложений.

Возможно, с точки зрения гибкости системы, это выглядит как шаг назад, так как жёсткое закрепление подов за конкретными узлами делает кластер менее устойчивым к сбоям.
Например, если один узел выйдет из строя, под не сможет автоматически переместиться на другой узел.
Но в нашем случае именно этого поведения и требовалось добиться.

Архитектура Kubernetes Scheduling Framework

Kubernetes Scheduling Framework представляет модульную архитектуру, позволяющую разработчикам внедрять собственную логику планирования без необходимости модифицкации основного кода планировщика.

Процесс планирования пода в Kubernetes делится на два этапа: scheduling cycle и binding cycle.

  • Scheduling cycle: планировщик оценивает узлы, чтобы выбрать подходящий для размещения пода; на этом этапе применяются различные фильтры и проверки.

  • Binding cycle: связывает под с выбранным узлом и обновляет состояние кластера.

Scheduling Framework определяет несколько точек расширения (extension points), которые представляют собой определенные этапы в процессе планирования пода. В каждой из этих точек могут быть зарегистрированы плагины, которые добавляют нужные проверки или изменяют поведение в планировании, что даёт возможность тонко управлять процессом.

Мы будем использовать следующие точки расширения:

  • PreEnqueue: вызывается перед добавлением пода в очередь планирования. Здесь можно выполнить предварительную проверку пода, например, убедиться, что он обладает нужными метками или запрашивает корректные ресурсы.

  • PreFilter: выполняет предварительную проверку на соответствие базовым критериям пода перед тем, как начнётся основной процесс планирования.

  • Filter: отвечает за исключение неподходящих узлов из списка кандидатов. Например, если под требует определённые ресурсы, плагин может отсеять узлы, которые не соответствуют этим требованиям.

Для того чтобы Kubernetes Scheduler знал о новом плагине, его необходимо зарегистрировать в конфигурации планировщика.

Создание кастомного scheduler’а

Основной задачей нашего кастомного планировщика является гарантированное размещение конкретных подов на определённых узлах, обеспечивая соответствие между порядковым номером пода и значением метки узла.

PreEnqueue: проверка принадлежности пода к StatefulSet

На этом этапе проводится первичная проверка, чтобы убедиться, что под принадлежит StatefulSet. Если под не управляется StatefulSet, планировщик помечает его как неподходящий для размещения.
Такая проверка помогает избежать ситуаций, когда под, не относящийся к определенной группе приложений, попадает в очередь для обработки.

// PreEnqueue проверяет, следует ли рассматривать pod для планирования.
func (s *Scheduler) PreEnqueue(_ context.Context, pod *v1.Pod) *framework.Status {
	if !isOwnedByStatefulSet(pod) {
		msg := fmt.Sprintf("Pod %s не принадлежит StatefulSet", pod.Name)
		klog.V(1).InfoS(msg, "pod", pod.Name)
		return framework.NewStatus(framework.UnschedulableAndUnresolvable, msg)
	}
	return nil
}

// isOwnedByStatefulSet проверяет, принадлежит ли pod к StatefulSet.
func isOwnedByStatefulSet(pod *v1.Pod) bool {
	for _, owner := range pod.OwnerReferences {
		if owner.Kind == "StatefulSet" {
			return true
		}
	}
	return false
}

PreFilter: проверка меток пода

Этот этап выполняет предварительную фильтрацию подов на основе их меток, проверяя наличие хотя бы одной из обязательных меток, указанных в конфигурации планировщика. Если необходимые метки отсутствуют, под помечается как неподлежащий к планированию. Это позволяет на раннем этапе отсеивать поды, которые не должны размещаться на неподходящих узлах кластера.

От этой проверки можно отказаться, если точно знать, что данный планировщик будет использоваться только для нужной группы подов.

Метки пода (s.Labels.Pod): список необходимых меток, которые должен иметь под для прохождения фильтрации, указываются в конфигурации.

// PreFilter проверяет, может ли pod быть запланирован на основе его меток.
func (s *Scheduler) PreFilter(_ context.Context, _ *framework.CycleState, pod *v1.Pod) (*framework.PreFilterResult, *framework.Status) {
	if !hasAnyLabel(pod, s.Labels.Pod) {
		msg := fmt.Sprintf("Pod %s не содержит ни одной из обязательных меток: %v", pod.Name, s.Labels.Pod)
		klog.V(1).InfoS(msg, "pod", pod.Name)
		return nil, framework.NewStatus(framework.Unschedulable, msg)
	}

	klog.V(1).InfoS("Pod успешно прошел prefilter", "pod", pod.Name)
	return nil, nil
}

// hasAnyLabel проверяет, содержит ли pod какую-либо из указанных меток.
func hasAnyLabel(pod *v1.Pod, labelKeys []string) bool {
	for _, key := range labelKeys {
		if _, exists := pod.Labels[key]; exists {
			return true
		}
	}
	return false
}

Filter: проверка соответствия значения метки узла и порядкового номера пода

Это последняя проверка перед непосредственным планированием пода на узел. Здесь проверяется, соответствует ли значение метки на узле порядковому номеру пода. Если эти значения не совпадают, узел считается непригодным для размещения пода, что предотвращает ошибочные размещения.

Порядковый номер пода в StatefulSet является ключевым для его идентификации и размещения, полагаемся на стандартный формат именования подов в StatefulSet: -.
Используется имя пода, а не метка apps.kubernetes.io/pod-index для совместимости со старыми версиями Kubernetes.

Метка узла (s.Labels.Node): необходимая метка узла, значение которой используется для сопоставления с порядковым номером пода, указывается в конфигурации.

// Filter проверяет, подходит ли узел для планирования пода на основе меток узла и порядкового номера пода.
func (s *Scheduler) Filter(_ context.Context, _ *framework.CycleState, pod *v1.Pod, nodeInfo *framework.NodeInfo) *framework.Status {
	node := nodeInfo.Node()

	// Получаем значение метки узла как целое число.
	nodeLabelValue, err := getNodeLabelValue(node, s.Labels.Node)
	if err != nil {
		klog.V(1).InfoS("Filter не выполнен", "node", node.Name, "ошибка", err)
		return framework.NewStatus(framework.Unschedulable, err.Error())
	}

	// Получаем порядковый номер pod.
	podOrdinal, err := getPodOrdinal(pod)
	if err != nil {
		klog.V(1).InfoS("Filter не выполнен", "pod", pod.Name, "ошибка", err)
		return framework.NewStatus(framework.Unschedulable, err.Error())
	}

	// Проверяем, совпадает ли значение метки узла с порядковым номером пода.
	if nodeLabelValue != podOrdinal {
		msg := fmt.Sprintf("Узел %s не подходит для размещения pod %s", node.Name, pod.Name)
		klog.V(1).InfoS(msg, "node", node.Name, "pod", pod.Name)
		return framework.NewStatus(framework.Unschedulable, msg)
	}

	klog.V(1).InfoS("Узел успешно прошел фильтр", "node", node.Name, "pod", pod.Name)
	return nil
}

Использование Scheduling Framework и написание собственного логического слоя на Go предоставляет широкие возможности для кастомизации поведения Kubernetes Scheduler в соответствии с потребностями вашего инфраструктурного решения.

Использование кастомного scheduler’а

Работа с кастомным планировщиком в Kubernetes сводится к нескольким шагам:

  1. Сборка Docker-образа с кастомным планировщиком и обеспечение доступа к нему из кластера Kubernetes.

  2. Создание Deployment для планировщика с настройками приложения и подключением плагинов в KubeSchedulerConfiguration.

  3. Настройка RBAC для обеспечения взаимодействия планировщика с компонентами кластера.

  4. Указание кастомного планировщика в манифестах StatefulSet, используя schedulerName.

Пример Deployment для scheduler’а и его конфигурации

apiVersion: apps/v1
kind: Deployment
...
spec:
...
  template:
...
    spec:
      serviceAccount: sts-scheduler
      containers:
        - name: sts-scheduler
          image: k8s-sts-scheduler:dev
          args:
            - --config=/configs/config.yaml
          env:
            - name: STS-SCHEDULER_LABELS_POD
              value: "example.io/kind"
            - name: STS-SCHEDULER_LABELS_NODE
              value: "example.io/node"

Регистрация наших плагинов в конфигурации планировщика Kubernetes

apiVersion: v1
kind: ConfigMap
...
data:
  config.yaml: |
    apiVersion: kubescheduler.config.k8s.io/v1
    kind: KubeSchedulerConfiguration
    profiles:
      - plugins:
          preEnqueue:
            enabled:
              - name: StatefulSetScheduler
          preFilter:
            enabled:
              - name: StatefulSetScheduler
          filter:
            enabled:
              - name: StatefulSetScheduler
        schedulerName: sts-scheduler

Пример манифеста StatefulSet, использующего кастомный scheduler

apiVersion: apps/v1
kind: StatefulSet
metadata:
  name: app
spec:
  selector:
    matchLabels:
      app: app
  serviceName: "app"
  replicas: 3
  template:
    metadata:
      labels:
        app: app
        example.io/kind: "custom-scheduler-testing"
    spec:
      schedulerName: sts-scheduler
      terminationGracePeriodSeconds: 3
      containers:
      - name: app
        image: busybox:1.36
        command: ["sleep", "infinity"]

Тестирование кастомного scheduler’а

Проверять работоспособность я буду, изспользуя minikube:

minikube start --nodes=5

Шаг 1: Деплой кастомного планировщика и приложения

kubectl apply -f sts-scheduler.yaml
kubectl apply -f sts-application.yaml

Изначально поды будут в состоянии Pending, так как узлам еще не присвоены метки:

NAME    READY   STATUS    RESTARTS   AGE
app-0   0/1     Pending   0          2m

Шаг 2: Присвоение меток узлам

Присваиваем метки узлам, чтобы планировщик мог разместить поды:

kubectl label nodes minikube-m02 example.io/node=0
kubectl label nodes minikube-m03 example.io/node=1
kubectl label nodes minikube-m04 example.io/node=2
kubectl label nodes minikube-m05 example.io/node=3

Теперь поды успешно запускаются на указанных узлах:

NAME    READY   STATUS    RESTARTS   AGE    IP           NODE           NOMINATED NODE   READINESS GATES
app-0   1/1     Running   0          3m     10.244.1.3   minikube-m02              
app-1   1/1     Running   0          22s    10.244.2.2   minikube-m03              
app-2   1/1     Running   0          9s     10.244.3.3   minikube-m04              

Проверка перезапуска подов

Удаляем поды и убеждаемся, что они перезапускаются на тех же узлах:

NAME    READY   STATUS              RESTARTS   AGE    IP           NODE           NOMINATED NODE   READINESS GATES
app-0   1/1     Running             0          4m     10.244.1.3   minikube-m02              
app-1   1/1     Running             0          1s     10.244.2.5   minikube-m03              
app-2   0/1     ContainerCreating   0          0s            minikube-m04              

Масштабирование приложения

Добавляем новый под, и он размещается на свободном узле с соответствующей меткой.

NAME    READY   STATUS              RESTARTS   AGE    IP           NODE           NOMINATED NODE   READINESS GATES
app-0   1/1     Running             0          5m     10.244.1.3   minikube-m02              
app-1   1/1     Running             0          27s    10.244.2.5   minikube-m03              
app-2   1/1     Running             0          26s    10.244.3.4   minikube-m04              
app-3   0/1     ContainerCreating   0          4s            minikube-m05              

Если добавить больше подов, они останутся в состоянии Pending до тех пор, пока не будут добавлены новые узлы с необходимыми метками.

Важное замечание

Если одному и тому же индексу присвоить несколько узлов (т.е. назначить одну и ту же метку example.io/node=N разным узлам), под может «прыгать» между узлами. В нашем случае это было некритично, так как метки назначались через IaC (Infrastructure as Code), и такой ситуации не возникало, однако, можно добавить проверку в код планировщика, чтобы исключить такое поведение.

Заключение

Kubernetes предоставляет все необходимые инструменты для создания кастомного scheduler’а, если вам требуется более тонкая настройка распределения подов, позволяя реализовать индивидуальные требования для вашего проекта.

Данный проект доступен в репозитории на Github.

© Habrahabr.ru