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

1 Лютого 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“.

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

Loading

AWS: EKS Pod Identities – заміна IRSA? Спрощуємо менеджмент IAM доступів
5 (1)

15 Грудня 2023

Ще з дуже цікавих новинок останнього re:Invent – це EKS Pod Identities: нова можливість керувати доступами подів до ресурсів AWS.

The current state: IAM Roles for Service Accounts

До цього ми використовували модель IAM Roles for Service Accounts, IRSA, де для того, щоб якомусь поду дати доступ до, наприклад, S3, ми створювали IAM Role з відповідною IAM Policy, налаштовували її Trust Policy – щоб дозволити виконувати AssumeRole тільки з відповідвідного кластеру, потім створювали Kubernetes ServiceAccount, в annotations якого вказували ARN цієї ролі.

За такою схемою ми мали декілька “error prone” моментів:

  • найбільш розповсюджена проблема, з якою і я стикався прям ну дуже багато раз – помилки в Trust Policy, де треба було вказувати OIDC кластеру
  • помилки в самому ServiceAccount, де можна було помилитись в ARN ролі

Див. AWS: EKS, OpenID Connect та ServiceAccounts.

The f(ea)uture state: EKS Pod Identities

Проте тепер EKS Pod Identities дозволяє нам один раз створити IAM Role, ніяк її не обмежувати конкретним кластером, і підключати цю роль до подів (знову-таки – через ServiceAccount) прямо з AWS CLI, AWS Console чи через AWS API (Terraform, CDK, etc).

Як це виглядає:

  • в EKS додаємо новий контроллер – Amazon EKS Pod Identity Agent add-on
  • створюємо IAM Role, в Trust Policy якої тепер використовуємо Principal: pods.eks.amazonaws.com
  • і з AWS CLI, AWS Console чи через AWS API підключаємо цю роль напряму до потрібного ServiceAccount

Го тестити!

Створення IAM Role

Переходимо в IAM, створюємо роль.

В Trusted entity type вибираємо EKS і новий тип – EKS – Pod Identity:

В Permissions візьмемо вже існуючу політику на S3ReadOnly:

Задаємо ім’я ролі, і як раз тут і бачимо нову Trust Policy:

І давайте порівняємо її з Trust Policy для IRSA ролі:

Набагато простіше, а значить – менше варіантів для помилок, і взагалі простіше менеджити. До того ж, ми більше не зав’язані на Cluster OIDC Provider.

До речі, з EKS Pod Identities ми можемо використовувати і role session tags.

Окей, йдемо далі.

Amazon EKS Pod Identity Agent add-on

Переходимо до нашого кластеру, встановлюємо новий компонент – Amazon EKS Pod Identity Agent add-on:

Чекаємо хвилину – готово:

І поди цього контролера:

$ kk -n kube-system get pod | grep pod
eks-pod-identity-agent-d7448                    1/1     Running   0               91s
eks-pod-identity-agent-m46px                    1/1     Running   0               91s
eks-pod-identity-agent-nd2xn                    1/1     Running   0               91s

Підключення IAM Role до ServiceAccount

Переходимо в Access, і клікаємо Create Pod Identity Association:

Вибираємо роль, яку створили вище.

Далі задаємо ім’я неймспейсу – або вибираємо зі списку існуючих, або вказуємо нове.

Аналогічно з ім’ям ServiceAccount – можна задати вже створенний SA, можно задати нове ім’я:

Створення Pod та ServiceAccount

Описуємо маніфест:

apiVersion: v1
kind: Namespace
metadata:
  name: ops-iam-test-ns
---
apiVersion: v1
kind: ServiceAccount
metadata:
  name: ops-iam-test-sa
  namespace: ops-iam-test-ns
---
apiVersion: v1
kind: Pod
metadata:
  name: ops-iam-test-pod
  namespace: ops-iam-test-ns
spec:
  containers:
    - name: aws-cli
      image: amazon/aws-cli:latest
      command: ['sleep', '36000']
  restartPolicy: Never
  serviceAccountName: ops-iam-test-sa

Деплоїмо:

$ kubectl apply -f iam-sa.yaml    
namespace/ops-iam-test-ns created
serviceaccount/ops-iam-test-sa created
pod/ops-iam-test-pod created

І пробуємо доступ:

$ kk -n ops-iam-test-ns exec -ti ops-iam-test-pod -- bash
bash-4.2# aws s3 ls
2023-02-01 11:29:34 amplify-staging-112927-deployment
2023-02-02 15:40:56 amplify-dev-174045-deployment
...

Але якщо ми спробуємо іншу операцію, на яку ми не підключали політику, наприклад – EKS, то отримаємо 403:

bash-4.2# aws eks list-clusters

An error occurred (AccessDeniedException) when calling the ListClusters operation: User: arn:aws:sts::492***148:assumed-role/EKS-Pod-Identities-test-TO-DEL/eks-atlas-eks--ops-iam-te-cc662c4d-6c87-44b0-99ab-58c1dd6aa60f is not authorized to perform: eks:ListClusters on resource: arn:aws:eks:us-east-1:492***148:cluster/*

Проблеми?

Наразі я бачу одну потенційну не проблему, але питання, яке варто мати на увазі: якщо раніьше ми налаштовували доступ на рівні сервісу, то з EKS Pod Identities це робиться на рівні управління кластером.

Тобо: в мене є сервіс, Backend API. В нього є власний репозиторій, в якому є каталог terrafrom, в якому створюються необхідні IAM-ролі.

Далі, є каталог helm, в якому маємо маніфест з ServiceAccount, в якому в анотаціях через змінні передається ARN цієї IAM ролі.

І на цьому все – мені (точніше – CI/CD пайплайну, який виконує деплой) потрібен доступ тільки до IAM, потрібен доступ в EKS на створення Ingress, Deployment та ServiceAccount.

Але тепер треба буде думати як давати доступ ще й до EKS на рівні AWS, бо треба буде виконувати додаткову операцію в AWS API на Create Pod Identity Assosiaction.

До речі, в Terraform вже новий ресурс для цього – aws_eks_pod_identity_association.

Проте виглядає дійсно класно, і може дуже спростити життя по менеджменту EKS та IAM.

EKS Pod Identity restrictions

Варто звернути увагу на документацію, бо в EKS Pod Identity restrictions говориться, що EKS Pod Identities доступна тількина Amazon Linux:

EKS Pod Identities are available on the following:

Loading

AWS: CloudWatch – Multi source query: збираємо метрики із зовнішнього Prometheus
0 (0)

13 Грудня 2023

Ще один цікавий анонс з останнього re:Invent – це те, що в CloudWatch додали можливість збирати метрики із зовнішніх ресурсів (див. дуже цікавий доклад AWS re:Invent 2023 – Cloud operations for today, tomorrow, and beyond (COP227)).

Тобто тепер ми можемо створювати графіки та/або алерти не тільки з дефолтних метрик самого CloudWatch – але й за допомогою конекторів для CloudWatch підключити збір метрик з Amazon Managed Service for Prometheus, звичайного Prometheus, Amazon OpenSearch Service, Amazon RDS для MySQL та PostgreSQL, CSV файлів з S3 бакетів і навіть з Microsoft Azure Monitor.

Виглядає це прям дуже круто, бо тепер можна буду переосмислили взагалі всю свою концепцію побудови моніторингу и observability на проекті.

Підключення метрик з vanilla Prometheus

Є Prometheus на голому EC2 інстансі, на якому відкрито порт 9090 – спочатку спробуємо тут, потім глянемо на VictoriaMetrcis в EKS.

Переходимо в CloudWatch Metrics, тепер маємо нову вкладку Multi source query:

Вибираємо Prometheus:

Задаємо параметри.

Поля логін-пароль обов’язкові, тож навіть якщо Prometheues не потребує аутентифікації – задаємо тут якісь значення.

Нижче можна налаштувати параметри мережі. Наприклад, якщо Prometheus доступний тільки всередені VPC – то тут можемо вибрати VPC і сабнети:

Клілкаємо Create data source – в CloudWatch запустить створення CloudFormation стеку, в якому створить Lambda-функції, які власне і будуть збирати дані з нашого дата-сорсу:

Повертаємось до CloudWatch, де тепер маємо новий дата-сорс:

І можемо з нього отримати метрики:

VictoriaMetrics, EKS та VPC

Зараз маємо VictoriaMetrics в Kubernetes, див. VictoriaMetrics: створення Kubernetes monitoring stack з власним Helm-чартом.

Щоб CloudWatch міг збирати метрики – нам треба відкрити доступ до VMSingle.

Тут маємо два варіанти – або звичайний Ingress/ALB, див. values.yaml, або через VMAuth з аутентифікацією, див. VictoriaMetrics: VMAuth – проксі, аутентифиікація та авторизація.

І при додаванні data source в CloudWatch єдина відмінність від звичайного Prometheus – це URI:

  • у Prometheues ми ходимо на URI hostname:9090/api/v1/labels
  • у VicrotiaMetrcis з VMSingle – через hostname:8429/prometheus/api/v1/labels

Тож в data source додаємо як https://vmsingle.ops.example.co/prometheus/.

Редагування і видалення data source

Не побачив, де і як можна змінити якісь параметри або видалити дата-сорс з панелі самого CloudWatch.

Схоже що поки що видаляється дата-сорс тільки через CloudFormation – видалення стеку, а змінити якісь параметри можна тільки в самій Lambda-функції.

Наприклад, щоб відредагувати Prometheus URL – він задається в змінних оточення функції:

Але anyway – виглядає це все дуже прикольно.

Loading

AWS: Amazon Q – знайомство, можливості та перші враження
0 (0)

8 Грудня 2023

В цікаві часи живемо.

Отже, поговоримо про дуже гучний запуск Amazon Q – нової системи від AWS, яка має допомогти нам, інженерам і не тільки, в роботі.

Сам Amazon його називає “AI-powered assistant”, по факту для нас, як інженерів, це просто chatbot, з яким ми можемо поговорити, щоб отримати допомогу у розв’язанні якихось проблем або для отримання рекомендації для налаштування сервісу. Для бізнесу ж він може мабуть багато іншого, але нам він цікавий, як асистент по роботі з AWS.

Під капотом Amazon Q використовує Amazon Bedrock, тож щоб краще розуміти що таке Q – давайте глянемо і на Bedrock.

Amazon Bedrock

Bedrock запустили у квітні 2023, але чомусь він не привернув до себе такої уваги, хоча сервіс дуже цікавий, і я постараюся написати про нього окремо.

Отже, AWS Bedrock – це managed сервіс від AWS, який дозволяє будувати ваші АІ-powered сервіси, використовуючи Foundation Models (FM) від Amazon, Мета, Amazon, Stability AI, та інших:

Див. What is a Foundation Model?

Для роботи з FL Bedrock надає єдиний API, і вам не потрібно будувати ніякої інфраструктури для запуску моделей.

Крім того, ви можете розширяти базу знань Bedrock за рахунок власних баз знань (“Knowledge base“). При цьому ваші дані не будуть об’єднані з самим FM, тобто повністю зберігається всяка privacy (включаючи підтримку стандартів GDPR, HIPAA).

По Bedrock варто глянути доповідь AWS re:Invent 2023 – Build your first generative AI application with Amazon Bedrock (AIM218) – там і про сам сервіс, і його архітектуру, і демо, і взагалі трохи розглядаються базові поняття – Machine Learning, Deep Learning, Generative AI.

Amazon Q

Тож Amazon Q – це система поверх Bedrock (я поки не знайшов інформації, яка саме модель використовується, але, мабуть, Titan – бо це система від самого Amazon).

По факту це чат-бот, з яким ми можемо поговорити, і який вже нам доступний в AWS Console справа, де ми звикли бачити розділ допомоги:

Хоча місцями Q не може відповісти навіть на прості питання 🙂

При цьому і сам Q, і Bedrock заточені під приватність даних, тому начебто можна спокійно (нєт) підключати їх до корпоративних даних – наприклад, до Git-репозиторію, або Atlassian Confluence, і тоді Q при формуванні відповіді буде використовувати дані з цих джерел для формування відповідей.

Чому “нєт?” Бо поки система в Preview, і про неї вже пишуть, що:

Knowledge baseQ is “experiencing severe hallucinations and leaking confidential data,”

Див. Amazon’s Q has ‘severe hallucinations’ and leaks confidential data in public preview, employees warn.

Проте я думаю, що це “дитячі хвороби”, і з часом Amazon все пофіксить.

Тож якщо стисло, то Q нам може допомогти у:

  • налаштуванні нової системи, або виборі архітектури
  • вирішенні проблем з сервісами
  • працювати з нашим кодом, написанні тестів

Amazon Q доступний у:

  • AWS Console
  • в його документації
  • в різних IDE (далі подивимось на VSCode)
  • Slack (через AWS Chatbot або Slack gateway)
  • в сервісах Amazon – наприклад, CodeWhisperer (аналог GitHub Copilot?)

Amazon Q vs ChatGPT

Мабуть, чи найперше питання, яке приходить в голову.

Знову-таки, чисто моє IMHO:

  • Amazon Q – це про бізнес: якщо ChatGPT це так би мовити “AI-чатбот загального призначення”, то Q може тісно інтегруватися з вашим бізнесом – вашими даними, користувачами тощо
  • Amazon Q – це про безпеку даних: нам обіцяють дуже потужні інструменти по обмеженню доступів: наприклад, якщо до Q буде підключена Confluence, то при формуванні відповіді на запит юзера будуть перевірятись його права в Confluence, і не будуть використані дані, до яких він там не має доступу (цікаво, як це реалізовано і як воно буде працювати, але поки просто маємо на увазі)
  • Amazon Q – це про інтеграцію:
    • модель, яка використовується для відповідей навчалася на даних самого AWS, яких за 17 років існування набралось багато – в тому числі мабуть і якісь дані, котрих зазвичай нема у відкритому доступі, тому по ідеї – Q в деяких моментах може давати більш точні відповіді (знову-таки – може не відразу, поки воно ще в Preview)
    • Q інтегрований з самими сервісами AWS, і ви можете використовувати його прямо в QuickSighe, або, як вже казалось, в самій AWS Console

(оці три пункта звучать так, наче Amazon мені заплатили за цей пост :-D)

Amazon Q pricing

Як завжди у AWS – “всьо сложно” 🙂

По-перше, поки система в Preview – більша частина можливостей безкоштовні.

По-друге – доступні будуть два різних плана: Business і Builder.

Якщо коротко, то Business – це більше про якісь маркетингові штуки на кшталт чат-бота для допомоги співробітникам у вирішення якихось питань, або такий собі “internal Google” – але з доступом до внутрішніх баз даних.

А от Builder, судячи з документації – це як раз те, що буде цікаво нам, як інженерам, бо саме в цьому плані буде доступ до “over 17 years’ worth of AWS knowledge and experience building in the cloud, including best practices, well-architected patterns“.

Див. Amazon Q pricing.

Amazon Q in AWS Console

Спробуємо потраблшутити з Amazon Q – створимо EC2, зламаємо нетворкінг, і спитаємо Q.

Створюємо інстанс, дозволяємо SSH:

Перевіряємо – доступ є:

$ ssh 16.170.217.131
The authenticity of host '16.170.217.131 (16.170.217.131)' can't be established.
...

Далі, редагуємо SecurityGroup – видаляємо доступ SSH:

І питаємо Amazon Q:

На що він відповідає, що може спробувати проаналізувати проблему з AWS VPC Reachability Analyzer. Спробуємо – переходимо за посиланням, і:

Ну, я все ж очікував, що Q прям зможе проаналузвати конфіг нетворкінгу EC2, і побачить, що там проблема в самій SecurityGroup. Але це я багато, мабуть, хочу)

Втім вже те, що він так інтегрується з системами типу VPC Reachability Analyzer – непогано, і далі, сподіваюсь, буде ще краще.

Amazon Q та інтеграція з VSCode

Документація – Set up Amazon Q in your IDE.

Встановлюємо AWS Toolkit:

Проходимо аутентифікацію – клікаємо Use free with AWS Builder ID:

Відкриється вікно в браузері, там підтверджуємо код, і вказуємо свою пошту – можна будь-яку, не обов’язкового ту, яка використовується в AWS акаунті:

На пошту буде відправлено листа з кодом – підтверджуємо, і задаємо собі пароль:

Дозволяємо доступ:

І тепер маємо підключений Amazon Q в нашому VSCode:

Задаємо питання – і він просканує відкритий у редакторі код, і на основі нього видасть відповідь:

Але питання розуміє не завжди коректно: в цілому він має підтримувати контекст розмови і враховувати відкритий код, але ось маємо відкритий код Terraform і пов’язане питання перед цим, а він дає відповідь про AWS Console:

Але якщо трохи перебудувати питання – то він повернув більш валідну відповідь:

На доповіді на самому re:Invent показували демо роботи з IDE, і там був приклад, як Q допомагає створювати код для AWS CDK та Python, і там виглядає прям дуже круто, бо я влітку цього року намучився з ChatGPT, який постійно видавав приклади для старої версії CDK або старих версій бібліотек (див. AWS: CDK – створення EKS з Python та загальні враження від CDK).

Amazon Q Application

Ми також можемо створити Application в AWS Console, і там використати власні дата-сорси, на основі яких Q буде формувати відповіді.

Плюс, там жеж можемо створити і веб-інтерфейс для юзерів:

Створюємо:

Далі налаштовується Retriever – яким чином Q буде отримувати дані з дата-сорса:

І останнім налаштовується вже сам дата-сорс через вибір конекторів – а їх тут дуже багато:

Візьмемо Web crawler:

Тут вже налаштовується і аутентифікація – і саме тут вочевидь будуть перевірятись user permissions – те, про що я вписав на початку, що Amazon Q не дасть інформації юзеру, якщо він не має до неї доступу в самому дата-сорсі:

І решта налаштувань – там і VPC, і IAM, параметри синхронізації тощо:

Робимо синхронізацію з нашим дата-сорсом:

І далі вже можемо відкрити веб-інтерфейс, і початитись з ботом:

А якщо клікнути Deploy web experience – то треба буде налаштовувати SAML:

І воно наче і має працювати без деплою, в Web preview режимі, але в мене воно просто зависало на запиті:

Можливо тому, що не закінчена синхронізація з дата-сорсом, але процес синхронізації з RTFM затягнувся на кілька годин, хоча тут всього-то близько 1500 постів, плюс всякі медіафайли типу скріншотів. Мабуть, вже не дочекаюсь її завершення, але якщо після закінчення синхронізації запрацює – то цю частину тут оновлю.

Висновки та враження від Amazon Q

Я не сказав би, що сервіс прям “producation ready” – але він дійсно ще в Preview, а тому багато чого буде допилюватись/фікситись/апгрейдитись, і тут все ще є над чим попрацювати, бо наразі вона виглядає трохи сирою – Amazon явно поспішали випустити її хоча б в Preview, бо вся ця “гонка Generative AI” і вот ето вот всьо.

Але в цілому система виглядає доволі перспективною, особливо, як вона навчиться толком допомагати траблшутити якісь проблеми в AWS.

Ну і головне – її дійсно можна буде використовувати різними бізнесами, інтегруючи зі своїми системами та базами даних, і не переживати за всякий privacy complience – а з ChartGPT це зараз дуже велика проблема.

Корисні посилання

Loading

Kubernetes: забезпечення High Availability для Pods
0 (0)

20 Листопада 2023

Що маємо: є у нас Kubernetes cluster, на якому скейлінгом WorkerNodes займається Karpenter, який для NodePool має  параметр disruption.consolidationPolicy=WhenUnderutilized, тобто він буде намагитись “ущільніти” розміщеня подів на нодах так, щоб максимально ефективно використати ресурси CPU та Memory.

В цілому все працює, але це призводить до того, що досить часто перестворюються WorkerNodes, а це викликає “переселення” наших Pods на інші ноди.

Тож задача зараз зробити так, щоб скейлінг і процес consolidation не викликав перебоїв в роботі наших сервісів.

Загалом це тема не стільки про сам Karpenter, скільки про забезпечення стабільності роботи подів в Kubernetes загалом, але зараз я детально зайнявся цим питанням саме через Karpenter, тому будемо трохи говорити і про нього.

Karpenter Disruption Flow

Щоб краще розуміти, що відбувається з нашими подами, давайте коротко глянемо як Karpenter виводить з пулу WorkerNode. Див. Termination Controller.

Після того, як Karpenter виявив, що є ноди, які треба термінейтити, він:

  1. ставить на ноду Kubernetes finalizer
  2. ставить на такую ноду taint karpenter.sh/disruption:NoSchedule щоб Kubernetes не створював нових подів на цій ноді
  3. при необхідності створює нову ноду, на яку буде переносити поди з ноди, яка буде виведена з роботи (або викорстає ноду яка вже є, якщо вона може прийняти додаткові поди відповідно до їх requests)
  4. виконує Pod Eviction подів з ноди (див. Safely Drain a Node та API-initiated Eviction)
  5. після того, як з ноди всі поди окрім DaemonSets видалені, Karpenter видаляє відповідний NodeClaim
  6. видаляє finalizer ноди, що дозволяє Kubernetes виконати видалення цієї ноди

Kubernetes Pod Eviction Flow

І коротко процес того, як сам Kubernetes виконує “виселення” поду:

  1. API Server отримує Eviction request і виконує перевірку – чи можна цей под виселити (наприклад – чи не порушить його видалення обмежень якогось PodDisruptionBudget)
  2. відмічає ресурс цього поду на видалення
  3. kubelet починає процес gracefully shut down – тобто відправляє сигнал SIGTERM
  4. Kubernetes видаляє IP цього поду зі списку ендпоінтів
  5. якщо под не закінчив роботи на протязі заданого – то kubelet відправляє сигнал SIGKILL, щоб вбити процес негайно
  6. kubelet відправляє сигнал API Server, що под можна видаляти зі списку об’єктів
  7. API Server видаляє под з бази

Див. How API-initiated eviction works та Pod Lifecycle – Termination of Pods.

Kubernetes Pod High Availability Options

Тож що ми можемо зробити з подами, щоб наш сервіс працював незалежно від роботи Karpenter і взагалі стабільно і “бєз єдіного разрива” (с) ?

  • мати мінімум по 2 поди на критичних сервісах
  • мати Pod Topology Spread Constraints, щоб Pods розміщались на різних WorkerNodes – тоді якщо вбивається одна нода з одним подом – інший под на іншій ноді залишиться живим
  • мати PodDisruptionBudget, щоб мінімум 1 под був завжди живий – це не дасть Karpenter виконати evict всіх подів відразу, бо він слідкує за додтриманням PDB
  • і щоб гарантовано не дати виконати Pod Eviction – можемо задати поду анотацію karpenter.sh/do-not-disrupt – тоді Karpenter буде ігнорувати таки поди (і, відповідно, ноди, на яких буде запущено такий под)

Kubernetes Deployment replicas

Саме просте і очевидне рішення – це мати як мінімум 2 одночасно працюючих поди.

Хоча це не гарантує, що Kubernetes не виконає їхній eviction одночасно, але це мінімальна умова для подальших дій.

Тож або виконуємо руками kubectl scale deployment --replicas=2, або оновлюємо поле replicas в Deployment/StatefulSets/ReplicaSet (див. Workload Resources):

apiVersion: apps/v1
kind: Deployment
metadata:
  name: nginx-demo-deployment
spec:
  replicas: 2
  selector:
    matchLabels:
      app: nginx-demo
  template:
    metadata:
      labels:
        app: nginx-demo
    spec:
      containers:
        - name: nginx-demo-container
          image: nginx:latest
          ports:
            - containerPort: 80

Pod Topology Spread Constraints

Більш детально описував у Pod Topology Spread Constraints, але якщо коротко, то ми можемо задати правила розміщення Kubernetes Pod так, щоб вони знаходились на різних WorkerNodes. Таким чином коли Karpenter захоче вивести одну ноду з роботи – то у нас залишиться под на іншій ноді.

Проте ніхто не завадить Karpenter-у виконати drain обох нод відразу, тож і це не є 100% гарантією, але це друга умова для забезпечення стабільності роботи нашого сервісу.

Крім того, з Pod Topology Spread Constraints, ми можемо задати розміщення подів у різних Availabilty Zones, що є фактичного must have опцією при побудові High-Availabiltiy архітектури.

Тож додаємо до нашого деплойменту topologySpreadConstraints:

apiVersion: apps/v1
kind: Deployment
metadata:
  name: nginx-demo-deployment
spec:
  replicas: 2
  selector:
    matchLabels:
      app: nginx-demo
  template:
    metadata:
      labels:
        app: nginx-demo
    spec:
      containers:
        - name: nginx-demo-container
          image: nginx:latest
          ports:
            - containerPort: 80
      topologySpreadConstraints:
        - maxSkew: 1
          topologyKey: kubernetes.io/hostname
          whenUnsatisfiable: DoNotSchedule
          labelSelector:
            matchLabels:
              app: nginx-demo

І тепер обидва поди мають бути розміщені на різних WorkerNodes:

$ kk get pod -l app=nginx-demo -o json | jq '.items[].spec.nodeName'
"ip-10-1-54-144.ec2.internal"
"ip-10-1-45-7.ec2.internal"

Див. також Scaling Kubernetes with Karpenter: Advanced Scheduling with Pod Affinity and Volume Topology Awareness.

Kubernetes PodDisruptionBudget

За допомогою PodDisruptionBudget ми можемо задати правило на мінімальну кількість доступних або максимальну кількість недоступних подів. Значення може бути як у виді числа, так і у виді відсотка від загальної кількості подів в replicas для Deployment/StatefulSets/ReplicaSet.

У випадку з Deployment в якому маємо два поди і який має topologySpreadConstraints по різним WorkerNodes це дасть гарантію того, що Karpenter не виконає Node Drain двох WorkerNdoes одночасно. Натомість він “переселить” спочатку один под, вб’є його ноду, а потім повторить процес для іншої ноди.

Див. Specifying a Disruption Budget for your Application.

Створимо PDB для нашого деплойменту:

apiVersion: apps/v1
kind: Deployment
metadata:
  name: nginx-demo-deployment
spec:
  replicas: 2
  selector:
    matchLabels:
      app: nginx-demo
  template:
    metadata:
      labels:
        app: nginx-demo
    spec:
      containers:
        - name: nginx-demo-container
          image: nginx:latest
          ports:
            - containerPort: 80
      topologySpreadConstraints:
        - maxSkew: 1
          topologyKey: kubernetes.io/hostname
          whenUnsatisfiable: DoNotSchedule
          labelSelector:
            matchLabels:
              app: nginx-demo
---
apiVersion: policy/v1
kind: PodDisruptionBudget
metadata:
  name: nginx-demo-pdb
spec:
  minAvailable: 50%
  selector:
    matchLabels:
      app: nginx-demo

Деплоїмо і перевіряємо:

$ kk get pdb
NAME             MIN AVAILABLE   MAX UNAVAILABLE   ALLOWED DISRUPTIONS   AGE
nginx-demo-pdb   50%             N/A               1                     21s

Анотація karpenter.sh/do-not-disrupt

Окрім налаштувань на стороні Kubernetes, ми можемо явно задати заборону на видаленя поду самому Karpenter через додавання анотації karpenter.sh/do-not-disrupt (раніше, до Beta, це були анотації karpenter.sh/do-not-evict та karpenter.sh/do-not-consolidate).

Це може знадобитись наприклад для подів, які мають бути запущені в одному екземплярі (як VictoriaMetircs VMSingle instance), і які небажано зупиняти.

Для цього в template цього поду додаємо annotation:

apiVersion: apps/v1
kind: Deployment
metadata:
  name: nginx-demo-deployment
spec:
  replicas: 1
  selector:
    matchLabels:
      app: nginx-demo
  template:
    metadata:
      labels:
        app: nginx-demo
      annotations:
        karpenter.sh/do-not-disrupt: "true"        
    spec:
      containers:
        - name: nginx-demo-container
          image: nginx:latest
          ports:
            - containerPort: 80
      topologySpreadConstraints:
        - maxSkew: 1
          topologyKey: kubernetes.io/hostname
          whenUnsatisfiable: DoNotSchedule
          labelSelector:
            matchLabels:
              app: nginx-demo

Див. Pod-Level Controls.

В цілому, це начебто всі основні рішення, які допоможуть забезпечити безперервну роботу подів.

Loading

Grafana Loki: збираємо логи AWS LoadBalancer з S3 за допомогою Promtail Lambda
0 (0)

15 Листопада 2023

Зараз ми вміємо збирати логи API Gateway та CloudWatch Logs, див. Loki: збір логів з CloudWatch Logs з використанням Lambda Promtail.

Але в процесі міграції в Kubernetes у нас з’явились Application Load Balancers, які вміють писати логи тільки в S3, і нам треба навчитись збирати логи і звідти.

Формат логу AWS ALB див. у Access logs for your Application Load Balancer, налаштування логування в S3 – в Enable access logs for your Application Load Balancer.

Технічна реалізація

Начебто і нічого складного, але по-перше є деякі нюанси, особливо з IAM та VPC, по-друге – я ніде не знаходив такої документації, тож довелося писати її самому.

В принципі, тут все майже однаково зі збором логів з CloudWatch Logs:

  • використовуємо Promtail Lambda
  • на S3 бакеті налаштовуємо тригер на відправку івента в Lambda, коли з’явлється або оновлюється об’єкт в корзині
  • Promtail сходить в корзину, і забере звідти лог

Схематично можна відобразити так:

Тепер давайте все зробимо руками, а потім вже будемо думати як його прикрутити до автоматизації з Terraform.

Створення S3 для логів

Для S3 бакету нам потрібна політика, яка буде дозволяти:

  • писати в корзину логи з Application Load Balancer
  • забирати з корзини логи в Lambda

Для цього нам будуть потрібні:

  • ELB Account ID для Load Balancer – див. Step 2: Attach a policy to your S3 bucket
    • в нашому випадку AWS Region == us-east-1, тож elb-account-id буде 127311923021
  • IAM Role ARN – ролі, яка підключена до нашої Lambda-фунції з Promtail

Створюємо файл s3-alb-logs-policy.json з двома Allow:

{
    "Version": "2012-10-17",
    "Statement": [
        {
            "Effect": "Allow",
            "Principal": {
                "AWS": "arn:aws:iam::127311923021:root"
            },
            "Action": "s3:PutObject",
            "Resource": "arn:aws:s3:::eks-alb-logs-test/AWSLogs/492***148/*"
        },
        {
            "Effect": "Allow",
            "Principal": {
                "AWS": "arn:aws:iam::492***148:role/grafana-atlas-monitoring-dev-1-28-loki-logger-backend"
            },
            "Action": "s3:GetObject",
            "Resource": "arn:aws:s3:::eks-alb-logs-test/*"
        }
    ]
}

Створюємо корзину:

$ aws --profile work s3api create-bucket --bucket eks-alb-logs-test --region us-east-1

І підключаємо політику:

$ aws --profile work s3api put-bucket-policy --bucket eks-alb-logs-test --policy file://s3-alb-logs-policy.json

Далі треба налаштувати відправку повідомлень до нашої Lambda.

S3 Event notifications

Переходимо в Properties > Event notifications:

Задаємо ім’я та префікс – каталог AWSLogs і в ньому каталог з ім’ям аккаунту:

Нижче вибираємо типи подій – всі ObjectCreated:

І задаємо Destination – ARN функції, до якої будемо слати повідомлення:

Promtail Lambda permissions

Перевіримо дозволи в Lambda. Це знадобиться, коли будемо робити автоматизацію, та й для дебагу.

Переходимо в Configuration > Permissions:

І внизу, в Resource-based policy statements, маємо побачити нові дозволи – додалось автоматом, коли ми створили Event notification в S3:

Створення Ingress/ALB з логуванням в S3

Створюємо Kubernetes Ingress, і в annotations alb.ingress.kubernetes.io/load-balancer-attributes включаємо йому логуваня в тестову корзину:

apiVersion: apps/v1
kind: Deployment
metadata:
  name: nginx-demo-deployment
spec:
  replicas: 1
  selector:
    matchLabels:
      app: nginx-demo
  template:
    metadata:
      labels:
        app: nginx-demo
    spec:
      containers:
        - name: nginx-demo-container
          image: nginx:latest
          ports:
            - containerPort: 80

---
apiVersion: v1
kind: Service
metadata:
  name: nginx-demo-service
spec:
  selector:
    app: nginx-demo
  ports:
    - protocol: TCP
      port: 80
      targetPort: 80
  type: ClusterIP

---
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
  name: example-ingress
  annotations:
    alb.ingress.kubernetes.io/scheme: internet-facing
    alb.ingress.kubernetes.io/target-type: ip
    alb.ingress.kubernetes.io/listen-ports: '[{"HTTP": 80}]'
    alb.ingress.kubernetes.io/load-balancer-attributes: access_logs.s3.enabled=true,access_logs.s3.bucket=eks-alb-logs-test
spec:
  ingressClassName: alb
  rules:
    - host: example.com
      http:
        paths:
          - path: /
            pathType: Prefix
            backend:
              service:
                name: nginx-demo-service
                port:
                  number: 80

Створюємо ресурси:

$ kk apply -f ingress-svc-deploy.yaml 
deployment.apps/nginx-demo-deployment created
service/nginx-demo-service created
ingress.networking.k8s.io/example-ingress created

Перевіряємо:

$ kk get ingress
NAME              CLASS   HOSTS         ADDRESS                                                                  PORTS   AGE
example-ingress   alb     example.com   k8s-default-examplei-b48cab7a95-1519459738.us-east-1.elb.amazonaws.com   80      22s

Перевіряємо файли в корзині:

$ aws s3 ls s3://eks-alb-logs-test/AWSLogs/492***148/
2023-11-15 14:51:43        106 ELBAccessLogTestFile

Робимо кілька запитів до АЛБ:

$ curl k8s-default-examplei-b48cab7a95-1519459738.us-east-1.elb.amazonaws.com

І за 2-3 хвилини – маємо новий файл в корзині:

$ aws s3 ls s3://eks-alb-logs-test/AWSLogs/492***148/elasticloadbalancing/us-east-1/2023/11/15/
...
2023-11-15 16:15:11        344 492***148_elasticloadbalancing_us-east-1_app.k8s-default-examplei-b48cab7a95.bc6b35b432aa3492_20231115T1415Z_54.211.225.139_2ir4tbe3.log.gz

І ще за хвилину маємо івент в Lambda:

Та її логи – можна пошукати по імені файла, bc6b35b432aa3492_20231115T1415Z_54.211.225.139_2ir4tbe3:

І логи в Loki – тут можна пошукати по X-Ray ID, який додається до всіх запитів до ALB в хедері X-Amzn-Trace-Id:

Помилка Lambda: Task timed out, та VPC Endpoints

Поки налаштовува це, зіткнувся з помилкою “Task timed out“.

В логах це виглядало так:

msg=”fetching s3 file: AWSLogs/492***148/elasticloadbalancing/us-east-1/2023/11/15/492***148_elasticloadbalancing_us-east-1_app.k8s-default-examplei-b48cab7a95.21d12877724a6c9f_20231115T1205Z_52.44.168.196_jin2v33x.log.gz”

Task timed out after 3.00 seconds

Спочатку подумав, що Promtail за 3 секунди не встигає отримати лог з S3, і збільшив таймаут функції:

Але виявилось, що причина в іншому: Lambda Promtail запущена в VPC, і до S3 ходить через VPC Ednpoint (див. Terraform: створення EKS, частина 1 – VPC, Subnets та Endpoints), але в SecurityGtoup, яка підключена до цієї функції, Outbound доступ був дозволений тільки до адрес в приватних сабнетах цієї VPC, бо тоді налаштовувалась тільки передача логів в Grafana Loki:

А нам потрібно надати доступ до VPC енпоінту S3, і якби це був тип Interface Ednpoint – то в SecurityGroup лямбди можна було б задати SecurityGroup ID цього Interface Endpoint-у:

Але у Gateway такої опції нема.

Проте ми можемо використати Prefix List ID – знаходимо його:

І додаємо в Outbound Rules:

Готово.

Loading

Kubernetes: Liveness та Readiness Probes – Best practices
0 (0)

13 Листопада 2023

Кілька корисних порад по використанню Liveness та Readiness Probes в Kubernetes – різниця між ними, та як правильно налаштовувати ці перевірки.

Якщо зовсім коротко, то:

  • livenessProbe: використовується Kubernetes, щоб знати, коли потрібно виконати restart поду
  • readinessProbe: використовується Kubernetes, щоб знати, коли контейнер готовий приймати трафік, тобто – коли відповідний Kubernetes Service може додавати цей под до своїх роутів
  • startupProbe: використовується Kubernetes, щоб знати, коли контейнер запустився і готовий до виконання перевірок з livenessProbe та readinessProbe
    • livenessProbe та readinessProbe почнуть виконуватись тільки після успішної перевірки startupProbe

Отже, livenessProbe використовується для визначення чи живий процес в поді, тоді як readinessProbe – чи готовий сервіс в поді приймати трафік, а startupProbe – коли починати виконувати livenessProbe та readinessProbe.

Основа цього посту три матеріали, які я колись зберіг, і досить користуюсь:

І в кцінці буде ще кілька посилань.

livenessProbe

livenessProbe потрібна коли, наприклад, процесс застряг в deadlock і не може виконувати свої задачі. Інший приклад – якщо процес увійшов в infinite loop і використовує 100% CPU, при цьому не маючи змоги обробляти запити від клієнтів, бо він все ще підключений до лоад-балансінгу в мережі Kubernetes.

У випадку, коли у вас є readinessProbe, але немає livenessProbe – то такий под буде відключений від трафіку, але залишиться в статусі Running, і буде продовжувати займати ресурси CPU/Memory.

Запити до livenessProbe виконуються процесом kubelet на тій самій WorkerNode, де працює конейтенер, і після рестарту под буде створено на тій самій WorkerNode

Процес в конейнері має зупинятись з кодом помилки 

livenessProbe не має бути інструментом реагування на помилки в роботі сервісу: натомість, процес має завершити роботу з кодом помилки, що призведе до зупинки контейнеру/поду, і створення нового.

livenessProbe використовується тільки для перевірки статусу самого процесу в контейнері.

Розділяйте Liveness та Readiness Probes

Розповсюджена практика використання одного ендпоінту для livenessProbe та readinessProbe, але для livenessProbe задавати вище значення failureThreshold, тобто – раніше відключати від трафіку і клієнтів, і якщо “діло зовсім погано” – то виконувати рестарт.

Але ці Probes мають різні призначення, а тому хоча й допустимо використовувати один ендпоінт, але краще мати різні перевірки. Крім того, якщо обидві перевірки будуть сфейлені, то Kubernetes виконає рестарт поду та його відключення від мережі одночасно, що може призвести до 502 помилок у клієнтів.

Уникайте залежностей

Поди не мають посилатись один на одного або на зовнішні сервіси під час виконання livenessProbe: ваш контейнер не має виконувати перевірки доступності серверу баз даних, бо якщо сервер БД впав, то рестарт вашого поду не допоможе вирішити цю проблему.

Замість цього, ви можете створити окремий ендпоінт для системи моніторингу, і виконувати такі перевірки там – задля алертів і дашбордів в Grafana.

Крім того, процес в контейнері не має падати, якщо не може отримати доступ до сервісу, від якого він залежить. Натомість, він має виконувати retry конекту, бо Kubernetes очікує, що поди можуть бути запущені в будь-якому порядку.

Корректна обробка SIGTERM

Процес в контейнері має коректно оброблювати сигнал SIGTERM – саме він відправляється від kubelet до контейнерів, коли їх треба перезапустити. Якщо на SIGTERM відповіді не було (бо процес “вісить”) – буде виконано SIGKILL.

Або процес може сприймати SIGTERM як SIGKILL, і зупинятись не закривши відкриті TCP-конекти – див. Kubernetes: NGINX/PHP-FPM graceful shutdown – избавляемся от 502 ошибок.

readinessProbe

readinessProbe потрібні, щоб не надсилати запити до подів, які ще запустились.

Наприклад, якщо процес старту вашого поду займає 2 хвилини (якийсь bootsrap процесу, особливо якщо це JVM, або завантаження якогось cache в пам’ять), а ви не маєте readinessProbe – то Kubernetes почне відправляти запроси, як тільки под перейде в статус Running, і вони будуть фейлитись.

Перевірка залежностей

На відміну від livenessProbe, в readinessProbe може бути сенс в перевірці доступності сервісів, від яких залежить под, бо у випадку коли сервіс не може виконати запит від клієнта, бо в нього немає конекта до бази даних – то пускати трафік на цей под не треба.

Втім пам’ятайте, що readinessProbe виконується постійно (кожні 15 секунд по дефолту), і на кожну таку перевірку буде виконуватись окремий запрос до бази даних.

Але в цілому це залежить від самого вашого застосунку. Наприклад, якщо ви можете віддавати відповіді з якогось локального кешу, то под може продовжувати роботу, а на виконання запитів з Write-операціями – повертати помилку 503.

startupProbe

Так як startupProbe виконується тільки на старті поду, то як раз тут можна виконати перевірки підключень до зовнішніх сервісів або доступу до кешу.

Наприклад, може бути корисним перевірити підключення до бази даних коли ви деплоїте новую версію Helm-чарту і маєте Kubernetes Deployment з Rolling Update, але в новій версії маєте помилку в URL або паролі до серверу бази даних.

Також startupProbe може бути корисна, щоб не збільшувати параметр initialDelaySeconds для livenessProbe та readinessProbe , а натомість вікласти їх запуск допоки не завершиться перевірка startupProbe, бо якщо livenessProbe при старті контейнера не встигне виконатись – то Kubernetes виконає рестарт поду, хоча він ще “прогрівається”.

Типи перевірок

В кожній Probe можемо використовувати перевірки по:

  • exec: виконати команду всередені контейнеру
  • httpGet: виконати запит HTTP GET
  • tcpSocket: відкрити TCP connect на порт
  • grpc: виконати запит gRPC на TCP порт

Параметри для Probes

Всі Probes мають параметри, якві дозволяють точно налаштувати час виконання перевірок:

  • initialDelaySeconds: затримка між стартом контейнеру та початком виконання перевірок
  • periodSeconds: як часто після initialDelaySeconds робити запити на перевірку стану
  • timeoutSeconds: як довго чекати відповіді на запит
  • failureThreshold: скільки має бути failed відповідей, щоб вважати перевірку не пройденою (або – скільки раз повторювати перевірку, перш ніж виконати рестарт поду або відключення від мережі)
  • successThreshold: аналогічно, але щоб вважати перевірку пройденою

Корисні посилання

Loading

VictoriaMetrics: Exporter в CronJob та пуш метрік без Pushgatway
0 (0)

7 Листопада 2023

В пості Prometheus: запуск Pushgateway у Kubernetes з Helm та Terraform писав про те, як для Prometheus додати Pushgateway, який дозволяє використовувати Push-модель замість Pull, тобто – експортер може відправити метрики прямо в базу замість того, щоб чекати, коли до нього прийде сам Prometheus або VMAgent.

У VictoriaMetrics з цим набагато простіше, бо ніякого Pushgateway не потрібно – VictoriaMetrics “з коробки” вміє приймати метрики, і це одна з тих чудовіих фіч, чому я вибрав VictoriaMetrics, і досі їй радуюсь.

Отже, що є і треба зробити:

  • в Kubernetes-кластері є VictoriaMetrics встановлена з VictoriaMetrics Operator – див. VictoriaMetrics: створення Kubernetes monitoring stack з власним Helm-чартом
  • у нас є новий експортер для Notion, який буде запускатись в Kubernetes по крону раз на добу
  • працювати він буде недовго, якісь секунди, тож “ходити” до нього з VMAgent ми не можемо
  • тому замість Pull-моделі з VMAgent – використаємо Push-модель – експортер буде відправляти метрики прямо в VictoriaMetrics

Тож спочатку давайте глянемо, як в VictoriaMetrics пушити метрики взагалі, а потім спробуємо написати простий експортер.

VictoriaMetrics API

Використовуємо ендпоінт /api/v1/import/prometheus.

Запускаємо тестовий под:

$ kubectl -n dev-monitoring-ns run pod --rm -i --tty --image ubuntu -- bash

Встановлюємо curl:

root@pod:/# apt update && apt -y install curl

Знаходимо Kubernetes Service для VMSingle:

$ kubectl -n dev-monitoring-ns get svc | grep vmsing
vmsingle-vm-k8s-stack                                  ClusterIP   172.20.45.38     <none>        8429/TCP                     35d

Виконуємо запит на ендпоінт http://vmsingle-vm-k8s-stack:8429/api/v1/import/prometheus:

root@pod:/# echo "some_metric 3.14" | curl --data-binary @- http://vmsingle-vm-k8s-stack:8429/api/v1/import/prometheus

І за півхвилини перевіряємо в VMui:

Все є, і ніяких тобі додаткових дій з Pushgateway!

Push метрик з експортера

Для тесту напишемо простий експортер:

#!/usr/bin/env python3

from time import sleep
from prometheus_client import Counter, CollectorRegistry, push_to_gateway

# local metrics "storage"
registry = CollectorRegistry()

# register a metric
counter = Counter('counter_metric', 'Example metric', ["label_name"], registry=registry)

while True:
  # increase metric's value to +1
  counter.labels(label_name="label_value").inc()
  # push to the VictoriaMetrics
  push_to_gateway('localhost:8428/api/v1/import/prometheus', job='test_job', registry=registry)
  # make sure this code is working :-)
  print("pushed")
  # wait before next run
  sleep(15)

Тут:

  • реєструємо “локальне сховище” для метрик (див. Overall structure)
  • реєструємо метрику counter_metric з типом Counter
  • виконуємо її інкремент з inc()
  • і відправляємо в localhost:8428/api/v1/import/prometheus – це я перевіряв на тестовому інтансі, де VictoriaMetrics запущена з Docker Compose, тому тут localhost

Перевіряємо в самій VictoriaMetrics:

prometheus_client, функції та grouping key

Трохи про те, як працює сам prometheus_client:

  • push_to_gateway() в registry будуть перезаписані всі метрики з однаковим набором labels (grouping key)
  • pushadd_to_gateway() перезапише метрики з однаковим іменем та grouping key
  • delete_from_gateway() видалить метрики з заданим job та grouping key

Див. Exporting to a Pushgateway та Prometheus Pushgateway.

Kubernetes CronJob та Prometheus Exporter

І приклад того, як воно все буде виглядати в Kubernetes.

Сам експортер, який буде ходити в Notion та збирати дані (код функцій не покажу, бо робочі моменти):

#!/usr/bin/env python3

import os
import requests

from datetime import datetime
from prometheus_client import Summary, CollectorRegistry, push_to_gateway

...

###################################################
### get_ios_ui_tests_status_and_failure_reasons ###
###################################################

registry = CollectorRegistry()

notion_ios_ui_test_last_100_results = Summary('notion_ios_ui_test_last_100_results',
                             'Status and failure reasons of last 100 iOS UI test runs',
                             labelnames=['reason'], registry=registry)

def get_last_ios_ui_test_results():
    print(f"\nStarted get_last_ios_ui_test_results() checks at {datetime.now()}")
    ...
    for reason in reasons.keys():
        notion_ios_ui_test_last_100_results.labels(reason=reason).observe(reasons[reason])
        print(f"collected reason: {reason} = {reasons[reason]} records")


def main():

    try:
        get_last_ios_ui_test_results()
        push_to_gateway('vmsingle-vm-k8s-stack:8429/api/v1/import/prometheus', job='notion-exporter', registry=registry)
    except Exception as e:
        print("Notion API error occurred:", str(e))


if __name__ == '__main__':
    print(f"\nStarting Notion Exporter at {datetime.now()}")
    main()

Kubernetes CronJob та Secret для нього – поки в тестуванні, тож запускається кожну хвилину:

apiVersion: batch/v1
kind: CronJob
metadata:
  name: notion-exporter-cronjob
spec:
  schedule: "*/1 * * * *"
  jobTemplate:
    spec:
      template:
        metadata:
          labels:
            app: notion-exporter
        spec:
          containers:
          - name: notion-exporter-container
            image: 492***148.dkr.ecr.us-east-1.amazonaws.com/notion-exporter:latest
            imagePullPolicy: Always
            env:
            - name: NOTION_REPORTS_DATABASE_ID
              value: "fc2***8e0"
            envFrom:
              - secretRef:
                  name: notion-exporter-secret
          restartPolicy: OnFailure
---
apiVersion: v1
kind: Secret
metadata:
  name: notion-exporter-secret
type: Opaque
stringData:
  NOTION_SECRET: "secret_SmX***ejH"

І дані в Grafana:

Готово.

Loading

Karpenter: Beta version – обзор змін та upgrade v0.30.0 на v0.32.1
0 (0)

4 Листопада 2023

Отже, Karpenter зробив ще один великий шаг до релізу, і у версії 0.32 вийшов з етапу Alpha до Beta.

Давайте кратко подивимось на зміни – а вони досить суттєві, а потім виконаємо апгрейд на EKS з Karpneter Terraform module та Karpenter Helm chart.

Сам процес установки Karpenter описував у пості Terraform: створення EKS, частина 3 – установка Karpenter, і нижче буде трохи відсилок на нього, типу імені файла karpenter.tf та деяких variables.

Основна документація:

Що нового в v0.32.1?

Починаючи з 0.32, API-ресурси Provisioner, AWSNodeTemplate та Machine будуть deprecated, а з версії 0.33 їх приберуть взагалі.

Замість них додані:

  • Provisioner => NodePool
  • AWSNodeTemplate => EC2NodeClass
  • Machine => NodeClaim

v1alpha5/Provisioner => v1beta1/NodePool

NodePool являється наступником Provisioner, і має параметри для:

  • налаштування запуску Pods та WorkerNodes – requirements подів до нод, taints, labels
  • налаштування того, як Karpenter буде розміщувати поди на нодах, та як буде виконувати deprovisioning зайвих нод

“Під капотом” NodePool буде створювати ресурси v1beta1/NodeClaims (які прийшли за зміну v1alpha5/Machine), і взагалі тут ідея приблизно як з Deployments та Pods: в Deployment (NodePool) ми описуємо template того, як буде створюватись Pod (NodeClaims). А v1beta1/NodeClaims в свою чергу являється наступником ресурсу v1alpha5/Machine.

Також була додана новая секція disruption – сюди були перенесені всі параметри, які відносяться до видалення зайвих нод та управління подами, див. Disruption.

v1alpha5/AWSNodeTemplate => v1beta1/EC2NodeClass

EC2NodeClass являється наступником AWSNodeTemplate, і має параметри для:

  • налаштування AMI
  • SecurityGroups
  • subnets
  • EBS
  • IMDS
  • User Data

Було видалене поле spec.instanceProfile – тепер Karpenter буде створювати Instance Profile для ЕС2 на основі IAM Role, яку буде передано в spec.role.

Також остаточно було видалене поле spec.launchTemplateName.

Див. документацію у NodeClasses.

Зміни в Labels

Лейбли karpenter.sh/do-not-evict та karpenter.sh/do-not-consolidate були об’єднані у нову лейблу karpenter.sh/do-not-disrupt, яку можна використовувати як для Pods, щоб заборонити Karpenetr виконувати Pod eviction, так і для WorkerNodes, щоб заборонити видалення цієї ноди.

Міграція 0.30.0 на v0.32.1

Далі я описую все досить детально і розглядаю різні варіанти, тож може скластися враження, що процес апгрейду досить геморний, бо буде трохи багато тексту, але насправді ні – все досить просто.

Що ми маємо зараз?

  • AWS EKS Cluster, створений з Terraform
  • за допомогою модулю terraform-aws-modules/eks/aws//modules/karpenter версії 0.30.0 в AWS створюються необхідні ресурси – IAM, SQS, etc
  • сам Karpenter встановлено з Helm-чарту karpenter
    • який приймає параметр version, який ми передаємо зі змінної var.helm_release_versions.karpenter
  • і маємо два ресурси kubectl_manifest – для karpenter_provisioner і karpenter_node_template

Процес міграції включає в себе:

  1. оновлення IAM Role, яка використовується подами контролера Karpenter для управління EC2 в AWS:
    1. замінити тег karpenter.sh/provisioner-name на karpenter.sh/nodepool (див. chore: Release v0.32.0) – стосується тільки ролі, яка була створена з Cloudformation, бо в Terraform модулі використовується інший Condition
    2. додати IAM Policy iam:CreateInstanceProfile, iam:AddRoleToInstanceProfile, iam:RemoveRoleFromInstanceProfile, iam:DeleteInstanceProfile та iam:GetInstanceProfile
  2. додавання нових CRD v1beta1, після чого Karpenter сам оновить ресурси Machine на NodeClaim
    1. для міграції AWSNodeTemplate => EC2NodeClass та Provisioner => NodePool можна використати утіліту karpenter-convert

Після чого виконати заміну WorkerNodes:

  • з використання фічі drift:
    • додати taint karpenter.sh/legacy=true:NoSchedule до існуючого Provisioner
    • Karpneter помітить всі ноди цього Provisioner як drifted
    • Karpenter запустить нові ЕС2, використовуючи новий NodePool
  • видалення нод:
    • створити NodePool аналогічний існуючому Provisioner
    • видалити існуючий Provisioner командою kubectl delete provisioner <provisioner-name> --cascade=foreground, в результаті чого Karpenter видалить всі його ноди виконавши node drain для всіх відразу, і поди, які перейдуть в стан Pending, запустить на нодах, які були створені з NodePool
  • ручна заміна:
    • створити NodePool аналогічний існуючому Provisioner
    • додати taint karpenter.sh/legacy=true:NoSchedule до старого Provisioner
    • по черзі вручну видалити всі його WorkerNopes з kubectl delete node

Що це значить для нас?

Всі зміни начебто backward compatible (перевірив – відкатував версії), тобто можемо спокійно оновлювати існуючі ресурси один за одним – поламатись нічого не повинно.

Тож, що зробимо:

  • оновимо модуль Terraform
  • додамо CRD
  • оновимо Helm-чарт з Karpenter
  • задеплоїмо, перевіримо – старі ноди від старого Provisioner продовжать працювати, поки ми їх не вб’ємо
  • додамо нові NodePool та EC2NodeClass
  • перестворимо WorkerNodes

Поїхали.

Step 1: оновлення Terraform Karpenter module

Виконуємо terraform apply до всіх змін, щоб мати задеплоєну останню версію нашого коду.

У файлі karpenter.tf маємо виклик модуля і його версію:

module "karpenter" {
  source  = "terraform-aws-modules/eks/aws//modules/karpenter"
  version = "19.16.0"
...

Karpenter Instance Profile

В самому модулі була додана нова змінна enable_karpenter_instance_profile_creation, яка визначає хто буде менеджити IAM Roles для WorkerNodes – Terraform, як було раніше, чи використати нову фічу від Karpenter. Якщо enable_karpenter_instance_profile_creation задати в true, то модуль просто додає ще один блок прав в IAM, див. main.tf.

Але тут “є нюанс” (с) в залежностях модулю Karpneter, і Helm-чарту: якщо enable_karpenter_instance_profile_creation включити в true (дефолтне значення false)  – то модуль не створить resource "aws_iam_instance_profile", який  далі використовується в чарті для Karpenter – параметр settings.aws.defaultInstanceProfile.

Тож тут два варіанти:

  1. спочатку оновити тільки версії – модуля і чарта, але використати старі параметри і Provisioner
    1. після апдейту – створити NodePool and EC2NodeClass, замінити параметри, і перестворити WorkerNodes
  2. або обновити відразу все – і модуль/чарт, і параметри, і Provisioner замінити на NodePool, і задеплоїти все разом

Спочатку можна зробити пошагово, десь на Dev-кластері, щоб подивитись, як воно все пройде, а потім на Prod викатувати вже весь апдейт відразу.

Почнемо, звісно, з Dev.

Міняємо версію на v19.18.0 – див. Releases, і додаємо enable_karpenter_instance_profile_creation = true – але поки закоментимо:

module "karpenter" {
  source  = "terraform-aws-modules/eks/aws//modules/karpenter"
  #version = "19.16.0"
  version = "19.18.0"

  cluster_name = module.eks.cluster_name

  irsa_oidc_provider_arn          = module.eks.oidc_provider_arn
  irsa_namespace_service_accounts = ["karpenter:karpenter"]

  create_iam_role      = false
  iam_role_arn         = module.eks.eks_managed_node_groups["${local.env_name_short}-default"].iam_role_arn
  irsa_use_name_prefix = false

  # In v0.32.0/v1beta1, Karpenter now creates the IAM instance profile
  # so we disable the Terraform creation and add the necessary permissions for Karpenter IRSA
  #enable_karpenter_instance_profile_creation = true
}

...

Поки не деплоїмо, йдемо до чарту.

Step 2: оновлення Karpenter Helm chart

Версії чартів в мене зібрані в одній змінній – оновлюємо тут karpneter v0.30.0 на v0.32.1:

...
helm_release_versions = {
  #karpenter                             = "v0.30.0"
  karpenter                             = "v0.32.1"
  external_dns                          = "1.13.1"
  aws_load_balancer_controller          = "1.6.1"
  secrets_store_csi_driver              = "1.3.4"
  secrets_store_csi_driver_provider_aws = "0.3.4"
  vpa                                   = "2.5.1"
}
...

Далі у нас два основних апдейти – це CRD та values чарту.

Karpenter CRD Upgrade

CRD є в основному чарті Karpneter, але:

Helm does not manage the lifecycle of CRDs using this method, the tool will only install the CRD during the first installation of the helm chart. Subsequent chart upgrades will not add or remove CRDs, even if the CRDs have changed. When CRDs are changed, we will make a note in the version’s upgrade guide.

Тобто наступні запуски helm install не оновлять CRD, які були встановлені при першій інсталяції.

Тож варіантів тут (знову!) два – або просто вручну їх додати з kubectl, або встановити з додаткового чарту karpenter-crd, див. CRD Upgrades.

При чому чарт встановить і старі CRD v1alpha5, і нові v1beta1, тобто ми будемо мати такий собі “backward compatible mode” – зможемо використовувати і старий Provisioner, і одночасно додати новий NodePool.

З helm template можна перевірити що саме чарт karpenter-crd буде робити:

$ helm template karpenter-crd oci://public.ecr.aws/karpenter/karpenter-crd --version v0.32.1
...

Але тут є нюанс: для встановлення нових CRD з чарту потрібно буде видалити вже існуючі CRD, а це призведе до того, що і існуючі Provisioner та Machine і відповідні WorkdeNodes будуть видалені.

Тож якщо це вже давно існуючий кластер, і ви хочете все зробити без downtime – то CRD встановлюємо руками.

Якщо кілька хвилин простою вам ОК – то краще вже робити з додатковим Helm-чартом, як й надалі буде все автоматично менеджити.

Ще можна спробувати заімпортити існуючі CRD в реліз нового чарту, див. Import Existing k8s Resources in Helm 3 – особисто я не пробував, але має працювати.

Отже в моєму випадку робимо апдейт CRD з чартом – додаємо його в наш Terraform:

...
resource "helm_release" "karpenter_crd" {
  namespace        = "karpenter"
  create_namespace = true

  name                = "karpenter-crd"
  repository          = "oci://public.ecr.aws/karpenter"
  repository_username = data.aws_ecrpublic_authorization_token.token.user_name
  repository_password = data.aws_ecrpublic_authorization_token.token.password
  chart               = "karpenter-crd"
  version             = var.helm_release_versions.karpenter
}
...

Переходимо до основного чарту.

Karpenter Chart values

Далі в resource "helm_release" "karpenter" прописуємо нові вальюси та додамо depends_on на чарт з CRD.

Додаємо параметр settings.aws.defaultInstanceProfile – потім його приберемо:

...
resource "helm_release" "karpenter" {
  namespace        = "karpenter"
  create_namespace = true

  name                = "karpenter"
  repository          = "oci://public.ecr.aws/karpenter"
  repository_username = data.aws_ecrpublic_authorization_token.token.user_name
  repository_password = data.aws_ecrpublic_authorization_token.token.password
  chart               = "karpenter"
  version             = var.helm_release_versions.karpenter

  values = [
    <<-EOT
    settings:
      clusterName: ${module.eks.cluster_name}
      clusterEndpoint: ${module.eks.cluster_endpoint}
      interruptionQueueName: ${module.karpenter.queue_name}
      aws:
        defaultInstanceProfile: ${module.karpenter.instance_profile_name}
    serviceAccount:
      annotations:
        eks.amazonaws.com/role-arn: ${module.karpenter.irsa_arn} 
    EOT
  ]

  depends_on = [
    helm_release.karpenter_crd
  ]

  /*
  set {
    name  = "settings.aws.clusterName"
    value = local.env_name
  }

  set {
    name  = "settings.aws.clusterEndpoint"
    value = module.eks.cluster_endpoint
  }

  set {
    name  = "serviceAccount.annotations.eks\\.amazonaws\\.com/role-arn"
    value = module.karpenter.irsa_arn
  }

  set {
    name  = "serviceAccount.annotations.eks\\.amazonaws\\.com/sts-regional-endpoints"
    value = "true"
    type  = "string"
  }

  set {
    name  = "settings.aws.defaultInstanceProfile"
    value = module.karpenter.instance_profile_name
  }

  set {
    name  = "settings.aws.interruptionQueueName"
    value = module.karpenter.queue_name
  }
  */
}
...

Виконуємо terraform init щоб завантажити нову версію модулю.

Зараз у нас вже є CRD, які були створені при першій інсталяції Karnepnter:

$ kk get crd | grep karpenter
awsnodetemplates.karpenter.k8s.aws                          2023-10-03T08:30:58Z
machines.karpenter.sh                                       2023-10-03T08:30:59Z
provisioners.karpenter.sh                                   2023-10-03T08:30:59Z

Видаляємо їх:

$ kk -n karpenter delete crd awsnodetemplates.karpenter.k8s.aws machines.karpenter.sh provisioners.karpenter.sh
customresourcedefinition.apiextensions.k8s.io "awsnodetemplates.karpenter.k8s.aws" deleted
customresourcedefinition.apiextensions.k8s.io "machines.karpenter.sh" deleted
customresourcedefinition.apiextensions.k8s.io "provisioners.karpenter.sh" deleted

І запускаємо terraform apply – зараз у нас має оновитись тільки сам чарт – в IAM поки змін не буде, бо маємо enable_karpenter_instance_profile_creation == false.

Після деплою перевіряємо CRD:

$ kk get crd | grep karpenter
awsnodetemplates.karpenter.k8s.aws                          2023-11-02T15:33:26Z
ec2nodeclasses.karpenter.k8s.aws                            2023-11-03T11:20:07Z
machines.karpenter.sh                                       2023-11-02T15:33:26Z
nodeclaims.karpenter.sh                                     2023-11-03T11:20:08Z
nodepools.karpenter.sh                                      2023-11-03T11:20:08Z
provisioners.karpenter.sh                                   2023-11-02T15:33:26Z

Перевіряємо поди і ноди – все має залишитись, як було – той самий ресурс Machine, щоб був створений зі старого Provisiner, і та сама WokrderNode:

$ kk get machine
NAME            TYPE       ZONE         NODE                         READY   AGE
default-b6hdr   t3.large   us-east-1a   ip-10-1-35-97.ec2.internal   True    30d

Якщо все ОК – то переходимо до створення NodePool та EC2NodeClass.

Step 3: створення NodePool та EC2NodeClass

Спочатку давайте розберемося з IAM ролями 🙂 Але це стосується конкретного мого сетапу, бо якщо ви всі ноди створюєте з Karpenter, то цю частину можна скіпнути.

В Terraform модулі EKS у нас створюється Managed Node Group, в якій створюється IAM Role, яка потім використовується в InstanceProfile для всіх нод кластера.

Далі ця роль передається в модуль karpenter, і тому create_iam_role в модулі Карпентер стоїть в false – бо роль вже є:

...
module "karpenter" {
  ...
  # disable create as doing in EKS NodeGroup resource
  create_iam_role      = false
  iam_role_arn         = module.eks.eks_managed_node_groups["${local.env_name_short}-default"].iam_role_arn
  irsa_use_name_prefix = false
  ...
}
...

Потім, коли Karpenter запускав нові EC2-інстанси, їм підключалась ця роль.

Але з  новою версією Karpenter він сам створює instanceProfile з spec.role.

Щоб в новому маніфесті з EC2NodeClass передати в поле spec.role ім’я замість iam_role_arn – шукаємо його в outputs.tf:

...
output "iam_role_name" {
  description = "The name of the IAM role"
  value       = try(aws_iam_role.this[0].name, null)
}

output "iam_role_arn" {
  description = "The Amazon Resource Name (ARN) specifying the IAM role"
  value       = try(aws_iam_role.this[0].arn, var.iam_role_arn)
}
...

Тепер можна додавати решту ресурсів.

Додавання EC2NodeClass

Див. доку в NodeClasses.

Тут робимо прямо в коді файлу karpenter.tf, як було і для AWSNodeTemplate.

Лишаємо і старий маніфест, і поруч додаємо новий:

...
resource "kubectl_manifest" "karpenter_node_template" {
  yaml_body = <<-YAML
    apiVersion: karpenter.k8s.aws/v1alpha1
    kind: AWSNodeTemplate
    metadata:
      name: default
    spec:
      subnetSelector:
        karpenter.sh/discovery: "atlas-vpc-${var.environment}-private"
      securityGroupSelector:
        karpenter.sh/discovery: ${local.env_name}
      tags:
        Name: ${local.env_name_short}-karpenter
        environment: ${var.environment}
        created-by: "karpneter"
        karpenter.sh/discovery: ${local.env_name}
  YAML

  depends_on = [
    helm_release.karpenter
  ]
}

resource "kubectl_manifest" "karpenter_node_class" {
  yaml_body = <<-YAML
    apiVersion: karpenter.k8s.aws/v1beta1
    kind: EC2NodeClass
    metadata:
      name: default
    spec:
      amiFamily: AL2
      role: ${module.eks.eks_managed_node_groups["${local.env_name_short}-default"].iam_role_name}
      subnetSelectorTerms:
        - tags:
            karpenter.sh/discovery: "atlas-vpc-${var.environment}-private"
      securityGroupSelectorTerms:
        - tags:
            karpenter.sh/discovery: ${local.env_name}
      tags:
        Name: ${local.env_name_short}-karpenter
        environment: ${var.environment}
        created-by: "karpneter"
        karpenter.sh/discovery: ${local.env_name}
  YAML

  depends_on = [
    helm_release.karpenter
  ]
}
...

В spec.amiFamily передаємо AmazonLinux v2, в spec.role – IAM Role для InstanceProfile – вона ж додана і до aws-auth ConfigMap нашого кластера (в модулі eks).

Додавання NodePool

Так як Provisioner/NodePools планувалось мати не один, то їхні параметри задані в variables – копіюємо саму змінну:

...
variable "karpenter_provisioner" {
  type = map(object({
    instance-family = list(string)
    instance-size   = list(string)
    topology        = list(string)
    labels          = optional(map(string))
    taints = optional(object({
      key    = string
      value  = string
      effect = string
    }))
  }))
}

variable "karpenter_nodepool" {
  type = map(object({
    instance-family = list(string)
    instance-size   = list(string)
    topology        = list(string)
    labels          = optional(map(string))
    taints = optional(object({
      key    = string
      value  = string
      effect = string
    }))
  }))
}
...

І значення:

...
karpenter_provisioner = {
  default = {
    instance-family = ["t3"]
    instance-size   = ["small", "medium", "large"]
    topology        = ["us-east-1a", "us-east-1b"]
    labels = {
      created-by = "karpenter"
    }
  }
}

karpenter_nodepool = {
  default = {
    instance-family = ["t3"]
    instance-size   = ["small", "medium", "large"]
    topology        = ["us-east-1a", "us-east-1b"]
    labels = {
      created-by = "karpenter"
    }
  }
}
...

Як і з Provisioner – додаємо файл шаблону configs/karpenter-nodepool.yaml.tmpl – тут формат теж трохи змінився, наприклад labels тепер в блоці spec.template.metadata.labels а не spec.labels, як було в Provisioner, див. NodePools.

Тож тепер шаблон виглядає так:

apiVersion: karpenter.sh/v1beta1
kind: NodePool
metadata:
  name: ${name}

spec:
  template:

    metadata:

    %{ if labels != null ~} 
      labels:
      %{ for k, v in labels ~}
        ${k}: ${v}
      %{ endfor ~}    
    %{ endif ~}

    spec:

    %{ if taints != null ~}
      taints:
        - key: ${taints.key}
          value: ${taints.value}
          effect: ${taints.effect}
    %{ endif ~}

      nodeClassRef:
        name: default
      requirements:
        - key: karpenter.k8s.aws/instance-family
          operator: In
          values: ${jsonencode(instance-family)}
        - key: karpenter.k8s.aws/instance-size
          operator: In
          values: ${jsonencode(instance-size)}
        - key: topology.kubernetes.io/zone
          operator: In
          values: ${jsonencode(topology)}
  # total cluster limits 
  limits:
    cpu: 1000
    memory: 1000Gi
  disruption:
    consolidationPolicy: WhenEmpty
    consolidateAfter: 30s

Важливо: якщо ви в AWS не використовуєте Spot Instances, то додайте karpenter.sh/capacity-type == "on-demand", див. причину нижче у Помилка: The provided credentials do not have permission to create the service-linked role for EC2 Spot Instances.

І додаємо новий ресурс kubectl_manifest в karpenter.tf, поруч зі старим Provisioner:

...
resource "kubectl_manifest" "karpenter_provisioner" {
  for_each = var.karpenter_provisioner

  yaml_body = templatefile("${path.module}/configs/karpenter-provisioner.yaml.tmpl", {
    name            = each.key
    instance-family = each.value.instance-family
    instance-size   = each.value.instance-size
    topology        = each.value.topology
    taints          = each.value.taints
    labels = merge(
      each.value.labels,
      {
        component   = var.component
        environment = var.environment
      }
    )
  })

  depends_on = [
    helm_release.karpenter
  ]
}

resource "kubectl_manifest" "karpenter_nodepool" {
  for_each = var.karpenter_nodepool

  yaml_body = templatefile("${path.module}/configs/karpenter-nodepool.yaml.tmpl", {
    name            = each.key
    instance-family = each.value.instance-family
    instance-size   = each.value.instance-size
    topology        = each.value.topology
    taints          = each.value.taints
    labels = merge(
      each.value.labels,
      {
        component   = var.component
        environment = var.environment
      }
    )
  })

  depends_on = [
    helm_release.karpenter
  ]
}
...

Далі:

  • в resource "helm_release" "karpenter" видаляємо з values aws.defaultInstanceProfile
  • в module "karpenter" включаємо enable_karpenter_instance_profile_creation в true

Тепер Terraform має:

  • додати права до ролі KarpenterIRSA
  • якщо для модулю karpenter не передавався параметр create_instance_profile == false – то видалиться module.karpenter.aws_iam_instance_profile, але в моєму випадку він все одно не використовувався
  • і додати kubectl_manifest.karpenter_nodepool["default"] та kubectl_manifest.karpenter_node_class

Деплоїмо, перевіряємо:

$ kk get nodepool
NAME      NODECLASS
default   default

$ kk get ec2nodeclass
NAME      AGE
default   40s

І все ще маємо нашу стару Machine:

$ kk get machine
NAME            TYPE       ZONE         NODE                         READY   AGE
default-b6hdr   t3.large   us-east-1a   ip-10-1-35-97.ec2.internal   True    30d

Все – нам лишилось перестворити WorkderNodes, перемістити Поди, і після деплою на Staging та Production прибратись в коді – видалити все, що лишилось від версії 0.30.0.

The provided credentials do not have permission to create the service-linked role for EC2 Spot Instances

В якийсь момент в логах пішла помилка такого плана:

karpenter-5dcf76df9-l58zq:controller {“level”:”ERROR”,”time”:”2023-11-03T14:59:41.072Z”,”logger”:”controller”,”message”:”Reconciler error”,”commit”:”1072d3b”,”controller”:”nodeclaim.lifecycle”,”controllerGroup”:”karpenter.sh”,”controllerKind”:”NodeClaim”,”NodeClaim”:{“name”:”default-ttx86″},”namespace”:””,”name”:”default-ttx86″,”reconcileID”:”6d17cadf-a6ca-47e3-9789-2c3491bf419f”,”error”:”launching nodeclaim, creating instance, with fleet error(s), AuthFailure.ServiceLinkedRoleCreationNotPermitted: The provided credentials do not have permission to create the service-linked role for EC2 Spot Instances.”}

Але, по-перше – чому Spot? Звідки це?

Якщо глянути NodeClaim, який був створений для ціїє ноди, то там бачимо "karpenter.sh/capacity-type == spot":

$ kk get nodeclaim -o yaml
...
  spec:
    ...
    - key: karpenter.sh/nodepool
      operator: In
      values:
      - default
    - key: karpenter.sh/capacity-type
      operator: In
      values:
      - spot
...

Хоча в документації сказано, що по-дефолту capacity-type має бути on-demand:

...
        - key: "karpenter.sh/capacity-type" # If not included, the webhook for the AWS cloud provider will default to on-demand
          operator: In
          values: ["spot", "on-demand"]
...

А ми в NodePool його не вказували.

Якщо ж в NodePool вказати karpenter.sh/capacity-type явно:

...
      requirements:
        - key: karpenter.k8s.aws/instance-family
          operator: In
          values: ${jsonencode(instance-family)}
        - key: karpenter.k8s.aws/instance-size
          operator: In
          values: ${jsonencode(instance-size)}
        - key: topology.kubernetes.io/zone
          operator: In
          values: ${jsonencode(topology)}
        - key: karpenter.sh/capacity-type
          operator: In 
          values: ["on-demand"]
...

То все працює, як треба.

І по-друге – яких саме пермішенів йому не вистачає? Що за помилка “ServiceLinkedRoleCreationNotPermitted“?

Я спотами в AWS не користувався, тому довелось трохи погуглити, і відповідь знайшлась в документації Work with Spot Instances та гайді Using AWS Spot instances, де мова йде про IAM Role AWSServiceRoleForEC2Spot, яка має бути створена в AWS Account, щоб мати змогу створювати Spot-інстанси.

Трохи дивне рішення по-дефолту створювати Spot, тим більш в документації говориться навпаки. Крім того – в 0.30 все працювало і без явного налаштування karpenter.sh/capacity-type.

Окей, будемо мати на увазі – якщо користуємось виключно On Demand – то треба додавати в конфіг NodePool.

Step 3: оновлення WorkerNodes

Що нам лишилося – це переселити наші поди на нові ноди.

Насправді всі поди переїхали на нові ноди ще під час апдейту, але давайте зробимо, бо нам ще апдейтити інші кластери.

Тут маємо три варіанти, про які говорили на початку. Давайте пробувати робити це без даунтайму – з використанням drift (але без даунтайму – це якщо маєте мінімум по 2 поди на сервіс, і на додачу PodDisruptionBudget).

Що нам треба зробити – це додати taint до існуючого Provisioner, задеплоїти зміни, щоб taint додався до Nodes, і тоді Karpenter виконає Node Drain та створить нові ноди, щоб перемістити наші workloads.

Додаємо в наш шаблон configs/karpenter-provisioner.yaml.tmpl:

apiVersion: karpenter.sh/v1alpha5
kind: Provisioner
metadata:
  name: ${name}
spec:

  taints:
    - key: karpenter.sh/legacy
      value: "true"
      effect: NoSchedule
...

В чарті версії 0.32.1 параметр drift досі в false, тому включаємо в values нашого resource "helm_release" "karpenter":

...
  values = [
    <<-EOT
    settings:
      clusterName: ${module.eks.cluster_name}
      clusterEndpoint: ${module.eks.cluster_endpoint}
      interruptionQueueName: ${module.karpenter.queue_name}
      featureGates:
        drift: true
...

І вже всі ці зміни разом можна викатувати на інші Kubernetes кластери – тільки не забудьте оновити tfvars для цих кластерів (якщо маєте щось типу окремих envs/dev/dev-1-28.tfvars, envs/staging/staging-1-28.tfvars, envs/prod/prod-1-28.tfvars).

Rolling back the upgrade

Навряд чи це знадобиться, бо в принці особливих проблема не має бути, але я робив під час ре-тесту апгрейду, тож запишу:

  • міняємо версію module "karpenter" з нової 19.18.0 на стару 19.16.0
  • в module "karpenter" коментуємо опцію enable_karpenter_instance_profile_creation
  • в tfvars для helm_release_versions міняємо версію чарту Karpenter з нової v0.32.1 на стару v0.30.0
  • лишаємо resource "helm_release" "karpenter_crd"
  • в resource "helm_release" "karpenter" коментуюємо новий блок values, розкоментуємо старі values через set
  • коментуємо ресурси resource "kubectl_manifest" "karpenter_nodepool" та resource "kubectl_manifest" "karpenter_node_class"
  • у файлі configs/karpenter-provisioner.yaml.tmpl прибираємо Taint

Loading

GitHub Actions: Docker-білд в AWS ECR та деплой Helm-чарту в AWS EKS
0 (0)

2 Жовтня 2023

Отже, маємо розгорнутий кластер Kubernetes – див. серію Terraform: створення EKS, частина 1 – VPC, Subnets та Endpoints.

Маємо GitHub Actions workflow для його деплою – див. GitHub Actions: деплой Dev/Prod оточень з Terraform.

Прийшов час почати деплоїти наш бекенд в Kubernetes.

Тут знов використаємо GitHub Actions – будемо білдити Docker-образ з API-сервісом бекенду, зберігати його в AWS Elastic Container Service, а потім деплоїти Helm-чарт, якому у values передамо новий Docker Tag.

Робоче оточення поки одне, Dev, пізніше додамо ще Staging та Production. Крім того, треба мати можливість задеплоїти feature-environment – на той же Dev EKS, але з кастомними значеннями деяких змінних.

Проте зараз будемо робити в тестовому репозиторії “atlas-test” і з тестовим Helm-чартом.

Release flow planning

Як будемо релізити?

Поки вирішили по такій схемі:

Тобто:

  • девелопер створює бранч, пише код, тестує локально в Docker Compose
  • після завершення роботи над фічею – створює Pull Request з лейблою “deploy
    • workflow Deploy Feature Env
      • тригер: створення  Pull Request з лейблою “deploy
      • білдить Docker-образ і тегає його з git commit sha --short
      • пушить його в ECR
      • створює feature-оточення в GitHub
      • деплоїть в Kubernetes Dev в feature-env namespace, де девелопер може додатково потестити свої зміни в умовах, наближених до реальних
  • після мержу PR в master-гілку:
    • workflow  Deploy Dev:
      • тригер: push to master або вручну
      • білдить Docker-образ і тегає його з git commit sha --short
      • деплой на Dev
      • якщо деплой пройшов (Helm не видав помилок, Pods запустились, тобто перевірки readiness та liveness Probes пройшли) – створюємо Git Tag
      • тегаємо вже існуючий Docker образ з цим тегом
    • workflow  Deploy Stage:
      • тригер: Git Tag created
      • деплоїться існуючий Docker образ з цим тегом
      • запускаються integration tests (mobile, web)
      • якщо тести пройшли – то створюємо GitHub Release – chanelog, etc
    • workflow  Deploy Prod:
      • тригер: Release created
      • деплоїться існуючий образ з тегом цього релізу
      • виконуються тести
  • ручний деплой
    • з будь якого існуючого образу на Dev або Staging

Сьогодні зробимо два workflow – Deploy Dev, і Deploy та Destroy Feature-env.

Setup AWS

Для початку нам треба мати ECR та IAM Role.

ECR -для зберігання образів, які будемо деплоїти, а IAM Role буде використовувати GitHub Action для доступу до ECR та логіну в EKS під час деплою.

Репозиторій в ECR у нас вже є, теж з назвою “atlas-test“, створено поки що руками – пізніше перенесемо менеджмент ECR в Terraform.

А от AWS IAM-ролі для проектів в GitHub, які будуть деплоїтись в Kubernetes можемо зробити відразу на етапі створення EKS-кластеру.

Terraform: створення IAM Role

Для деплою з GitHub в AWS ми використовуємо OpenID Connect, тобто аутентифікований юзер GitHub (або в нашому випадку – GitHub Actions Runner) може прийти в AWS, і там виконати AssumeRole, а потім з політиками цієї ролі пройти авторизацію в AWS – перевірку того, що він там може робити.

Щоб деплоїти з GitHub в EKS нам потрібни політики на:

  • eks:DescribeCluster та eks:ListClusters: щоб авторизуватись в EKS-кластері
  • ecr: push та read образів з ECR-репозиторію

Крім того, для цієї ролі задамо обмеження на те, з якого репозиторію GitHub можна буде виконати AssumeRole.

В проекті EKS додамо змінну github_projects з типом list, в якій будуть всі GitHub-проекти, яким ми будемо дозволяти деплоїти в цей кластер, поки він тут буде один:

...

variable "github_projects" {
  type        = list(string)
  default     = [
    "atlas-test"
  ]
  description = "GitHub repositories to allow access to the cluster"
}

Описуємо сам роль, де в циклі for_each перебираємо всі елементи списку github_projects:

data "aws_caller_identity" "current" {}

resource "aws_iam_role" "eks_github_access_role" {
  for_each = var.github_projects
  name = "${local.env_name}-github-${each.value}-access-role"

  assume_role_policy = jsonencode({
    Version = "2012-10-17"
    Statement = [
      {
        Action = "sts:AssumeRoleWithWebIdentity"
        Effect = "Allow"
        Sid    = ""
        Principal = {
          Federated : "arn:aws:iam::${data.aws_caller_identity.current.account_id}:oidc-provider/token.actions.githubusercontent.com"
        }
        Condition: {
            StringLike: {
                "token.actions.githubusercontent.com:sub": "repo:GitHubOrgName/${each.value}:*"
            },
            StringEquals: {
                "token.actions.githubusercontent.com:aud": "sts.amazonaws.com"
            }
        }
      }
    ]
  })

  inline_policy {
    name = "${local.env_name}-github-${each.value}-access-policy"

    policy = jsonencode({
      Version = "2012-10-17"
      Statement = [
        {
          Action   = [
            "eks:DescribeCluster*",
            "eks:ListClusters"
          ]
          Effect   = "Allow"
          Resource = module.eks.cluster_arn
        },
        {
          Action   = [
            "ecr:GetAuthorizationToken",
            "ecr:BatchGetImage",
            "ecr:BatchCheckLayerAvailability",
            "ecr:CompleteLayerUpload",
            "ecr:GetDownloadUrlForLayer",
            "ecr:InitiateLayerUpload",
            "ecr:PutImage",
            "ecr:UploadLayerPart"
          ]
          Effect   = "Allow"
          Resource = "*"
        },
      ]
    })
  }

  tags = {
    Name = "${local.env_name}-github-${each.value}-access-policy"
  }
}

Тут:

  • в assume_role_policy дозволяємо AssumeRoleWithWebIdentity цієї ролі для token.actions.githubusercontent.com і репозиторію repo:GitHubOrgName:atlas-test
  • в inline_policy:
    • дозволяємо eks:DescribeCluster кластеру, який деплоїться
    • дозволяємо eks:ListClusters всіх кластерів
    • дозволяємо операції в ECR на всі репозиторії

На ECR було б краще обмежити конкретними репозиторіями, але це поки в тесті, і ще не відомо який неймінг репозиторієв буде.

Див. Pushing an image, AWS managed policies for Amazon Elastic Container Registry та Amazon EKS identity-based policy examples.

Далі треба додати ці створені ролі в aws_auth_roles, де зараз вже маємо одну роль:

...
  aws_auth_roles = [
    {
      rolearn  = aws_iam_role.eks_masters_access_role.arn
      username = aws_iam_role.eks_masters_access_role.arn
      groups   = ["system:masters"]
    }
  ]
...

В locals будуємо новий list(map(any))github_roles, а потім в aws_auth_roles за допомогою flatten() створюємо новий list, в який включаємо eks_masters_access_role та ролі із github_roles:

...
locals {
  vpc_out = data.terraform_remote_state.vpc.outputs
  github_roles = [ for role in aws_iam_role.eks_github_access_role : {
      rolearn = role.arn
      username  = role.arn
      groups   = ["system:masters"]
    }]
  aws_auth_roles = flatten([ 
    {
      rolearn = aws_iam_role.eks_masters_access_role.arn
      username  = aws_iam_role.eks_masters_access_role.arn
      groups   = ["system:masters"]
    },
    local.github_roles
  ])
}
...

Поки тут використовуємо system:masters, бо це все ще в розробці, і RBAC поки не налаштовую. Але див. User-defined cluster role binding should not include system:masters group as a subject.

Деплоїмо, та перевіряємо aws-auth ConfigMap, де тепер маємо новий об’єкт в mapRoles:

Workflow: Deploy Dev manually

Тепре, маючи ролі, можемо робити Workflow.

Почнемо з ручного деплою на Dev, бо він самий простий. А потім вже маючи працючий білд і процеси – будемо робити решту.

Triggers

По яких умовах будемо запускати білд?

  • workflow_dispatch:
    • з будь-якого бранча або тега
  • push в master: в репозиторії бекенду master-бранч у нас має обмеження на push тільки з Pull Request, тож інших пушів тут не буде

Ще можна робити додаткову перевірку в джобах, на кшталт:

- name: Build
        if: github.event_name == 'pull_request' && github.event.action == 'closed' && github.event.pull_request.merged == true

Див. Trigger workflow only on pull request MERGE.

Environments та Variables

На рівні репозиторію додаємо змінні – переходимо в Settings > Secrets and variables > Actions:

  • ECR_REPOSITORY
  • AWS_REGION

Створюємо GitHub Environment “dev“, і йому задаємо:

  • AWS_IAM_ROLE: arn беремо з outputs деплою EKS з Terraform
  • AWS_EKS_CLUSTER: ім’я беремо з outputs деплою EKS з Terraform
  • ENVIRONMENT: “dev

Job: Docker Build

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

FROM alpine

Створюємо директорію .github/workflows:

$ mkdir -p .github/workflows

І в ній файл .github/workflows/deploy-dev.yml:

name: Deploy to EKS

on: 
  workflow_dispatch:
  push:
    branches: [ master ]  

permissions:
  id-token: write
  contents: read

jobs:

  build-docker:

    name: Build Docker image
    runs-on: ubuntu-latest
    environment: dev

    steps:

    - name: "Setup: checkout code"
      uses: actions/checkout@v3

    - name: "Setup: Configure AWS credentials"
      uses: aws-actions/configure-aws-credentials@v2
      with:
        role-to-assume: ${{ vars.AWS_IAM_ROLE }}
        role-session-name: github-actions-test
        aws-region: ${{ vars.AWS_REGION }}

    - name: "Setup: Login to Amazon ECR"
      id: login-ecr
      uses: aws-actions/amazon-ecr-login@v1
      with:
        mask-password: 'true'

    - name: "Setup: create commit_sha"
      id: set_sha
      run: echo "sha_short=$(git rev-parse --short HEAD)" >> $GITHUB_OUTPUT

    - name: "Build: create image, set tag, push to Amazon ECR"
      env:
        ECR_REGISTRY: ${{ steps.login-ecr.outputs.registry }}
        ECR_REPOSITORY: ${{ vars.ECR_REPOSITORY }}
        IMAGE_TAG: ${{ steps.set_sha.outputs.sha_short }}
      run: |
        docker build -t $ECR_REGISTRY/$ECR_REPOSITORY:$IMAGE_TAG -f Dockerfile .
        docker push $ECR_REGISTRY/$ECR_REPOSITORY:$IMAGE_TAG

В ньому маємо джобу, яка запускається з GitHub Environment == dev, і:

  • з actions/checkout зачекаутить код на GitHub Runner
  • з aws-actions/configure-aws-credentials залогіниться в AWS виконавши AssumeRole ролі, яку ми створили раніше
  • з aws-actions/amazon-ecr-login залогіниться в AWS ECR (мені стало цікаво як жеж він логиниться, але – 54.000 строк коду на JS!)
  • згенерує output sha_short, в який внесе Commit ID
  • і виконає docker build та docker push

Пушимо, мержимо в мастер, і запускаємо білд:

Перевіряємо образ в ECR:

Job: Helm deploy

Наступний шаг – це задеплоїти Helm-чарт в EKS.

Швиденько зробимо тестовий чарт:

$ mkdir -p helm/templates

В директорії helm створюємо файл Chart.yaml:

apiVersion: v2
name: test-chart
description: A Helm chart
type: application
version: 0.1.0
appVersion: "1.16.0"

Файл templates/deployment.yaml:

apiVersion: apps/v1
kind: Deployment
metadata:
  name: test-deployment
spec:
  replicas: 1
  selector:
    matchLabels:
      app: test-app
  template:
    metadata:
      labels:
        app: test-app
    spec:
      containers:
        - name: test-container
          image: {{ .Values.image.repo}}:{{ .Values.image.tag }}
          imagePullPolicy: Always

Та файл values.yaml:

image:
  repo: 492***148.dkr.ecr.us-east-1.amazonaws.com/atlas-test
  tag: latest

Передача змінних між GitHub Action Jobs

Далі питання по самому Workflow, а саме – як передати Docker tag, який ми створили в Job build-docker?

Можно зробити деплой з Helm в тій самій джобі, і тоді зможемо використати ту ж змінну IMAGE_TAG.

А можемо зробити окремою job, і передати значення тегу між джобами.

Великого сенсу розділяти на дві джоби тут нема, але по-перше це в білді виглядає більш логічно, по-друге – хочеться спробувати всякі штуки в GitHub Actions, тому давайте передамо значення змінної між джобами.

Для цього в першій джобі додаємо outputs:

...
jobs:

  build-docker:

    name: Build Docker image
    runs-on: ubuntu-latest
    environment: dev

    outputs:
      image_tag: ${{ steps.set_sha.outputs.sha_short }}
...

А потім його використаємо в новій джобі з Helm, в якій передаємо як values: image.tag:

...

  deploy-helm:

    name: Deploy Helm chart
    runs-on: ubuntu-latest
    environment: dev
    needs: build-docker

    steps:
    
    - name: "Setup: checkout code"
      uses: actions/checkout@v3

    - name: "Setup: Configure AWS credentials"
      uses: aws-actions/configure-aws-credentials@v2
      with:
        role-to-assume: ${{ vars.AWS_IAM_ROLE }}
        role-session-name: github-actions-test
        aws-region: ${{ vars.AWS_REGION }}

    - name: "Setup: Login to Amazon ECR"
      id: login-ecr
      uses: aws-actions/amazon-ecr-login@v1
      with:
        mask-password: 'true' 

    - name: Deploy Helm
      uses: bitovi/[email protected]
      with:
        cluster-name: ${{ vars.AWS_EKS_CLUSTER }}
        namespace: ${{ vars.ENVIRONMENT }}-testing-ns
        name: test-release
        # may enable roll-back on fail
        #atomic: true
        values: image.tag=${{ needs.build-docker.outputs.image_tag }}
        timeout: 60s
        helm-extra-args: --debug

Пушимо зміни, і перевіряємо деплой:

Job: створення Git Tag

Наступним кроком треба створити Git Tag. Тут можемо використати Action github-tag-action, який під капотом виконує перевірку заголовку комміту, і в залежності від нього – інкрементить major, minor або patch версію тегу.

Тож давайте спершу поглянемо на Commit Message Format, хоча взагалі-то це тема, яку можна було б винести окремим постом.

Git commit message format

Див. Understanding Semantic Commit Messages Using Git and Angular.

Отже, якщо кратко, то заголовок пишемо в форматі “type(scope): subject“, тобто, наприклад – git commit -m "ci(actions): add new workflow".

При цьому type умовно можна поділити на development та production, тобто – зміни, якві відносяться до розробки/розробників, або зміни, які відносяться до production-оточення та end-юзерів:

  • development:
    • build (раніше chore): зміни, якві відносяться до білду та пакетів (npm build, npm install, etc)
    • ci: зміни CI/CD (workflow-файли, terraform apply, etc)
    • docs: зміни в документації проекту
    • refactor: рефакторінг коду – нові назви змінних, спрощення коду
    • style: зміни коду в відступах, коми, лапки-дужки і т.д.
    • test: зміни в тестах коду – юніт-тести, інтеграційні, etc
  • production:
    • feat: нові фічі, функціональність
    • fix: баг-фікси
    • perf: зміни, які стосуються performance

Тож додаємо нову job:

...

  create_tag:
    name: "Create Git version tag"
    runs-on: ubuntu-latest
    timeout-minutes: 5
    needs: deploy-helm
    permissions:
      contents: write
    outputs:
      new_tag: ${{ steps.tag_version.outputs.new_tag }}

    steps:
      - name: "Checkout"
        uses: actions/checkout@v3

      - name: "Misc: Bump version and push tag"
        id: tag_version
        uses: mathieudutour/[email protected]
        with:
          github_token: ${{ secrets.GITHUB_TOKEN }}
          tag_prefix: eks-

В джобі вказуємо needs, щоб запускати тільки після деплою Helm, додаємо permissions, щоб github-tag-action мав змогу додавати теги в репозиторії, і додаємо tag_prefix, бо в репозіторії бекенду, де все це потім буде працювати, вже є стандартні теги з префіксом v. А токен в secrets.GITHUB_TOKEN є по дефолту в саміх Action.

Пушимо з комітом ga -A && gm "ci(actions): add git tag job" && gp, і маємо новий тег:

Workflow: Deploy Feature Environment

Окей – у нас є білд Docker, є деплой Helm-чарту. Все деплоїться на Dev-оточення, все працює.

Давайте додамо ще один workflow – для деплою на EKS Dev, але вже не як dev-оточення, а у тимчасовий Kubernetes Namespace, щоб девелопери мали змогу потестити свої фічі незалежно від Dev-оточення.

Для цього нам потрібно:

  • тригерити workflow при створенні Pull Request з лейблою “deploy”
  • створити custom name для нового Namespace – використаємо Pull Request ID
  • збілдити Docker
  • задеплоїти Helm-чарт у новий Namespace

Створюємо новий файл – create-feature-env-on-pr.yml.

В ньому буде три джоби:

  • Docker build
  • Deploy feature-env
  • Destroy feature-env

Умови запуску Jobs з if та github.event context

Docker build та Deploy мають запускатись, коли Pull Request створено і якщо він має лейблу “deploy”, а Destroy – коли Pull Request з лейблою “deploy” закрито.

Для тригеру workflow задаємо умову on.pull_request – тоді будемо мати PullRequestEvent з набором полів, які можемо перевірити.

Єдине, що в документації чомусь не вказано поле label, про що говорилось ще в 2017 (!) році, але на ділі вона є.

Тут дуже може допомогти додатковий step, в якому можна вивести весь payload.

Створюємо бранч для тестів, і у файлі create-feature-env-on-pr.yml додаємо першу джобу:

name: "Create feature environment on PR"

on:
  pull_request:
    types: [ opened, edited, closed, reopened, labeled, unlabeled, synchronize ]

permissions:
  id-token: write
  contents: read

concurrency:
  group: deploy-${{ github.event.number }}
  cancel-in-progress: false

jobs:

  print-event:    
    name: Print event
    runs-on: ubuntu-latest
    steps:
    - name: Dump GitHub context
      env:
        GITHUB_CONTEXT: ${{ toJson(github.event) }}
      run: |
        echo "$GITHUB_CONTEXT"

Пушимо в репозиторій, створюємо Pull Request, створюємо нову label:

І наш workflow запустився:

І видав нам всі дані, які маємо в event:

Тепер, маючи їх, можемо подумати про те, як будемо перевіряти умови для запуску jobs:

  • Створити Environment, якщо Pull Request: opened, editied, reopened, synchronize (якщо в source-бранч додано новий коміт)
    • але Pull Request може бути без лейбли – тоді нам деплоїти не треба
    • до вже існуючого Pull Request може бути додана лейбла – тоді event буде labeled, деплоїмо
  • Видалити Environment, якщо Pull Request: closed
    • але може бути без лейбли – тоді джобу на видалення запускати не треба
    • але у вже існуючого Pull Request може бути прибрана лейбла “deploy” – тоді event буде unlabeled, видаляємо Environment

Умови запуску Workflow у нас зараз виглядають так:

on:
  pull_request:
    types: [opened, edited, closed, reopened, labeled, unlabeled, synchronize]

Job: Deploy при створенні Pull Request з лейблою “deploy”

Отже, вище вже побачили, що при створенні Pull Request з лейблою “deploy” маємо "action": "opened" та pull_request.labels[].name: "deploy":

Тоді можемо перевірити умову як:

if: contains(github.event.pull_request.labels.*.name, 'deploy')

Див. contains.

Але якщо івент був на закриття Pull Request – то він все одно буде мати “deploy” лейблу, і тригерне нашу джобу.

Тому додаємо ще одну умову:

if: contains(github.event.pull_request.labels.*.name, 'deploy') && github.event.action != 'closed'

Тож тестова джоба, яка буде “деплоїти”, може виглядати так:

...

jobs:

  print-event:    
    name: Print event
    runs-on: ubuntu-latest
    steps:
    - name: Dump GitHub context
      env:
        GITHUB_CONTEXT: ${{ toJson(github.event) }}
      run: |
        echo "$GITHUB_CONTEXT"

  deploy:
    name: Deploy
    if: contains(github.event.pull_request.labels.*.name, 'deploy') && github.event.action != 'closed'
    runs-on: ubuntu-latest
    steps: 
    - name: Run deploy 
      run: echo "This is deploy"

Пушимо, створюмо PR, перевіряємо, і бачимо, що джоба запустилась два рази:

Бо при створенні PR з лейблою маємо два івенти – власне створення PR, тобто івент “opened“, та додавання лейбли – event “labeled“.

Взагалі виглядає трохи криво, як на мене. Але, мабуть, GitHub не зміг обійтися одним івентом в такому випадку.

Тому ми можемо просто прибрати opened з тригерів on.pull_request – і при створенні PR з лейблою джоба затригериться тільки на івент “labeled“.

Ще одна річ, яка виглядає кривувато, хоча тут ми її не використовуємо, але:

  • для перевірки string в contains() ми використовуємо форму contains(github.event.pull_request.labels.*.name, 'deploy') – спочатку вказуємо об’єкт, в якому шукаємо, потім строка, яку шукаємо
  • але щоб перевірити декілька strings – формат буде contains('["labeled","closed"]', github.event.action) – тобто спочатку список строк для перевірки, потім об’єкт, в якому їх шукаємо

Окей, йдемо далі: ми можемо мати ще одну умову для запуску – коли для вже створеного PR без лейбли “deploy” який не тригернув нашу джобу, була додана лейбла “deploy” – і тоді нам треба запустити джобу.

Це можемо перевірити з такою умовою:

github.event.action == 'labeled' && github.event.label.name == 'deploy'

А щоб вибрати одну із умов – першу, чи цю – використовуємо оператор “або” – “||“, тобто наш if буде виглядати так:

if: |
  (contains(github.event.pull_request.labels.*.name, 'deploy') && github.event.action != 'closed') ||
  (github.event.action == 'labeled' && github.event.label.name == 'deploy')'

Після чого для тесту створюємо PR без лейбли – і спрацює тільки джоба “Print event”:

Потім додаємо лейблу “deploy” – і маються стригеритись обидві джоби:

Job: Destroy при закритті Pull Request з лейблою “deploy”

Залишилось зробити job для видалення feature-env – коли Pull Request з лейблою “deploy” закривається.

Умови тут схожі з тими, що ми робили для деплою:

...

  destroy:
    name: Destroy 
    if: |
      (contains(github.event.pull_request.labels.*.name, 'deploy') && github.event.action == 'closed') ||
      (github.event.action == 'unlabeled' && github.event.label.name == 'deploy')
    runs-on: ubuntu-latest
    steps: 
    - name: Run destroy  
      run: echo "This is dstroy"
  • запуск джоби, якщо action == 'closed' і labels.*.name == 'deploy' АБО
  • запуск джоби, якщо action == 'unlabeled' і event.label.name == 'deploy'

Перевіряємо – створюємо PR з лейблою “deploy” – запускається джоба Deploy, мержимо цей PR – і маємо джобу Destroy:

Тож повністю файл зараз виглядає так:

name: "Create feature environment on PR"

on:
  pull_request:
    types: [ opened, edited, closed, reopened, labeled, unlabeled, synchronize ]

permissions:
  id-token: write
  contents: read

concurrency:
  group: deploy-${{ github.event.number }}
  cancel-in-progress: false

jobs:

  print-event:    
    name: Print event
    runs-on: ubuntu-latest
    steps:
    - name: Dump GitHub context
      env:
        GITHUB_CONTEXT: ${{ toJson(github.event) }}
      run: |
        echo "$GITHUB_CONTEXT"

  deploy:
    name: Deploy
    if: |
      (contains(github.event.pull_request.labels.*.name, 'deploy') && github.event.action != 'closed') ||
      (github.event.action == 'labeled' && github.event.label.name == 'deploy')  
    runs-on: ubuntu-latest
    steps: 
    - name: Run deploy 
      run: echo "This is deploy"

  destroy:
    name: Destroy 
    if: |
      (contains(github.event.pull_request.labels.*.name, 'deploy') && github.event.action == 'closed') ||
      (github.event.action == 'unlabeled' && github.event.label.name == 'deploy')
    runs-on: ubuntu-latest
    steps: 
    - name: Run destroy  
      run: echo "This is dstroy"

Добре – наче все працює. Якщо що – то вже по ходу діла поправимо умови, але в цілому ідея іх використання така.

Створення Feature Environment

Тут вже в принципі все нам відомо – використовуємо джоби, які робили для Deploy Dev, тільки міняємо пару параметрів.

В тому ж файлі create-feature-env-on-pr.yml описуємо джоби.

В першій, Docker build, нічого не міняється – тільки додаємо if:

...

  build-docker:

    name: Build Docker image
    if: |
      (contains(github.event.pull_request.labels.*.name, 'deploy') && github.event.action != 'closed') ||
      (github.event.action == 'labeled' && github.event.label.name == 'deploy')
    runs-on: ubuntu-latest
    timeout-minutes: 30
    environment: dev

    outputs:
      image_tag: ${{ steps.set_sha.outputs.sha_short }}

    steps:

    - name: "Setup: checkout code"
      uses: actions/checkout@v3

    - name: "Setup: Configure AWS credentials"
      uses: aws-actions/configure-aws-credentials@v2
      with:
        role-to-assume: ${{ vars.AWS_IAM_ROLE }}
        role-session-name: github-actions-test
        aws-region: ${{ vars.AWS_REGION }}

    - name: "Setup: Login to Amazon ECR"
      id: login-ecr
      uses: aws-actions/amazon-ecr-login@v1
      with:
        mask-password: 'true'

    - name: "Setup: create commit_sha"
      id: set_sha
      run: echo "sha_short=$(git rev-parse --short HEAD)" >> $GITHUB_OUTPUT

    - name: "Build: create image, set tag, push to Amazon ECR"
      env:
        ECR_REGISTRY: ${{ steps.login-ecr.outputs.registry }}
        ECR_REPOSITORY: ${{ vars.ECR_REPOSITORY }}
        IMAGE_TAG: ${{ steps.set_sha.outputs.sha_short }}
      run: |
        docker build -t $ECR_REGISTRY/$ECR_REPOSITORY:$IMAGE_TAG -f Dockerfile .
        docker push $ECR_REGISTRY/$ECR_REPOSITORY:$IMAGE_TAG

А в джобу Helm-деплой теж додамо if та новий step – “Misc: Set ENVIRONMENT variable“, який із значення github.event.number буде створювати нове значення для змінної ENVIRONMENT, яку ми передаємо в ім’я неймспейсу, в який буде деплоїтись чарт:

...

  deploy-helm:

    name: Deploy Helm chart
    if: |
      (contains(github.event.pull_request.labels.*.name, 'deploy') && github.event.action != 'closed') ||
      (github.event.action == 'labeled' && github.event.label.name == 'deploy')    
    runs-on: ubuntu-latest
    timeout-minutes: 15
    environment: dev
    needs: build-docker

    steps:
    
    - name: "Setup: checkout code"
      uses: actions/checkout@v3

    - name: "Setup: Configure AWS credentials"
      uses: aws-actions/configure-aws-credentials@v2
      with:
        role-to-assume: ${{ vars.AWS_IAM_ROLE }}
        role-session-name: github-actions-test
        aws-region: ${{ vars.AWS_REGION }}

    - name: "Setup: Login to Amazon ECR"
      id: login-ecr
      uses: aws-actions/amazon-ecr-login@v1
      with:
        mask-password: 'true'

    - name: "Misc: Set ENVIRONMENT variable"
      id: set_stage
      run: echo "ENVIRONMENT=pr-${{ github.event.number }}" >> $GITHUB_ENV

    - name: "Deploy: Helm"
      uses: bitovi/[email protected]
      with:
        cluster-name: ${{ vars.AWS_EKS_CLUSTER }}
        namespace: ${{ env.ENVIRONMENT }}-testing-ns
        name: test-release
        #atomic: true
        values: image.tag=${{ needs.build-docker.outputs.image_tag }}
        timeout: 60s
        helm-extra-args: --debug

І останнім додаємо нову джобу, яка буде видаляти Helm-реліз, та окремий step на видалення неймспейсу, бо сам Helm при uninstall такого не вміє, див. Add option to delete the namespace created during install. Для цього використовуємо ianbelcher/eks-kubectl-action:

...

  destroy-helm:

    name: Uninstall Helm chart
    if: |
      (contains(github.event.pull_request.labels.*.name, 'deploy') && github.event.action == 'closed') ||
      (github.event.action == 'unlabeled' && github.event.label.name == 'deploy')
    runs-on: ubuntu-latest
    timeout-minutes: 15
    environment: dev

    steps:
    
    - name: "Setup: checkout code"
      uses: actions/checkout@v3

    - name: "Setup: Configure AWS credentials"
      uses: aws-actions/configure-aws-credentials@v2
      with:
        role-to-assume: ${{ vars.AWS_IAM_ROLE }}
        role-session-name: github-actions-test
        aws-region: ${{ vars.AWS_REGION }}

    - name: "Misc: Set ENVIRONMENT variable"
      id: set_stage
      run: echo "ENVIRONMENT=pr-${{ github.event.number }}" >> $GITHUB_ENV

    - name: "Destroy: Helm"
      uses: bitovi/[email protected]
      with:
        cluster-name: ${{ vars.AWS_EKS_CLUSTER }}
        namespace: ${{ env.ENVIRONMENT }}-testing-ns
        action: uninstall
        name: test-release
        #atomic: true
        timeout: 60s
        helm-extra-args: --debug

    - name: "Destroy: namespace"
      uses: ianbelcher/eks-kubectl-action@master
      with:
        cluster_name: ${{ vars.AWS_EKS_CLUSTER }}
        args: delete ns ${{ env.ENVIRONMENT }}-testing-ns

Пушимо, створюємо Pull Request, і маємо деплой:

Перевіряємо Namespace:

$ kk get ns pr-5-testing-ns
NAME              STATUS   AGE
pr-5-testing-ns   Active   95s

$ kk -n pr-5-testing-ns get pod
NAME                               READY   STATUS             RESTARTS      AGE
test-deployment-77f6dcbd95-cg2gf   0/1     CrashLoopBackOff   3 (35s ago)   78s

Мержимо Pull Request – і видаляємо Helm release та Namespace:

Перевіряємо:

$ kk get ns pr-5-testing-ns
Error from server (NotFound): namespaces "pr-5-testing-ns" not found

Bonus: GitHub Deployments

У GitHub є нова фіча – Deployments, яка ще в Beta, але вже можна користуватись:

Ідея Deployments в тому, що для кожного GitHub Environment можна побачити список всіх деплоїв до ньго – зі статусами та комітами:

Для роботи з Deployments використаємо Action bobheadxi/deployments, який приймає input з іменем env. Якщо в env передається не існуючий в репозиторії Environment – то він буде створений.

Крім того, тут маємо набір stepsstart, finish, deactivate-env та delete-env.

Під час деплою нам треба викликати start і передати ім’я оточення, по завершенні деплою – викликати finish, щоб передати статус деплою, а при видаленні оточення – викликати delete-env.

До джоби deploy-helm у воркфлоу create-feature-env-on-pr.yml додаємо permissions і нові степи.

Степ “Misc: Create a GitHub deployment” – перед викликом “Deploy: Helm“, а степ “Misc: Update the GitHub deployment status” – після виконаня Helm install:

...
permissions:
  id-token: write
  contents: read
  deployments: write 

...
  deploy-helm:

    name: Deploy Helm chart
    if: |
      (contains(github.event.pull_request.labels.*.name, 'deploy') && github.event.action != 'closed') ||
      (github.event.action == 'labeled' && github.event.label.name == 'deploy')    
    runs-on: ubuntu-latest
    timeout-minutes: 15
    needs: build-docker
    ...

    - name: "Misc: Set ENVIRONMENT variable"
      id: set_stage
      run: echo "ENVIRONMENT=pr-${{ github.event.number }}" >> $GITHUB_ENV

    - name: "Misc: Create a GitHub deployment"
      uses: bobheadxi/deployments@v1
      id: deployment
      with:
        step: start
        token: ${{ secrets.GITHUB_TOKEN }}
        env: ${{ env.ENVIRONMENT }}

    - name: "Deploy: Helm"
      uses: bitovi/[email protected]
      with:
        cluster-name: ${{ vars.AWS_EKS_CLUSTER }}
        namespace: ${{ env.ENVIRONMENT }}-testing-ns
        name: test-release
        #atomic: true
        values: image.tag=${{ needs.build-docker.outputs.image_tag }}
        timeout: 60s
        helm-extra-args: --debug

    - name: "Misc: Update the GitHub deployment status"
      uses: bobheadxi/deployments@v1
      if: always()
      with:
        step: finish
        token: ${{ secrets.GITHUB_TOKEN }}
        status: ${{ job.status }}
        env: ${{ steps.deployment.outputs.env }}
        deployment_id: ${{ steps.deployment.outputs.deployment_id }} 
...

Пушимо, і маємо нове оточення “pr-7“:

Ну і на цьому поки все.

Наче все працює – можна додавати нові воркфлоу в репозиторій нашого бекенду.

Loading