The Reward by Tohad

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

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

Команда Kubernetes aaS от Mail.ru перевела статью о ресурсах контейнеров (CPU & MEM), запросах и ограничениях ресурсов. Вы узнаете, какие преимущества дают эти настройки и что произойдет, если их не установить.

Вычислительные ресурсы


У нас есть два типа ресурсов со следующими единицами:

  • Центральный процессор (CPU) — ядра;
  • Память (MEM) — байты.

Ресурсы указывают для каждого контейнера. В следующем YAML-файле Pod вы увидите раздел ресурсов, который содержит запрошенные и предельные ресурсы:

  • Запрошенные ресурсы Pod = сумма запрошенных ресурсов всех контейнеров;
  • Предельные ресурсы Pod = сумма предельных ресурсов всех контейнеров.

apiVersion: v1
kind: Pod
metadata:
  name: backend-pod-name
  labels:
    application: backend
spec:
  containers:
    — name: main-container
      image: my-backend
      tag: v1
      ports:
      — containerPort: 8080
      resources:
        requests:
          cpu: 0.2 # REQUESTED CPU: 200m cores
          memory: "1Gi" # REQUESTED MEM: 1Gi
        limits:
          cpu: 1 # MAX CPU USAGE: 1 core
          memory: "1Gi" # MAX MEM USAGE:  1Gi
    — name: other-container
      image: other-app
      tag: v1
      ports:
      — containerPort: 8000
      resources:
        requests:
          cpu: "200m" # REQUESTED CPU: 200m cores
          memory: "0.5Gi" # REQUESTED MEM: 0.5Gi
        limits:
          cpu: 1 # MAX CPU USAGE: 1 core
          memory: "1Gi" # MAX MEM USAGE:  1Gi
Пример запрошенных и предельных ресурсов

Поле resources.requested из спецификации Pod — один из элементов, который используют для поиска нужной ноды. Уже на нее можно запланировать развертывание Pod. Как же ищут подходящую ноду?

Kubernetes состоит из нескольких компонентов, в том числе содержит главный узел или мастер-ноду (Kubernetes Control Plane). В мастере-ноде несколько процессов: kube-apiserver, kube-controller-manager и kube-scheduler.

Процесс kube-scheduler отвечает за просмотр вновь созданных модулей и поиск возможных рабочих узлов, которые соответствуют всем запросам модулей, в том числе по количеству запрашиваемых ресурсов. Список узлов, найденных kube-scheduler, ранжируется. Pod планируется на узле с наивысшими баллами.

Куда будет помещен фиолетовый Pod?

На картинке видно, что kube-scheduler должен запланировать новый фиолетовый Pod. Кластер Kubernetes содержит два узла: A и B. Как можно заметить, kube-scheduler не может запланировать Pod на узел A — доступные (незапрошенные) ресурсы не соответствуют запросам фиолетового Pod. Так, запрошенный фиолетовым Pod 1 Гб памяти не уместится на узле А, поскольку доступный объем памяти — 0,5 Гб. Но у узла В достаточно ресурсов. В итоге kube-scheduler решает, что место назначения фиолетового Pod — узел B.

Теперь мы знаем, как запрошенные ресурсы влияют на выбор ноды для запуска Pod. Но как влияют предельные ресурсы?

Предельные ресурсы — граница, которую CPU/MEM не может пересекать. Тем не менее ресурс CPU гибкий, поэтому контейнеры, достигшие предельных значений по CPU, не приведут к завершению работы Pod. Вместо этого запустится троттлинг по CPU. Если же будет достигнут предел по использованию MEM, то контейнер будет остановлен по причине OOM-Killer и перезапущен, если это разрешено настройкой RestartPolicy.

Запрашиваемые и предельные ресурсы в деталях


Связь ресурсов между Docker и Kubernetes

Лучший способ объяснить, как работают запрашиваемые и предельные ресурсы — представить связь между Kubernetes и Docker. На рисунке выше вы можете посмотреть, как связаны поля Kubernetes и флаги запуска Docker.

Память: запрос и ограничение


containers:
...
 resources:
   requests:
     memory: "0.5Gi"
   limits:
     memory: "1Gi"

Как упоминалось выше, память измеряется в байтах. Основываясь на документации Kubernetes, мы можем указать память в виде числа. Обычно оно целое, например 2678 — то есть 2678 байт. Можно также использовать суффиксы G и Gi, главное — помнить, что они не равнозначны. Первый — десятичный, а второй — двоичный. Как пример, упомянутый в документации k8s: 128974848, 129e6, 129M, 123Mi — они практически эквивалентны.

Параметр Kubernetes limits.memory соответствует флагу --memory из Docker. В случае с request.memory стрелка для Docker отсутствует, поскольку Docker не использует это поле. Вы можете спросить, нужно ли это вообще? Да, нужно. Как я уже говорил, поле имеет значение для Kubernetes. На основе информации из него kube-scheduler решает, на какой узел запланировать Pod.

Что будет, если установить для запроса недостаточно памяти?

Если контейнер достиг границ запрошенной памяти, то Pod помещается в группу Pod, которые останавливаются при нехватке памяти в ноде.

Что произойдет, если установить слишком маленькое предельное значение памяти?

Если контейнер превышает предельное значение памяти, он будет завершен по причине OOM-Killed. И будет перезапущен, если это возможно на основании RestartPolicy, где значение по умолчанию — Always.

Что будет, если не указать запрашиваемую память?

Kubernetes возьмет предельное значение и установит его как значение по умолчанию.

Что может произойти, если не указать предельную память?

У контейнера нет ограничений, он может использовать столько памяти, сколько захочет. Если же он начнет использовать всю доступную память ноды, то его убьет OOM. Затем контейнер будет перезапущен, если это возможно на основе RestartPolicy.

Что будет, если не указать лимитов памяти?

Это худший сценарий: планировщик не знает, сколько ресурсов требуется контейнеру, и это может вызвать серьезные проблемы на ноде. В этом случае хорошо бы иметь ограничения по умолчанию в пространстве имен (устанавливаемые LimitRange). Ограничений по умолчанию нет — у Pod нет ограничений, он может использовать столько памяти, сколько захочет.

Если запрашиваемая память больше, чем может предложить нода — Pod не будет запланирован. Важно помнить, что Requests.memory — не минимальное значение. Это описание объема памяти, достаточного для постоянной работы контейнера.

Обычно рекомендуют устанавливать одно и то же значение для request.memory и limit.memory. Благодаря этому Kubernetes не запланирует Pod на узле, у которого достаточно памяти для запуска Pod, но недостаточно для работы. Имейте в виду: при планировании Pod Kubernetes учитывает только requests.memory, а limits.memory не учитывает.

CPU: запрос и ограничение


containers:
...
 resources:
   requests:
     cpu: 1
   limits:
     cpu: "1200m"

C CPU все немного сложнее. Возвращаясь к картинке со взаимосвязью между Kubernetes и Docker, можно заметить, что request.cpu соответствует --cpu-shares, тогда как limit.cpu соответствует флагу cpus в Docker.

CPU, который запрашивает Kubernetes, умножается на 1024 — пропорцию циклов CPU. Если вы хотите запросить 1 полное ядро, то должны добавить cpu: 1, как показано выше.

Запрос полного ядра (пропорция = 1024) не означает, что ваш контейнер его получит. Если у вашего хост-компьютера только одно ядро, и вы используете более одного контейнера, то все контейнеры должны совместно использовать доступный CPU между собой. Как это происходит? Давайте посмотрим на картинку.


Запрос CPU — система с одним ядром

Давайте представим, что у вас есть хост-система с одним ядром, на которой запущены контейнеры. Мама (Kubernetes) испекла пирог (CPU) и хочет разделить его между детьми (контейнерами). Трое детей хотят по целому пирогу (пропорция = 1024), еще один ребенок хочет половину пирога (512). Мама хочет быть справедливой и проводит несложный расчет.

# Сколько пирогов хотят дети?
# 3 ребенка хотят по целому пирогу и еще один хочет половину пирога
cakesNumberKidsWant = (3 * 1) + (1 * 0.5) = 3.5
# Выражение получается так:
3 (ребенка/контейнера) * 1 (целый пирог/полное ядро) + 1 (ребенок/контейнер) * 0.5 (половина пирога/половина ядра)
# Сколько пирогов испечено?
availableCakesNumber = 1
# Сколько пирога (максимально) дети реально могут получить?
newMaxRequest = 1 / 3.5 =~ 28%

Исходя из расчета, трое детей получат по 28% ядра, а не по целому ядру. Четвертому ребенку достанется 14% от полного ядра, а не половина. Но все будет по-другому, если у вас мультиядерная система.


Запрос CPU — мультиядерная (4) система

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

Приведенные выше расчеты упрощены для понимания того, как CPU распределяется между контейнерами. Конечно, помимо самих контейнеров, есть и другие процессы, которые также используют ресурсы CPU. Когда процессы в одном контейнере простаивают, другие могут использовать его ресурс. CPU: "200m" соответствует CPU: 0,2, что означает примерно 20% одного ядра.

Теперь давайте поговорим о limit.cpu. CPU, который ограничивает Kubernetes, умножается на 100. Результат — количество времени, которое контейнер может использовать каждые 100 мкс (cpu-period).

limit.cpu соответствует флагу Docker --cpus. Это новая комбинация старых --cpu-period и --cpu-quota. Устанавливая его, мы указываем, сколько доступных ресурсов CPU контейнер может максимально использовать до тех пор, пока не начнется троттлинг:

  • cpus — комбинация cpu-period и cpu-quota. cpus = 1.5 эквивалентно установке cpu-period = 100000 и cpu-quota = 150000;
  • cpu-period — период планировщика CPU CFS, по умолчанию 100 микросекунд;
  • cpu-quota — количество микросекунд внутри cpu-period, которым ограничен контейнер.

Что будет, если установить недостаточно запрошенного CPU?

Если контейнеру нужно больше, чем установлено, то он украдет CPU у других процессов.

Что произойдет, если установить недостаточный лимит CPU?

Поскольку ресурс CPU регулируемый, то включится троттлинг.

Что случится, если не указать запрос CPU?

Как и в случае с памятью, значение запроса равно лимиту.

Что будет, если не указать лимит CPU?

Контейнер будет использовать столько CPU, сколько ему необходимо. Если в пространстве имен определена политика CPU по умолчанию (LimitRange), то этот лимит используют и для контейнера.

Что произойдет, если не указать ни запрос, ни лимит CPU?

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

Помните: если вы запросите больше CPU, чем могут предоставить ноды, то Pod не будет запланирован. Requests.cpu — не минимальное значение, а значение, достаточное для запуска Pod и работы без сбоев. Если приложение не выполняет сложных вычислений, лучший вариант — установить request.cpu <= 1 и запустить столько реплик, сколько необходимо.

Идеальное количество запрошенных ресурсов или лимита ресурсов


Мы узнали об ограничении вычислительных ресурсов. Теперь пришло время ответить на вопрос: «Сколько ресурсов требуется моему Pod для работы приложения без проблем? Какое количество идеальное?».

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

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

В качестве примера посмотрите этот дашборд Grafana. Он отображает разницу между запрошенными ресурсами или лимитом ресурсов и текущим использованием ресурсов.

Заключение


Запрос и ограничение ресурсов помогают поддерживать работоспособность кластера Kubernetes. Правильная конфигурация лимитов сводит к минимуму затраты и постоянно поддерживает приложения в рабочем состоянии.

Если вкратце, то нужно помнить о нескольких моментах:

  1. Запрашиваемые ресурсы — конфигурация, которая учитывается во время запуска (когда Kubernetes планирует размещение приложения). Напротив, ограничение ресурсов важно во время работы — когда приложение уже запущено на узле.
  2. По сравнению с памятью, CPU — регулируемый ресурс. В случае нехватки CPU ваш Pod не завершит работу, включится механизм троттлинга.
  3. Запрашиваемые ресурсы и лимит ресурсов — это не минимальные и максимальные значения! Определяя запрашиваемые ресурсы, вы гарантируете, что приложение будет работать без проблем.
  4. Хорошая практика — устанавливать запрос памяти, равный лимиту памяти.
  5. Хорошо устанавливать запрошенный CPU <=1, если приложение не выполняет сложных вычислений.
  6. Если вы запросите больше ресурсов, чем есть на ноде, то Pod никогда не будет запланирован на эту ноду.
  7. Чтобы определить правильное количество запрошенных ресурсов/лимитов ресурсов, используйте нагрузочное тестирование и мониторинг.

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

Успехов!

Что еще почитать:

  1. Наблюдаемость SRE: пространства имен и структура метрик.
  2. 90+ полезных инструментов для Kubernetes: развертывание, управление, мониторинг, безопасность и не только.
  3. Наш канал Вокруг Kubernetes в Телеграме.