Создание кастомного Kubernetes Scheduler для StatefulSet
В этой статье мы рассмотрим процесс создания кастомного scheduler’а для Kubernetes, ориентируясь на Kubernetes Scheduling Framework.
Обычно для назначения подов на вычислительные узлы используется стандартный планировщик, который, проанализировав различные параметры, автоматически выполнит оптимальное размещение (например, распределит поды таким образом, чтобы не размещать их на вычислительных узлах с недостаточными ресурсами).
В одном из наших проектов, где мы использовали оператор Strimzi для развёртывания кластеров Kafka, заказчик выдвинул специфические требования по размещению данных, резервному копированию и восстановлению. Одним из ключевых пунктов стал вопрос строгой привязки экземпляров приложения к вычислительным узлам. Для этого нам пришлось создать кастомный 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 сводится к нескольким шагам:
Сборка Docker-образа с кастомным планировщиком и обеспечение доступа к нему из кластера Kubernetes.
Создание Deployment для планировщика с настройками приложения и подключением плагинов в KubeSchedulerConfiguration.
Настройка RBAC для обеспечения взаимодействия планировщика с компонентами кластера.
Указание кастомного планировщика в манифестах 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.