Привет! Меня зовут Александр Лебедев, я SRE в Samokat.tech. Мы строим надёжную платформу для сервисов быстрой доставки и в зону ответственности моей команды входят поддержка Kubernetes-кластеров и управление их ресурсами.

Под катом история о том, как мы пришли к своему варианту resource management через борьбу с овербукингом по CPU.


Интро

Для начала определимся с понятиями.

Овербукинг возникает, когда доля забронированных ресурсов (cpu requests или memory requests) превышает или равна ёмкости кластера, что приводит к невозможности планирования новой рабочей нагрузки в нём.

Пример. Кластер состоит из 3 worker нод по 64 CPU каждая. Таким образом, максимальная сумма всех cpu requests в кластере равна 192 CPU (64 * 3). Если в какой-то момент реквестами занято 191 CPU и новый под захочет развернуться с CPU requests = 2, он повиснет в статусе pending, ожидая либо расширения кластера, либо высвобождения ресурсов.

На первых порах борьба с таким явлением была нехитрой. Кто-то из команды обращал внимание, что ресурсы в скором времени иссякнут и через определённые манипуляции новые ноды подъезжали в кластер.

Стоит отметить, что мы живём на своём железе, потому процесс «обратили внимание - заказали сервера - добавили в кластер» мог занимать неделю и больше.

Общего у такого подхода с надёжностью и выстроенным процессом было мало, к тому же дефицит на рынке железа повышал приоритетность задачи. Нам требовалось использовать ресурсы более бережно.

Немного теории

Отмечу, что в этом посте мы будем говорить только про cpu requests, оставив другие виды ресурсов для будущих публикаций.

Ресурсная модель kubernetes (линк) построена вокруг requests/ limits, что, в свою очередь, есть абстракции над linux cgroups-v1 (или cgroup-v2) — cpu.shares / cpu.cfs_period_us и cpu.cfs_quota_us.

Предназначение cpu requests/limits в следующем:

  1. requests призван обеспечивать равномерное распределение рабочей нагрузки на нодах кластера -- одна из политик kube-scheduler стремится размазывать Поды равномерно по кластерам;

  2. requests резервирует запрашиваемый объём ресурсов и, таким образом, гарантирует его наличие в случае общего дефицита на ноде - все Поды будут троттлиться до своих значений в requests (конечно если везде проставлены cpu requests, а за этим надо следить отдельно);

  3. Оба параметра позволяют задавать QoS политики;

  4. limits бьёт по шапке сверху, если контейнер планирует потребить больше выделенного, опять же через cpu throttling.

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

  • limits как можно выше (или вообще без них);

  • requests как можно ближе к limits.

Что при злоупотреблении может привести к общей низкой утилизации — реальное потребление кластера становится заметно ниже величины зарезервированных ресурсов (requests). Такой эффект мы и поймали — реквесты были под 80% от ёмкости кластера при 15-20% реального потребления, а значит, не за горами тот самый овербукинг.

Суровый подход

«Если сервисы так мало потребляют, давайте порежем реквесты!» — таков был наш первый лозунг и с ним мы направились в одну из продуктовых команд. Там идею оперативно завернули с такой аргументацией:

  • приложения на старте могут потреблять больше CPU (проливка кешей и прочая инициализация) нежели при своей «будничной» работе, потому реквесты ставим повыше — нам нужны гарантии, что ресурсов для старта хватит;

  • мы и так периодически троттлимся по CPU. А вы предлагаете их понижать, не хотим!

Такой фидбек, особенно второй пункт, нас озадачил. Пошли копать глубже.

Неожиданный CPU Throttling

Признаться, поначалу я не поверил в его наличие — откуда ему взяться, если реальное потребление на четверть от ёмкости кластера?

Но нужны были факты для следующего обсуждения, поэтому мы написали promQL запрос для отображения cpu throttling:

sum by (container) (rate(container_cpu_cfs_throttled_seconds_total{job="kubelet", cluster="cluster", namespace="namespace", image!="", pod="pod", container=~"container", container!="POD"}[1m])) or on() vector(0)

И к нашему недоумению увидели следующее:

Вне зависимости от загрузки ноды и/или близости от значения CPU limits, сервисы на постоянной основе троттлятся! 

Гугл подсказал причины — в старых версиях ядра linux (4-) был найден баг в CFS, что и вызывал подобный эффект. В своё время коллеги из Flant переводили статью на эту тему, спасибо им. Вот вам ещё ссылок на эту тему: 1, 2.

Как решение нам требовалось обновить ядра во всех кластерах до 5+ версии. Или отказаться от limits, но про это позже.

Тяжелый старт JVM

Эта ситуация выглядит примерно так:

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

А раз реквесты дают нам гарантии, то почему этим не пользоваться и не крутить по максимуму? Логично для разработчиков, но для инфраструктуры звучит очень дорого.

Задача разрасталась — требовалось обеспечить гарантии доступности ресурсов и сделать это максимально эффективно, а также убрать баг с CFS.

Гарантии ресурсов - namespace quotas

Если баг CFS требовал лишь обновления ядра, то вопрос гарантий требовал дополнительной проработки.

Начали копать в сторону квотирования на уровне k8s namespaces.

Идея простая — команды живут в выделенных namespace’ах, что в теории позволит выделить строго лимитированное кол-во ресурсов под каждый из них. А там внутри команды сами разберутся, на что и как им эти ресурсы потратить.

Плюсы:

  • команды не заезжают на «соседские» ресурсы.

Минусы:

  • честно наделить всех нужным количеством ресурсов непросто;

  • это абстракция на уровне kubernetes, какой-то магии в виде хитрого конфигурирования cgroups каждого пода на каждой ноде в рамках Неймспейса с учетом квот — нет. Потому полезность инструмента в наших глазах падает;

  • потребует введение дополнительного уровня бюрократии по подаче заявок на запрос квот, их проверок, отслеживание использования и дальнейшая корректировка. Звучит как долго и больно для всех сторон.

Идём дальше.

Гарантии сверху и гарантии снизу

Мы переформулировали цель — дать сервисам возможность использовать столько ресурсов, сколько им потребуется, гарантируя остальным свою долю.

Другими словами, ввести два уровня гарантий:

  1. Подам, всегда будет доступна заранее оговорённая доля ресурсов;

  2. Поды имеют неограниченный доступ* к ресурсам в случае такой потребности.

*в рамках capacity конкретной ноды за минусом гарантий из п.1.

Первый пункт закрывают CPU requests, для этого внедрим:

  1. понятный алгоритм выставления конкретного значения CPU requests;

  2. отслеживание, что никто не нарушает правила из п.1.

Для организации модели «неограниченного доступа к ресурсам» потребуется:

  • мониторинг и алертинг на потребление ресурсов кластера;

  • отлаженная процедура по расширению кластера.

Отказ от CPU limits

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

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

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

Подробнее про «пользу» от лимитов можно почитать тут.

Ещё одним аргументом против использования лимитов был quick win в разрезе проблемы CFS (спойлер: мы пофиксили баг обновлением ядер).

Определение значения CPU requests

Реквесты, как мы договорились ранее, требуются для гарантированного наличия ресурсов на ноде, для штатного функционирования сервисов.

Как следствие, потребуется определённый запас сверху обычного потребления. Опытным путём мы пришли к следующей формуле: CPU requests = (99 перцентиль потребления CPU в нагруженные часы) помноженный на (коэффициент критичности сервиса).

Подробно про коэффициент критичности расписывать не буду, скажу только, что он может варьироваться от 1 до 3.

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

Кстати, верхний потолок в значении requests обозначили как 25% от ёмкости ноды, иначе могут возникнуть проблемы с rescheduling подов.

Немного про JVM

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

Обсудили, выставили MR’ы, и спустя несколько часов прилетает аларм. Наш сервис на проде выделил себе огромные тред пулы (в данном случае jetty) и работает с жуткими тормозами, потому что количество выделенных CPU не может обеспечить параллелизм для этих пулов. 

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

Какая-то магия: только один сервис из десятка ведёт себя странно без лимитов. Начали углубляться в контекст. У JVM есть команда, которая позволяет выяснить, какое эффективное количество процессоров доступно JVM процессу:

java -XshowSettings:system -version

Вывод этой команды для сервисов где нет проблемы:

/bin/runner # java -XshowSettings:system -version

Operating System Metrics:

    Provider: cgroupv1

    Effective CPU Count: 2

    CPU Period: 100000us

    CPU Quota: -1

    CPU Shares: 2048us

    List of Processors, 64 total: 

    0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 

    List of Effective Processors, 64 total: 

    0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 

    List of Memory Nodes, 1 total: 

    0 

    List of Available Memory Nodes, 1 total: 

    0 

    Memory Limit: 6.84G

    Memory Soft Limit: Unlimited

    Memory & Swap Limit: Unlimited

    Maximum Processes Limit: 1648013

где нас на самом деле интересовало вот это:

 Effective CPU Count: 2

Что это за показатель? Это как раз показатель эффективного параллелизма, JVM его узнаёт для того, чтобы выставлять минимумы или лимиты, для множества тред пулов, с которыми она работает; например ForkJoinPool, GC Thread Workers или JettyThreadPool. Рассмотрим на GC G1:

Согласно доке, под G1 выделяется потоков:

  • 5/8 от общего числа ядер в качестве STW worker threads;

  • ещё 1/4 от STW worker threads под parallel marking threads.

То есть, если наш показатель эффективного параллелизма равен 256, то JVM создаст:

  • 160 STW worker threads;

  • 40 parallel marking threads.

Перед тем как разобраться с этим показателем, надо оговориться, что у JVM по дефолту с 11 версии присутствует параметр UseContainerSupport, который по дефолту включён и при отсутствии лимитов (quota) у контейнера будет оперировать реквестами (shares). 

Теперь давайте разберёмся, что это за показатель такой — Effective CPU Count, и от чего он зависит. Выяснилось, что всё очень просто, каждому контейнеру передаётся CPU.Shares (в нашем случае — потому что нет лимитов), значение этого показателя в JVM делится на 1024, и округляет полученное значение вверх. Тогда получается такая цепочка:

requests: 2 -> cpu-shares: 2048 -> Effective CPU count: 2

Теперь, когда со значением разобрались, давайте посмотрим на результат этой команды при разных значениях 

Все тесты провели на openjdk version “17.0.3” 2022-04-19 и на машине с 12 физическими ядрами, исключили слой kubernetes, работая с докер-контейнером, передавали различные параметры и выяснилось страшное:

0. 17 JVM no-args - Effective = 12

1. 17 JVM --cpus=2 - Effective = 2

2. 17 JVM --cpu-shares=2048 - Effective = 2

3. 17 JVM --cpu-shares=1024 - Effective 12!!!

4. 17 JVM --cpu-shares=1024 -XX:ActiveProcessorCount=1 - Effective 12!!!

5. 17 JVM --cpu-shares=1024 -XX:ActiveProcessorCount=1 -XX:+PreferContainerQuotaForCPUCount - Effective 12!!!

6. 17 JVM --cpu-shares=1024 -XX:ActiveProcessorCount=1 -XX:-PreferContainerQuotaForCPUCount - Effective 12!!!

7. 17 JVM --cpu-shares=1000 - Effective 1 

Как мы видим, есть какое то магическое число в виде 1024 cpu.shares, которое заставляет JVM считать эффективными все ядра машины. Именно это объясняет точечность нашей проблемы на проде, мы просто во всех остальных случаях имели в реквестах число, отличное от 1. 

Временно полечили наши сервисы следующим:

resources:

  limits:

    memory: 2048Mi

  requests:

    cpu: 999m

    memory: 2048Mi

и пошли разбираться, как сделать красиво. 

Тогда был найден довольно странный баг. В нём говорится о том, что это намеренное хардкод значение. О причинах этого можно почитать в глубинах багрепортов, но суть бага в том, что необходимо поменять логику работы параметров. На момент, когда этот баг-репорт был найден, он ещё был открыт. На момент написания статьи — все популярные JVM уже выпустили этот фикс.

Сильно не вдаваясь в подробности, настоящим решением стало то, что теперь параметр, который должен был перетирать количество ядер машины ActiveProcessorCount, который до этого не мог победить cpu.shares = 1024, стал сильнее и теперь начиная с JVM 19, 18.0.2, 17.0.5, 17.0.3.0.1-oracle, 11.0.17-oracle, 11.0.17 (версии указаны для OpenJDK) будет работать так:

--cpu-shares=1024 -XX:ActiveProcessorCount=1

output: Effective 1

Дополнительно стоит отметить, что после обновления на указанные выше версии мы начали наблюдать проблему с выделением всех ядер хостовой машины не только для cpu.shares = 1024, а вообще во всех случаях, когда у нас не указан параметр cpu.quota (limits). Таким образом, если мы хотим жить без лимитов — надо дружить с опцией ActiveProcessorCount.

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

Мониторинг ресурсов кластера

Я не буду подробно расписывать этот пункт, ибо он за рамками статьи. Ограничусь общими моментами.

Для отслеживания объёма потребления ресурсов мы настроили дашборд с алертами, которые триггерятся, когда объём зареквестированных ресурсов начинает превышать 70% от ёмкости кластера. 

Как только алерт срабатывает — он улетает команде Платформы, которая, в свою очередь, стартует рутину по заказу и добавлению требуемых объёмов ресурсов в кластер. 

Послесловие

Подведём итоги. Если вы тоже сталкиваетесь с овербукингом и нехваткой ресурсов — стоит покопать в сторону:

  • понятных и единообразных подходов в использовании ресурсов как на уровне рантайма языка, так и на уровне Kubernetes;

  • расширения observability ресурсов для более точного их отслеживания;

  • формализации процесса расширения кластера;

  • проверки наличия бага с CFS. 

А у нас на очереди работа с memory. До встречи в будущих публикациях!

Отдельная благодарность нашему SRE-лиду Кириллу Юркову aka @login40k, за помощь в написании статьи.

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


  1. lazy_val
    19.06.2023 05:53

    64 * 3 = 192

    Надо бы поправить


    1. alekslebedev Автор
      19.06.2023 05:53

      Спасибо, внесли правки!