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"
Вот так у нас выглядит график работы за неделю:
Здесь зеленая линия — это количество занятых серверов, оранжевая — доступно всего. Соответственно, разница между ними — это и есть запас прочности, который у нас есть на случай резкого прилива игроков.
Еще можно заметить желтую линию — это сервера, находящиеся в состоянии Initializing. Обычно такое происходит при первой загрузке пода или после завершения игровой сессии.
Вывод
Автоскалирование, какой и любой другой автоматизированный процесс, бывает сложен в изучении, настройки и внедрении. А современные инструменты позволяют сильно облегчить эти процессы.
Мы выбрали для себя Custom Pod Autoscaler и продолжаем активно его использовать. Он дает нам возможность настроить все так, как мы хотим. Если это можно описать с помощью кода, то оно будет работать.