Что я знаю о бриллиантах? Я устраиваю боксерские бои. Всего неделю назад я устраивал боксерские бои и радовался жизни, и вдруг... Что я знаю о бриллиантах?

На носу 2026 год, а я хочу поделиться своим путешествием по переводу приложения на инфраструктуру Kubernetes. Самой сложной и интересной частью была настройка автоскейлинга. Не слишком ли заезженная тема? Думаю нет, потому что я буду рассказывать именно с позиции разработчика приложения, а не девопса. Мне повезло, я без понятия как это всё настраивается. Я буду рассказывать как это всё работает. Конфигов кубера будет минимум, рассуждений и погружений в метрики максимум. В конце оставил TL;DR. Поехали?

Что имеем на начало

У меня было приложение на инстансах AWS EC2, скалирующееся в EC2 Auto Scaling Group. Основной потребляемый ресурс — CPU.

  • Нагрузка плавно меняется в течение дня в 2-3 раза.

  • В редких случаях нагрузка может резко увеличиться на десятки процентов.

  • В очень редких случаях (вследствие поломки других систем или аномальной активности пользователей) нагрузка может увеличиться в 2-3 раза на непродолжительное время.

Принцип скалирования был довольно прост:

Настройки старой Auto Scaling Group
Настройки старой Auto Scaling Group

То есть в случае нагрузки на CPU выше заданного порога (55%) скалируемся ровно в 2 раза вверх. А потом уже смотрим, если это был кратковременный всплеск и загрузка CPU упала ниже 35%, понемногу глушим по 26% серверов.

Случай небольшого увеличения нагрузки
Случай небольшого увеличения нагрузки

Если же после первого скалирования в два раза нагрузка не падает, скалируемся ещё раз.

Случай более резкого увеличения нагрузки
Случай более резкого увеличения нагрузки

Идея 0: Оставить как есть

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

HPA (Horizontal Pod Autoscaling) — компонент Кубернетис, который управляет количеством запущенных экземпляров приложения (подов). Делает он это на основе собираемых метрик и выставленных настроек в своём конфиге. В том числе он может реагировать и на потребление CPU. Так почему же не полу��илось реализовать описанную выше схему? Чтобы это понять, потребуется углубиться в принципы работы HPA и в организацию приложения.

Хотя время ответа (latency) и является важным параметром, приложение всё же спроектировано с упором в наиболее эффективную утилизацию ресурсов всего сервера (throughput). Грубо говоря, большая часть приложения работает на одном ядре. Это позволяет, с одной стороны, избежать накладных расходов на распараллеливание, с другой стороны позволяет четко задать наиболее эффективную схему размещения приложений на серверах — по одному экземпляру приложения на одно ядро сервера. Приложение может получать небольшой буст от простаивающих соседних ядер, однако при дефиците CPU каждому приложению достанется не меньше чем одно ядро.

Как же работает HPA? Он позволяет задать целевое значение утилизации ресурсов. Упрощённо это выглядит так:

apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
spec:
  minReplicas: 24
  maxReplicas: 240
  scaleTargetRef:
    kind: Deployment
    name: application
  metrics:
  - type: ContainerResource
    containerResource:
      container: application
      name: cpu
      target:
        type: Utilization
        averageUtilization: 80

Когда средняя утилизация всех экземпляров приложения поднимается выше заданного значения, HPA добавляет новые поды. Но делает он это по строго заданной формуле:

desired_pods = current_pods * avg_utilization / target_utilization

И на первый взгляд это кажется логичным: если нагрузка на CPU больше целевой, то растём вверх. Проблема кроется в том, что метрика нагрузки на CPU имеет лимит. Нельзя потреблять больше 100% CPU на сервере. Это значит, что и при увеличении нагрузки на 20% и в 2 раза, мы получим одинаковое значение метрики и HPA примет одинаковое решение: добавить 20% подов, хотя в реальности возможно стоило бы добавить 100% или даже 200%.

Может показаться, что проблема в архитектуре приложения и если бы оно могло распределяться по ядрам, оно бы могло потреблять больше 100% и HPA бы принимал правильное решение. Однако это не так. Помните, я сказал, что приложение может получать преимущество от свободных ядер? Условно приложение может генерировать 125% нагрузки CPU, если нет дефицита ресурсов. И получается что если дефицита нет, то при увеличении нагрузки на 20% приложение будет показывать метрику даже выше, чем в условиях дефицита ресурсов, когда нагрузка увеличилась на 100%. Это делает решение, принимаемое HPA, ещё хуже.

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

Идея 1: Очередь запросов

Окей, мы выяснили, что нагрузка на CPU не является надежной метрикой «нехватки ресурсов» в случае сильных всплесков. А что же тогда является? Можно подумать, что количество запросов, но тут, к сожалению, тоже мимо. Дело в том, что запросы могут сильно отличаться по сложности. Зачастую бывает, что нагрузка сильно подскакивает даже без существенного увеличения количества запросов. Приходит +5% запросов, но они могут быть тяжелее текущих в 5-6 раз.

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

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

  2. Обработка. Это как раз наиболее затратный по памяти и CPU шаг. Он выполняется в отдельном потоке и никак не блокирует остальные шаги. Однако о��новременно выполняется обработка только одного запроса, все остальные подготовленные запросы ждут в очереди.

  3. Отдача результата. Это наиболее простой и понятный шаг. Тело ответа хранится в памяти и отдается клиенту. При этом требования по памяти сильно ниже, чем во время второго шага, поэтому отдача также происходит параллельно.

Упрощённо в коде это выглядит так:

from concurrent.futures import ThreadPoolExecutor

processing_pool = ThreadPoolExecutor(max_workers=1)

@run_on_executor(processing_pool)
def processing_task_1():
   pass

@run_on_executor(processing_pool)
def processing_task_2():
    pass

async def handler_GET(req):
    info = await get_info(req)
    body = await processing_task_1(info)
    await stream_body(body)

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

from datadog import statsd
from tornado.ioloop import PeriodicCallback

def report_queue_length(executor: ThreadPoolExecutor, metric_name):
    qsize = max(0, executor._work_queue.qsize())
    statsd.histogram(metric_name, qsize)

async def async_app_initialization():
    ...
    report_queue = partial(report_queue_length, processing_pool,
                           "processing_queue.backlog")
    PeriodicCallback(report_queue, 1000).start()  # every second

Идея была простой: если приложение обрабатывает запрос и в очереди находится в среднем ещё один запрос на обработку, то для него нам нужно в среднем ещё одно приложение (т.е. нужно скейлиться в два раза). Если 2 запроса — скейлиться нужно в 3 раза и т.д.

Если вы не понимаете, что такое метрики, statsd, почему histogram и при чём тут HPA, не переживайте, сейчас как раз объясню.

Идея 1 (всё ещё): Реализация

Я пришёл к девопсам со своей идеей скейлинга по метрикам из приложения, они мне дали инструменты. Я без понятия, насколько они хороши, есть ли альтернативы и что выбрать вам. Я просто описываю рабочий вариант. Итак, дано:

  • Prometheus — так называемая база данных для временных серий + язык PromQL для запросов к этим данным. Позволяет получать, хранить, обрабатывать историю разных метрик приложения. Внутри себя не делает различий между разными типами данных, всегда хранит временные серии (т.е. последовательность из временной метки + значения). Но за счет разного характера данных и разной их обработки через PromQL позволяет покрыть огромное количество вариантов мониторинга данных: запросы в приложение в разрезах по кодам ответа, ошибкам, обработчикам. Потребление ресурсов, разной статистики и даже одиночных событий.

  • VictoriaMetrics — более продвинутая замена Прометеусу с совместимым языком запросов (MetricsQL) и лучшим распределением нагрузки. Именно она и была у нас в кластере, поэтому я буду пользоваться её преимуществами, но в целом в Прометеусе будет всё то же самое.

  • statsd-exporter — легковесный сервер, который принимает метрики в удобном для приложения формате (в моем случае DogStatsD) и отдаёт их в слегка агрегированном виде Прометеусу или Виктории. Принимает по UDP, поэтому практически не увеличивает latency даже при большом количестве собираемых метрик.

  • KEDA — сторонний компонент для Кубернетиса, надстройка над HPA, которая с одной стороны, им управляет, с другой расширяет возможности мониторинга. Именно KEDA позволяет сделать скейлинг по результ��ту запроса в Прометеус или Викторию.

Что же касается самой метрики, то у меня были жаркие споры с другом по поводу того, какой тип метрики более уместен. Друг настаивал на gauge. Это мгновенное значение, каждое следующее измерение затирает предыдущее. В результате если мы собираем метрику раз в 10 секунд, то вся история затирается, имеем только одно значение раз в 10 секунд. Я же настаивал, что тут больше подходит counter. Это только возрастающее значение, когда каждое следующее измерение суммируется с предыдущим. Например если в счетчике было число 800, мы измерили очередь и в ней нет задач, следующее значение будет тоже 800. А если есть, то мы увеличиваем счетчик. В результате мы можем восстановить полную картину, не потеряв данные.

Но почему же в итоге используется histogram? Потому что это более продвинутый набор счетчиков, когда в одну метрику processing_queue_backlog_sum идёт счетчик значений измерений, а в другую метрику processing_queue_backlog_count — счетчик количества самих измерений. В результате, поделив rate (то есть — скорость изменения) одного на другое мы получаем среднее значение за интервал независимо от количества измерений.

В результате первая версия скейлинга с КЕДОй выглядела как-то так:

apiVersion: keda.sh/v1alpha1
kind: ScaledObject
spec:
  triggers:
    - type: prometheus
      metadata:
        serverAddress: http://vmselect.monitoring:8481/select/0/prometheus
        metricName: processing_queue_backlog
        query: |
          sum(rate(processing_queue_backlog_sum{pod!=""}[1m]))
          / sum(rate(processing_queue_backlog_count{pod!=""}[1m]))
          + {{ .metricTargetValue }}
        # This name is confusing, actually this is metric scale:
        # pods = ceil( alive * metricValue / threshold )
        threshold: "1"

Что ещё за metricTargetValue? Тут есть несколько эффектов и все они учитываются в этой константе:

  • Метрика должна отвечать на вопрос: "сколько нужно добавить". При этом значение "1" должно означать "нисколько, сейчас достаточно", а например "1.5" — добавить 50%. Следовательно отсутствие задач в очереди — это и есть "1", ничего не добавлять.

  • Но при этом? если мы просто добавим 1, мы никогда не сможем скейлиться вниз. "1" также означает "ничего не нужно убирать".

  • К счастью, значение размера очереди шумное, оно может иметь какое-то небольшое значение даже когда реальной нагрузки нет. Например, вследствие неравномерного распределения запросов между приложениями. Следовательно неплохо было бы иметь какой-то трешхолд среднего размера задач в очереди, на который мы не реагируем. Например 0.4 задачи.

В результате неплохое значение для metricTargetValue = 1 - 0.4, т.е. 0.6. Это позволит и не реагировать на шум и скейлиться вниз если очередь настолько пустая, что даже шумов нет.

Но остается вопрос: если очередь пустая, откуда мы знаем, что можно скейлиться вниз? Действительно, ведь если приложения успевают обрабатывать запросы, может быть и в два раза меньше приложений будут успевать. А может быть не будут, метрика не способна ответить на этот вопрос. Но на этот вопрос как раз способна ответить загрузка CPU. Если CPU загружен — скейлиться вниз нельзя. Поэтому правильно было не заменить метрику по CPU на метрику по очереди, а добавить её. При наличии нескольких метрик HPA берет ту, что дает больший результат. В результате метрика по очереди будет говорить, как сильно нужно скейлиться вверх, а метрика по CPU будет говорить можем ли мы скейлиться вниз.

Идея 1 (всё ещё): Почему провалилась

Я залил свои конфиги и приложение на staging, начал тестировать. Казалось, всё было хорошо. Если я запускал ab -c 10 (утилита ApacheBench), поднималось ≈10 экземпляров приложения. Если ab -c 30 — ≈30. Всё хорошо, верно? Неверно!

Когда я залил приложение на прод и пустил на него часть трафика, то на первом же всплеске нагрузки метрика ушла в небеса, запустились сотни подов, Виктория начала захлёбываться и перестала вовремя принимать метрики (впрочем эта проблема не касалась напрямую темы статьи).

Оказалось, что реальные пользователи не ждут, пока освободится коннект, прежде чем отправить запрос, как это делает ab. Если у вас стало на 10% больше запросов, то каждую секунду эти 10% остаются в очереди на обработку и образуется накапливающийся хвост запросов, который начнет спадать только когда новые поды реально встанут на обработку. И всё это время HPA будет думать, что нагрузка только растёт. То есть моя схема работала в идеальном мире, где при увеличении очереди мы моментально реагировали скейлингом. В реальности же размер очереди рос за какое-то время.

Идея 2: Скорость разбора очереди

Хорошо, если мы знаем, что размер очереди — это дело наживное, наверное нужно исходить не из факта наличия очереди, а из скорости с которой мы эту очередь сможем разобрать. В этом и была моя следующая идея.

Если мы можем посчитать моментальное количество задач в очереди, то можем и текущую скорость разбора очереди тоже. Правда для этого придется регистрировать событие «задача обработана» (т.е. очередь продвинулась вперёд). Это не сложно:

from concurrent.futures import ThreadPoolExecutor

class ReportConsumptionThreadPoolExecutor(ThreadPoolExecutor):
    def __init__(self, /, inc_metric_name: str, dec_metric_name: str,
                 **kwargs):
        self.inc_metric_name = inc_metric_name
        self.dec_metric_name = dec_metric_name
        super().__init__(**kwargs)

    def metric_dec(self, f: Future):
        statsd.increment(self.dec_metric_name)

    def submit(self, fn, *args, **kwargs):
        statsd.increment(self.inc_metric_name)
        f = super().submit(fn, *args, **kwargs)
        f.add_done_callback(self.metric_dec)
        return f

processing_pool = ReportConsumptionThreadPoolExecutor(
    inc_metric_name='processing_queue.income.count',
    dec_metric_name='processing_queue.done.count',
    max_workers=1)

Следующая версия моего запроса выглядела так:

# Average queue size per pod
sum(rate(processing_queue_backlog_sum{pod!=""}[1m]))
/ sum(rate(processing_queue_backlog_count{pod!=""}[1m]))
# Average processing queue speed per pod
/ clamp_min(avg(
  sum by (pod) (rate(processing_queue_done_count{pod!=""}[1m]))
), 1)
# Time for which we want to consume the queue 
/ {{ .backlogDrainTime }}
# Baseline
+ 1

То есть мы находим среднее значение очереди на один под и делим на среднюю скорость разбора очереди. Остается два вопроса: что такое backlogDrainTime и почему baseline стал 1 вместо 0.6.

Смотрите, очередь у нас имеет размерность "задачи". Скорость разбора очереди — "задачи/секунду". Когда мы разделим одно на другое, то получим размерность "секунды". Очевидно, мы не можем скейлиться в секундах, результат нужно разделить на "количество секунд, за которые мы хотим разгрести очередь". Я экспериментально выяснил, что для меня неплохо работает 7 секунд. При таком значении скалирование реагирует достаточно быстро, но не сильно шумит.

Что касается baseline — мы больше не можем учитывать колебания метрики как шум. С подходом «скорость разбора очереди» теперь мы точно знаем, что скейлиться нужно прямо сейчас и точно знаем, насколько, поэтому тут может быть только единица. Да, как результат мы вообще теряем возможность скейлиться вниз, потому что общее значение метрики никогда не будет меньше единицы. И именно поэтому данная версия не является финальной. Но для скалирования вверх текущая формула чертовски хороша.

metricType: AverageValue

Отвлечемся от задачи, поговорим о том как вообще HPA интерпретирует метрики. Я уже приводил формулу:

desired_pods = current_pods * avg_utilization / target_utilization

Точно так же HPA поступает по умолчанию и с тем значением, которое передает KEDA:

desired_pods = ceil(current_pods * metric_value / threshold)

И тут как раз кроется проблема: дело в том, что HPA вообще ничего не знает про ваше приложение, он даже мало что понимает в инфраструктуре самого Кубера. Для него current_pods — это то, значение которое он попросил на предыдущем шаге. Запущены ли поды, живы ли они, есть ли для них сервера, ему пофиг. Он просто передал эту информацию дальше по цепочке.

В результате, когда current_pods меняется, то на следующем шаге HPA думает что метрика пришла уже для нового количества подов. И теперь представьте: допустим, метрика показывает, что нужно добавить +50% подов, а для запуска подов нужно поднять сервера. За это время проходит несколько шагов автоскейлинга и на каждом HPA видит, что нужно +50%. Допустим было 6 шагов автоскейлинга, в результате HPA будет думать, что нужно уже в 1.5^6 ≈ 11.4 раз больше подов! А нам всё это время нужно было просто +50%!

К счастью, это поведение можно поменять. Для этого достаточно поменять metricType с дефолтного Value на AverageValue. В конфиге KEDA для этого нужно добавить:

apiVersion: keda.sh/v1alpha1
kind: ScaledObject
spec:
  triggers:
    - type: prometheus
      metricType: AverageValue
      metadata:
        ...

Тогда формула изменится на такую:

desired_pods = ceil(metric_value / threshold)

Т.е. тогда метрика должна будет возвращать не относительное число, например, 1,5, а абсолютное значение подов, которое нужно для текущей нагрузки, например, 42.

Если вам показалось, что значения Value и AverageValue перепутаны и должны значить противоположное, то вам не показалось. Дело в том, что metricType: AverageValue из конфига КЕДЫ транслируется в конфиге HPA в target.type: AverageValue, а как мы знаем metric_value стоит в числителе формулы, тогда как target_utilization в знаменателе. Т.е. КЕДА инвертировала смысл параметра, но не инвертировала его значения. Вот такой занимательный факт.

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

# Total queue size
sum(
  rate(processing_queue_backlog_sum{pod!=""}[1m])
  / rate(processing_queue_backlog_count{pod!=""}[1m])
)
# Average processing queue speed per pod
/ clamp_min(avg(
  sum by (pod) (rate(processing_queue_done_count{pod!=""}[1m]))
), 1)
# Time for which we want to consume the queue 
/ {{ .backlogDrainTime }}
# Number of alive pods
+ count(
  processing_queue_done_count{pod!=""}
)

Идея 3: Гибридная метрика

У нас есть метрика, которая хорошо отвечает на вопрос «как сильно скейлиться вверх», но не позволяет скейлиться вниз. И есть метрика по CPU, которая отвечает на вопрос, можно ли скейлиться вниз. А что если их объединить? Что если прибавлять не количество подов, а потребляемое ими CPU? Буквально. Если под работает, он будет потреблять 100% CPU или около того, т.е. это единица. Если прохлаждается, там будет меньше единицы, мы сможем скейлиться вниз.

Первая идея реализации была в задействовании уже существующих метрик, собираемых Кубернетис. Действительно, на каждом сервере крутится свой источник метрик по всему, что происходит вокруг (условно можно сказать, что это cadvisor). В частности из него доступна метрика container_cpu_usage_seconds_total. И тут в пору поговорить про сбор метрик Викторией.

Дело в том, что метрики появляются в базе не сами, statsd-exporter и cadvisor никак не подключаются к базе, чтобы слать метрики. Вместо этого агент Виктории vmagent с заданной регулярностью ходит по всем местам, которые у него прописаны, и забирает метрики в базу. Интервал, с которым ходит агент называется scrape interval. По умолчанию интервал сбора метрик с cadvisor — 30 секунд (по крайней мере так было в нашем кластере). 30 секунд — это очень много, за это время уже может набежать много запросов. Для метрик из приложения я задал интервал 10 секунд. Но когда я попробовал задать такой же интервал для метрик cadvisor, я получил очень неприятную картину:

Аномальные значения при маленьком scrape interval
Аномальные значения при маленьком scrape interval

Это график за 5 минут с interval=5s (в 2 раза чаще чем scrape), функция:

irate(container_cpu_usage_seconds_total{pod!=""}[1m])

Видно, что каждое значение дублируется, что ожидаемо, потому что я задал интервал графика в 2 раза меньше чем scrape interval метрики. Но так же видно, что после двух-трех нормальных значений идёт провал до нуля. Этот провал в функции irate (instant rate) означает, что между двумя скрейпами значение не менялось. Т.е. несмотря на то, что vmagent ходил за метрикой раз в 10 секунд, он не всегда получал свежее значение.

На этом этапе у меня отпало желание разбираться почему cadvisor запущенный в Кубернетис в инфраструктуре Амазона собирает метрики не регулярно. Даже если бы я это починил, вся конструкция была слишком ненадежной. Я решил пойти другим путём.

Идея 4: Собери сам

Итак, нам нужна загрузка CPU в Виктории. Почему бы не слать её самому вместе с метриками приложения? В этом случае не только интервал скрейпа будет совпадать, но и даже временные метки на точках, ведь данные будут браться из одного statsd-exporter, верно? Верно!

Сначала я написал скрипт, который собирает из /proc/<pid>/stat потребление CPU по всем известным процессам и шлёт их в statsd-exporter. Естественно, я запустил этот скрипт в отдельном контейнере и мне пришлось поставить опцию shareProcessNamespace чтобы скрипт мог видеть процессы всего пода.

К сожалению, я быстро понял, что с моей идеей не так. Дело в том, что я могу посмотреть точную статистику по запущенным процессам, это правда. Но что если какой-то процесс завершится? А у меня как раз приложение часто запускает короткоживущие процессы для обработки и из CPU просто не посчитать.

Идея 5: Едим чужой хлеб

Ладно, из контейнера собрать информацию по CPU проблематично. Но cadvisor же её где-то берёт — что, если и нам можно? Оказалось, что не просто можно, а даже довольно легко. И для этого даже не пришлось повышать привилегии, чего я опасался. Вот часть Deployment, которая отвечает за контейнер мониторинга:

  template:
    spec:
      containers:
        - name: cgroup-stat
          image: "{{ .Values.image.ecr }}/{{ .Values.image.application }}:{{ .Values.image.tag }}"
          command: [ "python", "./sidecar-tools/cgroup_stat.py" ]
          env:
            - name: POD_UID
              valueFrom:
                fieldRef:
                  fieldPath: metadata.uid
          volumeMounts:
            - name: host-cgroup
              mountPath: /host_cgroup
              readOnly: true

      volumes:
        - name: host-cgroup
          hostPath:
            path: /sys/fs/cgroup
            type: Directory

А вот сам скрипт мониторинга:

cgroup_stat.py
import os
import signal
import sys
from glob import glob
from time import sleep

from datadog import statsd


def get_cgroup():
    try:
        pod_uid = os.environ["POD_UID"].replace("-", "_")
    except KeyError:
        raise SystemError("POD_UID environment is required") from None

    candidates = glob("/host_cgroup/kubepods.slice/kubepods-*")
    if not candidates:
        raise SystemError("/host_cgroup should be mounted to host /sys/fs/cgroup")

    for candidate in candidates:
        found = glob(f"{candidate}/kubepods-*pod{pod_uid}*")
        if len(found) == 1:
            return found[0]
    raise SystemError("Can't find POD_UID in /host_cgroup dir")


def parse_cgroup_stat(cgroup):
    stat = {}
    with open(cgroup + "/cpu.stat") as f:
        for line in f:
            name, _, value = line.partition(' ')
            if name.endswith("_usec"):
                value = int(value) / 1000 / 1000
                name = name[:-len("_usec")] + "_sec"
            else:
                value = int(value)
            stat[name] = value
    return stat


def start_monitoring(metric: str, cgroup, pause=1.0):
    while True:
        stat = parse_cgroup_stat(cgroup)
        statsd.gauge(metric, stat["user_sec"], ["kind:user"])
        statsd.gauge(metric, stat["system_sec"], ["kind:system"])
        statsd.gauge(metric, stat["usage_sec"], ["kind:total"])

        sleep(pause)


def handle_sigterm(_signum, _frame):
    sys.exit(0)


if __name__ == "__main__":
    signal.signal(signal.SIGTERM, handle_sigterm)

    cgroup = get_cgroup()
    start_monitoring("pod.cpu_usage.seconds", cgroup)

Скрипт принимает переменную окружения POD_UID, которая идентифицирует под, в котором он запущен, потом ищет свой под в смонтированной папке cgroup (не спрашивайте, что это). А потом просто шлёт эту статистику в statsd-exporter к остальным метрикам приложения.

И вот эта статистика уже содержит весь CPU, который потребляет под, включая все короткоживущие процессы.

Окончательный вид запроса

with(
  instant_queue_size = sum(
    sum by (pod) (irate(processing_queue_backlog_sum{pod!=""}[1m]))
    / sum by (pod) (irate(processing_queue_backlog_count{pod!=""}[1m]))
  ),
  adjusted_queue_size = (instant_queue_size ^ 0.5) * 10,
  avg_queue_consumption_speed = avg(sum by (pod) (rate(processing_queue_done_count{pod!=""}[1m]))),
  instant_cpu_load_by_pods = irate(pod_cpu_usage_seconds{kind="total",pod!=""}[1m]),
  total_cpu_load = quantile(0.9, instant_cpu_load_by_pods) * count(instant_cpu_load_by_pods),
)

adjusted_queue_size / clamp_min(avg_queue_consumption_speed * {{ .backlogDrainTime }}, 1)
+ total_cpu_load * {{ .cpuUsageWeight }} + {{ .cosmologicalConstant }}

Тут конечно нужны некоторые пояснения.

  • Что за with? Это как раз расширение Виктории Метрикс. По сути задание имен для выражений.

  • Почему где-то rate(), где-то irate()? irate() — это мгновенное значение скорости между двумя последними точками. По сути для processing_queue_backlog и pod_cpu_usage_seconds это мгновенное значение в момент последнего измерения. А для processing_queue_done_count брать мгновенное значение как раз не нужно. Скорость можно сгладить за окно.

  • Для чего adjusted_queue_size? Это квадратный корень из настоящего размера очереди, умноженный на 10. Т.е. для очереди 10 скорректированное значение будет ≈31, для очереди 100 останется 100, а для 1000 будет ≈316. Это нужно чтобы немного сгладить огромный хвост и дать разбирать его дольше.

  • Как считается total_cpu_load? Вместо суммы потреблений CPU всех подов я решил взять потребление 10% самых загруженных и умножить на количество подов. Мне кажется, так честнее.

  • Для чего cpuUsageWeight? По сути, этот параметр отвечает за то, какую нагрузку считать нормальной. Например, если вы считаете, что при нагрузке 80% уже стоит реагировать на очередь, то этот параметр нужно поставить в 1/0.8 = 1,25. Я ставил 1.5 для серверов с HyperThreading и 1 для честных ядер.

  • cosmologicalConstant? Ну да, постоянная расширения кластера ?. Сколько подов держать наготове для коротких колебаний, даже если прямо сейчас нет нужды.

Важный нюанс. Задержка метрик

В процессе тестирования я заметил, что реальная нагрузка приводит к изменению метрик HPA с ощутимой задержкой. Я уже сказал, что собираю метрики с приложения раз в 10 секунд. Плюс, сама КЕДА имеет какой-то интервал опроса метрик. Но даже в сумме эти два интервала не объясняли задержку. Оказалось, что у Виктории есть предохранитель в виде latency_offset, который просто прячет для запроса данные за последние 30 секунд, что якобы улучшает консистентность. Само собой для меня это непозволительно много. К счастью это легко исправить на уровне запроса из КЕДЫ. Вроде бы у Прометеуса нет механизма latency offset.

Итоговый конфиг стал таким:

apiVersion: keda.sh/v1alpha1
kind: ScaledObject
spec:
  triggers:
    - type: prometheus
      metricType: AverageValue
      metadata:
        serverAddress: http://vmselect.monitoring:8481/select/0/prometheus
        metricName: processing_queue_backlog
        query: ...
        queryParameters: "latency_offset=1s"
        threshold: "1"

TL;DR как сделать нормальный скейлинг по метрикам

  • Несколько метрик можно использовать одновременно. Очередь отвечает, как сильно расти вверх, CPU отвечает, можно ли падать вниз.

  • Обязательно используйте metricType: AverageValue, и выдавайте в метрике точное количество подов.

  • Нормальная формула выглядит так:
    queue_size / avg_queue_consumption_speed / drain_time + total_cpu_load

  • Не полагайтесь на cadvisor, собирайте сами через cgroup и посылайте в тот же сервер, что и остальные метрики приложения.

  • Ставьте маленький scrape interval для метрик, по которым вы скейлитесь.

  • Для метрик скорости и очереди используйте counter или histogram, но не gauge.

  • Для VictoriaMetrics настройте latency_offset=1s.

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