Всем привет! Это вновь Илья Смирнов, архитектор решений из Cloud.ru. В прошлой статье мы рассмотрели традиционные подходы к масштабированию подов и узлов кластера Kubernetes. Но остался нерешенным вопрос — как масштабировать приложение по событиям из внешней системы? Ведь мы хотим, чтобы каждое новое сообщение в очереди RabbitMQ масштабировало нагрузку вверх, реализовать event-driven подход и масштабировать приложение не по метрикам утилизации ресурсов, а по факту появления новых событий от внешних систем. Так как же быть?
Я предлагаю использовать решение KEDA ?

Что такое KEDA
KEDA (Kubernetes-based Event Driven Autoscaler) — это open-source решение для Kubernetes, которое позволяет настроить автомасштабирование нагрузок в соответствии с event-driven подходом.
KEDA не заменяет стандартные механизмы Kubernetes, а интегрируется с ними. Под капотом она создает и управляет обычным Horizontal Pod Autoscaler (HPA), но вместо CPU/Memory может использовать метрики из внешних систем — например, RabbitMQ, Kafka, Prometheus, PostgreSQL, и другие источники.
После установки KEDA вы сможете создать сущность ScaledObject или ScaledJob. Эти сущности будут масштабировать deployment, statefulset, кастомные ресурсы или создавать Kubernetes job в зависимости от внешних систем.

Установим KEDA
KEDA устанавливается тремя способами:
через Helm-чарт;
через Operator Hub;
скачать исходные файлы из репозитория KEDA и развернуть решение через kubectl, используя yaml-манифесты.
Мы будем использовать Helm. Более подробно про методы установки можно посмотреть в официальной документации KEDA.
А мы продолжаем практику из предыдущей части. И если вы ее еще не создали или уже случайно удалили инфраструктуру, то сначала подготовьте среду:
Сгенерируйте ключи доступа к облачной платформе Cloud.ru Evolution.
Создайте кластер в сервисе Evolution Managed Kubernetes. Как его создать и работать с ним, можно посмотреть в документации, а также в лабораторной работе.
Разверните
mongodb
с помощью helm:
helm install mongodb oci://registry-1.docker.io/bitnamicharts/mongodb --set useStatefulSet=true --set auth.rootPassword=mongo
Разверните
rabbitmq
с помощью helm:
helm install rabbitmq oci://registry-1.docker.io/bitnamicharts/rabbitmq --set auth.username=user --set auth.password=P@ssw0rd
Создайте приватный репозиторий в Evolution Artefact Registry.
Также, чтобы не перечитывать предыдущую часть, вспомним приложение, которое мы развернули и в чем были наши проблемы с HPA.

Когда мы отправляем POST-запрос на url http://complex-app-service/send?name=<имя-записи>&content=<контент>
, то complex-app
отправляет сообщение с параметрами name
и content
в формате json
в очередь RabbitMQ. processor
слушает очередь RabbitMQ и если есть новые сообщения, то читает их, добавляет в mongodb
json с name
и content
и засыпает на 20 секунд.
Функция sleep
имитирует бурную деятельность processor
’а. Мы, таким образом, можем представить, что processor
по сообщению из RabbitMQ начинает обрабатывать какой-то «тяжелый» файл. Например, конвертирует видео.
Чтобы повысить эффективность приложения, мы хотим масштабировать processor
, когда приходит несколько файлов на обработку параллельно. Несколько реплик справятся с большим количество файлов быстрее.
HPA не позволяет реализовать такое масштабирование. Мы не сможем подобрать универсальный порог срабатывания по метрикам утилизации, чтобы масштабирование всегда срабатывало. Именно в этой точке возникает идея масштабировать processor
по событию — когда приходит новое сообщение в RabbitMQ, мы хотим масштабировать processor
вверх на одну реплику.
Теперь вернемся к нашему приложению и установим в нашем кластере KEDA:
helm repo add kedacore https://kedacore.github.io/charts
helm repo update
helm install keda kedacore/keda --namespace keda --create-namespace

KEDA добавляет следующие Custom Resource Definitions:
ScaledObject — масштабирует Deployment, StatefulSet или кастомные ресурсы;
ScaledJob — создает Kubernetes Job на основе событий;
TriggerAuthentication и ClusterTriggerAuthentication — хранят секреты для аутентицифкации во внешних системах.
ScaledObject и ScaledJob — это основные сущности, через которые мы будем описывать логику event-driven масштабирования.
В манифестах ScaledObject и ScaledJob мы прописываем один или несколько триггеров. В триггере мы указываем с какой внешней системы собираем события и настройки срабатывания триггера. Например, для RabbitMQ это могут быть:
параметры подключения к RabbitMQ;
имя очереди событий;
целевые метрики — длина очереди или текущая скорость обработки сообщений;
пороговое значение, после которого триггер срабатывает и делает масштабирование вверх или вниз.
Архитектура KEDA
После установки KEDA в кластере появятся три deployment’а:
• keda-operator
— оператор, который управляет объектами KEDA и создает HPA;
• keda-operator-admission-webhook
— валидирует манифесты ScaledObject и ScaledJob перед их сохранением в etcd
;
• keda-metrics-apiserver
— отдает метрики из внешних систем HPA, который создает оператор.

Наша задача — масштабировать processor
, когда приходит новое сообщение в очередь RabbitMQ. Давайте на примере ее рассмотрим как работает KEDA.
Мы пишем манифест ресурса ScaledObject и применяем его с помощью
kubectl
.Чтобы валидировать манифест, API Server отправляет его в keda-operator-admission-webhook.
Если ошибок в манифесте нет, то keda-operator-admission-webhook возвращает его API Server’у.
API Server сохраняет манифест в etcd.
В бизнес-логике оператора keda есть сущность controller. Контроллер регулярно проверяет через API Server — не появилось ли новых ScaledObject или ScaledJob в etcd.
Если новые ScaledObject или ScaledJob обнаруживаются, то контроллер создает:
внутри оператора сущность ScaleHandler,
HPA, который будет масштабировать наш deployment.
Наш ScaledObject содержит один триггер для масштабирования по событиям из RabbitMQ. Поэтому ScaleHandler создаст внутри оператора один объект scaler (скейлер). Кстати, скейлер создается для каждого отдельного триггера, обращается во внешнюю систему и собирает метрики.
Масштабирование ресурса из 0 в 1 и из 1 в 0 всегда выполняет контроллер. Если контроллер на основании метрик от скейлера видит, что ресурс нужно масштабировать (из 0 в 1 или наоборот), то он через API Server увеличивает количество реплик.
Во всех остальных случаях масштабирование выполняет HPA. Чтобы это было возможно, скейлер адаптирует метрики и отправляет их в metrics-apiserver. HPA выполняет дальнейшее масштабирование на базе метрик, которые он получил из metrics-apiserver.
Как устроены скейлеры
Подытожим. Мы прописываем триггер в манифесте и указываем в нем параметры для подключения к внешней системе и параметры сбора метрик и запуска масштабирования. Скейлер создается оператором для того, чтобы подключиться к внешней системе и собирать с нее нужные метрики.
KEDA поддерживает больше 60 скейлеров, включая:
очереди (RabbitMQ, Kafka, и т. д.);
базы данных (PostgreSQL, MySQL, MongoDB);
системы мониторинга (Prometheus, Datadog, New Relic);
и т. д.
Также, вы можете масштабировать нагрузки в Kubernetes с попомощью HTTP Add-on Scaler. Правда, пока нужно быть аккуратнее — на момент написания статьи HTTP-скейлер находился в статусе бета-версии.
Если нужного скейлера нет, можно написать свой внешний скейлер, который будет взаимодействовать с KEDA посредством GRPC — по ссылке можно узнать больше.
Как работают ScaledObject
ScaledObject описывает правила масштабирования для Deployment, StatefulSet или кастомного ресурса.
Пример манифеста:
apiVersion: keda.sh/v1alpha1
kind: ScaledObject
metadata:
name: processor-scaledobject
spec:
scaleTargetRef:
apiVersion: apps/v1
kind: Deployment
name: processor
pollingInterval: 15
cooldownPeriod: 60
minReplicaCount: 0
maxReplicaCount: 10
triggers:
- type: rabbitmq
metadata:
protocol: amqp
queueName: myqueue
hostFromEnv: RABBITMQ_CONN_STR
Пройдемся по ключевым параметрам. В целом они очень напоминают параметры HPA:
scaleTargetRef — Deployment, StatefulSet или кастомный ресурс, который нужно масштабировать;
pollingInterval — как часто скейлер (или скейлеры) будет опрашивать внешнюю систему (в секундах);
cooldownPeriod — через сколько секунд KEDA будет масштабировать количество реплик в 0 после последних зарегистрированных признаков активности от внешней системы;
min/maxReplicaCount — минимальное или максимальное количество реплик;
triggers — список триггеров.
Остановимся на triggers чуть подробнее.
Как работают triggers
Блок triggers включает как минимум один триггер. Для каждого отдельного триггера оператор keda будет создавать скейлер.
Каждый триггер включает в себя два обязательных параметра:
type — тип создаваемого триггера;
metadata — настройки создаваемого параметра.
А также два опциональных параметра:
authenticationRef — ссылка на **triggerAuthentication ** или clusterTriggerAuthentication. Про эти объекты мы поговорим чуть позднее;
metricType — тип используемой метрики: AverageValue, Value, Utilization.
Кстати, эти типы мы подробно ракзбирали в предыдущей части статьи.
Расширенные настройки ScaledObject
Мы написали манифест ScaledObject и можем его создать, чтобы масштабировать processor по событиям от RabbitMQ. Но возникает вопрос, а что случится, если скейлер перейдет в статус ошибки? Количество реплик вернется к исходному, останется текущим? Или произойдет что-то еще?
Чтобы быть уверенным в логике работы ScaledObject, вы можете определить параметр fallback. Fallback как раз определяет сколько реплик будет, если скейлер перейдет в состояние ошибки.
apiVersion: keda.sh/v1alpha1
kind: ScaledObject
metadata:
name: processor-scaledobject
spec:
…
fallback:
failureThreshold: 3
replicas: 6
…
Также, мы обсуждали, что любой ScaledObject создает собственный HPA. В предыдущей части статьи мы говорили, что для HPA можно настроить behavior, чтобы определить окно стабилизации и политики масштабирования. А можем ли мы эти параметры настроить для HPA от ScaledObject?
В блоке advanced манифеста ScaledObject можно прописать все параметры HPA, а если вдруг корпоративные политики потребуют, чтобы HPA имел имя, отличное от того, которое по умолчанию присваивает KEDA, то в этом блоке можно задать и его.
apiVersion: keda.sh/v1alpha1
kind: ScaledObject
metadata:
name: processor-scaledobject
spec:
…
advanced:
restoreToOriginalReplicaCount: true
horizontalPodAutoscalerConfig:
name: processor-scaledobj-hpa
behavior:
scaleDown:
stabilizationWindowSeconds: 300
policies:
- type: Percent
value: 100
periodSeconds: 15
…
ScaledObject vs HPA
Если мы эксплуатируем систему не первый год и решили внедрять KEDA, то наверняка у нас уже настроено горизонтальное масштабирование на базе HPA. Как нам в этом случае совместить все радости KEDA с уже работающими HPA?
Сначала может показаться, что стоит использовать KEDA вместе HPA. Это рабочая схема, но ровно до тех пор, пока мы с помощью KEDA и HPA не начинаем масштабировать один ресурс.
Т. е., если вы масштабируете условный deployment1
с помощью HPA, а deployment2
с помощью KEDA, то в вашем кубере будет царить мир и согласие. Но если HPA и KEDA будут масштабировать deployment1
, то результаты могут получиться непредсказуемые.
В таких случаях в документации KEDA рекомендуется оставить только ScaledObject и добавить в него триггеры для CPU и RAM.
Как работают ScaledJob
В конце предыдущей части мы обсуждали, что масштабирование processor
вниз с помощью HPA также имеет свои подводные камни.

Если мы масштабируем processor
до пяти реплик, то в какой-то момент один из подов закончит обработку и необходимо будет масштабировать нагрузку вниз. HPA не знает какой из подов закончил обработку видео и удалит любой из подов случайным образом. С высокой вероятностью мы получаем ситуацию, когда минимум один из файлов у нас будет потерян.
Внимательный читатель наверняка заметил, что KEDA не решает этой проблемы, ведь под капотом у нее все тот же HPA. Как тогда быть? Вопрос довольно холиварный. Документация KEDA нам предлагает поиграться с отправкой SIGTERM
внутри нашего ПО или использовать ScaledJob.
Вариант с SIGTERM
в рамках сегодняшнего текста мы посчитаем довольно экзотическим и лучше разберемся со ScaledJob, который позволяет запускать Kubernetes job по событию.
Пример манифеста ScaledJob:
apiVersion: keda.sh/v1alpha1
kind: ScaledJob
metadata:
name: processor-job
spec:
jobTargetRef:
template:
spec:
containers:
- name: processor
image: my-processor-image
pollingInterval: 30
successfulJobsHistoryLimit: 5
failedJobsHistoryLimit: 5
scalingStrategy:
strategy: "default"
triggers:
- type: rabbitmq
metadata:
protocol: amqp
queueName: myqueue
hostFromEnv: RABBITMQ_CONN_STR
Мы видим, что ScaledJob вместо TargetRef содержит параметр jobTargetRef, в котором мы указываем шаблон создаваемого job’а.
Также, в сравнении со ScaledObject, мы имеем здесь следующие новые параметры:
successfulJobsHistoryLimit — как много job’ов в статусе
Completed
должны хранится;failedJobsHistoryLimit — как много job’ов в статусе
Failed
должны «хранится» (здесь мы понимаем, что если выполним командуkubectl get pods
, то увидим поды со статусомCompleted
илиFailed
);scalingStrategy — стратегия масштабирования job’ов. С помощью этого параметра вы можете определить как будут масштабироваться job’s и даже написать собственную логику масштабирования.
Как работают triggerauthentication
Скейлеры объектов ScaledObject и ScaledJob подключаются к внешним системам. Подключение к очереди сообщений, базе данных или любой другой внешней системе зачастую требует аутентификации.
Чтобы передать в ScaledObject или ScaledJob секрет, нужно действовать стандартно:
создать секрет;
смонтировать его в deployment или statefulset, который масштабируем;
указать данные из этого секрета в блоке triggers ScaledObject или ScaledJob.
В этом случае ScaledObject/Job не имеет прямого доступа к секрету. Непосредственно секрет мы монтируем в масштабируемую нагрузку. Особенно этот подход плохо работает, если секрет мы храним, например, в hashiCorp Vault, а не внутри Kubernetes. KEDA же добавляет ресурсы TriggerAuthentication и ClusterTriggerAuthentication, которые позволяют напрямую предоставить доступ к секретам сразу в ScaledObject или ScaledJob.
Пример настройки TriggerAuthentication для RabbitMQ:
apiVersion: keda.sh/v1alpha1
kind: TriggerAuthentication
metadata:
name: rabbitmq-auth
spec:
secretTargetRef:
- parameter: host
name: rabbitmq-secret
key: host
- parameter: password
name: rabbitmq-secret
key: password
TriggerAuthentication действует на уровне namespace, а ClusterTriggerAuthentication — на уровне кластера.
KEDA поддерживает провайдеров аутентификации (например hashiCorp Vault, который мы упоминали выше) и вы можете прописывать в TriggerAuthentication и ClusterTriggerAuthentication не только секреты Kubernetes, но и секреты из этих провайдеров аутентификации.
В этой статье мы не будем углубляться в аутентификацию, подробности можно узнать в документации.
Завершим демо-приложение
Теперь попробуем KEDA на практике.
Cкачайте файлы из репозитория.
Соберите образы контейнеров и загрузите их в ваш приватный репозиторий.
Откройте скриптbuild.sh
в корне репозитория для редактирования и подставьте URI вашего приватного репозитория и ключи доступа к облаку в переменные в начале скрипта:
REPO=<uri репозитория>
LOGIN=<Key ID>
PASSWORD=<Secret Key>
VERSION=<Можно не менять — это просто тег образа>
Сделайте скрипт исполняемым и запустите:
chmod +x $HOME/keda-p2/build-images.sh
$HOME/keda-p2/build-images.sh
Теперь разверните приложение:
kubectl apply -f $HOME/keda-p2/deploy/
Теперь то же приложение, которое мы развертывали в конце предыдущей части, теперь разворачивается не как deployment, а как ScaledJob.
apiVersion: keda.sh/v1alpha1
kind: ScaledJob
metadata:
name: processor-scaled-job
spec:
jobTargetRef:
template:
spec:
containers:
- name: processor-container
image: <REPO>/processor-job:1.0
imagePullPolicy: Always
env:
- name: MONGODB_USER
value: "root"
- name: MONGODB_PASSWORD
value: "mongo"
- name: MONGODB_HOST
value: "mongodb"
- name: MONGODB_PORT
value: "27017"
- name: MONGODB_DB
value: "sample-db"
- name: MONGODB_COLLECTION
value: "sample-collection"
- name: RABBITMQ_HOST
value: "rabbitmq"
- name: RABBITMQ_USER
value: "user"
- name: RABBITMQ_PASSWORD
value: "P@ssw0rd"
restartPolicy: Never
imagePullSecrets:
- name: cloud-registry-secret
pollingInterval: 5
successfulJobsHistoryLimit: 5
failedJobsHistoryLimit: 5
maxReplicaCount: 10
triggers:
- type: rabbitmq
metadata:
mode: QueueLength
value: "1"
host: amqp://user:P%40ssw0rd@rabbitmq.default.svc.cluster.local:5672/
queueName: "task_queue"
В результате получается такая схема:

Повторим эксперимент из конца первой части и попробуем сделать сразу пять POST-запросов:
Запустим curl-под внутри кластера Kubernetes:
kubectl run -it --rm curl-pod --image=curlimages/curl -- /bin/sh
Отправим подряд пять POST-запросов, чтобы сымитировать пять файлов, ожидающих обработки.
curl -X POST http://complex-app-keda-service/send?name=record1&content=content1
curl -X POST http://complex-app-keda-service/send?name=record2&content=content2
curl -X POST http://complex-app-keda-service/send?name=record3&content=content3
curl -X POST http://complex-app-keda-service/send?name=record4&content=content4
curl -X POST http://complex-app-keda-service/send?name=record6&content=content5
Сразу посмотрим какие записи созданы:
curl http://complex-app-keda-service/data
Убеждаемся, что что все записи созданы — это может занять около 30 секунд. И затем вводим команду
exit
, чтобы выйти из командной оболочки пода.Проверяем, какие Kubernetes Job были созданы:
kubectl get job
И убеждаемся, что job создавал ScaledJob:

Выводы и масштабирование в ноль
Итак, мы разобрались, как масштабировать ресурсы Kubernetes по метрикам утилизации и по событиям из внешних систем, что поможет неплохо оптимизировать приложение. Например, для таких нагрузок как processor, которые запускаются по событиям и обрабатывают большие файлы, мы можем предусмотреть отдельный нодпул с дорогими ресурсами и по умолчанию масштабировать его в 0. А когда прилетит событие и KEDA создаст ScaledJob, то на время работы этих подов создать дорогие ресурсы, обработать файлы, а затем автоматически масштабируем всю нагрузку в ноль, чтобы не переплачивать за дорогие ресурсы.
Благодарю всех за внимание. Надеюсь, было полезно ?