Kubernetes: чому так важливо настроїти управління ресурсами системи?
Як правило, завжди виникає необхідність забезпечити виділений пул ресурсів будь-якому додатку для його коректної та стабільної роботи. Але що, якщо на тих самих потужностях працюють відразу кілька додатків? Як забезпечити мінімально необхідними ресурсами кожен із них? Як можна обмежити споживання ресурсів? Як грамотно розподілити навантаження між нодами? Як забезпечити роботу механізму горизонтального масштабування у разі зростання навантаження на програми?
Почати потрібно з того, які основні типи ресурсів існують у системі — це, звичайно, процесорний час і оперативна пам'ять. У маніфестах k8s ці типи ресурсів вимірюються у наступних одиницях:
CPU - в ядрах
RAM - у байтах
Причому для кожного ресурсу є можливість задавати два типи вимог. запитів и рамки. Requests - визначає мінімальні вимоги до вільних ресурсів ноди для запуску контейнера (і пода в цілому), в той час як limits встановлює жорстке обмеження ресурсів, доступних контейнеру.
Важливо розуміти, що у маніфесті не обов'язково явно визначати обидва типи, при цьому поведінка буде такою:
Якщо явно заданий тільки limits ресурсу, то requests для цього ресурсу автоматично приймає значення, що дорівнює limits (у цьому можна переконатися, викликавши describe сутності). Тобто. фактично робота контейнера буде обмежена такою ж кількістю ресурсів, що він вимагає для свого запуску.
Якщо ресурсу явно заданий лише requests, то жодних обмежень зверху цей ресурс не задається — тобто. контейнер обмежений лише ресурсами самої ноди.
Також існує можливість настроїти керування ресурсами не тільки на рівні конкретного контейнера, але і на рівні namespace за допомогою наступних сутностей:
LimitRange — описує політику обмеження на рівні контейнера/пода в ns і потрібна для того, щоб описати дефолтні обмеження на контейнер/під, а також запобігати створенню свідомо жирних контейнерів/подів (або навпаки), обмежувати їх кількість та визначати можливу різницю значень у limits та requests
ResourceQuotas - описують політику обмеження в цілому по всіх контейнерах в ns і використовується, як правило, для розмежування ресурсів по оточенням (корисно, коли середовища жорстко не розмежовані на рівні нод)
Нижче наведено приклади маніфестів, де встановлюються обмеження на ресурси:
Тобто. в даному випадку для запуску контейнера з nginx знадобиться як мінімум наявність вільних 1G ОП і 0.2 CPU на ноді, при цьому максимум контейнер може з'їсти 0.2 CPU і всю доступну ОП на ноді.
Тобто. у дефолтному namespace для всіх контейнерів за замовчуванням буде встановлений request в 100m для CPU та 1G для ОП, limit - 1 CPU та 2G. При цьому встановлено обмеження на можливі значення в request/limit для CPU (50m < x < 2) і RAM (500M < x < 4G).
Тобто. для кожного пода в дефолтному ns буде встановлено обмеження в 4 vCPU та 1G.
Тепер хотілося б розповісти, які переваги може нам дати встановлення даних обмежень.
Механізм балансування навантаження між нодами
Як відомо, за розподіл подів по нодах відповідає такий компонент k8s, як планувальникякий працює за певним алгоритмом. Цей алгоритм під час вибору оптимального вузла для запуску проходить дві стадії:
фільтрація
ранжування
Тобто. згідно з описаною політикою спочатку обираються ноди, на яких можливий запуск пода на основі набору предикати (у тому числі перевіряється чи достатньо у ноди ресурсів для запуску пода — PodFitsResources), а потім для кожної з цих нод, згідно пріоритети нараховуються окуляри (у тому числі, чим більше вільних ресурсів у ноди - тим більше очок їй присвоюється - LeastResourceAllocation/LeastRequestedPriority/BalancedResourceAllocation) і під запускається на ноді з найбільшою кількістю очок (якщо цій умові задовольняють відразу кілька нід, то вибирається .
У цьому слід розуміти, що scheduler в оцінці доступних ресурсів ноди орієнтується дані, які зберігаються в etcd — тобто. у сумі requested/limit ресурсу кожного пода, запущеного цієї ноді, але з фактичне споживання ресурсів. Цю інформацію можна отримати у висновку команди kubectl describe node $NODE, Наприклад:
Тут ми бачимо всі поди, запущені на конкретній ноді, а також ресурси, які запитує кожен із подів. А ось як виглядають логи scheduler при запуску пода cronjob-cron-events-1573793820-xt6q9 (дана інформація в лозі scheduler з'явиться при установці 10-го рівня логування в аргументах команди запуску -v = 10):
лог
I1115 07:57:21.637791 1 scheduling_queue.go:908] About to try and schedule pod nxs-stage/cronjob-cron-events-1573793820-xt6q9
I1115 07:57:21.637804 1 scheduler.go:453] Attempting to schedule pod: nxs-stage/cronjob-cron-events-1573793820-xt6q9
I1115 07:57:21.638285 1 predicates.go:829] Schedule Pod nxs-stage/cronjob-cron-events-1573793820-xt6q9 on Node nxs-k8s-s5 is allowed, Node is running only 16 out of 110 Pods.
I1115 07:57:21.638300 1 predicates.go:829] Schedule Pod nxs-stage/cronjob-cron-events-1573793820-xt6q9 on Node nxs-k8s-s6 is allowed, Node is running only 20 out of 110 Pods.
I1115 07:57:21.638322 1 predicates.go:829] Schedule Pod nxs-stage/cronjob-cron-events-1573793820-xt6q9 on Node nxs-k8s-s3 is allowed, Node is running only 20 out of 110 Pods.
I1115 07:57:21.638322 1 predicates.go:829] Schedule Pod nxs-stage/cronjob-cron-events-1573793820-xt6q9 on Node nxs-k8s-s4 is allowed, Node is running only 17 out of 110 Pods.
I1115 07:57:21.638334 1 predicates.go:829] Schedule Pod nxs-stage/cronjob-cron-events-1573793820-xt6q9 on Node nxs-k8s-s10 is allowed, Node is running only 16 out of 110 Pods.
I1115 07:57:21.638365 1 predicates.go:829] Schedule Pod nxs-stage/cronjob-cron-events-1573793820-xt6q9 on Node nxs-k8s-s12 is allowed, Node is running only 9 out of 110 Pods.
I1115 07:57:21.638334 1 predicates.go:829] Schedule Pod nxs-stage/cronjob-cron-events-1573793820-xt6q9 on Node nxs-k8s-s11 is allowed, Node is running only 11 out of 110 Pods.
I1115 07:57:21.638385 1 predicates.go:829] Schedule Pod nxs-stage/cronjob-cron-events-1573793820-xt6q9 on Node nxs-k8s-s1 is allowed, Node is running only 19 out of 110 Pods.
I1115 07:57:21.638402 1 predicates.go:829] Schedule Pod nxs-stage/cronjob-cron-events-1573793820-xt6q9 on Node nxs-k8s-s2 is allowed, Node is running only 21 out of 110 Pods.
I1115 07:57:21.638383 1 predicates.go:829] Schedule Pod nxs-stage/cronjob-cron-events-1573793820-xt6q9 on Node nxs-k8s-s9 is allowed, Node is running only 16 out of 110 Pods.
I1115 07:57:21.638335 1 predicates.go:829] Schedule Pod nxs-stage/cronjob-cron-events-1573793820-xt6q9 on Node nxs-k8s-s8 is allowed, Node is running only 18 out of 110 Pods.
I1115 07:57:21.638408 1 predicates.go:829] Schedule Pod nxs-stage/cronjob-cron-events-1573793820-xt6q9 on Node nxs-k8s-s13 is allowed, Node is running only 8 out of 110 Pods.
I1115 07:57:21.638478 1 predicates.go:1369] Schedule Pod nxs-stage/cronjob-cron-events-1573793820-xt6q9 on Node nxs-k8s-s10 is allowed, existing pods anti-affinity terms satisfied.
I1115 07:57:21.638505 1 predicates.go:1369] Schedule Pod nxs-stage/cronjob-cron-events-1573793820-xt6q9 on Node nxs-k8s-s8 is allowed, existing pods anti-affinity terms satisfied.
I1115 07:57:21.638577 1 predicates.go:1369] Schedule Pod nxs-stage/cronjob-cron-events-1573793820-xt6q9 on Node nxs-k8s-s9 is allowed, existing pods anti-affinity terms satisfied.
I1115 07:57:21.638583 1 predicates.go:829] Schedule Pod nxs-stage/cronjob-cron-events-1573793820-xt6q9 on Node nxs-k8s-s7 is allowed, Node is running only 25 out of 110 Pods.
I1115 07:57:21.638932 1 resource_allocation.go:78] cronjob-cron-events-1573793820-xt6q9 -> nxs-k8s-s10: BalancedResourceAllocation, capacity 39900 millicores 66620178432 memory bytes, total request 2343 millicores 9640186880 memory bytes, score 9
I1115 07:57:21.638946 1 resource_allocation.go:78] cronjob-cron-events-1573793820-xt6q9 -> nxs-k8s-s10: LeastResourceAllocation, capacity 39900 millicores 66620178432 memory bytes, total request 2343 millicores 9640186880 memory bytes, score 8
I1115 07:57:21.638961 1 resource_allocation.go:78] cronjob-cron-events-1573793820-xt6q9 -> nxs-k8s-s9: BalancedResourceAllocation, capacity 39900 millicores 66620170240 memory bytes, total request 4107 millicores 11307422720 memory bytes, score 9
I1115 07:57:21.638971 1 resource_allocation.go:78] cronjob-cron-events-1573793820-xt6q9 -> nxs-k8s-s8: BalancedResourceAllocation, capacity 39900 millicores 66620178432 memory bytes, total request 5847 millicores 24333637120 memory bytes, score 7
I1115 07:57:21.638975 1 resource_allocation.go:78] cronjob-cron-events-1573793820-xt6q9 -> nxs-k8s-s9: LeastResourceAllocation, capacity 39900 millicores 66620170240 memory bytes, total request 4107 millicores 11307422720 memory bytes, score 8
I1115 07:57:21.638990 1 resource_allocation.go:78] cronjob-cron-events-1573793820-xt6q9 -> nxs-k8s-s8: LeastResourceAllocation, capacity 39900 millicores 66620178432 memory bytes, total request 5847 millicores 24333637120 memory bytes, score 7
I1115 07:57:21.639022 1 generic_scheduler.go:726] cronjob-cron-events-1573793820-xt6q9_nxs-stage -> nxs-k8s-s10: TaintTolerationPriority, Score: (10)
I1115 07:57:21.639030 1 generic_scheduler.go:726] cronjob-cron-events-1573793820-xt6q9_nxs-stage -> nxs-k8s-s8: TaintTolerationPriority, Score: (10)
I1115 07:57:21.639034 1 generic_scheduler.go:726] cronjob-cron-events-1573793820-xt6q9_nxs-stage -> nxs-k8s-s9: TaintTolerationPriority, Score: (10)
I1115 07:57:21.639041 1 generic_scheduler.go:726] cronjob-cron-events-1573793820-xt6q9_nxs-stage -> nxs-k8s-s10: NodeAffinityPriority, Score: (0)
I1115 07:57:21.639053 1 generic_scheduler.go:726] cronjob-cron-events-1573793820-xt6q9_nxs-stage -> nxs-k8s-s8: NodeAffinityPriority, Score: (0)
I1115 07:57:21.639059 1 generic_scheduler.go:726] cronjob-cron-events-1573793820-xt6q9_nxs-stage -> nxs-k8s-s9: NodeAffinityPriority, Score: (0)
I1115 07:57:21.639061 1 interpod_affinity.go:237] cronjob-cron-events-1573793820-xt6q9 -> nxs-k8s-s10: InterPodAffinityPriority, Score: (0)
I1115 07:57:21.639063 1 selector_spreading.go:146] cronjob-cron-events-1573793820-xt6q9 -> nxs-k8s-s10: SelectorSpreadPriority, Score: (10)
I1115 07:57:21.639073 1 interpod_affinity.go:237] cronjob-cron-events-1573793820-xt6q9 -> nxs-k8s-s8: InterPodAffinityPriority, Score: (0)
I1115 07:57:21.639077 1 selector_spreading.go:146] cronjob-cron-events-1573793820-xt6q9 -> nxs-k8s-s8: SelectorSpreadPriority, Score: (10)
I1115 07:57:21.639085 1 interpod_affinity.go:237] cronjob-cron-events-1573793820-xt6q9 -> nxs-k8s-s9: InterPodAffinityPriority, Score: (0)
I1115 07:57:21.639088 1 selector_spreading.go:146] cronjob-cron-events-1573793820-xt6q9 -> nxs-k8s-s9: SelectorSpreadPriority, Score: (10)
I1115 07:57:21.639103 1 generic_scheduler.go:726] cronjob-cron-events-1573793820-xt6q9_nxs-stage -> nxs-k8s-s10: SelectorSpreadPriority, Score: (10)
I1115 07:57:21.639109 1 generic_scheduler.go:726] cronjob-cron-events-1573793820-xt6q9_nxs-stage -> nxs-k8s-s8: SelectorSpreadPriority, Score: (10)
I1115 07:57:21.639114 1 generic_scheduler.go:726] cronjob-cron-events-1573793820-xt6q9_nxs-stage -> nxs-k8s-s9: SelectorSpreadPriority, Score: (10)
I1115 07:57:21.639127 1 generic_scheduler.go:781] Host nxs-k8s-s10 => Score 100037
I1115 07:57:21.639150 1 generic_scheduler.go:781] Host nxs-k8s-s8 => Score 100034
I1115 07:57:21.639154 1 generic_scheduler.go:781] Host nxs-k8s-s9 => Score 100037
I1115 07:57:21.639267 1 scheduler_binder.go:269] AssumePodVolumes for pod "nxs-stage/cronjob-cron-events-1573793820-xt6q9", node "nxs-k8s-s10"
I1115 07:57:21.639286 1 scheduler_binder.go:279] AssumePodVolumes for pod "nxs-stage/cronjob-cron-events-1573793820-xt6q9", node "nxs-k8s-s10": all PVCs bound and nothing to do
I1115 07:57:21.639333 1 factory.go:733] Attempting to bind cronjob-cron-events-1573793820-xt6q9 to nxs-k8s-s10
Тут ми бачимо, що спочатку scheduler здійснює фільтрацію та формує список з 3-х нід, на яких можливий запуск (nxs-k8s-s8, nxs-k8s-s9, nxs-k8s-s10). Потім здійснює підрахунок очок за декількома параметрами (у тому числі BalancedResourceAllocation, LeastResourceAllocation) для кожної з цих нід з метою визначити найбільш вдалий вузол. Зрештою під планується на ноді з найбільшою кількістю очок (тут відразу дві ноди мають однакову кількість очок 100037, тому вибирається випадкова з них – nxs-k8s-s10).
Висновок: якщо на ноді працюють поди, для яких не задані обмеження, то для k8s (з точки зору споживання ресурсів) це буде рівносильно тому, якби на цій ноді таких подів взагалі не було. Тому, якщо у вас, умовно, є pod з ненажерливим процесом (наприклад, wowza) і для нього не задані обмеження, то може виникнути така ситуація, коли фактично даний під з'їв усі ресурси ноди, але при цьому для k8s це нода вважається ненавантаженою і їй будуть нараховуватися така ж кількість очок при ранжируванні (саме в пунктах з оцінкою доступних ресурсів), як і ноді, на якій немає робочих подів, що в кінцевому підсумку може призвести до нерівномірного розподілу навантаження між нодами.
Виселення пода
Як відомо - кожному поду присвоюється один із 3 QoS-класів:
guaranuted - призначається тоді, як для кожного контейнера в поді для memory і cpu заданий request і limit, причому ці значення повинні збігатися
burstable - хоча б один контейнер у поді має request і limit, при цьому request < limit
найкращих зусиль — коли жоден контейнер у поді не обмежений за ресурсами
При цьому, коли на ноді спостерігається нестача ресурсів (диску, пам'яті), kubelet починає ранжувати та виселяти під'ї за певним алгоритмом, який враховує пріоритет пода та його QoS-клас. Наприклад, якщо йдеться про RAM, то на основі QoS класу нараховуються окуляри за таким принципом:
Тобто. при однаковому пріоритеті, kubelet в першу чергу виселятиме з ноди поди з QoS-класом best effort.
Висновок: якщо ви хочете зменшити ймовірність виселення потрібного пода ноди у разі браку ресурсів на ній, то нарівні з пріоритетом необхідно також подбати і про завдання request/limit для нього.
Механізм горизонтального автомасштабування подів програми (HPA)
Коли стоїть завдання автоматично збільшувати та зменшувати кількість pod залежно від використання ресурсів (системного – CPU/RAM або користувальницького – rps) у її вирішенні може допомогти така сутність k8s як HPA (Horizontal Pod Autoscaler). Алгоритм якого полягає в наступному:
Визначаються поточні показання ресурсу, що спостерігається (currentMetricValue)
Визначаються бажані значення для ресурсу (desiredMetricValue), які задаються для системних ресурсів за допомогою request
Визначається поточна кількість реплік (currentReplicas)
За наступною формулою розраховується бажана кількість реплік (desiredReplicas)
desiredReplicas = [ currentReplicas * ( currentMetricValue / desiredMetricValue )]
При цьому масштабування не відбудеться, коли коефіцієнт (currentMetricValue/desiredMetricValue) близький до 1 (при цьому допустиму похибку ми можемо ставити самі, за умовчанням вона дорівнює 0.1).
Розглянемо роботу hpa на прикладі додатка app-test (описане як Deployment), де необхідно змінювати кількість реплік, залежно від споживання CPU:
Тобто. ми бачимо, що під додатком спочатку запускається у двох примірниках, кожен з яких містить два контейнери nginx і nginx-exporter, для кожного з яких заданий запитів для CPU.
Тобто. ми створили hpa, який буде стежити за Deployment app-test і регулювати кількість подів з додатком на основі показника cpu (ми очікуємо, що під повинен споживати 30% відсотків від запитуваного ним CPU), при цьому кількість реплік знаходиться в проміжку 2-10.
Тепер, розглянемо механізм роботи hpa, якщо подати навантаження на один із подов:
# kubectl top pod
NAME CPU(cores) MEMORY(bytes)
app-test-78559f8f44-pgs58 101m 243Mi
app-test-78559f8f44-cj4jz 4m 240Mi
Разом маємо таке:
Бажане значення (desiredMetricValue) - згідно з налаштуваннями hpa у нас дорівнює 30%
Поточне значення (currentMetricValue) - для розрахунку controller-manager розраховує середнє споживання ресурсу в %, тобто. умовно робить таке:
Набуває абсолютних значень метрик подів з metric-сервера, тобто. 101m та 4m
Отримує абсолютне значення для бажаного споживання ресурсу (для цього сумуються потреби всіх контейнерів) 60m + 30m = 90m
Розраховує середній відсоток споживання CPU щодо request пода, тобто. 53m / 90m * 100% = 59%
Тепер у нас є все необхідне для того, щоб визначити, чи потрібно змінювати кількість реплік, для цього розраховуємо коефіцієнт:
ratio = 59% / 30% = 1.96
Тобто. кількість реплік має бути збільшена в ~2 рази та становити [2 * 1.96] = 4.
Висновок: Як можна помітити, для того, щоб цей механізм працював необхідною умовою, є наявність requests для всіх контейнерів у спостережуваному поді.
Механізм горизонтального автомасштабування нод (Cluster Autoscaler)
Для того, щоб нівелювати негативний вплив на систему під час сплесків навантаження, наявність налаштованого hpa буває недостатньо. Наприклад, згідно з налаштуваннями в hpa controller manager приймає рішення про те, що кількість реплік необхідно збільшити в 2 рази, проте на нодах немає вільних ресурсів для запуску такої кількості подів (тобто нода не може надати запитувані ресурси пода requests) і ці поди переходять у стан Pending.
У цьому випадку, якщо провайдер має відповідний IaaS/PaaS (наприклад, GKE/GCE, AKS, EKS і т.д.), нам може допомогти такий інструмент, як Node Autoscaler. Він дозволяє задати максимальну та мінімальну кількість нод у кластері та автоматично регулювати поточну кількість нод (шляхом звернення до API хмарного провайдера для замовлення/видалення ноди), коли спостерігається нестача ресурсів у кластері та піди не можуть бути заплановані (перебувають у стані Pending).
Висновок: Для можливості автомасштабування нід необхідно задавати requests в контейнерах подів, щоб k8s міг правильно оцінити навантаженість нід і відповідно повідомляти, що для запуску чергового подавання ресурсів у кластері немає.
Висновок
Слід зазначити, що встановлення обмежень ресурсів контейнера не є обов'язковою умовою для успішного запуску програми, проте це все ж таки краще зробити з наступних причин:
Для більш точної роботи scheduler у частині балансування навантаження між нодами k8s
Для зменшення ймовірності виникнення події “виселення пода”
Для роботи горизонтального автомасштабування подів програми (HPA)
Для роботи горизонтального автомасштабування нід (Cluster Autoscaling) у хмарних провайдерів