В этом разделе мы покажем, как управлять ресурсами приложений в Kubernetes.

Приоритет

priorityClassName влияет на то, какие Pod’ы будут schedule’иться в первую очередь, а также на то, какие Pod’ы могут быть «вытеснены» (evicted) планировщиком, если места для новых Pod’ов на узлах не осталось.

Потребуется создать несколько ресурсов типа PriorityClass и ассоциировать их с Pod’ами через priorityClassName. Набор PriorityClass’ов может выглядеть примерно так:

  • Cluster. Priority > 10000. Критичные для функционирования кластера компоненты, такие как kube-apiserver.
  • Daemonsets. Priority: 10000. Обычно мы хотим, чтобы Pod’ы DaemonSet’ов не вытеснялись с узлов обычными приложениями.
  • Production-high. Priority: 9000. Stateful-приложения.
  • Production-medium. Priority: 8000. Stateless-приложения.
  • Production-low. Priority: 7000. Менее критичные приложения.
  • Default. Priority: 0. Приложения для окружений не категории production.

Это предохранит нас от внезапных evict’ов важных компонентов и позволит более важным приложениям вытеснять менее важные при недостатке узлов.

Резервирование ресурсов

Планировщик на основании resources.requests Pod’а принимает решение о том, на каком узле этот Pod запустить. К примеру, Pod не будет schedule’иться на узел, на котором свободных (т. е. non-requested) ресурсов недостаточно, чтобы удовлетворить запросам (requests) нового Pod’а. А resources.limit позволяют ограничить потребление ресурсов Pod’ами, которые начинают расходовать ощутимо больше, чем ими было запрошено через requests. Лучше устанавливать лимиты равные запросам, так как если указать лимиты сильно выше, чем запросы, то это может лишить другие Pod’ы узла выделенных для них ресурсов. Это может приводить к выводу из строя других приложений на узле или даже самого узла. Также схема ресурсов Pod’а присваивает ему определенный QoS class: например, он влияет на порядок, в котором Pod’ы будут вытесняться (evicted) с узлов.

Поэтому необходимо выставлять и запросы, и лимиты и для CPU, и для памяти. Единственное, что можно/нужно опустить, так это CPU-лимит, если версия ядра Linux ниже 5.4 (для EL7/CentOS7 версия ядра должна быть ниже 3.10.0-1062.8.1.el7).

Подробнее о том, что такое requests и limits, какие бывают QoS-классы в Kubernetes, можно почитать в этой внешней статье.

Также некоторые приложения имеют свойство бесконтрольно расти в потреблении оперативной памяти: к примеру, Redis, использующийся для кэширования, или же приложение, которое «течёт» просто само по себе. Чтобы ограничить их влияние на остальные приложения на том же узле, им можно и нужно устанавливать лимит на количество потребляемой памяти. Проблема только в том, что, при достижении этого лимита приложение будет получать сигнал KILL. Приложения не могут ловить/обрабатывать этот сигнал и, вероятно, не смогут корректно завершаться. Поэтому очень желательно использовать специфичные для приложения механизмы контроля за потреблением памяти в дополнение к лимитам Kubernetes, и не доводить эффективное потребление памяти приложением до limits.memory Pod’а.

Конфигурация для Redis, которая поможет с этим:

maxmemory 500mb   # если данные начнут занимать 500 Мб...
maxmemory-policy allkeys-lru   # ...Redis удалит редко используемые ключи

А для Sidekiq это может быть Sidekiq worker killer:

require 'sidekiq/worker_killer'
Sidekiq.configure_server do |config|
  config.server_middleware do |chain|
    # Корректно завершить Sidekiq при достижении им потребления в 500 Мб
    chain.add Sidekiq::WorkerKiller, max_rss: 500
  end
end

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

Далее мы рассмотрим использование VerticalPodAutoscaler для автоматического выставления ресурсов.

VerticalPodAutoscaler

VPA позволяет считать потребление ресурсов Pod’ами и (в случае соответствующего режима работы) вносить изменения в ресурсы контроллеров — править их requests и limits.

Представим ситуацию, когда была выкачена новая версия приложения с новыми функциональными возможностями. Так случилось, что импортированная библиотека оказалось тяжелой или код местами недостаточно оптимальным, из-за чего приложение начало потреблять больше ресурсов. На тестовом контуре это изменение не проявилось, потому что обеспечить такой же уровень тестирования, как в production, сложно.

Конечно, актуальные для приложения (до этого обновления) requests и limits уже были выставлены. И вот теперь приложение достигает лимита по памяти, приходит мистер ООМ и совершает над Pod’ом насильственные действия. VPA может это предотвратить! Если посмотреть на ситуацию в таком срезе, то, казалось бы, это замечательный инструмент, который надо использовать всегда и везде. Но в реальности, конечно, все не так просто и важно понимать сопутствующие нюансы.

Основная проблема, которая на текущий момент не решена, это изменение ресурсов через перезапуск Pod’а. В каком-то ближайшем будущем VPA научится их патчить и без рестарта приложения, но сейчас не умеет. Допустим, это не страшно для хорошо написанного приложения, всегда готового к перекату: приложение разворачивается Deployment’ом с кучей реплик, настроенными PodAntiAffinity, PodDistruptionBudget, HorizontalPodAutoscaler… В таком случае, если VPA изменит ресурсы и аккуратно (по одному) перезапустит Pod’ы, мы это переживем.

Но бывает всё не так гладко: приложение не очень хорошо переживает перезапуск, или мы ограничены в репликах по причине малого количества узов, или у нас вообще StatefulSet…. В худшем сценарии придет нагрузка, у Pod’ов вырастет потребление ресурсов, HPA начал масштабировать, а тут VPA: «О! Надо бы поднять ресурсы!» — и начнет перезапускать Pod’ы. Нагрузка начнет перераспределяться по оставшимся, из-за чего Pod может просто упасть, нагрузка уйдет на еще живые и в результате произойдет каскадное падение.

Поэтому и важно понимать разные режимы работы VPA. Но начнем с рассмотрения самого простого — Off.

Режим Off

Данный режим занимается только подсчетами потребления ресурсов Pod’ами и выносит рекомендации. Забегая вперед, можно сказать, что в подавляющем большинстве случаев мы у себя используем именно этот режим, именно он и является основной рекомендацией. Но вернемся к этому вопросу после рассмотрения нескольких примеров.

Итак, вот простой манифест:

apiVersion: autoscaling.k8s.io/v1
kind: VerticalPodAutoscaler
metadata:
  name: my-app-vpa
spec:
  targetRef:
    apiVersion: "apps/v1"
    kind: Deployment
    name: my-app
  updatePolicy: 
    updateMode: "Recreate"
    containerPolicies:
      - containerName: "*"
        minAllowed:
          cpu: 100m
          memory: 250Mi
        maxAllowed:
          cpu: 1
          memory: 500Mi
        controlledResources: ["cpu", "memory"]
        controlledValues: RequestsAndLimits

Подробно про все параметры манифеста можно почитать в этой статье.

Вкратце: здесь мы указываем, на какой контроллер нацелен наш VPA (targetRef) и какова политика обновления ресурсов, а также задаем нижнюю и верхнюю границы ресурсов, которыми VPA волен распоряжаться. Главное внимание — полю updateMode. В случае режима Recreate или Auto будет происходить пересоздание Pod’а со всеми вытекающими последствиями, но мы этого не хотим и пока просто поменяем режим работы на Off:

apiVersion: autoscaling.k8s.io/v1
kind: VerticalPodAutoscaler
metadata:
  name: my-app-vpa
spec:
  targetRef:
    apiVersion: "apps/v1"
    kind: Deployment
    name: my-app
  updatePolicy: 
    updateMode: "Off"   # !!!
  resourcePolicy:
    containerPolicies:
      - containerName: "*"
        controlledResources: ["cpu", "memory"]

Теперь VPA начнет собирать метрики. Если спустя несколько минут посмотреть на него через describe, появятся рекомендации:

Recommendation:
Container Recommendations:
  Container Name:  nginx
  Lower Bound:
    Cpu:     25m
    Memory:  52428800
  Target:
    Cpu:     25m
    Memory:  52428800
  Uncapped Target:
    Cpu:     25m
    Memory:  52428800
  Upper Bound:
    Cpu:     25m
    Memory:  52428800

Спустя пару дней (неделю, месяц, …) работы приложения рекомендации будут точнее. И тогда можно будет корректировать лимиты в манифесте приложения. Это позволит спастись от ООМ’ов в случае недостатка requests/limits и поможет сэкономить на инфраструктуре (если изначально был выделен слишком большой запас).

А теперь — к некоторым сложностям.

Другие режимы VPA

Режим Initial выставляет ресурсы только при старте Pod’а. Следовательно, если у вас неделю не было нагрузки, после чего случился выкат новой версии, VPA проставит низкие requests/limits. Если резко придет нагрузка, будут проблемы, поскольку запросы/лимиты установлены сильно ниже тех, что требуются при такой нагрузке. Этот режим может быть полезен, если у вас равномерная, линейно растущая нагрузка.

В режиме Auto будут пересозданы Pod’ы, поэтому самое сложное, чтобы приложение корректно обрабатывало процедуру рестарта. Если же оно не готово к корректному завершению работы (т.е. с правильной обработкой завершения текущих соединений и т.п.), то вы наверняка поймаете ошибки (5XX) «на ровном месте». Использование режима Auto со StatefulSet вообще редко оправдано: только представьте, что у вас VPA решит добавить ресурсы PostgreSQL в production…

А вот в dev-окружении можно и поэкспериментировать, чтобы выяснить необходимые ресурсы для выставления их для production. Например,мы решили использовать VPA в режиме Initial, у нас есть Redis, а у него — параметр maxmemory. Скорее всего нам надо его изменить под свои нужды. Но загвоздка в том, что Redis’у нет дела до лимитов на уровне cgroups. Если случится так, что maxmemory будет равен 2GB, а в системе выставлен лимит в 1GB, то ничего хорошего не выйдет. Как же выставить maxmemory в то же значение, что и лимит? Выход есть! Можно пробросить значения, взяв их из VPA:

apiVersion: apps/v1
kind: Deployment
metadata:
  name: redis
  labels:
    app: redis
spec:
  replicas: 1
  selector:
    matchLabels:
      app: redis
  template:
    metadata:
      labels:
        app: redis
    spec:
      containers:
      - name: redis
        image: redis:6.2.1
        ports:
        - containerPort: 6379
        resources:
           requests:
             memory: "100Mi"
             cpu: "256m"
           limits:
             memory: "100Mi"
             cpu: "256m"
        env:
          - name: MY_MEM_REQUEST
            valueFrom:
              resourceFieldRef:
                containerName: app
                resource: requests.memory
          - name: MY_MEM_LIMIT
            valueFrom:
              resourceFieldRef:
                containerName: app
                resource: limits.memory

Получив переменные окружения, можно извлечь нужный лимит по памяти и по-хорошему отнять от него условные 10%, оставив их на нужды приложения. А остальное выставить в качестве maxmemory. Вероятно, придется также придумывать что-то с init-контейнером, который sed’ает конфиг Redis, потому что базовый образ Redis не позволяет ENV-переменной пробросить maxmemory. Тем не менее, решение будет работать.

Напоследок, следует упомянуть неприятный момент, связанный с тем, что VPA «выбрасывает» Pod’ы DaemonSet сразу, скопом. Со своей стороны мы начали работу над патчем, который исправляет эту ситуацию.

Итоговые рекомендации по VPA

  • Всем приложениям будет полезен режим Off.
  • В dev можно экспериментировать с Auto и Initial.
  • В production стоит применять только тогда, когда вы уже собрали/опробовали рекомендации и точно понимаете, что делаете и зачем.

А вот когда VPA научится патчить ресурсы без перезапуска… тогда заживем!

Важно также помнить, что существует ряд нюансов при совместном использовании HPA и VPA. Например, эти механизмы нельзя использовать совместно, опираясь на одни и те же базовые метрики (CPU или Memory), потому что при достижении порога по ним VPA начнет поднимать ресурсы, а HPA начнет добавлять реплики. Как результат, нагрузка резко упадет и процесс пойдет в обратную сторону — может возникнуть «flapping». Существующие ограничения более подробно описаны в документации.

HorizontalPodAutoscaler

Рассмотрим другую ситуацию: что происходит, если на приложение приходит незапланированная нагрузка, которая значительно выше той, что мы «привыкли» обрабатывать? Да, ничто не мешает вручную зайти в кластер и отмасштабировать Pod’ы… но ради чего тогда мы тут все тогда собрались, если все делать руками?

На помощь приходит HorizontalPodAutoscaler (HPA). Этот механизм позволяет указать нужную метрику(и) настроить автоматический порог масштабирования Pod’ов в зависимости от изменения её значений. Представьте, что вы спокойно спите, но внезапно, ночью, приходит небывалая нагрузка — скажем, заокеанские пользователи узнали про ваш сервис на Reddit. Нагрузка на CPU (или показатель иной метрики) у Pod’ов вырастает, достигает порога… после чего HPA начинает доблестно масштабировать Pod’ы, чтобы способствовать распределению нагрузки благодаря выделению новых ресурсов.

В итоге, все входящие запросы обработаны в нормальном режиме. Причем — и это важно! — как только нагрузка вернется в привычное русло, HPA отмасштабирует Pod’ы обратно, тем самым снижая затраты на инфраструктуру. Звучит здорово, не так ли?

Разберемся, как именно HPA вычисляет, сколько реплик надо добавить. Вот формула из документации:

desiredReplicas = ceil[currentReplicas * ( currentMetricValue / desiredMetricValue )]

Предположим:

  • текущее количество реплик = 3;
  • текущее значение метрики = 100;
  • пороговое значение метрики = 60.

Получаем следующее выражение: 3 * ( 100 / 60 ), т.е. на выходе получаем «около» 5 (HPA округлит результат в большую сторону). Таким образом, приложению будут добавлены еще две реплики. А значение будет по-прежнему вычисляться по формуле, чтобы, как только нагрузка снизится, уменьшилось и количество необходимых реплик для обработки этой нагрузки.

Здесь начинается самое интересное. Что же выбрать в качестве метрики? Первое, что приходит на ум, — это базовые показатели, такие как CPU, Memory… И такое решение действительно сработает, если у вас… нагрузка на CPU и Memory растет прямо пропорционально входящей нагрузке. Но что, если Pod’ы обрабатывают разные запросы: одни могут потребовать много тактов процессора, другие — много памяти, а третьи — вообще укладываются в минимальные ресурсы?

Рассмотрим на примере с очередью на RabbitMQ и теми, кто эту очередь будет разбирать. Допустим, в очереди 10 сообщений. Мы видим (спасибо мониторингу!), что очередь разбирается довольно быстро. То есть для нас нормально, когда в среднем в очереди скапливается до 10 сообщений. Но вот пришла нагрузка — очередь сообщений выросла до 100. Однако нагрузка на CPU и Memory не изменится у worker’ов: они будут монотонно разбирать очередь, оставляя там уже около 80-90 сообщений.

А ведь если бы мы настроили HPA по нашей (кастомной) метрике, описывающей количество сообщений в очереди, то получили бы понятную такую картину:

  • текущее количество реплик = 3;
  • текущее значение метрики = 80;
  • пороговое значение метрики = 15.

Т.е.: 3 * ( 80 / 15 ) = 16. Тогда HPA начнет масштабировать worker’ы до 16 реплик, и они быстро разберут все сообщения в очереди (после чего HPA может масштабировать их вниз). Однако для этого важно, чтобы мы были «инфраструктурно готовы» к тому, что дополнительно может развернуться еще столько Pod’ов. То есть они должны влезть на текущие узлы, или же новые узлы должны быть заказаны у поставщика инфраструктуры (облачного провайдера), если вы используете Cluster Autoscaler. В общем, это очередная отсылка к планированию ресурсов кластера.

Теперь взглянем на несколько манифестов:

apiVersion: autoscaling/v1
kind: HorizontalPodAutoscaler
metadata:
  name: php-apache
spec:
  scaleTargetRef:
    apiVersion: apps/v1
    kind: Deployment
    name: php-apache
  minReplicas: 1
  maxReplicas: 10
  targetCPUUtilizationPercentage: 50

Тут все просто. Как только Pod достигает нагрузки по CPU в 50%, HPA начнет масштабировать максимум до 10.

А вот более интересный вариант:

apiVersion: autoscaling/v1
kind: HorizontalPodAutoscaler
metadata:
  name: worker
spec:
  scaleTargetRef:
    apiVersion: apps/v1
    kind: Deployment
    name: worker
  minReplicas: 1
  maxReplicas: 10
metrics:
  - type: External
    external:
      metric:
        name: queue_messages
      target:
        type: AverageValue
        averageValue: 15

Мы уже смотрим на custom metrics. Опираясь на значение queue_messages, HPA будет принимать решение о необходимости масштабирования. Учитывая, что для нас нормально, если в очереди около 10 сообщений, здесь выставлено среднее значение в 15 как пороговое. Так можно контролировать количество реплик уже более точно. Согласитесь, что и автомасштабирование будет куда лучше и точнее [чем по условному CPU] в случае разбора очереди?

Дополнительные фичи

Возможности настройки HPA уже весьма разнообразны. Например, можно комбинировать метрики. Вот что получится для размера очереди сообщений и CPU:

apiVersion: autoscaling/v1
kind: HorizontalPodAutoscaler
metadata:
  name: worker
spec:
  scaleTargetRef:
    apiVersion: apps/v1
    kind: Deployment
    name: worker
  minReplicas: 1
  maxReplicas: 10
metrics:
  - type: Resource
    resource:
      name: cpu
      target:
        type: Utilization
        averageUtilization: 50
  - type: External
    external:
      metric:
        name: queue_messages
      target:
        type: AverageValue
        averageValue: 15

Как будет считать HPA? У какой из метрик при подсчете получилось большее количество реплик, на тот он и будет опираться. Если из расчета потребления CPU выходит, что надо масштабировать до 5, а по расчетам на основании размерности очереди — до 3, то произойдет масштабирование до 5 Pod’ов.

С релиза Kubernetes 1.18 появилась возможность прописывать политики scaleUp и scaleDown. Например:

behavior:
  scaleDown:
    stabilizationWindowSeconds: 60
    policies:
    - type: Percent
      value: 5
      periodSeconds: 20
    - type: Pods
      value: 5
      periodSeconds: 60
    selectPolicy: Min
  scaleUp:
    stabilizationWindowSeconds: 0
    policies:
    - type: Percent
      value: 100
      periodSeconds: 10

Здесь заданы две секции: одна определяет параметры масштабирования вниз (scaleDown), вторая — вверх (scaleUp). В каждой из секций есть интересный параметр — stabilizationWindowSeconds. Он позволяет избавиться от «флаппинга», то есть ситуации, при которой HPA будет масштабировать то вверх, то вниз. Грубо говоря, это некий таймаут после последней операции изменения количества реплик.

Теперь о политиках, и начнем со scaleDown. Эта политика позволяет указать, какой процент Pod’ов (type: Percent) можно масштабировать вниз за указанный период времени. Если мы понимаем, что нагрузка на приложение — волнообразная, что спадает она так же волнообразно, надо выставить процент поменьше, а период — побольше. Тогда при снижении нагрузки HPA не станет сразу убивать множество Pod’ов по своей формуле, а будет вынужден делать это постепенно. Вдобавок, мы можем указать явное количество Pod’ов (type: Pods), больше которого за период времени убивать никак нельзя.

Также стоит обратить внимание на параметр selectPolicy: Min — он указывает на необходимость исходить из политики минимального количества Pod’ов. То есть: если 5 процентов меньше, чем 5 единиц Pod’ов, будет выбрано это значение. А если наоборот, то убирать будем 5 Pod’ов. Соответственно, выставление selectPolicy: Max даст обратный эффект.

Со scaleUp аналогичная ситуация. В большинстве случаев требуется, чтобы масштабирование вверх происходило с минимальной задержкой, поскольку это может повлиять на пользователей и их опыт взаимодействия с приложением. Поэтому stabilizationWindowSeconds здесь выставлен в 0. Зная, что нагрузка приходит волнами, мы позволяем HPA при необходимости поднять реплики до значения maxReplicas, которое определено в манифесте HPA. За это отвечает политика, позволяющая раз в periodSeconds: 10, поднимать до 100% реплик.

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

behavior:
  scaleDown:
    selectPolicy: Disabled

Как правило, политики нужны тогда, когда у вас HPA работает не так, как вы на это рассчитываете. Политики дают большую гибкость, но усложняют восприятие манифеста.

А в скором времени получится даже опираться на ресурсы конкретного контейнера в Pod’е (представлено как alpha в Kubernetes 1.20).

Итог по HPA

Закончим примером финального манифеста для HPA:

apiVersion: autoscaling/v1
kind: HorizontalPodAutoscaler
metadata:
  name: worker
spec:
  scaleTargetRef:
    apiVersion: apps/v1
    kind: Deployment
    name: worker
  minReplicas: 1
  maxReplicas: 10
metrics:
  - type: External
    external:
      metric:
        name: queue_messages
      target:
        type: AverageValue
        averageValue: 15
  behavior:
    scaleDown:
      stabilizationWindowSeconds: 60
      policies:
      - type: Percent
        value: 5
        periodSeconds: 20
      - type: Pods
        value: 5
        periodSeconds: 60
      selectPolicy: Min
    scaleUp:
      stabilizationWindowSeconds: 0
      policies:
      - type: Percent
        value: 100
        periodSeconds: 10

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

Подведем итог по Horizontal Pod Autoscaler. Использовать HPA для production-окружений всегда полезно. Но выбирать метрики, на которых он будет основываться, надо тщательно. Неверно выбранная метрика или некорректный порог ее срабатывания будет приводить к тому, что либо получится перерасход по ресурсам (из-за лишних реплик), либо клиенты увидят деградацию сервиса (если реплик окажется недостаточно). Внимательно изучайте поведение вашего приложения и проверяйте его, чтобы достичь нужного баланса.