В прошлой части были рассмотрены рекомендации по множеству механизмов Kubernetes для оптимального деплоя высокодоступных приложений включая особенности работы планировщика, стратегии обновления, приоритеты, пробы и т.п. Во втором и заключительном материале поговорим о трёх важных оставшихся темах: PodDisruptionBudget, HorizontalPodAutoscaler, VerticalPodAutoscaler, — продолжив нумерацию из первой части.

9. PodDisruptionBudget

Механизм PodDisruptionBudget (PDB) — это must have для работы приложения в production-среде. Он позволяет контролировать, какое количество pod’ов приложения могут быть недоступны в момент времени. Читая первую часть статьи, можно было подумать, что мы уже ко всему подготовлены, если у приложения запущены несколько реплик и прописан podAntiAffinity, который не позволит pod’ам schedule’иться на один и тот же узел.

Однако может случиться ситуация, при которой из эксплуатации одновременно выйдет не один узел. Например, вы решили поменять инстансы на более мощные. Могут быть и другие причины, но сейчас это не так важно. Важно, что несколько узлов выведены из эксплуатации в один момент времени. «Это же Kubernetes! — скажете вы. — Тут всё эфемерно. Ну, переедут pod’ы на другие узлы — что такого?» Давайте разберёмся.

Предположим, приложение состоит из 3-х реплик. Нагрузка распределена равномерно по ним, а pod’ы — по узлам. Оно выдержит, если упадет одна из реплик. Но вот при падении двух реплик из трёх начнётся деградация сервиса: один pod просто не справится с нагрузкой, клиенты начнут получать 500-е ошибки. Ок, если мы подготовились и заранее прописали rate limit в контейнере c nginx (конечно, если у нас есть контейнер с nginx в pod’е…), то ошибки будут 429-е. Но это все равно деградация сервиса.

Тут нам на помощь и приходит PodDisruptionBudget. Взглянем на его манифест:

apiVersion: policy/v1beta1
kind: PodDisruptionBudget
metadata:
  name: app-pdb
spec:
  maxUnavailable: 1
  selector:
    matchLabels:
      app: app

Конфиг довольно простой, и большая часть полей в нем скорее всего знакома (по аналогии с другими манифестами). Самое интересное — это maxUnavailable. Данное поле позволяет указать, сколько pod’ов (максимум) могут быть недоступны в момент времени. Указывать значение можно как в единицах pod’ов, так и в процентах.

Предположим, что для приложения настроен PDB. Что теперь произойдет, когда два или более узлов, на которые выкачены pod’ы приложения, начнут по какой-либо причине вытеснять (evict) pod’ы? PDB позволит вытеснить лишь один pod, а второй узел будет ждать, пока реплик не станет хотя бы две (из трёх). Только после этого еще одну из реплик можно вытеснить.

Есть также возможность определять и minAvailable. Например:

apiVersion: policy/v1beta1
kind: PodDisruptionBudget
metadata:
  name: app-pdb
spec:
  minAvailable: 80%
  selector:
    matchLabels:
      app: app

Так можно гарантировать, что кластер будет следить за тем, чтобы 80% реплик всегда были доступны, а вытеснять с узлов [при такой необходимости] можно только оставшиеся 20%. Указывать это соотношение снова можно в процентах и единицах.

Есть и обратная сторона медали: у вас должно быть достаточное количество узлов, причем с учетом podAntiAffinity. Иначе может сложиться ситуация, что одну реплику вытеснили с узла, а вернуться она никуда не может. Результат: операция drain просто ждет вечность, а вы остаетесь с двумя репликами приложения… В describe pod’а, который висит в Pending, можно, конечно, найти информацию о том, что нет свободных узлов, и исправить ситуацию, но лучше до этого не доводить.

Итоговая рекомендация: всегда настраивайте PDB для критичных компонентов вашей системы.

10. 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

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

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

11. 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. Вкратце: здесь мы указываем, на какой контроллер нацелен наш 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». Существующие ограничения более подробно описаны в документации. Благодарю @Rinck за это полезное дополнение!

Заключение

На этом мы завершаем обзор лучших практик в контексте деплоя HA-приложений в Kubernetes. Будем рады любой обратной связи и пожеланиям на будущие подобные материалы.

P.S.

Читайте также в нашем блоге:

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