Karpenter: моніторинг та Grafana dashboard для Kubernetes WorkerNodes

Автор |  01/02/2024

Маємо AWS EKS з Karpenter, який займається автоскелінгом EC2 – див. AWS: знайомство з Karpenter для автоскейлінгу в EKS, та встановлення з Helm-чарту.

В цілому проблем з ним поки не маємо, але в будь-якому разі потрібен його моніторинг, для чого Karpeneter “з коробки” надає метрики, які можемо використати в Grafana та Prometheus/VictoriaMetrics алертах.

Тож що будемо робити сьогодні:

  • додамо збір метрик до VictoriaMetrics
  • подивимось які метрики нам можуть бути корисні
  • додамо Grafana Dashboard для WorkerNodes + Karpenter

Взагалі пост вийшов більше про Grafana, ніж про Karpenter, але в графіках в основному використовуються метрики саме від Karpenter.

Окремо треба буде створити алерти – але це вже іншим разом. Маючи уяву про доступні метрики Karpenter та Prometheus-запити для графіків в Grafana проблем з алертами не має бути.

Поїхали.

Збір метрик Karpenter з VictoriaMetrics VMAgent

Наша VictoriaMetrics деплоїться власним чартом, див. VictoriaMetrics: створення Kubernetes monitoring stack з власним Helm-чартом.

Для додавання нового target оновлюємо values цього чарту – додаємо ендпоінт karpenter.karpenter.svc.cluster.local:8000:

...
  vmagent:
    enabled: true
    spec: 
      replicaCount: 1
      inlineScrapeConfig: |
        - job_name: yace-exporter
          metrics_path: /metrics
          static_configs:
            - targets: ["yace-service:5000"]
        - job_name: github-exporter
          metrics_path: /
          static_configs:
            - targets: ["github-exporter-service:8000"]     
        - job_name: karpenter
          metrics_path: /metrics
          static_configs:
            - targets: ["karpenter.karpenter.svc.cluster.local:8000"]  
...

Деплоїмо, і для перевірки таргету відкриваємо порт до VMAgent:

$ kk port-forward svc/vmagent-vm-k8s-stack 8429

Перевіряємо таргети:

Для перевірки метрик відкриваємо порт до VMSingle:

$ kk port-forward svc/vmsingle-vm-k8s-stack 8429

І шукаємо дані по запиту {job="karpenter"}:

Корисні метрики Karpenter

Тепер глянемо, які саме метрики нам можуть бути корисні.

Але перед тим, як розбиратись з метриками – давайте проговоримо основні поняття в Karpenter (окрім очевидних типу Node, NodeClaim або Pod):

  • controller: компонент Karpenter, який віподвідає за певний аспект його роботи, наприклад, Pricing controller відповідає за перевірку вартості інстансів, а Disruption Controller відповідає за керування процесом зміни стану WorkerNodes
  • reconciliation (“узгодження”): процес, коли Karpenter виконує узгодження бажаного стану (desired state) и реального (current state), наприклад – при появі Pod, для якого нема вільних ресурсів на існуючих WorkerNodes, Karpenter створить нову Node, на якій зможе запустись Pod, і його статус стане Running – тоді reconciliation процес статусу цього поду завершиться
  • consistency (“когерентність” або “узгодженість”): процес внутрішнього контролю і забезпечення відповідності необхідним параметрам (наприклад, перевірка того, що створена WorkerNode має диск розміром саме 30 GB)
  • disruption: процес зміни WorkerNodes в кластері, наприклад перестворення WorkerNode (для заміни на інстанс з більшою кількістю CPU або Memory), або видалення існуючої ноди, на якій нема запущених Pods
  • interruption: випадки, коли EC2 буде зупинено у зв’язку з помилками на hardware, виключення інстансу (коли робиться Stop або Terminate instance), або у випадку зі Spot – коли AWS “відкликає” інстанс; ці евенти йдуть на віподвідну SQS, звідки їх отримує Karpenter, щоб запустити новий інстанс на заміну
  • provisioner: компонент, який аналізує поточні потреби кластера, такі як запити на створення нових Pod, визначає, які ресурси потрібно створити (WorkerNodes), і ініціює створення нових (взагалі, Provisioner був замінений на NodePool, але окремі метрики по ньому залишились)

Тут я зібрав тільки ті метрики, які мені вважаються найбільш корисними в даний момент, але варто самому передивитись документацію Inspect Karpenter Metrics і є трохи більше деталей у документації Datadog:

Controller:

  • controller_runtime_reconcile_errors_total: кількість помилок при оновленні WorkerNodes (тобто в роботі Disruption Controller при виконанні операцій по Expiration, Drift, Interruption та Consolidation) – корисно мати графік або алерт 
  • controller_runtime_reconcile_total: загальна кількість таких операції – корисно мати уяву про активність Karpenter і, можливо, мати алерт, якщо це відбувається надто часто

Сonsistency:

  • karpenter_consistency_errors: виглядає як корисна метрика, але в мене вона пуста (принаймні поки що)

Disruption:

  • karpenter_disruption_actions_performed_total: загальна кількість дій по disruption (видалення/перестворення WorkerNodes), в лейблах метрик вказується disruption method – корисно мати уяву про активність Karpenter і, можливо, мати алерт, якщо це відбувається надто часто
  • karpenter_disruption_eligible_nodes: загальна кількість WorkerNodes для виконання disruption (видалення/перестворення WorkerNodes), в лейблах метрик вказується disruption method
  • karpenter_disruption_replacement_nodeclaim_failures_total: загальна кількість помилок при створенні нових WorkerNodes на заміну старим, в лейблах метрик вказується disruption method

Interruption:

  • karpenter_interruption_actions_performed: кількість дій за повідомленнями про EC2 Interruption (з SQS) – можливо має сенс, але в мене за тиждень збору метрик такого не траплялось

Nodeclaims:

  • karpenter_nodeclaims_created: загальна кількість створенних NodeClaims з лейблами по причині створення та відповідним NodePool
  • karpenter_nodeclaims_terminated: аналогічно, але по видаленним NodeClaims

Provisioner:

  • karpenter_provisioner_scheduling_duration_seconds: можливо, має сенс моніторити, бо якщо цей показник буде рости але буде надто великим – то це може бути ознакою проблем; проте, в мене за тиждень хістограма karpenter_provisioner_scheduling_duration_seconds_bucket незмінна

Nodepool:

  • karpenter_nodepool_limit: ліміт CPU/Memory NodePool, заданий в його Provisioner (spec.limits)
  • karpenter_nodepool_usage: використання ресурсів NodePool – CPU, Memory, Volumes, Pods

Nodes:

  • karpenter_nodes_allocatable: інформація по існуючим WorkerNodes – тип, кількість CPU/Memory, Spot/On-Demand, Availability Zone, etc
    • можна мати графік по кількості Spot/On-Demand інстансів
    • можна використовувати для отримання даних по доступних ресурсах ЦПУ/пам’яті – sum(karpenter_nodes_allocatable) by (resource_type)
  • karpenter_nodes_created: загальна кількість створенних нод
  • karpenter_nodes_terminated: загальна кількість видалених нод
  • karpenter_nodes_total_pod_limits: загальна кількість всіх Pod Limits (окрім DaemonSet) на кожній WorkerNode
  • karpenter_nodes_total_pod_requests: загальна кількість всіх Pod Requests (окрім DaemonSet) на кожній WorkerNode

Pods:

  • karpenter_pods_startup_time_seconds: час від сторення поду до його переходу в статус Running (сума по всім подам)
  • karpenter_pods_state: досить корисна метрика, бо в лейблах має статус поду, на якій він ноді запущений, неймспейс тощо

Cloudprovider:

  • karpenter_cloudprovider_errors_total: кількість помилок від AWS
  • karpenter_cloudprovider_instance_type_price_estimate: вартість інстансів по типам – можна на дашборді виводити вартість compute-потужностей кластеру

Створення Grafana dashboard

Для Grafana є готовий дашборд – Display all useful Karpenter metrics, але він якось зовсім не інформативний. Втім, з нього можна взяти деякі графіки та/або запити.

Зараз в мене є власна борда для перевірки статусу та ресурів по кожній окремій WorkerNode:

В графіках цієї борди є Data Links на дашборду з деталями по на дашборду з інформацією по конкретному Pod:

Графіки ALB внизу будуються з логів в Loki.

Тож що зробимо: нову борду, на якій будуть всі WorkerNodes, а в Data Links графіків цієї борди зробимо лінки на перший дашборд.

Тоді буде непогана навігація:

  1. загальна борда по всім WorkerNodes з можливістю перейти на дашборду з більш детальною інформацією по конкретній ноді
  2. на борді по конкретній ноді вже буде інформація по подах на цій ноді, і data links на борду по конкретному поду

Планування дашборди

Давайте поміркуємо, що саме ми б хотіли бачити на новій борді.

Фільтри/змінні:

  • мати змогу бачити всі WorkerNodes разом, або обрати одну чи кілька окремо
  • мати змогу бачити ресурси по конкретним Namespaces або Applications (в моєму випадку кожен сервіс має власний неймспейс, тому використаємо їх)

Далі, інформація по нодам:

  • загальна інформаця по нодам:
    • кількість нод
    • кількість подів
    • кількість ЦПУ
    • кількість Мем
    • spot vs on-deman ratio
    • вартість всіх нод за добу
  • відсотки від allocatable використано:
    • cpu – від pods requested
    • mem – від pods requested
    • pods allocation
  • реальне використання ресурсів – графіки по нодам:
    • CPU та Memory подами
    • кількість подів – процент від максимума на ноді
    • створено-видалено нод (by Karpenter)
    • вартість нод
    • процент EBS used
    • network – in/our byes/sec

По Karpenter:

  • controller_runtime_reconcile_errors_total – загальна кількість помилок
  • karpenter_provisioner_scheduling_duration_seconds – час створення подів
  • karpenter_cloudprovider_errors_total – загальна кількість помилок

Looks like a plan?

Поїхали творити.

Створення дашборди

Робимо нову борду, задаємо основні параметри:

Grafana variables

Нам потрібні дві змінні – по нодам і неймпспейсам.

Ноди можемо вибрати з karpenter_nodes_allocatable, неймспейси отримати з karpenter_pods_state.

Створюємо першу змінну – node_name, включаємо можливість вибору All або Multi-value:

Створюємо другу змінну – $namespace.

Щоб вибирати неймспейси тільки з обраних в фільтрі нод – додаємо можливість фільтру по $node_name яку створили вище і використовуємо регулярку “=~” – якщо нод буде обрано кілька:

Переходимо до графіків.

Кількість нод в кластері

Запит – використовуємо фільтр под обраним нодам:

count(sum(karpenter_nodes_allocatable{node_name=~"$node_name"}) by (node_name))

Кількість подів в кластері

Запит – тут фільтр і по нодам, і по неймспейсу:

sum(karpenter_pods_state{node=~"$node_name", namespace=~"$namespace"})

Кількість ядер CPU на всіх нодах

Частина ресурсів зайнята системою то ДемонСетами – вони у karpenter_nodes_allocatable не враховються. Можна перевірити запитом sum(karpenter_nodes_system_overhead{resource_type="cpu"}).

Тому можемо вивести або загальну кількість – karpenter_nodes_allocatable{resource_type="cpu"} + karpenter_nodes_system_overhead{resource_type="cpu"}, або тільки дійсно доступну для наших workloads – karpenter_nodes_allocatable{resource_type="cpu"}.

Так як тут ми хочемо бачити саме загальну кількість – то давайте використаємо суму:

sum(karpenter_nodes_allocatable{node_name=~"$node_name", resource_type="cpu"}) + sum(karpenter_nodes_system_overhead{node_name=~"$node_name", resource_type="cpu"})

Загальний доступний об’єм пам’яті

JFYI:

  • SI standard: 1000 bytes in a kilobyte.
  • IEC standart: 1024 bytes in a kibibyte

Але давайте просто самі зробимо / 1024, і використаємо Кілобайти:

sum(sum(karpenter_nodes_allocatable{node_name=~"$node_name", resource_type="memory"}) + sum(karpenter_nodes_system_overhead{node_name=~"$node_name", resource_type="memory"})) / 1024

Spot instances – % від загальної кількості Nodes

Окрім нод створенних самим Karpenter у нас є окрема “дефолтна” нода, яка створються при створенні кластеру – для всяких controllers. Вона теж Spot (поки що), тож рахуймо і її.

Формула буде такою:

загальна сума нод = всі спот від karpenter + 1 дефолтна / на загальну кількість нод

Сам запит:

sum(count(sum(karpenter_nodes_allocatable{node_name=~"$node_name", nodepool!="", capacity_type="spot"}) by (node_name)) + 1) / count(sum(karpenter_nodes_allocatable{node_name=~"$node_name"}) by (node_name)) * 100

CPU requested – % від загального allocatable

Запит беремо з дефолтної борди Karpenter, трохи підпилюємо під свої фільтри:

sum(karpenter_nodes_total_pod_requests{node_name=~"$node_name", resource_type="cpu"}) / sum(karpenter_nodes_allocatable{node_name=~"$node_name", resource_type="cpu"})

Memory requested – % від загального allocatable

Аналогічно:

sum(karpenter_nodes_total_pod_requests{node_name=~"$node_name", resource_type="memory"}) / sum(karpenter_nodes_allocatable{node_name=~"$node_name", resource_type="memory"})

Pods allocation – % від загального allocatable

Скільки подів маємо від загальної ємності:

sum(karpenter_pods_state{node=~"$node_name", namespace=~"$namespace"} / sum(karpenter_nodes_allocatable{node_name=~"$node_name", resource_type="pods"})) * 100

Controller errors

Метрика controller_runtime_reconcile_errors_total включає в себе і контролери від VictoriaMetrcis, тож виключаємо їх через {container!~".*victoria.*"}:

sum(controller_runtime_reconcile_errors_total{container!~".*victoria.*"})

Cloudprovider errors

Рахуємо як рейт в секунду (з контролерами мабуть теж краще рейт, подивимось, як будуть помилки):

sum(rate(karpenter_cloudprovider_errors_total[15m]))

Nodes cost 24h – вартість всіх Nodes за добу

А от тут прям дуже цікаво вийшло.

По-перше – у AWS є дефолтні метрики білінгу від CloudWatch, але наш проект користується кредитами від AWS і ці метрики пусті.

Тому скористаємось метриками від Karpenter – karpenter_cloudprovider_instance_type_price_estimate.

Щоб відобразити вартість серверверів нам треба вибрати кожен тип інстансу які використовуються і потім порахувати загальну вартість по кожному типу і іх кількості.

Що ми маємо:

  • дефолта нода: з типом Spot, але створюється не Karpenter – можемо її ігнорувати
  • ноди, створені Karpenter: можуть бути або spot або on-demand, і можуть бути різних типів (t3.medium. c5.large, etc)

Спочатку нам треба отримати кількість нод по кожному типу:

count(sum(karpenter_nodes_allocatable) by (node_name, instance_type,capacity_type)) by (instance_type, capacity_type)

Отримуємо 4 spot, і один інстанс без лейбли capacity_type – бо це з дефолтної нод-групи:

Можемо його виключити з {capacity_type!=""} – він у нас один, без скейлінгу, можемо не враховувати, бо це тільки для CriticalAddons.

Для кращої картини візьмемо більший проміжок часу, бо там був ще t3.small:

Далі, у нас є метрика karpenter_cloudprovider_instance_type_price_estimate, використовуючи яку нам треба порахувати вартість всіх інстансів по кожному instance_type і capacity_type.

Запит буде виглядати так (дякую ChatGPT):

sum by (instance_type, capacity_type) (
    count(sum(karpenter_nodes_allocatable) by (node_name, instance_type, capacity_type)) by (instance_type, capacity_type)
    * on(instance_type, capacity_type) group_left 
    avg(karpenter_cloudprovider_instance_type_price_estimate) by (instance_type, capacity_type)
)

Тут:

  1. “внутрішній” запит sum(karpenter_nodes_allocatable) by (node_name, instance_type, capacity_type): рахується сума всіх CPU, memory тощо для кожної комбінації node_name, instance_type, capacity_type
  2. “зовнішній” count(...) by (instance_type, capacity_type): результат попереднього запиту рахуємо з count, щоб отримати кількість кожної комбінації – отримуємо кількіть WorkerNodes кожного instance_type та capacity_type
  3. другий запит – avg(karpenter_cloudprovider_instance_type_price_estimate) by (instance_type, capacity_type): повертає нам середню ціну по кожному instance_type та capacity_type
  4. використовуючи * on(instance_type, capacity_type): множимо кількість нод с запита номер 2 (count(...)) на результат с запита номер 3 (avg(...)) по співпадаючим комбінаціям метрик instance_type та capacity_type
  5. і самий перший “зовнішній” запит sum by (instance_type, capacity_type) (...): повертає нам суму по кожій комбінації

В результаті маємо такий графік:

Отже, що ми тут маємо:

  • 4 інстанси t3.medium та 2 t3.small
  • загальна вартість всіх t3.medium на годину виходить 0.074, всіх t3.small – 0.017

Для перевірки порахуємо вручну.

Спочатку по t3.small:

{instance_type="t3.small", capacity_type="spot"}

Виходить 0.008:

І по t3.medium:

{instance_type="t3.medium", capacity_type="spot"}

Виходить 0.018:

Тож:

  • 4 інстанси t3.medium по 0.018 == 0.072 usd/година
  • 2 інстанси t3.small по 0.008 == 0.016 usd/година

Все сходиться.

Залишилось все це зібрати разом, і вивести загальную вартість всіх серверів за 24 години – використаємо avg() і результат помножимо на 24 години:

avg(
  sum by (instance_type, capacity_type) (
    count(sum(karpenter_nodes_allocatable{capacity_type!=""}) by (node_name, instance_type, capacity_type)) by (instance_type, capacity_type)
    * on(instance_type, capacity_type) group_left 
    avg(karpenter_cloudprovider_instance_type_price_estimate) by (instance_type, capacity_type)
  )
) * 24

І в результаті все у нас зараз виглядає так:

Йдемо далі – до графіків.

CPU % use by Node

Тут вже використаємо дефолтні метрики від Node Exporter – node_cpu_seconds_total, але вони мають лейбли instance у вигляді instance="10.0.32.185:9100", а не node_name або node як у  метриках від Karpenter (karpenter_pods_state{node="ip-10-0-46-221.ec2.internal"}).

Тож щоб їх застосувати node_cpu_seconds_total з нашою змінною $node_name – додамо нову змінну node_ip, яку будемо формувати з метрики kube_pod_info з фільтром по лейблі node, де використовуємо нашу стару змінну node_name – щоб вибирати поди тільки з обраних в фільтрах нод.

Додаємо нову змінну, поки для перевірки не вимикаємо “Show on dashboard“:

І тепер можемо створити графік с запитом:

100 * avg(1 - rate(node_cpu_seconds_total{instance=~"$node_ip:9100", mode="idle"}[5m])) by (instance)

Але в такому випадку instance нам поверне результати як “10.0.38.127:9100” – а ми всюди використовуємо “ip-10-0-38-127.ec2.internal“. До того ж ми не зможемо додати data links, бо друга панель використовує формат ip-10-0-38-127.ec2.internal.

Тож ми можемо використати label_replace(), і переписати запит так:

100 * avg by (instance) (
    label_replace(
        rate(node_cpu_seconds_total{instance=~"$node_ip:9100", mode="idle"}[5m]), 
        "instance", 
        "ip-${1}-${2}-${3}-${4}.ec2.internal", 
        "instance", 
        "(.*)\\.(.*)\\.(.*)\\.(.*):9100"
    )
)

Тут label_replace отримує 4 агрументи:

  1. перший – метрика, над якою будемо виконувати трансформацію (результат rate(node_cpu_seconds_total))
  2. другий – лейбла, над якою ми будемо виконувати трансформацію – instance
  3. третій – новий формат value для лейбли – “ip-${1}-${2}-${3}-${4}.ec2.internal
  4. четвертий – ім’я лейбли, з якої ми будемо тримувати дані за допомогою regex

І останнім описуємо сам regex “(.*)\\.(.*)\\.(.*)\\.(.*):9100“, за яким треба отрмати кожен октет з IP 10.0.38.127, а потім кожен результат відповідно записати у ${1}-${2}-${3}-${4}.

Тепер маємо графік в такому вигляді:

Memory used by Node

Тут все аналогічно:

sum by (instance) (
  label_replace(
    (
      1 - (
        node_memory_MemAvailable_bytes / node_memory_MemTotal_bytes
      )
    ) * 100,
    "instance", "ip-${1}-${2}-${3}-${4}.ec2.internal", "instance", "(.*)\\.(.*)\\.(.*)\\.(.*):9100"
  )
)

Pods use % by Node

Тут нам треба виконати запит між двома метриками – karpenter_pods_state та karpenter_nodes_allocatable:

(
  sum by (node) (kube_pod_info{node=~"$node_name", created_by_kind!="Job"})
  / 
  sum by (node) (kube_node_status_allocatable{node=~"$node_name", resource="pods"})
) * 100

Або ми можемо виключити нашу дефолтну ноду “ip-10-0-41-2.ec2.internal” і відобразити тільки ноди самого Карпентеру додавши вибірку по karpenter_nodes_allocatable{capacity_type!=""} – бо нам тут більше цікаво наскільки зайняті ноди, які створено самим Karpenter під наші аплікейшени.

Але для цього нам знабиться метрика karpenter_nodes_allocatable, в якій ми можемо перевірити наявність лейбли capacity_typecapacity_type!="".

Проте karpenter_nodes_allocatable має лейблу node_name а не node як в попередніх двух, тому ми знову можемо додати label_replace, і зробити такий запит:

(
  sum by (node) (kube_pod_info{node=~"$node_name", created_by_kind!="Job"})
  / 
  on(node) group_left
  sum by (node) (kube_node_status_allocatable{node=~"$node_name", resource="pods"})
) * 100
and on(node)
label_replace(karpenter_nodes_allocatable{capacity_type!=""}, "node", "$1", "node_name", "(.*)")

Тут в and on(node) ми використовуємо лейблу node в результатах запиту зліва (sum by()) і в результаті справа, щоб зі списку нод в karpenter_nodes_allocatable{capacity_type!=""} (тобто всі ноди, окрім нашої “дефолтної”) вибрати тільки ті, які є в результатх першого запиту:

EBS use % by Node

Тут вже простіше:

sum(kubelet_volume_stats_used_bytes{instance=~"$node_name", namespace=~"$namespace"}) by (instance) 
/ 
sum(kubelet_volume_stats_capacity_bytes{instance=~"$node_name", namespace=~"$namespace"}) by (instance) 
* 100

Nodes created/terminated by Karpenter

Для відображення активності автоскейлінгу додамо графік з двома запитами:

increase(karpenter_nodes_created[1h])

Та:

- increase(karpenter_nodes_terminated[1h])

Тут в функції increase() перевіряємо наскільки змінилося значення за годину:

А щоб позбутися цих “сходинок” – можемо додатково загорнути результат у функцію avg_over_time():

Grafana dashboard: фінальний результат

І все разом у нас тепер виглядає так:

Додавання Data links

Останім кроком буде додавання Data Links на графіки: потрібно додати лінку на іншу дашборду, по конкретній ноді.

Ця борда має такий URL: https://monitoring.ops.example.co/d/kube-node-overview/kubernetes-node-overview?var-node_name=ip-10-0-41-2.ec2.internal

Де в var-node_name=ip-10-0-41-2.ec2.internal задається ім’я ноди, по якій треба вивести дані:

Тож відкриваємо графік, знаходимо Data links:

Задаємо ім’я та URL – список всіх полів можна отримати по Ctrl+Space:

__field візьме дані з labels.node з результату запиту в панелі:

І сформує посилання у вигляді “https://monitoring.ops.example.co/d/kube-node-overview/kubernetes-node-overview?var-node_name=ip-10-0-38-110.ec2.internal“.

Ну і на цьому начебто все.