Custom Pod Autoscaler – сверхгибкое автоскалирование в Kubernetes

Преимущества использования системы оркестрации контейнеров — удобство их развертывания, обновления и масштабирования. И одним из наиболее популярных таких инструментов является Kubernetes. 

Многие знают, что Kubernetes имеет встроенный механизм для автоскалирования подов — Horizontal Pod Autoscaling (HPA). Но что, если надо принимать решение с учетом множества факторов: суммы метрик, зависимости от количества готовых контейнеров, процента или доли доступных/недоступных подов или даже времени суток? А если эти показатели важны для нас все вместе?  

Мы в студии Whalekit смогли решить эту задачу. И отличным решением для этого стал Custom Pod Autoscaler (CPA).

CPA — инструмент, который позволяет настраивать логику принятия решения с использованием любых доступных метрик. С минимальными знаниями любого языка программирования можно организовать настолько функциональную и гибкую модель автоскалирования, насколько позволяет вам ваша фантазия.

Немного о преимуществах автоскалирования и существующих решениях

Если вы платите за используемые вычислительные мощности или делите сервера с другими проектами, важно потреблять именно столько, сколько действительно требует приложение или проект в целом ровно в данный момент. Автоскалирование позволяет создавать новые копии приложения в зависимости от заданных параметров — например, потребляемых ресурсов, количества пользователей, RPS или времени суток. Реализация такого подхода обеспечивает наиболее оптимальное соотношение запаса прочности вашего сервиса и его стоимости.

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

  • Гибкость — инструмент, который позволял бы реализовать любую логику работы в зависимости от условий, входных данных и даже времени суток.

  • Легкость настройки — решение должно быть простым и понятным. Инженер с любым уровнем навыков программирования должен иметь возможность разобраться в происходящем и произвести отладку.

  • Надежность — решение должно стабильно работать на высоких нагрузках, а в случае сбоя — быстро восстанавливаться.

Из тех решений, что мы рассматривали:  

  • Встроенный механизм в Kubernetes — Horizontal Pod Autoscaler. И хоть HPA довольно прост в настройке, но не самый гибкий;  

  • Keda — расширяет функционал HPA, добавляет много полезных возможностей, например — AI-based predictive, но все это существенно усложняет процесс внедрения и настройки;

  • Vertical Pod Autoscaler — как и другие механизмы вертикального масштабирования, нам он не подходил из-за особенности архитектуры приложения. 

А остановились мы в итоге, как и было сказано, на CPA, и вот почему.

Custom Pod Autoscaler и его особенности

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

Вся суть работы основана на двух основных этапах:  

  • Scrape metrics;

  • Evaluation.

Они описываются в файле config.yaml. Я буду использовать Python для описания логики, но вам ничего не мешает использовать любой доступный способ, в том числе привычный язык программирования. Достаточно просто указать на исполняемый файл для загрузки окружения.

evaluate:
  type: "shell"
  timeout: 10000
  shell: 
    entrypoint: "/usr/local/bin/python"
    command: 
      - "/evaluate.py"
metric:
  type: "shell"
  timeout: 10000
  shell: 
    entrypoint: "/usr/local/bin/python"
    command: 
      - "/metric.py"
runMode: "per-resource"
logVerbosity: 3

Первый этап — это сбор метрик. Откуда и как их собирать, вы вольны выбирать сами. Можно обращаться к API Prometheus, Zabbix, Munin, HTTP Request — хоть из файла читайте. Каких-либо ограничений здесь нет.

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

  • Busy — сервер занят, на нем идет игра;

  • Ready — сервер полностью готов принять игру;

  • Initializing — сервер находится в состоянии загрузки.

Вычисленные значения на основании этих состояний:

Первое, что нам нужно, — это получить информацию о текущем состоянии Deployments. Тут я просто обращаюсь к Prometheus за необходимыми мне данными с учетом окружения и версии. В проде у нас может быть несколько окружений одновременно.

#!/usr/bin/env python3

import os
import json
import sys
from pprint import pprint

# Подключаем библиотеку для работы с API Prometheus
from prometheus_api_client import PrometheusConnect

# Получаем адрес Prometheus из env-переменной
_prometheus_url = os.getenv('prometheusUrl')

# Определяем функцию с запросом в Prometheus
def probe_http_status_code(_version, _environment, _status_code):
    return 'count(probe_http_status_code{{container="gameplay", service="gps", version="{}", namespace="{}"}} == {}) or on() vector(0)'.format(_version, _environment, _status_code)

# Функция с запросом в Prometheus и получением нужных данных
def query(prom, _version, _environment, _status_code):
    return(int(prom.custom_query(query=probe_http_status_code(_version, _environment, _status_code))[0]['value'][1]))

# Получаем различные метрики состояния игровых серверов
def get_status_containers(_prometheus_url, _version, _environment):
    prom = PrometheusConnect(url=_prometheus_url)
    _busy = query(prom, _version, _environment, _status_code="202")
    _ready = query(prom, _version, _environment, _status_code="200")
    _initializing = query(prom, _version, _environment, _status_code="425")
    _available = query(prom, _version, _environment, _status_code="202") + query(prom, _version, _environment, _status_code="200")
    _busy_containers = ( _busy / _available ) * 100

# Агрегируем все данные в один JSON и возвращаем на вызов функции
    metrics = {"busy": _busy, "ready": _ready, "initializing":  _initializing, "available": _available, "busy_containers":  _busy_containers}
    return(metrics)

# Получаем Environment из Deployments
def get_environment(spec):
    metadata = spec["resource"]["metadata"]
    return(metadata["namespace"])

# Получаем Version из Deployments
def get_version(spec):
    metadata = spec["resource"]["metadata"]
    labels = metadata["labels"]
    if "version" in labels:
        return(labels["version"])

def main():
    spec = json.loads(sys.stdin.read())
 metrics = get_status_containers(_prometheus_url, _version=get_version(spec), _environment=get_environment(spec))
    sys.stdout.write(json.dumps(metrics))

if __name__ == "__main__":
    main()

Самое интересное — это второй этап: оценка метрик и принятие решения об автоскалировании вверх или вниз (upscale и downscale). Тут можно производить любые арифметические операции с данными или метриками, добавить условия о состоянии соседних или зависимых подов (контейнеров), времени суток и количестве пользователей. Причем вам не надо выбирать: все эти параметры могут быть важны вместе именно в вашем случае, и можно написать модель, которая будет идеально подходить вам с учетом метрик и параметров. 

Для меня важны шесть параметров, которые могут быть уникальны для каждого окружения или версии игровых серверов, поэтому я получаю их из env-переменных:

  • Min/max threshold — минимальные и максимальные значения срабатывания автосканирования. Часто выставлены как 70 и 90, соответственно. Таким образом мы получаем некий коридор. Если наша метрика находится в нем, CPA не будет делать ничего. Если же значение превысит maxThreshold, будут подняты еще сервера, а если станет ниже minThreshold, убраны лишние.

  • Min/max replicas — минимальное и максимальное количество реплик. Может быть полезно в разных ситуациях — например, при ограничении ресурсов в кластере. 

  • ScaleUp/ScaleDown — количество подов, которое мы прибавляем или убираем. Позволяет гибко задавать динамику изменения подов в кластере. Обычно мы хотим, чтобы кластер активно увеличивался (scaleUp указываем +10) и плавно уменьшался (scaleDown –3).

#!/usr/bin/env python3

import json
import sys
import os
import math
from pprint import pprint

# Получаем значения параметров из env-переменных
_min_threshold = int(os.getenv('minThreshold'))
_max_threshold = int(os.getenv('maxThreshold'))
_min_replicas = int(os.getenv('minReplicas'))
_min_ready = int(os.getenv('minReady'))
_scale_up = int(os.getenv('scaleUp'))
_scale_down = int(os.getenv('scaleDown'))

# Функция с основным алгоритмом принятия решения
def evaluate(spec):
    # Обнуляем переменную
    total_available = 0
    # Распарсиваем значения по текущему состоянию игровых серверов, которые получили на прошлом этапе сбора метрик
    for metric in spec["metrics"]:
        _json_value = json.loads(metric["value"])
        _busy_containers = _json_value["busy_containers"]
        _available = _json_value["available"]
        _ready = _json_value["ready"]
        _busy = _json_value["busy"]

    # Получаем текущее количество подов
    _target_replica_count = int(spec["resource"]["spec"]["replicas"])

    # Мы хотим, чтобы у нас всегда был небольшой запас серверов, поэтому проверяем, чтобы количество подов в состоянии Ready было не меньше значения в minReplicas
    if _ready <= _min_ready:
        return _target_replica_count + _scale_up

    # Если занятых контейнеров больше, чем maxThreshold, поднимаем еще
    if _busy_containers > _max_threshold:
        return _target_replica_count + _scale_up

    # Перед уменьшением проверяем, что количество используемых контейнеров меньше minThreshold и что после уменьшения у нас все еще будет больше minReplicas
    if _busy_containers < _min_threshold and _ready - _scale_down > _min_ready:
        return _target_replica_count - _scale_down

    return(_target_replica_count)

def main():
    # Читаем stdout, чтобы получить значения, собранные на предыдущем шаге
    spec = json.loads(sys.stdin.read())
    _target_replica_count = evaluate(spec)

    try:
        evaluation = {}
       # Запускаем функцию принятия решения и пишем в stdout требуемое количество подов
        evaluation["targetReplicas"] = _target_replica_count
        sys.stdout.write(json.dumps(evaluation))
    except ValueError as err:
        sys.stderr.write(f"Invalid metric value: {err}")
        exit(1)

if __name__ == "__main__":
    main()

Интересная особенность, что все общение и передача данных происходит внутри CPA через stdout в формате JSON. Поэтому в скриптах вы можете видеть:

sys.stdout.write(json.dumps(evaluation))

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

На этом этапе у нас уже есть значение с требуемым количеством реплик, которое надо просто передать в нужный нам деплоймент. Для этого создаем ресурс в кластере, который и будет следить за состоянием Deployments и выставлять значение target replicas:

apiVersion: custompodautoscaler.com/v1
kind: CustomPodAutoscaler
metadata:
  name: CustomName
spec:
  template:
    spec:
      containers:
      - name: gameplay
        image: python-custom-autoscaler:latest
        imagePullPolicy: Always
  scaleTargetRef:
    apiVersion: apps/v1
    kind: Deployment
    name: TargetDeployment
  config:
    - name: interval
      value: "60"
    - name: minReplicas
      value: "100"
    - name: maxReplicas
      value: "500"
    - name: minReady
      value: "10"
    - name: downscaleStabilization
      value: "60"
    - name: minThreshold
      value: "80"
    - name: maxThreshold
      value: "90"
    - name: scaleUp
      value: "7"
    - name: scaleDown
      value: "3"

Вот так у нас выглядит график работы за неделю:

46417c67208db4605602287557ce62d3.png

Здесь зеленая линия — это количество занятых серверов, оранжевая — доступно всего. Соответственно, разница между ними — это и есть запас прочности, который у нас есть на случай резкого прилива игроков.

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

Вывод

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

Мы выбрали для себя Custom Pod Autoscaler и продолжаем активно его использовать. Он дает нам возможность настроить все так, как мы хотим. Если это можно описать с помощью кода, то оно будет работать.

© Habrahabr.ru