Прим. перев.: эта поучительная история Omio — европейского агрегатора путешествий — проводит читателей от базовой теории до увлекательных практических тонкостей в конфигурации Kubernetes. Знакомство с такими случаями помогает не только расширять кругозор, но и предотвращать нетривиальные проблемы.



Доводилось ли вам сталкиваться с тем, что приложение «застревало» на месте, переставало отвечать на запросы о проверке состояния (health check'и) и вы не могли понять причину такого поведения? Одно из возможных объяснений связано с лимитом квот на ресурсы CPU. О нем и пойдет речь в этой статье.

TL;DR:
Мы настоятельно рекомендуем отказаться от CPU limit'ов в Kubernetes (или отключить квоты CFS в Kubelet), если используется версия ядра Linux с ошибкой CFS-квот. В ядре имеется серьезный и хорошо известный баг, который приводит к избыточному троттлингу и задержкам
.

В Omio вся инфраструктура управляется Kubernetes. Все наши stateful- и stateless-нагрузки работают исключительно на Kubernetes (мы используем Google Kubernetes Engine). В последние полгода мы стали наблюдать рандомные подтормаживания. Приложения зависают или перестают отвечать на health check'и, теряют связь с сетью и т.п. Подобное поведение долго ставило нас в тупик, и, наконец, мы решили заняться проблемой вплотную.

Краткое содержание статьи:

  • Несколько слов о контейнерах и Kubernetes;
  • Как реализованы CPU request'ы и limit'ы;
  • Как CPU limit работает в средах с несколькими ядрами;
  • Как отслеживать троттлинг CPU;
  • Решение проблемы и нюансы.

Несколько слов о контейнерах и Kubernetes


Kubernetes, по сути, является современным стандартом в мире инфраструктуры. Его основная задача — оркестровка контейнеров.

Контейнеры


В прошлом нам приходилось создавать артефакты вроде Java JAR'ов/WAR'ов, Python Egg'ов или исполняемых файлов для последующего запуска на серверах. Однако, чтобы заставить их функционировать, приходилось проделывать дополнительную работу: устанавливать среду выполнения (Java/Python), размещать необходимые файлы в нужных местах, обеспечивать совместимость с конкретной версией операционной системы и т.д. Другими словами, приходилось уделять пристальное внимание управлению конфигурациями (что часто служило причиной раздоров между разработчиками и системными администраторами).

Контейнеры всё изменили. Теперь артефактом выступает контейнерный образ. Его можно представить в виде этакого расширенного исполняемого файла, содержащего не только программу, но и полноценную среду выполнения (Java/Python/…), а также необходимые файлы/пакеты, предустановленные и готовые к запуску. Контейнеры можно развертывать и запускать на различных серверах без каких-либо дополнительных действий.

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

Kubernetes


Как было сказано ранее, Kubernetes — это оркестратор контейнеров. Он работает следующим образом: вы предоставляете ему пул машин, а затем говорите: «Эй, Kubernetes, запусти-ка десять экземпляров моего контейнера с 2 процессорами и 3 Гб памяти на каждый, и поддерживай их в рабочем состоянии!». Kubernetes позаботится обо все остальном. Он найдет свободные мощности, запустит контейнеры и будет перезапускать их при необходимости, выкатит обновление при смене версий и т.д. По сути, Kubernetes позволяет абстрагироваться от аппаратной составляющей и делает все разнообразие систем пригодным для развертывания и работы приложений.


Kubernetes с точки зрения простого обывателя

Что такое request'ы и limit'ы в Kubernetes


Окей, мы разобрались с контейнерами и Kubernetes. Также мы знаем, что несколько контейнеров могут находиться на одной машине.

Можно провести аналогию с коммунальной квартирой. Берется просторное помещение (машины/узлы) и сдается нескольким арендаторам (контейнерам). Kubernetes выступает в роли риэлтора. Возникает вопрос, как удержать квартирантов от конфликтов друг с другом? Что, если один из них, скажем, решит занять ванную комнату на полдня?

Именно здесь в игру вступают request'ы и limit'ы. CPU Request нужен исключительно для планирования. Это нечто вроде «списка желаний» контейнера, и используется он для подбора самого подходящего узла. В то же время CPU Limit можно сравнить с договором аренды — как только мы подберем узел для контейнера, тот не сможет выйти за установленные пределы. И вот тут возникает проблема…

Как реализованы request'ы и limit'ы в Kubernetes


Kubernetes использует встроенный в ядро механизм троттлинга (пропуска тактов) для реализации CPU limit'ов. Если приложение превышает лимит, включается троттлинг (т.е. оно получает меньше тактов CPU). Request'ы и limit'ы для памяти организованы иначе, поэтому их легче обнаружить. Для этого достаточно проверить последний статус перезапуска pod'а: не является ли он «OOMKilled». С троттлингом CPU все не так просто, поскольку K8s делает доступными только метрики по использованию, а не по cgroups.

CPU Request



Как реализован CPU request

Для простоты давайте рассмотрим процесс на примере машины с 4-ядерным CPU.

K8s использует механизм контрольных групп (cgroups) для управления распределением ресурсов (памяти и процессора). Для него доступна иерархическая модель: потомок наследует limit'ы родительской группы. Подробности распределения хранятся в виртуальной файловой системе (/sys/fs/cgroup). В случае процессора это /sys/fs/cgroup/cpu,cpuacct/*.

K8s использует файл cpu.share для распределения ресурсов процессора. В нашем случае корневая контрольная группа получает 4096 долей ресурсов CPU — 100% доступной мощности процессора (1 ядро = 1024; это фиксированное значение). Корневая группа распределяет ресурсы пропорционально в зависимости от долей потомков, прописанных в cpu.share, а те, в свою очередь, аналогичным образом поступают со своими потомками, и т.д. В типичном узле Kubernetes корневая контрольная группа имеет три потомка: system.slice, user.slice и kubepods. Две первых подгруппы используются для распределения ресурсов между критически важными системными нагрузками и пользовательскими программами вне K8s. Последняя — kubepods — создается Kubernetes'ом для распределения ресурсов между pod'ами.

На схеме выше видно, что первая и вторая подгруппы получили по 1024 доли, при этом подгруппе kuberpod выделено 4096 долей. Как такое возможно: ведь корневой группе доступны всего 4096 долей, а сумма долей ее потомков значительно превышает это число (6144)? Дело в том, что значение имеет логический смысл, поэтому планировщик Linux (CFS) использует его для пропорционального распределения ресурсов CPU. В нашем случае первые две группы получают по 680 реальных долей (16,6% от 4096), а kubepod получает оставшиеся 2736 долей. В случае простоя первые две группы не будут использовать выделенные ресурсы.

К счастью, в планировщике есть механизм, позволяющий избежать потери неиспользуемых ресурсов CPU. Он передает «простаивающие» мощности в глобальный пул, из которого они распределяются по группам, нуждающимся в дополнительных мощностях процессора (передача происходит партиями, чтобы избежать потерь от округления). Аналогичный метод применяется и ко всем потомкам потомков.

Этот механизм обеспечивает справедливое распределение мощностей процессора и следит за тем, чтобы ни один процесс не «воровал» ресурсы у других.

CPU Limit


Несмотря на то, что конфигурации limit'ов и request'ов в K8s выглядят похоже, их реализация кардинально отличается: это самая вводящая в заблуждение и наименее задокументированная часть.

K8s задействует механизм квот CFS для реализации лимитов. Их настройки задаются в файлах cfs_period_us и cfs_quota_us в директории cgroup (там же расположен файл cpu.share).

В отличие от cpu.share, квота основана на периоде времени, а не на доступной мощности процессора. cfs_period_us задает продолжительность периода (эпохи) — это всегда 100000 мкс (100 мс). В K8s есть возможность изменить это значение, однако она пока доступна только в альфа-версии. Планировщик использует эпоху для перезапуска использованных квот. Второй файл, cfs_quota_us, задает доступное время (квоту) в каждой эпохе. Обратите внимание, что она также указывается в микросекундах. Квота может превышать продолжительность эпохи; другими словами, она может быть больше 100 мс.

Давайте рассмотрим два сценария на 16-ядерных машинах (наиболее распространенный тип компьютеров у нас в Omio):


Сценарий 1: 2 потока и лимит в 200 мс. Без троттлинга


Сценарий 2: 10 потоков и лимит в 200 мс. Троттлинг начинается после 20 мс, доступ к ресурсам процессора возобновляется еще через 80 мс

Допустим, вы установили CPU limit на 2 ядра; Kubernetes переведет это значение в 200 мс. Это означает, что контейнер может использовать максимум 200 мс процессорного времени без троттлинга.

И здесь начинается самое интересное. Как было сказано выше, доступная квота составляет 200 мс. Если у вас параллельно работают десять потоков на 12-ядерной машине (см. иллюстрацию к сценарию 2), пока все остальные pod'ы простаивают, квота будет исчерпана всего через 20 мс (поскольку 10 * 20 мс = 200 мс), и все потоки данного pod'а «зависнут» (throttle) на следующие 80 мс. Усугубляет ситуацию уже упомянутый баг планировщика, из-за которого случается избыточный троттлинг и контейнер не может выработать даже имеющуюся квоту.

Как оценить троттлинг в pod'ах?


Просто войдите в pod и выполните cat /sys/fs/cgroup/cpu/cpu.stat.

  • nr_periods — общее число периодов планировщика;
  • nr_throttled — число throttled-периодов в составе nr_periods;
  • throttled_time — совокупное throttled-время в наносекундах.



Что же на самом деле происходит?


В итоге мы получаем высокий троттлинг во всех приложениях. Иногда он в полтора раза сильнее расчетного!

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

Решение и последствия


Тут все просто. Мы отказались от limit'ов CPU и занялись обновлением ядра ОС в кластерах на самую свежую версию, в которой баг был исправлен. Число ошибок (HTTP 5xx) в наших сервисах сразу же значительно упало:

Ошибки HTTP 5xx



Ошибки HTTP 5xx одного критически важного сервиса

Время отклика p95



Задержка запросов критически важного сервиса, 95-я процентиль

Расходы на эксплуатацию



Число потраченных экземпляро-часов

В чем подвох?


Как было сказано в начале статьи:

Можно провести аналогию с коммунальной квартирой… Kubernetes выступает в роли риэлтора. Но как удержать квартирантов от конфликтов друг с другом? Что, если один из них, скажем, решит занять ванную комнату на полдня?

Вот в чем подвох. Один нерадивый контейнер может поглотить все доступные ресурсы процессора на машине. Если у вас толковый стек приложений (например, должным образом настроены JVM, Go, Node VM), тогда это не проблема: можно работать в таких условиях в течение длительного времени. Но если приложения оптимизированы плохо или совсем не оптимизированы (FROM java:latest), ситуация может выйти из-под контроля. У нас в Omio имеются автоматизированные базовые Dockerfiles с адекватными настройками по умолчанию для стека основных языков, поэтому подобной проблемы не существовало.

Мы рекомендуем наблюдать за метриками USE (использование, насыщение и ошибки), задержками API и частотой появления ошибок. Следите за тем, чтобы результаты соответствовали ожиданиям.

Ссылки


Такова наша история. Следующие материалы сильно помогли разобраться в том, что происходит:


Отчеты об ошибках Kubernetes:


Сталкивались ли вы с подобными проблемами в своей практике или обладаете опытом, связанным с троттлингом в контейнеризованных production-средах? Поделитесь своей историей в комментариях!

P.S. от переводчика


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