Настройка потребления CPU в Kubernetes. Как научить микросервисы дышать полной грудью?

Valentin Zayash
Targetprocess
Published in
8 min readFeb 6, 2020

Часть нашего программного продукта организована как Kubernetes-кластер. Мы периодически занимаемся повышением его производительности. Один из способов это сделать ― правильно настроить потребление CPU для каждого микросервиса. Ниже мы объясняем, как это можно сделать на уровне конфигурации контейнеров, нод и самого K8s.

Основные понятия в ограничении ресурсов CPU в Kubernetes

Конфигурация контейнера в Kubernetes включает параметры использования им процессорного времени: CPU requests и CPU limits.

CPU requests преобразуется в Docker в CpuShares (можно увидеть, сделав docker inspect), а они, в свою очередь, ― в cpu.shares на уровне cgroups.

CPU limits представляются в Docker в виде CpuPeriod и CpuQuota, а те, в свою очередь, ― в виде cpu.cfs_period_us и cpu.cfs_quota_us на уровне cgroups.

cpu.shares ― это доли времени CPU, выделенные для процессов. Это относительные показатели, обозначающие, в каком соотношении будет распределяться время CPU между процессами, когда система находится под нагрузкой. Измеряются условно в millicores: 1 millicore (1m) ― это 1/1000 (в K8s) либо 1/1024 (в Docker) одного CPU. cpu.shares не предоставляют возможности ограничивать потребление CPU, они устанавливают только распределение. Если запущен только один процесс, то он использует такую долю времени CPU, какая ему потребуется (в этом случае он ограничен только значением CPU limits или возможностями сервера). Ограничения по доле включаются, только когда процессов два или больше и они конкурируют за время CPU.

cpu.cfs_period_us ― период регулирования доступа процесса к ресурсам CPU (min: 1ms, max: 1s). Ядро Linux (более конкретно, планировщик задач CFS) разделяет время работы всех процессов в каждой cgroup на отрезки, равные этой величине, и управляет доступом процессов к CPU в рамках каждого такого отрезка. В Kubernetes до версии 1.12 период регулирования жёстко фиксирован и равен 100 мс.

cpu.cfs_quota_us ― квота доступа. Это количество времени CPU, которое процесс суммарно может использовать в течение одного периода, указанного выше. Квоты жёстко ограничивают ресурсы, выделяемые для процесса. Если процесс потратил выделенное ему квотой время, то всю оставшуюся часть периода ему будет закрыт доступ к процессорному времени (т.е. будет происходить “гашение”, throttling, процесс не будет работать). Значение cpu.cfs_quota_us может быть больше, чем cpu.cfs_period_us; чтобы выработать такую квоту, процесс должен будет в какие-то моменты потреблять время CPU двух или более ядер одновременно.

На рисунке 1 показан график работы некоего процесса на протяжении двух периодов регулирования (cpu.cfs_period_us), равных 100 мс. Величина минимального деления на графике равна 5 мс. Процессу задана квота: cpu.cfs_quota_us = 25 мс. Свою квоту процесс вырабатывает, только будучи в состоянии “running”. После того, как суммарно было выработано 25 мс, включается throttling этого процесса до начала следующего периода.

Рисунок 1 ― Период регулирования и квота доступа (взято из CFS Bandwidth Control-Warmup @maxwell9215)

Как работает ограничение ресурсов?

Дальнейшие примеры ― это сильно упрощенное описание работы CFS Scheduler. В конце статьи приведены ссылки на доклады и документацию для желающих погрузиться глубже.

Процессорное время, выделенное для конкретной cgroup, распределяется между всеми дочерними группами в соответствии с указанными для каждой из них cpu.shares. По умолчанию 1 СPU = 1024 shares. (Тут есть момент, что Kubernetes делит 1 CPU на 1000, а Docker и cgroups оперируют степенями двойки, и в них 1 CPU = 1024 shares, поэтому в итоге происходит операция вида total_cpu / 1000 * 1024.)

Представим, что у нас есть 8-ядерная система и мы запускаем 2 контейнера, не указывая значения CPU requests и CPU limits. Каждому из контейнеров автоматически выделяется своя cgroup, в которой по умолчанию устанавливается cpu.shares = 1024. Поскольку cgroups не предоставляют полную изоляцию, оба контейнера будут видеть все 8 ядер системы и по умолчанию могут потреблять суммарно эквивалент 4 ядер.
Если мы запустим еще 2 контейнера с теми же cpu.shares = 1024, то каждый получит возможность потреблять уже только по 25% от каждого ядра.

Теперь снова оставим всего 2 контейнера и для первого установим cpu.shares в 2048. В этой ситуации один контейнер будет вправе потреблять эквивалент 67% имеющихся CPU-ресурсов, а второй ― оставшихся 33%.

Теперь представим, что мы сконфигурировали для любого из запущенных ранее контейнеров только лимит по CPU: CPU limits = 500m. Поскольку CPU requests не указаны, то Kubernetes автоматически присвоит им те же значения, что и у лимитов. При такой конфигурации значения, установленные на уровне cgroups, будут выглядеть так:

cpu.shares = 500m / 1000 * 1024 = 512m;

cpu.cfs_period_us = 100,000 мкс (т.е. фиксированные в Kubernetes 100 мс);

cpu.cfs_quota_us = 500m / 1000m * 100000 мкс = 50,000 мкс = 50 мс (т.е., учитывая значение периода регулирования (100 мс), в каждые 100 мс наш процесс сможет получать не более 50% от эквивалента 1 CPU).

Также предположим, что этот контейнер запущен в подгруппе одной-единственной cgroup, внутри которой, по соседству, существуют еще 2 подгруппы, у каждой из которых cpu.shares=1024 и cpu.cfs_quota_us = -1. Последнее означает, что они могут потреблять 100 мс из всего 100-миллисекундного периода, т.е. “гашения” в их случае не будет, т.к. -1 ― это значение по умолчанию в CFS, означающее, что никаких ограничений по потреблению не накладывается. CFS выделяет процессорное время по умолчанию кусочками по 5 мс.

Чтобы понять, какой у нашего контейнера коэффициент на получение CPU в этой группе, надо разделить наше значение cpu.shares на сумму всех шар в группе:

512 / (512 + 1024 + 1024) = 0,2

Теперь умножим это на количество имеющихся ядер и узнаем, сколько времени CPU наш контейнер получит в разных системах:

0,2 * 4 = 0,8 ядра при 4 ядрах
0,2 * 16 = 3,2 ядра при 16 ядрах

Как видно, при одном и том же значении CPU requests в 500m в разных окружениях мы будем (потенциально!) получать совершенно разное количество процессорного времени. Но здесь нужно сделать два важных замечания.

Во-первых, повторим, что всё это актуально только при наличии конкуренции за ресурсы. В случае простоя кластера процесс сможет занять хоть все ядра (если этому не мешает CPU limits).

Во-вторых, поскольку нашему контейнеру назначено CPU limits = 500m, то он сможет использовать максимум 0,5 ядра, сколько бы ядер ему ни причиталось согласно cpu.shares.

Иными словами, лимиты (CPU limits) работают независимо от CPU requests. Мы будем получать наши “кусочки” (доли, shares) CPU, но в описанном случае мы никогда не сможем выполнять свой процесс на протяжении более чем 50 мс за период в 100 мс.

CPU limits задают абсолютное значение cpu.cfs_quota_us, т. е. квота доступа не умножается автоматически на количество ядер. И чтобы полностью воспользоваться ресурсом 3,2 CPU, выделенным нашему контейнеру в 16-ядерной системе, нам следовало бы установить для контейнера CPU limits = 3200m или выше или совсем отключить лимит. Тогда на протяжении физических 100 мс периода регулирования наш процесс мог бы потреблять 320 мс времени CPU, распараллеливая свою работу на несколько ядер. Этот принцип проиллюстрирован рисунком 2.

Рисунок 2 ― Параллельное потребление времени нескольких CPU.
Процесс пытается потреблять время приблизительно 4-х ядер CPU одновременно. При
CPU limits = 4000m “гашение” (throttling) отсутствует. А при CPU limits = 1000m “гашение” есть и его время в среднем составляет 3 секунды за секунду; т.е. для процесса закрывается доступ приблизительно к трём ядрам.

Действие ресурсных ограничений может сильно влиять на процессы, чувствительные к переключениям контекста или к воздействию самого CPU throttling. Такие процессы могут превысить свою квоту до окончания периода регулирования, в результате чего для них сработает “гашение” (throttling) и они будут простаивать до следующего периода.

В качестве примера можно взять процесс, относящийся к рисунку 1. Допустим, этот процесс отвечает на входящий запрос, и для ответа ему требуется произвести вычисления, требующие суммарно 50 мс времени CPU. При периоде 100 мс и установленной для этого процесса квоте 25 мс время его ответа не сможет быть ниже 200 мс из-за throttling.

Варианты решения проблемы throttling в кластере

Снятие CPU limits для контейнеров

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

Но в таком случае соответствующий этому контейнеру pod автоматически получает пониженный класс QoS. Это будет влиять на все аспекты его жизненного цикла, начиная от выделения ресурсов и заканчивая приоритетным выселением с ноды и невозможностью получать выделенные ядра через CPU manager.

Отключение механизма CFS-квот для Kubernetes

Этот вариант подразумевает полное отключение (в Kubernetes) механизма квот, на котором основан контроль потребления процессорного времени для cgroups. При отключении CFS Quotas лимиты больше не будут преобразовываться в cpu.cfs_quota_us.

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

Однако при отключении квот полностью теряется контроль над тем, чтобы один “взбесившийся” процесс не влиял на соседние процессы, ноду или кластер целиком. Не стоит недооценивать такую возможность: мы сталкивались с этим на нескольких кластерах, и в каждом находился новый желающий “отличиться”.

Рисунок 3― Результат отключения механизма квот в Kubernetes.
Горизонтальная ось соответствует времени работы сервиса. По вертикальной оси отложены перцентили времени его отклика (в секундах). Красная вертикальная линия ― момент отключения механизма квот.

Контроль ресурсов и автоматизация

Третий подход позволит обезопасить себя от “проблемы плохого соседа” и даст больше контроля над потреблением ресурсов, однако throttling всё равно равно будет присутствовать, хотя мы и постараемся его минимизировать.

Высокоуровневый план выглядит примерно так.

1. Обновление ядра Linux на всех нодах в кластере до версии 4.18+.

В этой версии ядра исправлен баг, из-за которого процессы в cgroups агрессивно ограничивались в потреблении процессорного времени без объективных причин. Они попадали под “гашение”, не успевая выработать свою квоту.

Рисунок 4― Результат обновления Linux до v4.18+.
В данном случае количество периодов, в которых происходил throttling, снизилось приблизительно в 1,5 раза.

2. Обновление Kubernetes до версии 1.12+ (если она у вас ниже).

В версии 1.12 появилась возможность переопределения жёстко заданного периода регулирования (cpu.cfs_period_us).

3. Проведение экспериментов, связанных с изменением периода регулирования.

Этот пункт подразумевает изменение cpu.cfs_period_us (в большинстве случаев в сторону уменьшения) и замер результатов, чтобы определить наилучшую конфигурацию для вашего процесса. Значение cpu.cfs_period_us устанавливается per node, т.е. в пределах каждой ноды Kubernetes’а.

4. Установка выверенных CPU requests / CPU limits для контейнеров.

Подразумевает выставление requests / limits для контейнеров на основании их реального потребления за некоторый период. Например, можно использовать 90-й перцентиль потребления за 14 дней для CPU requests. Для CPU limits в случае эксперимента можно взять такое значение: (максимум потребления за 14 дней * 1,1).

5. Автоматизация пункта 4.

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

Настраивая параметры потребления CPU ― пока что вручную, ― в нашем продукте мы уже смогли снизить время ответа таких компонентов, как PostgreSQL, MongoDB, Nginx. Мы рассчитываем, что настройка ограничений CPU позволит избавиться от всех “узких мест”, где задержки вызваны нехваткой процессорного времени.

Источники

Video:

Everything You Ever Wanted to Know About Resource Scheduling, But Were Afraid to Ask by Tim Hockin

Inside Kubernetes Resource Management QoS — Mechanics and Lessons from the Field — Michael Gasch

Optimizing Kubernetes Resource Requests/Limits for Cost-Efficiency and Latency / Henning Jacobs

Text:

Understanding resource limits in kubernetes: cpu time

CFS Bandwidth Control-Warmup

Understanding Linux Container Scheduling

Linux kernel CFS design docs

https://github.com/kubernetes/kubernetes/issues/67577

--

--

Valentin Zayash
Targetprocess

Infrastructure Development (Targetproceess(Apptio), PandaDoc)