Всем привет! Это вновь Илья Смирнов, архитектор решений из 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.

А мы продолжаем практику из предыдущей части. И если вы ее еще не создали или уже случайно удалили инфраструктуру, то сначала подготовьте среду:

  1. Сгенерируйте ключи доступа к облачной платформе Cloud.ru Evolution.

  2. Создайте кластер в сервисе Evolution Managed Kubernetes. Как его создать и работать с ним, можно посмотреть в документации, а также в лабораторной работе.

  3. Подключитесь к кластеру

  4. Разверните mongodb с помощью helm:

helm install mongodb oci://registry-1.docker.io/bitnamicharts/mongodb --set useStatefulSet=true --set auth.rootPassword=mongo
  1. Разверните rabbitmq с помощью helm:

helm install rabbitmq oci://registry-1.docker.io/bitnamicharts/rabbitmq --set auth.username=user --set auth.password=P@ssw0rd
  1. Создайте приватный репозиторий в Evolution Artefact Registry.

Также, чтобы не перечитывать предыдущую часть, вспомним приложение, которое мы развернули и в чем были наши проблемы с HPA.

Disclaimer: приложение носит демонстрационный характер и на самом деле пользы никому не причиняет
Disclaimer: приложение носит демонстрационный характер и на самом деле пользы никому не причиняет

Когда мы отправляем 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.

  1. Мы пишем манифест ресурса ScaledObject и применяем его с помощью kubectl.

  2. Чтобы валидировать манифест, API Server отправляет его в keda-operator-admission-webhook.

  3. Если ошибок в манифесте нет, то keda-operator-admission-webhook возвращает его API Server’у.

  4. API Server сохраняет манифест в etcd.

  5. В бизнес-логике оператора keda есть сущность controller. Контроллер регулярно проверяет через API Server — не появилось ли новых ScaledObject или ScaledJob в etcd.

  6. Если новые ScaledObject или ScaledJob обнаруживаются, то контроллер создает:

  • внутри оператора сущность ScaleHandler,

  • HPA, который будет масштабировать наш deployment.

  1. Наш ScaledObject содержит один триггер для масштабирования по событиям из RabbitMQ. Поэтому ScaleHandler создаст внутри оператора один объект scaler (скейлер). Кстати, скейлер создается для каждого отдельного триггера, обращается во внешнюю систему и собирает метрики.

  2. Масштабирование ресурса из 0 в 1 и из 1 в 0 всегда выполняет контроллер. Если контроллер на основании метрик от скейлера видит, что ресурс нужно масштабировать (из 0 в 1 или наоборот), то он через API Server увеличивает количество реплик.

  3. Во всех остальных случаях масштабирование выполняет 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 на практике.

  1. Cкачайте файлы из репозитория.

  2. Соберите образы контейнеров и загрузите их в ваш приватный репозиторий.
    Откройте скрипт 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
  1. Теперь разверните приложение:

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-запросов:

  1. Запустим curl-под внутри кластера Kubernetes:

kubectl run -it --rm curl-pod --image=curlimages/curl -- /bin/sh
  1. Отправим подряд пять 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
  1. Сразу посмотрим какие записи созданы:

curl http://complex-app-keda-service/data
  1. Убеждаемся, что что все записи созданы — это может занять около 30 секунд. И затем вводим команду exit, чтобы выйти из командной оболочки пода.

  2. Проверяем, какие Kubernetes Job были созданы:

kubectl get job

И убеждаемся, что job создавал ScaledJob:

Выводы и масштабирование в ноль

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

Благодарю всех за внимание. Надеюсь, было полезно ?

Комментарии (0)