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

Terraform: створення Lambda-функцій у VPC
0 (0)

26 Вересня 2023

В пості Loki: збір логів з CloudWatch Logs з використанням Lambda Promtail описано як можна збирати логи з CloudWatch Logs за допомогою Lambda-функції з Promtail, який пересилає логи в Grafana Loki.

Що треба зробити зараз – це описати створення чотирьох таких функцій, по одній на кожен компонент проекту. Функції мають бути розміщені в приватних мережах VPC, щоб мати доступ до Ingress Loki у вигляді Internal Load Balancer.

Підготовка

Використаємо “flat-layout” – всі файли Terraform будуть в корні проекту, а значення змінних для Dev та Prod передамо через окремі файли tfvars, див. Terraform Dev/Prod – Helm-like “flat” approach.

У файлі providers.tf описуємо провайдер AWS:

provider "aws" {
  region = var.aws_region
  default_tags {
    tags = {
      component   = var.component
      created-by  = "terraform"
      environment = var.environment
    }
  }
}

У файлі backend.tf – бекенд для стейт-файлу в S3:

terraform {
  backend "s3" {
    bucket         = "tf-state-backend-atlas-monitoring"
    region         = "us-east-1"
    encrypt        = true
  }
}

Значення key та dynamodb_table передамо під час виконання terraform init, бо для Dev і Prod вони будуть різними.

Додаємо файл variables.tf з поки що двома змінними:

variable "aws_region" {
  type    = string
  default = "us-east-1"
}

variable "project_name" {
  description = "A project name to be used in resources"
  type        = string
  default     = "atlas-lambda"
}

variable "component" {
  description = "A team using this project (backend, web, ios, data, devops)"
  type        = string
}

variable "environment" {
  description = "Dev/Prod, will be used in AWS resources Name tag, and resources names"
  type        = string
}

variable "eks_version" {
  description = "Kubernetes version, will be used in AWS resources names and to specify which EKS version to create/update"
  type        = string
}

Значенням можно передати в defaults, але мені більш подобається задавати значення явно, а не в дефолтах, тому додаємо файл envs/dev/dev.tfvars:

aws_region   = "us-east-1"
environment  = "dev"
component    = "devops"
eks_version  = "1.27"

eks_version використовуємо, щоб створювати окремі ресурси під кожну версію EKS-кластеру, бо під час оновлення версій спокійніше буде створити новий кластер і мігрувати workloads, ніж оновлювати живий Production.

Додаємо файл versions.tf з версіями Terraform та провайдерів:

terraform {

  required_version = "~> 1.5"

  required_providers {
    aws = {
      source  = "hashicorp/aws"
      version = "~> 5.14"
    }
  }
}

Для аутентификації та авторизації в AWS маємо AWS CLI Profile, в якому виконується AssumeRole:

[profile work]
region = us-east-1
output = json

[profile tf-admin]
role_arn = arn:aws:iam::492***148:role/tf-admin
source_profile = work

Для Terraform – задаємо змінну AWS_PROFILE та виконємо terraform init з -backend-config:

$ terraform init -backend-config="key=test/atlas-monitoring-test.tfstate" -backend-config="dynamodb_table=tf-state-lock-atlas-monitoring-test"

Initializing the backend...

Successfully configured the backend "s3"! Terraform will automatically
use this backend unless the backend configuration changes.

Initializing provider plugins...
- Finding hashicorp/aws versions matching "~> 5.14"...
- Installing hashicorp/aws v5.17.0...
- Installed hashicorp/aws v5.17.0 (signed by HashiCorp)

...

Terraform has been successfully initialized!

Створення Lambda-функції

Використовуємо модуль, знов від @Anton Babenkoterraform-aws-modules/lambda/aws.

Для запуску функції нам потрібно мати Docker-образ з Promtail – він вже є, зберігається у ECR-репозиторії.

Крім того нам потрібно мати параметри для VPC – їх отримаємо із outputs іншого Terraform-проекту, який у нас займається управлінням мережами, аналогічно тому, як робили в Terraform: terraform_remote_state – отримання outputs інших state-файлів.

І третє, що треба буде мати – це Security-група, яка дозволить трафік від та до цих Lambd у приватних Subnets нашої VPC. Для її створення візьмемо ще один модуль Антона – terraform-aws-modules/security-group/aws.

SecurityGroup та remote_state

Готуємо файл main.tf, описуємо terraform_remote_state:

data "terraform_remote_state" "vpc" {
  backend = "s3"
  config = {
    bucket         = "tf-state-backend-atlas-vpc"
    key            = "${var.environment}/atlas-vpc-${var.environment}.tfstate"
    region         = var.aws_region
    dynamodb_table = "tf-state-lock-atlas-vpc-${var.environment}"
  }
}

Додаємо локальні змінні:

locals {
  # create a name like 'atlas-monitorig-dev-1-27'
  env_name = "test-${var.project_name}-${var.environment}-${replace(var.eks_version, ".", "-")}"
  # 1.27 => 1-27
  env_version = replace(var.eks_version, ".", "-")
  # save 'outputs' from the VPC project
  vpc_out = data.terraform_remote_state.vpc.outputs
}

В outputs проекту з VPC маємо всі необхідні дані:

...
output "vpc_id" {
  value = module.vpc.vpc_id
}
...
output "vpc_private_subnets_cidrs" {
  value = module.vpc.private_subnets_cidr_blocks
}
...

І vpc_private_subnets_cidrs видається у формі list(string):

Описуємо SecurityGroup:

module "security_group_lambda" {
  source  = "terraform-aws-modules/security-group/aws"
  version = "~> 4.0"

  name        = "${local.env_name}-loki-logger-lambda-sg"
  description = "Security Group for Lambda Egress"

  vpc_id = local.vpc_out.vpc_id

  egress_cidr_blocks      = local.vpc_out.vpc_private_subnets_cidrs
  egress_ipv6_cidr_blocks = []

  ingress_cidr_blocks      = local.vpc_out.vpc_private_subnets_cidrs
  ingress_ipv6_cidr_blocks = []

  egress_rules  = ["https-443-tcp"]
  ingress_rules = ["https-443-tcp"]
}

В egress_cidr_blocks та ingress_cidr_blocks вносимо адреси приватних мереж, а в egress_rules та ingress_rules – значення з auto_groups, де вже маємо готовий набор правил.

Ще раз виконуємо terraform init, щоб додати модуль SecurityGroup, та деплоїмо:

$ terraform apply -var-file=envs/dev/dev.tfvars
...
module.security_group_lambda.aws_security_group.this_name_prefix[0]: Creating...
module.security_group_lambda.aws_security_group.this_name_prefix[0]: Creation complete after 3s [id=sg-006d09a7a0ff0beb5]
module.security_group_lambda.aws_security_group_rule.ingress_rules[0]: Creating...
module.security_group_lambda.aws_security_group_rule.egress_rules[0]: Creating...
module.security_group_lambda.aws_security_group_rule.ingress_rules[0]: Creation complete after 1s [id=sgrule-1358616028]
module.security_group_lambda.aws_security_group_rule.egress_rules[0]: Creation complete after 2s [id=sgrule-892435573]
Releasing state lock. This may take a few moments...

Apply complete! Resources: 3 added, 0 changed, 0 destroyed.

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

Lambda

Приклад є у файлі examples/with-vpc/main.tf.

До variables.tf додаємо ще дві змінних – список Components, та URL образу з Promtail:

...

variable "promtail_lambdas" {
  type = set(string)
}

variable "promtail_image" {
  type    = string
  default = "492***148.dkr.ecr.us-east-1.amazonaws.com/lambda-promtail:latest"
}

І значення promtail_lambdas в dev.tfvars:

...

promtail_lambdas = [
  "backend",
  "web",
  "ios",
  "eks"
]

До locals додаємо змінну з URL інстансу Grafana Loki:

...
locals {
  ...
  # save 'outputs' from the VPC project
  vpc_out = data.terraform_remote_state.vpc.outputs
  # build URL 'logger.1-27.dev.example.co'
  loki_write_address = "https://logger.${replace(var.eks_version, ".", "-")}.${var.environment}.example.co:443/loki/api/v1/push"
}
...

Додаємо модуль terraform-aws-modules/lambda/aws до нашого main.tf:

...

module "lambda_function_from_container_image" {
  source   = "terraform-aws-modules/lambda/aws"
  version  = "~> 6.0"
  for_each = var.promtail_lambdas

  function_name = "grafana-${local.env_name}-loki-logger-${each.value}"
  description   = "Promtail instance to colelct logs from CloudWatch Logs"

  create_package = false

  image_uri     = var.promtail_image
  package_type  = "Image"
  architectures = ["x86_64"]

  environment_variables = {
    EXTRA_LABELS             = "component,${each.value}"
    KEEP_STREAM              = "true"
    OMIT_EXTRA_LABELS_PREFIX = "true"
    PRINT_LOG_LINE           = "true"
    WRITE_ADDRESS            = local.loki_write_address
  }

  vpc_subnet_ids                     = local.vpc_out.vpc_private_subnets_ids
  vpc_security_group_ids             = [module.security_group_lambda.security_group_id]
  attach_network_policy              = true
}

Тут в циклі for_each перебираємо всі Components, для яких треба створити фукцію, в package_type вказуємо, що функція буде запускатись з Docker-образу, в environment_variables.EXTRA_LABELS задаємо лейблу component, яка буде додана до логів в Loki.

У vpc_subnet_ids знов використовуємо outputs проекту VPC, в vpc_security_group_ids вказуємо SecurityGroup, яку створили вище.

Параметр attach_network_policy підключить до IAM Role, яка буде підключена до функції, політику AWSLambdaENIManagementAccess, див. terraform-aws-lambda/blob/master/iam.tf.

Виконуємо terraform init, деплоїмо та перевіряємо:

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

Додамо Subcription Filter до лог-групи кластеру EKS, щоб перевірити, що функція працює:

Метрики функції – дивимось Invocations та Error count and success rate (%):

І логи в Loki:

Готово.

Loading

Terraform: terraform_remote_state – отримання outputs інших state-файлів
0 (0)

22 Вересня 2023

За допомогою data "terraform_remote_state" ми можемо отримати outputs одного проекту, щоб використати в іншому.

Наприклад, у нас AWS VPC створюється окремо від AWS EKS (хоча в серії Terraform: створення EKS, частина 1 – VPC, Subnets та Endpoints VPC створювалась як частина кластеру, але потім я їх розділив).

Для створення EKS – йому треба передати VPC ID, щоб потім з data "aws_subnets" отримати список сабнетів, в яких буде створено кластер Kubernetes.

Можна захаркодити значення VPC – просто задати змінну string, в якій зберігати значення, і в данному випадку це більш-менш робоче рішення, бо VPC ID навряд чи буде часто змінюватись. Проте якщо у вас досить багато значень, або вони динамічні – то є сенс використати terraform_remote_state, який зможе “сходити” в AWS S3 бакет іншого проекту, і отримати актуальні значення прямо зі стейт-файлу.

Отже, що маємо: проект з VPC модулем, який має output:

output "vpc_id" {
  value = module.vpc.vpc_id
}

Він вже задеплоїний, і ми можемо отримати цей ID зі стейту за допомогою terraform output:

$ terraform output vpc_id
"vpc-0958e335e1c910ece"

Сам стейт проекту з VPC зберігається в AWS S3:

$ aws --profile tf-admin s3 ls tf-state-backend-atlas-vpc/dev/
2023-09-22 15:14:43      81292 atlas-vpc-dev.tfstate

Далі, в проекті з модулем EKS додаємо data "terraform_remote_state":

data "terraform_remote_state" "vpc" {
  backend = "s3"
  config = {
    bucket         = "tf-state-backend-atlas-vpc"
    key            = "${var.environment}/atlas-vpc-${var.environment}.tfstate"
    region         = "${var.aws_region}"
    dynamodb_table = "tf-state-lock-atlas-vpc-${var.environment}"
  }
}

При чому тут, на відміну від звичайної конфіграції terraform.backend{} ми можемо використовувати variables.

Створюємо локальну змінну, щоб потім не міняти по всьому коду EKS при якихось змінах в outputs VPC:

locals {
  vpc_out = data.terraform_remote_state.vpc.outputs
}

І використовуємо цей vpc_out:

...
data "aws_subnets" "private" {
  filter {
    name   = "vpc-id"
    values = [local.vpc_out.vpc_id]
  }

  tags = {
    subnet-type = "private"
  }
}

data "aws_subnets" "intra" {
  filter {
    name   = "vpc-id"
    values = [local.vpc_out.vpc_id]
  }

  tags = {
    subnet-type = "intra"
  }
}
...
module "eks" {
  source  = "terraform-aws-modules/eks/aws"
  version = "~> 19.0"
  ...
  vpc_id                   = local.vpc_out.vpc_id
  subnet_ids               = data.aws_subnets.private.ids
  control_plane_subnet_ids = data.aws_subnets.intra.ids
...

Готово.

Loading

GitHub Actions: деплой Dev/Prod оточень з Terraform
0 (0)

21 Вересня 2023

Тепер, як маємо готовий код для розгортання кластеру AWS Elastic Kubernetes Service (див. Terraform: створення EKS, частина 1 – VPC, Subnets та Endpoints і наступні частини), прийшов час подумати про автоматизацію, тобто – про створення пайплайнів в CI/CD, які би виконували створення нових енвів для тестування фіч, або деплоїли апдейти на Dev/Prod оточення Kubernetes.

І тут знову поговоримо про менеджмент Dev/Prod оточень з Terraform.

В попередніх записах я перебирав варіанти з Terraform Workspaces, Git-бранчами та розділенням по директоріям (див. Terraform: початок роботи та планування нового проекту – Dev/Prod та bootsrap та Terraform: динамічний remote state з AWS S3 та multiple environments по директоріям), але поки писав код Terraform, то подумав про те, як зазвичай робиться з Helm-чартами: весь код чарту зберігається в корні репозиторію або окремій директорії, а значення для Dev/Prod передаються з різних файлів values.yaml.

То чому б не спробувати аналогічний підхід з Terraform?

В будь-якому разі те, що описано в цьому пості не треба сприймати як приклад “це робиться саме так”: тут я більше експерементую і пробую можливості GitHub Actions та Terraform. А в якому вигляді воно піде в продакшен – подивимось.

Проте вийшло доволі цікаво.

Terraform Dev/Prod – Helm-like “flat” approach

Отже, конфігурація для Terraform може бути такою:

  • аутентифікацію та атворизацію робимо локально зі змінною AWS_PROFILE, в якому виконується AssumeRole, а в GitHub Actions – через OIDC і AssumeRole – таким чином зможемо мати різні параметри для AWS Provider для Dev та Prod оточень
  • весь код Terraform зберігається в корні
  • tfvars для Dev/Prod в окремих директоріях envs/dev та envs/prod
  • загальні параметри backend описуємо у файлі backend.hcl, а ключі і таблицю Дінамо передаємо через -backend-config

А флоу розробки та деплою – таким:

  • бранчуємось від мастер-бранчу
  • робимо зміни в коді, тестуємо локально або через GitHub Actions, передаючи потрібні параметри через -var (ім’я енву, якісь версії і т.д.)
  • як закінчили розробку і тести – пушимо в репозиторій, і робимо Pull Request
  • при створенні Pull Request можемо задати спеціиальну лейблу, і тоді GitHub Actions виконують деплой на feature-енв, тобто створюється тимчасова інфрастуктура в AWS
  • Dev-оточення, щоб перевірити, що все працює
  • після мержу Pull Request можемо додатково задеплоїти на Dev-оточення, щоб перевірити, що зміни між строю версією та новою працюють (такий собі staging env)
  • якщо треба задеплоїти на Прод – створюємо Git Release
  • вручну або автоматично тригерим GitHub Actions на деплой з цим релізом
  • profit!

А що там Terragrunt?

Звісно, для управління оточеннями і бекендами можна було б просто взяти Terragrunt, але я поки не впевнений в тому, яка модель управління інфрастуктурою з Terraform складеться на проекті: якщо цим будуть займатись виключно девопси – то, скоріш за все, візьмемо Terragrunt. Якщо діло зайде і девелоперам, і вони захочуть самі щось менеджити – то, можливо, для управління візьмемо якесь рішення на кштал Atlantis або Gaia.

Тому поки це все тільки починається – робимо все максимально просто і “ванільним” Terraform, а далі подивимось, що і як буде краще.

Terraform layout for multiple environments

Зараз структура файлів і каталогів виглядає так – в environments/dev весь код для Dev, в environments/prod – весь код для Prod (поки пусто):

$ tree terraform/
terraform/
└── environments
    ├── dev
    │   ├── backend.tf
    │   ├── configs
    │   │   └── karpenter-provisioner.yaml.tmpl
    │   ├── controllers.tf
    │   ├── eks.tf
    │   ├── iam.tf
    │   ├── karpenter.tf
    │   ├── main.tf
    │   ├── outputs.tf
    │   ├── providers.tf
    │   ├── terraform.tfvars
    │   ├── variables.tf
    │   └── vpc.tf
    └── prod

Однак так як ми тут не використвуємо модулі, то код дуже дублюється, і менеджити його буде важко і як то кажусь, “error prone”.

Можна було б створити модулі, але тоді були б модулі в модулях, бо весь EKS-стек створюється через модулі – VPC, EKS, Subnets, IAM, etc, і ускладнювати код, виносячи код в модулі поки що не хочеться, бо знов-таки – подивимось, як підуть процеси. Поки це все на самому початку – то маємо змогу досить швидко переробити так, як потім буде краще в залежності від того, яку модель і систему управління оточеннями оберемо.

Тож що нам треба зробити:

  • перенести файли .tf в корень директорії terraform
  • оновити файл providers.tf – видалити з нього assume_role та прибрати --profile в args провайдерів Kubernetes, Helm та kubectl
  • створити файл backend.hcl для common-конфігурації
  • у файлі backend.tf лишити тільки backend "s3"
  • environments перейменуємо в envs
    • всередені будуть каталоги dev та prod, в яких будуть зберігатись terraform.tfvars для кожного енва

Providers

providers.tf зараз виглядає так:

provider "aws" {
  region = var.aws_region
  assume_role {
    role_arn = "arn:aws:iam::492***148:role/tf-admin"
  }
  default_tags {
    tags = {
      component   = var.component
      created-by  = "terraform"
      environment = var.environment
    }
  }
}

Прибираємо з нього assume_role:

provider "aws" {
  region = var.aws_region
  default_tags {
    tags = {
      component   = var.component
      created-by  = "terraform"
      environment = var.environment
    }
  }
}

В інші провайдерах – Kubernetes, Helm та Kubectl  – в args прибираємо --profile:

...
provider "kubernetes" {
  host                   = module.eks.cluster_endpoint
  cluster_ca_certificate = base64decode(module.eks.cluster_certificate_authority_data)

  exec {
    api_version = "client.authentication.k8s.io/v1beta1"
    command     = "aws"
    args        = ["eks", "get-token", "--cluster-name", module.eks.cluster_name]
  }
}
...

Versions

Версії виносимо в окремий файл vesrions.tf:

terraform {

  required_version = "~> 1.5"
  required_providers {
    aws = {
      source  = "hashicorp/aws"
      version = "~> 5.14"
    }
    kubernetes = {
      source  = "hashicorp/kubernetes"
      version = "~> 2.23"
    }
    helm = {
      source  = "hashicorp/helm"
      version = "~> 2.11"
    }
    kubectl = {
      source  = "gavinbunney/kubectl"
      version = "~> 1.14"
    }
  }
}

Переносимо файли, і тепер структура виглядає так:

$ tree
.
├── backend.tf
├── configs
│   └── karpenter-provisioner.yaml.tmpl
├── controllers.tf
├── eks.tf
├── iam.tf
├── karpenter.tf
├── main.tf
├── outputs.tf
├── providers.tf
├── variables.tf
├── envs
│   ├── dev
│   │   └── dev.tfvars
│   └── prod
├── versions.tf
└── vpc.tf

Backend

В корні створюємо файл backend.hcl:

bucket         = "tf-state-backend-atlas-eks"
region         = "us-east-1"
dynamodb_table = "tf-state-lock-atlas-eks"
encrypt        = true

У файлі backend.tf залишаємо тільки s3:

terraform {
  backend "s3" {}
}

Окей – наче все готово? Давайте тестити.

Тестування з terraform init && plan для різних оточень

Маємо AWS Profile в ~/.aws/config:

[profile work]
region = us-east-1
output = json

[profile tf-assume]
role_arn = arn:aws:iam::492***148:role/tf-admin
source_profile = work

Перевіряємо існуючий ключ в S3 – бо вже маємо задеплоїний Dev:

$ aws s3 ls tf-state-backend-atlas-eks/dev/
2023-09-14 15:49:15     450817 atlas-eks.tfstate

Задаємо змінну AWS_PROFILE, і пробуємо terraform init з backend-config:

$ export AWS_PROFILE=tf-admin

$ terraform init -backend-config=backend.hcl -backend-config="key=dev/atlas-eks.tfstate"

Initializing the backend...

Successfully configured the backend "s3"! Terraform will automatically
...
Terraform has been successfully initialized!

Наче ОК – модулі загрузились, існуючий стейт-файл знайшло, все гуд.

Пробуємо terraform plan – в коді нічого не мінялось, тож нічого не має змінитись і в AWS:

$ terraform plan -var-file=envs/dev/dev.tfvars
...
Terraform will perform the following actions:

  # helm_release.karpenter will be updated in-place
  ~ resource "helm_release" "karpenter" {
        id                         = "karpenter"
        name                       = "karpenter"
      ~ repository_password        = (sensitive value)
        # (28 unchanged attributes hidden)

        # (6 unchanged blocks hidden)
    }

Plan: 0 to add, 1 to change, 0 to destroy.
...

Найс! Змінить тільки пароль для AWS ECR, це ОК, бо там токен, що міняється.

Спробуємо зробити теж саме, але для Prod – копіюємо файл dev.tfvars як prod.tfvars в каталог envs/prod, міняємо в ньому данні – тільки VPC CIDR та environment:

project_name = "atlas-eks"
environment  = "prod"
component    = "devops"
eks_version  = "1.27"
vpc_params = {
  vpc_cidr               = "10.2.0.0/16"
  enable_nat_gateway     = true
...

І пробуємо новий terraform init з -reconfigure, бо робимо це все локально. В backend-config передаємо новий ключ для стейт-файлу в S3:

$ terraform init -reconfigure -backend-config=backend.hcl -backend-config="key=prod/atlas-eks.tfstate"

Initializing the backend...

Successfully configured the backend "s3"! Terraform will automatically
...
Terraform has been successfully initialized!

Перевіряємо з terraform plan:

$ terraform plan -var-file=envs/prod/prod.tfvars
...
Plan: 109 to add, 0 to change, 0 to destroy.
...

Добре! Збирається створити купу нових ресурсів, бо Prod ще не робив, тобто все, як задумано.

Для feature-енвів все теж саме, тільки в -var передаємо ще environment і в backend-config задаємо новий ключ по імені енва:

$ terraform init -reconfigure -backend-config=backend.hcl -backend-config="key=feat111/atlas-eks.tfstate" -var="environment=feat111"
...

Для plan та apply використовуємо параметри з файлу dev.tfvars, бо фіча-енви в принципі більш-менш ~= dev-оточенню:

$ terraform plan -var-file=envs/dev/dev.tfvars -var="environment=feat111" 
...

Якщо потрібно задати наприклад нову VPC, яка у нас object, в якому є string vpc_cidr:

...
variable "vpc_params" {
  description = "AWS VPC for the EKS cluster parameters. Note, that this VPC will be environment-wide used, e.g. all Dev/Prod AWS resources must be placed here"
  type = object({
    vpc_cidr               = string
    enable_nat_gateway     = bool
    one_nat_gateway_per_az = bool
    single_nat_gateway     = bool
    enable_vpn_gateway     = bool
    enable_flow_log        = bool
  })
}
...

Тож її передаємо як -var='vpc_params={vpc_cidr="10.3.0.0/16"}'. Але в такому випадку потрібно передавати всі значення об’єкту vpc_params, тобто:

$ terraform plan -var-file=envs/dev/dev.tfvars -var="environment=feat111" \
> -var='vpc_params={vpc_cidr="10.3.0.0/16",enable_nat_gateway=true,one_nat_gateway_per_az=true,single_nat_gateway=false,enable_vpn_gateway=false,enable_flow_log=false}'

Окей – з цим розібрались, можна переходити до GitHub Actions.

Авторизація і аутентифікація – OIDC та IAM

Для доступу з GitHub Actions до AWS ми використовуємо OpenID Connect – тут GitHub виступає в ролі Identity Provider (IDP), а AWS – Service Prodider (SP), тобто на GitHub ми проходимо аутентифікацю, після чого GitHub “передає” нас до AWS, кажучи, що “це дійсно Вася Пупкін”, а вже AWS виконує авторизацію – тобто, AWS перевіряє, чи може цей Вася Пупкін створювати нові ресурси.

Для авторизації в AWS ми в Terraform виконуємо IAM Role Assume, і вже від імені цієї ролі і IAM Policy, які підключені до неї, ми проходимо авторизацію.

Щоб мати можливість виконувати AsuumeRole цієї ролі використовуючи GitHub Identity Provider – треба змінити її Trusted Policy, бо зараз вона дозволяє AssumeRole тільки для юзерів з AWS акаунту:

{
    "Version": "2012-10-17",
    "Statement": [
        {
            "Sid": "Statement1",
            "Effect": "Allow",
            "Principal": {
                "AWS": "arn:aws:iam::492***148:root"
            },
            "Action": "sts:AssumeRole"
        }
    ]
}

Документація – Configuring the role and trust policy.

Додаємо token.actions.githubusercontent.com IDP з нашого AWS АІМ:

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Sid": "AccountAllow",
      "Effect": "Allow",
      "Principal": {
        "AWS": "arn:aws:iam::492***148:root"
      },
      "Action": "sts:AssumeRole"
    },
        {
            "Sid": "GitHubAllow",
            "Effect": "Allow",
            "Principal": {
                "Federated": "arn:aws:iam::492***148:oidc-provider/token.actions.githubusercontent.com"
            },
            "Action": "sts:AssumeRoleWithWebIdentity",
            "Condition": {
                "StringEquals": {
                    "token.actions.githubusercontent.com:aud": "sts.amazonaws.com"
                }
            }
        }		
  ]
}

GitHub Actions: recall the moment!

Колись писав про них – Github: обзор Github Actions и деплой с ArgoCD, але то було давно, та й сам я Actions майже не користувався, то треба трохи згадати що до чього.

Див. також Understanding GitHub Actions.

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

  • є events, як тригерять jobs
  • в jobs маємо steps з одним чи кількома actions
  • actions виконують конкретні дії, і actions однієї job мають запускатись на одному GitHub Actions Runner

 

При цьому в Actions можемо використовувати готові “бібліотеки” з Github Actions Marketplace.

GitHub Environments

GitHub дозволяє налаштувати декілька оточень для роботи, і в кожному мати свій набір Variables та Secrets. Крім того, для них можна налаштувати правила, згідно з якими має відбуватись деплой. наприклад – дозволити деплой на Prod тільки з master-гілки.

Див. Using environments for deployment.

Тож що ми можемо зробити:

  • Dev env зі своїми змінними
  • Prod env зі свіоми змінними
  • і створювати feature-енви динамічно під час деплою

GitHub Actions Reusable Workflow та Composite Actions

З Reusable Workflow ми можемо використати вже описаний Workflow в іншому workflow, а з Composite Actions – створити власну Action, яку потім включимо в steps наших workflow-файлів.

Детальніше про різницю між ними можна почитати тут – GitHub: Composite Actions vs Reusable Workflows [Updated 2023] і чудовий пост Github Not-So-Reusable Actions, але якщо кратко, то:

  • при створенні Workflow ви групуєте декілька Jobs в єдиний файл і використовувати тільки в Jobs, тоді як в Actions будуть тільки steps, які групуються в єдиний Action, який потім можна використовувати тільки як step
  • Workflow не можуть викликати інші Workflow, тоді як в Actions ви можете викликати інші Actions

Крім того, замість Reusable Workflow можна використати Running a workflow based on the conclusion of another workflow – тобто тригерити запуск воркфлоу після завершення роботи іншого воркфлоу.

Створення тестового workflow для Terraform validate && lint

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

Див. Quickstart for GitHub Actions, документація по сінтаксісу – Workflow syntax for GitHub Actions, і по permissions нашої джоби – Assigning permissions to jobs.

Для Terraform будемо використовувати setup-terraform Action.

Для логіну в AWS – configure-aws-credentials Action.

В репозиторії створюємо директорію:

$ mkdir -p .github/workflows

І в ній створюємо файл воркфлоу – testing-terraform.yaml.

Так як код Terraform лежить в каталозі terraform, то в workflow додамо defaults.run.working-directory та умову on.push.paths, тобто трігерити білд тільки якщо зміни відбулися в каталозі terraform, див. paths.

Описуємо флоу:

name: Test Terraform changes

defaults:
  run:
    working-directory: terraform

on: 
  workflow_dispatch:
  push:
    paths:
      - terraform/**

env:
  ENVIRONMENT : "dev"
  AWS_REGION : "us-east-1"

permissions:
  id-token: write
  contents: read

Тут:

  • в defaults задаємо з якої директорії будуть виконуватись всі степи
  • в on – умови, по пушу і тільки для директорії terraform і додатково workflow_dispatch для можливості ручного запуску
  • в env – значення змінних оточення
  • в  permissions – права джоби на створення токену.

В джобах ми будемо виконувати AssumeRole, давайте її занесемо в змінні оточення репозиторію.

Переходимо в Setting > Secrets and variables > Actions > Variables, і додаємо нову змінну:

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

  1. git checkout
  2. логін в AWS
  3. terraform fmt -check для перевірки “красоти коду” (див. fmt)
  4. terraform init з Dev-оточенням – загрузити модулі і підключитись до state-бекенду
  5. terraform validate для перевірки синтаксису коду (див. validate)

Додаємо джобу з цими степами, тепер файл повністю буде таким:

name: Test Terraform changes

defaults:
  run:
    working-directory: terraform

on: 
  workflow_dispatch:
  push:
    paths:
      - terraform/**

env:
  ENVIRONMENT : "dev"
  AWS_REGION : "us-east-1"

permissions:
  id-token: write
  contents: read

jobs:
  test-terraform:
    runs-on: ubuntu-latest
    steps:

      - uses: actions/checkout@v3
      - uses: hashicorp/setup-terraform@v2

      - name: Terraform fmt
        run: terraform fmt -check
        continue-on-error: true

      - name: "Setup: Configure AWS credentials"
        # this step adds `env.AWS_*` variables
        uses: aws-actions/configure-aws-credentials@v2
        with:
          role-to-assume: ${{ vars.TF_AWS_ROLE }}
          role-session-name: github-actions-terraform
          role-duration-seconds: 7200
          aws-region: ${{ env.AWS_REGION }}

      - name: Terraform Init
        run: terraform init -backend-config=backend.hcl -backend-config="key=${{ env.ENVIRONMENT }}/atlas-eks.tfstate"

      - name: Terraform Validate
        run: terraform validate

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

І маємо наш перший ран:

Note: аби з’явилась кнопка для ручного запуску (workflow_dispatch) – зміни в workflow-файлі мають бути змержені з дефолтним бранчем репозиторія

GitHub Actions TFLint step

Тепер давайте додамо степ з Terraform linter – TFLint.

Спочатку глянемо, як воно працює локально:

$ yay -S tflint

$ tflint
3 issue(s) found:

Warning: module "ebs_csi_irsa_role" should specify a version (terraform_module_version)

  on iam.tf line 1:
   1: module "ebs_csi_irsa_role" {

Reference: https://github.com/terraform-linters/tflint-ruleset-terraform/blob/v0.1.1/docs/rules/terraform_module_version.md

Warning: module "karpenter" should specify a version (terraform_module_version)

  on karpenter.tf line 1:
   1: module "karpenter" {

Reference: https://github.com/terraform-linters/tflint-ruleset-terraform/blob/v0.1.1/docs/rules/terraform_module_version.md

Слушні зауваження – забув додати версії модулів, треба пофіксити.

Далі, додамо його на нашого воркфлоу – використаємо marketplace/actions/setup-tflint, версію беремо на сторінці релізів TFLint:

...

      - name: Setup TFLint
        uses: terraform-linters/setup-tflint@v3
        with:
          tflint_version: v0.48.0

      - name: Terraform lint
        run: tflint -f compact

Запускаємо, і маємо сфейлений білд:

Добре.

Фіксимо проблеми, і йдемо далі до деплоїв.

Планування деплоїв з GitHub Actions

Тепер можна подумати про те, як нам побудувати workflow.

Які ми маємо дії в GitHub-репозиторії, і які дії з Terraform нам можуть знадобитись?

В GitHub:

  • Push в будь-який бранч
  • створення Pull Request на merge в master
  • закриття Pull Request на merge в master

З Terraform:

  • тестування: fmt, validate, lint
  • деплой: plan та apply
  • дестрой: plan та destroy

І як ми можемо це все скомбінувати в різні workflow?

  • при Push – виконувати тест
  • при створенні PR в мастер з лейблою “deploy” – деплоїти feature-енв (тест + деплой)
  • при закритті PR – видаляти feature-енв (дестрой)
  • вручну мати можливість:
    • задеплоїти на Dev будь-який бранч або реліз (тест + деплой)
    • задеплоїти на Prod реліз з мастер-бранчу (тест + деплой)

Тобто виглядає, як п’ять воркфлоу, при чому майже в усіх маємо дії, які повторюються – логін, init та test, тож їх має сенс винести в окремі “функції” – використати Reusable Workflow або Composite Actions.

AWS Login та init можемо об’єднати, і додатково створити “функцію” test. Крім того – додати “функції” “deploy” та “destroy”, і потім “модульно” їх використовувати у наших workflow:

  • terraform-init:
    • AWS Login
    • terraform init
  • test:
    • terraform validate
    • terraform fmt
    • tflint
  • deploy:
    • terraform plan
    • terraform apply
  • destroy:
    • terraform plan
    • terraform destroy

Тож якщо планувати з п’ятьма окремими воркфлоу з використанням GitHub Environments та “функціями”, то це будуть:

  • тест-он-пуш:
    • запускається на push event
    • запускається з environment Dev, де маємо змінні для Dev-оточення
    • викликає “функції” terraform-init та test
  • деплой-дев:
    • запускається вручну
    • запускається з environment Dev, де маємо змінні для Dev-оточення
    • викликає “функції” terraform-init, test та deploy
  • деплой-прод:
    • запускається вручну
    • запускається з environment Prod, де маємо змінні для Prod-оточення та protection rules
    • викликає “функції” terraform-init, test та deploy
  • деплой-фіча-енв:
    • запускається при створенні ПР з лейблою “деплой”
    • запускається з динамічним оточенням (ім’я генерується з Pull Request ID і створюється під час деплою)
    • викликає “функції” terraform-init, test та deploy
  • дестрой-фіча-енв:
    • запускається при закритті ПР з лейблою “деплой”
    • запускається з динамічним оточенням (ім’я генерується з Pull Request ID  і під час деплою оточення вибирається з існуючих)
    • викликає “функції” terraform-init, test та destroy

Як саме робити ці “функції” – з Reusable Workflow чи Composite Actions? В прицнипі, в данному випадку мабуть різниці великої нема, бо будемо мати один рівень вкладеності, але тут є нюанс з GitHub Runners: якщо в Workflow робити задачі “terraform-init”, “test” та “deploy” в різних jobs (а Reusable Workflow мають бути тільки в jobs, більш того – ці jobs не можуть мати інших steps) – то вони скоріш за все будуть запускатись на різних Runners, і в такому випадку для виконання terraform plan && apply && destroy нам доведеться кілька раз виконувати step з AWS Login та terraform init.

Тому виглядає більш правильним запускати всі задачі в рамках однієї job, тобто для створення наших “функцій” використати Composite Actions.

Єдине, що в цьому пості я все ж не буду описувати деплой feature-енвів, бо по-перше – пост і так вже досить довгий і містить багато нвоої (принаймні для мене) інформації, по-друге – створення додаткових тестових кластерів AWS EKS буде скоріш виключенням, і буде робитись мною, а я поки що це можу зробити і без додаткової автоматизації. Втім, скоріш за все опишу створення динамічних оточень, коли буду робити GitHub Actions Workflow для деплоїв Helm-чартів.

Окей – давайте пробувати деплоїти Dev та Prod.

Підготовка деплоїв

Створення Actions secrets and variables

У нас буде декілька змінних, які будуть однакові для всіх оточень – AWS_REGION та TF_AWS_PROFILE, тож їх задамо на рівні всього репозиторію.

Переходимо в Settings  – Actions secrets and variables > Variables, і додаємо Repository variables:

Створення GitHub Environments

Додаємо два оточення – Dev та Prod:

Аналогічно для Prod.

Створення test-on-push Workflow

Створення Composite Action “terraform-init”

В директорії .github додаємо каталог actions, в якому створюємо каталог terraform-init з файлом action.yaml, в якому описуємо сам Action.

Див. Creating a composite action.

В inputs приймаємо три параметри, які потім передамо з Workflow:

name: "AWS Login and Terraform Init"
description: "Combining AWS Login && terraform init actions into one"

inputs:
  environment:
    type: string
    required: true
  region:
    type: string
    required: true
  iam-role:
    type: string
    required: true

runs:
  using: "composite"
  
  steps:

    - uses: hashicorp/setup-terraform@v2

    - name: "Setup: Configure AWS credentials"
      # this step adds `env.AWS_*` variables
      uses: aws-actions/configure-aws-credentials@v2
      with:
        role-to-assume: ${{ inputs.iam-role }}
        role-session-name: github-actions-terraform
        role-duration-seconds: 7200
        aws-region: ${{ inputs.region }}

    - name: Terraform Init
      run: terraform init -backend-config=backend.hcl -backend-config="key=${{ inputs.environment }}/atlas-eks.tfstate"
      shell: bash
      working-directory: terraform

Тут є нюанс з working-directory, який в Composite Action треба задавати окремо для кожного step, бо сам Action в workflow запускається в корні репозиторія, а використати defaults, як це можна зробити в workflow не можна, хоча feature request є давно.

Створення Composite Action “test”

В директорії actions створюємо ще один каталог – terraform-test з файлом action.yaml, в якому описуємо другий Action:

name: "Test and verify Terraform code"
description: "Combining all test actions into one"

inputs:
  environment:
    type: string
    required: true
  region:
    type: string
    required: true
  iam-role:
    type: string
    required: true

runs:
  using: "composite"
  
  steps:

    - uses: hashicorp/setup-terraform@v2

    - name: Terraform Validate
      run: terraform validate
      shell: bash
      working-directory: terraform

    - name: Terraform fmt
      run: terraform fmt -check
      shell: bash
      working-directory: terraform
      continue-on-error: false

    - name: Setup TFLint
      uses: terraform-linters/setup-tflint@v3
      with:
        tflint_version: v0.48.0

    - name: Terraform lint
      run: tflint -f compact
      shell: bash
      working-directory: terraform

Створення workflow

Далі, в директорії .github/workflows створюємо файл test-on-push.yaml з новим флоу:

name: Test Terraform code

on: 
  workflow_dispatch:
  push:
    paths:
      - terraform/**

permissions:
  id-token: write
  contents: read

jobs:
  test-terraform:
    environment: 'dev'
    runs-on: ubuntu-latest
    steps: 

      - uses: actions/checkout@v3

      - name: 'Setup: AWS Login and Terraform init'
        uses: ./.github/actions/terraform-init
        with:
          environment: ${{ vars.ENVIRONMENT }}
          region: ${{ vars.AWS_REGION }}
          iam-role: ${{ vars.TF_AWS_ROLE }}

      - name: 'Test: check and validate'
        uses: ./.github/actions/terraform-test
        with:
          environment: ${{ vars.ENVIRONMENT }}
          region: ${{ vars.AWS_REGION }}
          iam-role: ${{ vars.TF_AWS_ROLE }}

В job: test-terraform задаємо використання environment: 'dev' і його змінних (у нас там вона одна – ENVIRONMENT=dev, а в stepsAWS Login and Terraform init” і “Test and validate” викликаємо наші файли з Actions, які через with передаємо параметри зі значеннями із змінних.

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

Можемо переходити до деплою.

Створення deploy-dev Workflow

Отже, що ми хочемо?

  • деплоїти на Дев з бранчу або тегу
  • деплоїти на Прод вручну з релізу

Давайте почнемо з Дев-деплою.

Створення Composite Action “deploy”

Створюємо ще один каталог для нового Action – actions/terraform-apply, в ньому у файлі action.yaml описуємо сам Action:

name: "Plan and Apply Terraform code"
description: "Combining plan && apply actions into one"

inputs:
  environment:
    type: string
    required: true
  region:
    type: string
    required: true
  iam-role:
    type: string
    required: true

runs:
  using: "composite"
  
  steps:

    - uses: hashicorp/setup-terraform@v2

    - name: Terraform plan
      run: terraform plan -var-file=envs/${{ inputs.environment }}/${{ inputs.environment }}.tfvars 
      shell: bash
      working-directory: terraform

    - name: Terraform apply
      run: terraform apply -var-file=envs/${{ inputs.environment }}/${{ inputs.environment }}.tfvars -auto-approve
      shell: bash
      working-directory: terraform

Створення workflow

Додаємо новий файл workflow – deploy-dev.yaml:

name: Deploy Dev environment

concurrency:
  group: deploy-${{ vars.ENVIRONMENT }}
  cancel-in-progress: false

on: 
  workflow_dispatch:
  push:
    paths:
      - terraform/**

permissions:
  id-token: write
  contents: read

jobs:
  deploy-to-dev:
    environment: dev
    runs-on: ubuntu-latest
    steps: 

      - uses: actions/checkout@v3

      - name: 'Setup: AWS Login and Terraform init'
        uses: ./.github/actions/terraform-init
        with:
          environment: ${{ vars.ENVIRONMENT }}
          region: ${{ vars.AWS_REGION }}
          iam-role: ${{ vars.TF_AWS_ROLE }}

      - name: 'Test: check and validate'
        uses: ./.github/actions/terraform-test
        with:
          environment: ${{ vars.ENVIRONMENT }}
          region: ${{ vars.AWS_REGION }}
          iam-role: ${{ vars.TF_AWS_ROLE }}

      - name: 'Deploy: to ${{ vars.ENVIRONMENT }}'
        uses: ./.github/actions/terraform-apply
        with:
          environment: ${{ vars.ENVIRONMENT }}
          region: ${{ vars.AWS_REGION }}
          iam-role: ${{ vars.TF_AWS_ROLE }}

В умовах on.workflow_dispatch можна додати ще inputs, див. on.workflow_dispatch.inputs.

Усі умови, якими можна тригерити воркфлоу – див. у Events that trigger workflows.

В concurrency задаємо умову “ставити в чергу, а не виконувати паралельно, якщо запущено іншу job, яка використовує такий же concurrency.group“, див. Using concurrency.

Тобто, якщо ми робимо перевірку по group: deploy-${{ vars.ENVIRONMENT }} – то якщо після запуску Dev запустити деплой на Prod – то він буде очікувати, поки не завершиться деплой на Dev:

Пушимо, мержимо в master, щоб з’вилась кнопка “Run workflow“, і перевіряємо:

І після деплою:

Створення deploy-prod Workflow та захист від випадкового запуску

Composite Action ми вже маємо, тож створювати її не треба.

В принципі, тут маємо все аналогчно до деплою на Dev – concurrency, ручний запуск, jobs, едине, що тут має бути інша умова запуску – по тегам замість бранчів і тегів.

Тобто на Дев можемо деплоїти будь-який бранч або тег, а на Прод – тільки тег.

Взагалі я тут намагаюсь уникнути автоматичного деплою, бо це все ж інфрастуктура і все ще в процессі роботи напильником, хоча якщо б у нас був GitHub Enterprise – то можна було б використати Deployment protection rules і Required reviewers в них, а в умові on для запуску воркфлоу деплою на Прод використати створення релізів:

on:
  release:
    types: [published]

Тобто – деплоїти на Prod тільки коли створено новий GitHub Release, і тільки після того, як, наприклад, деплой апрувнуто кимось з DevOps-тіми.

Ще один варіант забезпечення безпеки Prod-оточення – це дозволяти мерж в master тільки від Code Owners в Branch protection rules:

А Code Owners описати у файлі CODEOWNERS, і дозволити деплой на Prod тільки з мастер-гілки:

Але я хочу тут використати флоу з деплояіми тільки з релізів або тегів, щоб мати щось на кшталт версіонювання.

Тож єдиний варіант – це використати умову if в самій job, див. Using conditions to control job execution:

...
jobs:
  deploy-to-prod:
    if: github.ref_type == 'tag'
    environment: prod
...

Ну і можна комбінувати умови, наприклад – додатково через github.actor або github.triggering_actor перевіряти хто саме запускає деплой, і дозволити тільки певним юзерам. Див. всі типи в github context.

Як додатковий захист від випадкового запуску – можна додати input, до треба явно вказати “yes”:

...
on: 
  workflow_dispatch:
    inputs:
      confirm-deploy:
        type: boolean
        required: true
        description: "Set to confirm deploy to the PRODUCTION environment"
...

А потім перевірити в if обидві умови – github.ref_type та github.event.inputs.confirm-deploy.

Тож поки вокрфлоу для Проду буде таким:

name: Deploy Prod environment

concurrency:
  group: deploy-${{ vars.ENVIRONMENT }}
  cancel-in-progress: false

on: 
  workflow_dispatch:
    inputs:
      confirm-deploy:
        type: boolean
        required: true
        description: "Set to confirm deploy to the PRODUCTION environment"

permissions:
  id-token: write
  contents: read

jobs:
  deploy-to-prod:
    if: ${{ github.ref_type == 'tag' && github.event.inputs.confirm-deploy == 'true' }}

    environment: prod
    runs-on: ubuntu-latest
    steps: 

      - uses: actions/checkout@v3

      - name: 'Setup: AWS Login and Terraform init'
        uses: ./.github/actions/terraform-init
        with:
          environment: ${{ vars.ENVIRONMENT }}
          region: ${{ vars.AWS_REGION }}
          iam-role: ${{ vars.TF_AWS_ROLE }}

      - name: 'Test: check and validate'
        uses: ./.github/actions/terraform-test
        with:
          environment: ${{ vars.ENVIRONMENT }}
          region: ${{ vars.AWS_REGION }}
          iam-role: ${{ vars.TF_AWS_ROLE }}

      - name: 'Deploy: to ${{ vars.ENVIRONMENT }}'
        uses: ./.github/actions/terraform-apply
        with:
          environment: ${{ vars.ENVIRONMENT }}
          region: ${{ vars.AWS_REGION }}
          iam-role: ${{ vars.TF_AWS_ROLE }}

І при деплої – треба по-перше вибрати тег, а не бранч, по-друге – поставити відмітку “Confirm”:

А вже по ходу діла подивимось, як все буде складатись.

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

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

Так що – далі буде ще.

Пару моментів на додачу, доробити потім:

  • Action setup-terraform приймає інпут terraform_version, але не вміє це робити з versions-файлу; поки варіант робити через міні-костиль з додатковим step, див. цей коммент
  • в jobs має сенс додати параметр timeout-minutes, щоб джоби при проблемах не зависали на довгий час, і не вичерпували Runners time

Loading