CPU-ліміти та агресивний тротлінг у Kubernetes

Прим. перев.: ця повчальна історія Omio - європейського агрегатора подорожей - проводить читачів від базової теорії до захоплюючих практичних тонкощів у конфігурації Kubernetes. Знайомство з такими випадками допомагає не лише розширювати кругозір, а й запобігати нетривіальним проблемам.

CPU-ліміти та агресивний тротлінг у 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, запусти десять екземплярів мого контейнера з 2 процесорами і 3 Гб пам'яті на кожен, і підтримуй їх у робочому стані!». Kubernetes подбає про все інше. Він знайде вільні потужності, запустить контейнери і перезапускатиме їх за потреби, викотить оновлення при зміні версій тощо. По суті, Kubernetes дозволяє абстрагуватися від апаратної складової та робить усю різноманітність систем придатною для розгортання та роботи додатків.

CPU-ліміти та агресивний тротлінг у Kubernetes
Kubernetes з погляду простого обивателя

Що таке request'и та limit'и в Kubernetes

Окей, ми розібралися з контейнерами та Kubernetes. Також ми знаємо, що кілька контейнерів можуть бути на одній машині.

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

Саме тут у гру вступають request'и та limit'и. CPU Запит потрібний виключно для планування. Це щось на кшталт «списку бажань» контейнера, і використовується він для підбору найвідповіднішого вузла. У той же час CPU Limit можна порівняти з договором оренди - як тільки ми підберемо вузол для контейнера, той не зможе вийти за встановлені межі. І ось тут виникає проблема.

Як реалізовані request'и та limit'и в Kubernetes

Kubernetes використовує вбудований в ядро ​​механізм троттлінга (пропускання тактів) для реалізації CPU limit'ов. Якщо програма перевищує ліміт, включається троттлінг (тобто вона отримує менше тактів CPU). Request'и та limit'и для пам'яті організовані інакше, тому їх легше виявити. Для цього достатньо перевірити останній статус перезапуску pod'а: чи не є він OOMKilled. З тротлінгом CPU все не так просто, оскільки K8s робить доступними тільки метрики використання, а не cgroups.

CPU Request

CPU-ліміти та агресивний тротлінг у Kubernetes
Як реалізовано 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, що не використовуються. Він передає потужності, що «простують», у глобальний пул, з якого вони розподіляються по групах, що потребують додаткових потужностей процесора (передача відбувається партіями, щоб уникнути втрат від округлення). Аналогічний метод застосовується і до всіх нащадків нащадків.

Цей механізм забезпечує справедливий розподіл потужностей процесора і стежить, щоб жоден процес не «крав» ресурси в інших.

Обмеження ЦП

Незважаючи на те, що конфігурації 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):

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

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

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

І тут починається найцікавіше. Як було зазначено вище, доступна квота становить 200 мс. Якщо у вас паралельно працюють десять потоків на 12-ядерній машині (див. ілюстрацію до сценарію 2), поки всі інші pod'и простоюють, квота буде вичерпана всього через 20 мс (оскільки 10 * 20 мс = 200 мс), і всі потоки даного pod'а «зависнуть » (дросель) на наступні 80 мс. Посилює ситуацію вже згаданий баг планувальника, Через який трапляється надлишковий троттлінг і контейнер не може виробити навіть наявну квоту.

Як оцінити тротлінг у pod'ах?

Просто увійдіть до і виконайте cat /sys/fs/cgroup/cpu/cpu.stat.

  • nr_periods - загальна кількість періодів планувальника;
  • nr_throttled - Число throttled-періодів у складі nr_periods;
  • throttled_time - сукупний throttled-час у наносекундах.

CPU-ліміти та агресивний тротлінг у Kubernetes

Що ж насправді відбувається?

У результаті ми отримуємо високий тротлінг у всіх додатках. Іноді він у півтора рази сильніше за розрахунковий!

Це призводить до різних помилок - збоїв перевірок готовності (readiness), зависання контейнерів, розривів мережевих підключень, таймутів всередині сервісних викликів. Зрештою це виявляється у збільшеній затримці та підвищенні кількості помилок.

Рішення та наслідки

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

Помилки HTTP 5xx

CPU-ліміти та агресивний тротлінг у Kubernetes
Помилки HTTP 5xx одного критично важливого сервісу

Час відгуку p95

CPU-ліміти та агресивний тротлінг у Kubernetes
Затримка запитів критично важливого сервісу, 95-а процентиль

Витрати на експлуатацію

CPU-ліміти та агресивний тротлінг у Kubernetes
Число витрачених екземпляро-годин

У чому підступ?

Як було сказано на початку статті:

Можна провести аналогію з комунальною квартирою… Kubernetes виступає у ролі ріелтора. Але як утримати квартирантів від конфліктів? Що, якщо один із них, скажімо, вирішить зайняти ванну на півдня?

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

Ми рекомендуємо спостерігати за метриками ВИКОРИСТАННЯ (використання, насичення та помилки), затримками API та частотою появи помилок. Слідкуйте за тим, щоб результати відповідали очікуванням.

Посилання

Такою є наша історія. Наступні матеріали дуже допомогли розібратися в тому, що відбувається:

Звіти про помилки Kubernetes:

Чи стикалися ви з подібними проблемами у своїй практиці чи маєте досвід, пов'язаний з тротлінгом у контейнеризованих production-середовищах? Поділіться своєю історією у коментарях!

PS від перекладача

Читайте також у нашому блозі:

Джерело: habr.com

Додати коментар або відгук