Хоча в заголовку цього поста я вказав “Helm Charts”, але з коробки і з дефолтними параметрами Renovate виконає перевірку просто всього, що є в репозиторії і має якісь versions та dependencides.
Ну і коли я писав, що Dependabot “швидко і просто конфігуриться“, то у випадку з Renovate це взагалі фактично робиться в кілька кліків і працює просто з коробки.
Підключення Renovate до GitHub
Переходимо на сторінку Renovate GitHub App, клікаємо Install, вибираємо в які репозиторії його підключити.
Я поки для тестів додам тільки в один репозиторій з нашим моніторингом де маємо Terraform та Helm:
Переходимо до репозиторію, і маємо відкритий Pull Request для ініціалізації Renovate:
Ііі… В принципі – це все 🙂
Налаштування Renovate
В цьому PR маємо створений файл renovate.json з мінімальним конфігом:
Крім того, Renovate відразу визначив, які пакети в цьому репозиторії є:
І відразу визначає, що треба оновити:
А на сторінці репозиторію на developer.mend.io побачимо всі деталі перевірки:
Тепер можемо додати трохи свої параметрів, яких прям дуже багато, бо Renovate дозволяє дуже гнучко налаштувати ваші перевірки – див. всі на Configuration Options.
Наприклад, додамо розклад запуску, лейбли та будемо PR відразу асайнити на мене:
З часом, коли проект росте, то рано чи пізно постане питання про апгрейд версій пакетів, модулів, чартів.
Робити це вручну, звісно, можна – але тільки до якоїсь межі, бо врешті-решт ви просто фізично не зможете моніторити та оновлювати все.
Для автоматизації таких процесів існує багато рішень, але найчастіше зустрічаються два – Renovate та Dependabot.
За результатами опитування в UkrOps Slack, Renovate набрав набагато більше голосів, і в принципі він вміє більше, ніж Dependabot.
З іншої сторони – Dependabot вже є в GitHub репозиторіях, доступний у всіх тарифних планах, тож якщо ви використовуєте GitHub – то для налаштування Dependabot вам просто потрібно додати води додати файл конфігурації. Хоча, трохи забігаючи наперед – Renovate налаштовується ще простіше, але про це в наступному пості – Renovate: GitHub та Helm Charts versions management.
Взагалі, Dependabot можна мати майже на всіх платформах – GitHub, Github Enterprise, Azure DevOps, GitLab, BitBucket та AWS CodeCommit, див. How to run Dependabot.
Але – це для мене був прям surprize-surprize – Dependabot не вміє в Helm-чарти. Хоча з Terraform працює, і вже є в деяких наших репозиторіях з Python-кодом, тож для початку давайте глянемо на нього.
Знов-таки, забігаючи наперед – Renovate мені зайшов набагато більше, і ми будемо використовувати його.
Налаштування Dependabot
Отже, як це працює:
в репозиторії створюється файл конфігурації Dependabot
в ньому описується що саме він має перевіряти – бібліотеки pip, модулі Terraform тощо
описується що саме цікавить – secrity updates, або versions updates
при знаходженні апдейтів – Dependabot створює Pull Request, в якому додає деталі по апдейту
Що можемо моніторити з Dependabot в контексті Terraform – це версії провайдерів та модулів.
Наприклад, маємо два файли – versions.tf, де задаються версії провайдерів, і файл lambda.tf, де використовуємо кілька модулів – terraform-aws-modules/security-group/aws, terraform-aws-modules/lambda/aws і інші:
Тепер, щоб Dependabot почав моніторити версії в них – створюємо каталог .github, і в ньому файл dependabot.yml:
Але для Dependabot ці сікрети необхідно задавати окремо – не в Actions secrets and variables > Actions, а в Actions secrets and variables > Dependabot:
Додаємо йому новий сікрет – і тепер перевірка працює:
Dependabot та приватні registries/repositories
Серед іншого, у нас є власні модулі Terraform, які зберігаються в приватному репозиторії.
При доступі до них – Dependabot сфейлить перевірку з помилкою “Dependabot can’t access ORG_NAME/atlas-tf-modules“:
Варіант перший – додати цей репозиторій або інший registry явно в файлі dependabot.yml – див. Configuring private registries.
Варіант другий – це просто клікнути Grant access, що відкриє доступ до репозиторію для всіх репозиторіїв в організації.
Або зробити вручну – переходимо в Settings організації > Code security > Global settings, і в Grant Dependabot access to private repositories додаємо доступ до потрібного репозиторію:
Ручний запуск Dependabot
Тепер, як додали доступ – повертаємось до репозиторію, переходимо в Insights > Dependency graph > Dependabot, клікаємо Check for updates:
І перевірка запущена:
В цілому, на цьому все. Тепер будемо мати апдейти для Terraform без необхідності самому підписуватись на всі репозиторії.
Іноді під час деплою Helm-чартів може з’являтись помилка “UPGRADE FAILED: another operation (install/upgrade/rollback) is in progress“:
Виникати може через те, що попередній деплой не відбувся через помилки в чарті, або втрачений зв’язок між білд-машиною та Kubernets-кластером.
Перевіряємо статус релізу з ls --all:
$ helm -n dev-backend-api-ns ls --all
NAME NAMESPACE REVISION UPDATED STATUS CHART APP VERSION
dev-backend-api dev-backend-api-ns 590 2024-05-23 09:11:51.332096671 +0000 UTC pending-upgrade kraken-0.1.0 1.16.0
І бачимо, що дійсно – маємо “pending-upgrade” замість “deployed“.
Також можна глянути з helm history – що там відбувалося до цього і який статус зараз:
І знов бачимо той самий статус “Preparing upgrade” замість “Upgrade complete“.
Окей, давайте фіксити.
Перший варіант – the hard way – просто видалити реліз з helm uninstall, і передеплоїти з helm upgrade --install, але це призведе до видалення всіх ресурсів, які були створені цим чартом.
Інший варіант – зробити helm rollback до попереднього стабільного деплою.
В цьому кейсі це був 587 – Upgrade complete.
Виконуємо:
$ helm -n dev-backend-api-ns rollback dev-backend-api 587
Rollback was a success! Happy Helming!
Перевіряємо статус тепер:
$ helm -n dev-backend-api-ns ls --all
NAME NAMESPACE REVISION UPDATED STATUS CHART APP VERSION
dev-backend-api dev-backend-api-ns 591 2024-05-23 13:01:36.905101349 +0300 EEST deployed kraken-0.1.0 1.16.0
Або:
$ helm -n dev-backend-api-ns status dev-backend-api
NAME: dev-backend-api
LAST DEPLOYED: Thu May 23 13:01:36 2024
NAMESPACE: dev-backend-api-ns
STATUS: deployed
REVISION: 591
TEST SUITE: None
Перезапускаємо джобу в GitHub Actions – і все працює.
Маємо відносно великі витрати на AWS NAT Gateway Processed Bytes, і стало цікаво що ж саме процеситься через нього.
Здавалося б, все просто – включи собі VPC Flow Logs, да подивись, що до чого. Але як діло стосується AWS Elasitc Kubernetes Service та NAT Gateways, то все трохи цікавіше.
Отже, про що будемо говорити:
що таке NAT Gateway у AWS VPC
що таке NAT та Source NAT
включимо VPC Flow Logs, і розберемося з тим, що саме в них пишеться
і розберемося з тим, як знайти Kubernetes Pod IP у VPC Flow Logs
Схема нетворкінгу досить стандартна:
AWS EKS кластер
VPC
публічні сабнети для Load Balancers та NAT Gateways
Отже, коли ми використовуємо NAT Gateway, то платимо за:
за кожну годину роботи NAT Gateway
за гігабайти, які він оброблює
Година роботи NAT Gateway коштує $0.045, тобто в місяць це буде:
0.045*24*30
32.400
32 долари.
Є варіант з використання NAT Instance замість NAT Gateway, але тоді мусимо мати справу з його менеджментом – і створення інстансу, і його апдейти, і конфігурація.
Амазон предоставляє AMI для цього – але вони давно не оновлюються, і не будуть.
Крім того, Terraform-модуль terraform-aws-modules/vpc/aws працює тільки з NAT Gateway, тому, якщо ви хочете використати NAT Instance – то маєте ще й автоматизацію писати під нього.
Отже – скіпаємо варіант з NAT Instance, і використовуємо NAT Gateway – як рішення, яке повністю підтримується і менеджиться Амазоном та VPC-модулем для Terraform.
Щодо вартості трафіку: платимо ті ж самі $0.045, але вже за кожен гігабайт. При чому рахується весь processed трафік – тобто і outbound (egress, TX – Transmitted), і inbound (ingress, RX – Recieved).
Отже, коли ви відправляєте один гігабайт даних в S3-бакет, а потім завантажуєте його назад на EC2 в приватній мережі – ви платите 0.045 + 0.045 долари.
Що таке NAT?
Давайте згадаємо, що таке NAT взагалі, і як він працює на рівні пакетів і архітектури мережі.
NAT – Network Address Translation – виконує операції над заголовками TCP/IP пакетів, міняючи (translate) адресу відправника або отримувача, дозволяючи мережевий доступ з або до машин, які не мають власного публічного IP.
Знаємо, що є декілька типів NAT:
Source NAT: пакет “виходить” з приватної мережі, і NAT перед відправкою в Internet заміняє source IP пакету на власний (SNAT)
Destination NAT: пакет “входить” в приватну мережу з Inernet, і NAT перед відправкою всередину мережі заміняє destination IP пакету з власного на приватну IP всередині мережі (DNAT)
Окрім того, є Static NAT, Port Address Translation (PAT), Twice NAT, Multicast NAT.
Нас зараз цікавить саме Source NAT, і далі ми будемо в основному розглядати саме його і те, як пакет потрапляє з VPC до інтернету.
Якщо відобразити це схемою, то вона буде виглядати так:
Ініціація запиту з EC2: сервіс на EC2 з Private IP 10.0.1.5 генерує запит до External Server з IP 203.0.113.5
ядро операційної системи EC2 створює пакет
source IP: 10.0.1.5
packet source IP: 10.0.1.5
destintation IP: 203.0.113.5
packet destination IP: 203.0.113.5
Маршрутизація пакета: мережевий інтерфейс на EC2 включений в Private Subnet, і має Route Table, яка підключена до цього сабнету
ядро операційної системи визначає, що destintation IP не належить до VPC, і переадресує пакет до NAT GW Private IP 10.0.0.220
source IP: 10.0.1.5
packet source IP: 10.0.1.5
destination IP: 10.0.0.220
packet destination IP: 203.0.113.5
Обробка пакета NAT Gateway: пакет приходить на мережевий інтерфейс NAT GW, який має адресу 10.0.0.220
NAT Gateway зберігає запис про походження пакету з IP 10.0.1.5:10099 => 203.0.11.443 у своїй NAT-таблиці
NAT GW змінює source IP з 10.0.1.5 на адресу свого інтерфейсу у публічній мережі з IP 77.70.07.200 (власне, сама операція SNAT), і пакет відправляється в Інтернет
source IP: 77.70.07.200
packet source IP: 10.0.1.5
destination IP: 203.0.113.5
packet destination IP: 203.0.113.5
Що таке NAT Table?
NAT-таблиця зберігається в пам’яті NAT Gateway та використовується, аби прийняти пакет від External Server до нашої EC2, коли він буде слати відповідь, і переадресувати його до відповідного серверу в приватній мережі.
Схематично його можна відобразити так:
Отримуючи відповідь від 203.0.113.5 до себе на 77.70.07.200 і порт 20588, NAT Gateway по таблиці знаходить відповідного адресата – IP 10.0.1.5 і порт 10099.
Добре. Тепер, як згадали що таке NAT – давайте включимо VPC Flow Logs, і розберемося з записами, які він створює.
Виконуємо terraform apply, і маємо логи у VPC з власним форматом:
VPC Flow Logs – формат
У flow_log_log_format описується формат того, як лог буде записаний, а саме – які поля в ньому будуть.
Я завжди використовую custom format з додатковою інформацією, бо дефолтний формат може бути недостатньо інформативним, особливо про роботі через NAT Gateways.
Для Terraform, екрануємо записи з ${...} через додатковий $.
Вартість VPC Flow Logs в CloudWatch Logs
flow_log_cloudwatch_log_group_class дозволяє задати клас Standard або Infrequent Access, і Infrequent Access буде дешевшим, але він має обмеження – див. Log classes.
В моєму випадку, я планую збирати логи до Grafana Loki через CloudWatch Log Subscription Filter – тому потрібен Standard. Але подивимось – може налаштую через S3 бакет, і тоді, мабуть, можна буде використати Infrequent Access.
Бо насправді витрати на логування трафіку досить помітні.
Зберігання логів в CloudWatch Logs буде дорожчим – але дає можливість виконувати запити у CloudWatch Logs Insights.
Крім того, як на мене, то налаштування збору логів до Grafana Loki простіше через CloudWatch Subscription Filters, аніж робити через S3 – просто менше головної болі з IAM.
Втім, поки що тримаю Flow Logs в CloudWatch Logs, а як закінчу розбиратись з тим, звідки йде трафік – то подумаю про використання S3, і звідти вже буду збирати до Grafana Loki.
VPC Flow Logs та Log Insights
Окей – отже, маємо налаштовані VPC Flow Logs в CloudWatch Logs.
Що нас особливо цікавить – це трафік через NAT Gateway.
Використовуючи кастомний формат логів – в Logs Insights можемо зробити такий запит:
flow-direction: The direction of the flow with respect to the interface where traffic is captured. The possible values are: ingress | egress.
Тобто, відносно до мережевого інтерфейсу: якщо на інтерфейс EC2 або NAT Gateway (який під капотом є звичайним EC2) приходить трафік – то це ingress, якщо виходить з інтерфейсу – то egress.
Різниця srcaddr vs pkt-srcaddr та dstaddr vs pkt-dstaddr
У нас є чотири поля, які вказують на адресатів.
При цьому для source та destination у нас є два різних типи полів – з pkt-, або без.
В чому різниця:
srcaddr – “поточна” маршрутизація:
адреса вхідного трафіку – звідки прийшов пакет, або:
адреса інтерфейсу, який відправляє трафік
dstaddr – “поточна” маршрутизація:
адреса “пункту призначення” пакета у вихідному трафіку, або
Тут робимо виборку по записам з мережевого інтерфейсу NAT Gateway та Private IP нашого EC2:
Отже, в результатах у нас буде “instance_id, interface_id, flow_direction, srcaddr, dstaddr, pkt_srcaddr, pkt_dstaddr”
NAT Gateway Elastic Network Interface records
Спочатку глянемо записи, які стосуються мережевого інтерфейсу NAT Gateway.
Від EC2 через NAT GW до Remote server
Перший приклад запису в Flow Logs відображає інформацію з мережевого інтерфейсу NAT Gateway, де записано проходження пакету від EC2 в приватній мережі до зовнішнього серверу:
При роботі з VPC Flow Logs головне пам’ятати, що записи робляться для кожного інтерфейсу.
Тобто, якщо ми робимо curl 1.1.1.1 з EC2-інстансу – то отримаємо два записи у Flow Log:
з Elastic Network Interface на самому EC2
з Elastic Network Interface на NAT Gateway
В цьому прикладі ми бачимо запис з інтерфейсу NAT Gateway, бо:
поле instace-id пусте (NAT GW хоч і є EC2, але це все ж Amazon-managed сервіс)
flow-direction – ingress, пакет прийшов на інтерфейс NAT Gateway
в полі dstaddr бачимо Private IP нашого NAT GW
і поле pkt-dstaddr не співпадає з dstaddr – в pkt-dstaddr у нас адреса “кінцевого отримувача”, а пакет прийшов на dstaddr – NAT Gateway
Від NAT Gateway до Remote Server
В другому прикладі бачимо запис про пакет, який було відправлено з NAT Gateway до Remote Server:
flow-direction – egress, пакет відправлено з інтерфейсу NAT Gateway
srcaddr та pkt-srcaddr однакові
dstaddr та pkt-dstaddr однакові
Від Remote Server до NAT Gateway
Далі – наш Remote Server відправляє відповідь до нашого NAT Gateway:
flow-direction – ingress, пакет прийшов на інтерфейс NAT Gateway
srcaddr та pkt-srcaddr однакові
dstaddr та pkt-dstaddr однакові
Від Remote Server через NAT Gateway до EC2
Запис про пакет від Remote Server до нашого EC2 через NAT Gateway:
flow-direction – egress, пакет відправлено з інтерфейсу NAT Gateway
srcaddr та pkt-srcaddr різні – в srcaddr маємо NAT GW IP, а в pkt-srcaddr – Remote Server
dstaddr та pkt-dstaddr однакові, з IP нашого EC2
EC2 Network Interface records
І пара прикладів записів у Flow Logs, які відносяться до EC2 Elastic Network Interface.
Від EC2 до Remote Server
Відправка пакета з EC2 до Remote Server:
instance_id не пустий
flow-direction – egress, бо запис с інтерфейсу EC2, який відправляє пакет до Remote Server
srcaddr та pkt-srcaddr однакові, з Private IP цього EC2
поля dstaddr та pkt-dstaddr – теж однакові, з адресою Remote Server
Від Remote Server до EC2
Відправка пакета з Remote Server до EC2:
instance_id не пустий
flow-direction – ingress, бо запис с інтерфейсу EC2, який отримує пакет від Remote Server
srcaddr та pkt-srcaddr однакові, з адресою Remote Server
поля dstaddr та pkt-dstaddr – теж однакові, з Private IP цього EC2
VPC Flow Logs, NAT, Elastic Kubernetes Service та Kubernetes Pods
Окей – ми побачили, як знайти інформацію по трафіку через NAT Gateway з EC2-інстансів.
А як щодо Kubernetes Pods?
Тут ситуація ще цікавіша, бо маємо різні типи мережевої комунікації:
При комунікації Pod to Pod, якщо вони в одній VPC, то використовуються їхні IP/WorkerNode Secondary Private IP. Але якщо вони знаходяться на одній WorkerNode – то пакет піде через віртуальні мережеві інтерфейси, а не через “фізичний” інтерфейс на WorkerNode/EC2, і, відповідно, ми цей трафік у Flow Logs не побачимо взагалі.
А от коли Pod відправляє трафік до зовнішнього ресурсу – то по дефолту плагін VPC CNI транслює (міняє) Pod IP на WorkerNode Primary Private IP, і, відповідно, у Flow Logs ми не побачимо IP поду, який шле трафік через NAT Gateway.
Тобто, у нас на рівні ядра операційної системи WorkerNode/EC2 виконується один SNAT, а потім на NAT Gateway – ще один.
Виключення – якщо под запускається з hostNetwork: true.
Обидва IP однакові, відповідно, якщо зробимо з цього поду curl 1.1.1.1 – то у Flow Logs будемо бачити IP пода (а фактично – IP тієї Worker Node, на якій запущено цей Pod).
Але використання hostNetwork: true ідея погана (безпека, можливі проблеми з TCP-портами тощо), тому можемо зробити інакше.
AWS EKS та Source NAT for Pods
Якщо ми відключимо SNAT for Pods у VPC CNI нашого кластеру, то SNAT буде виконуватись тільки на NAT Gateway у VPC, а не двічі – спочатку на WorkerNode, а потім на NAT Gateway.
Перший запис – з інтерфейсу NAT Gateway, який отримав пакет від Pod з IP 10.0.37.171 для Remote Server 1.1.1.1:
Другий запис – з інтерфейсу EC2, який робить запит до Remote Server, тільки тепер у нас pkt_srcaddr не такий же, як srcadd (як було на схемі “З EC2 до Remote Server” вище), а має запис про IP нашого Kubernetes Pod:
І ось тепер ми зможемо відслідкувати який саме Kubernetes Pod шле або отримує трафік через NAT Gateway з таблиць DynamoDB або S3-корзин.
Сподіваюсь, я на схемах нічого не наплутав, бо трохи складна тема. В принципі, як майже завжди з нетворкінгом.
Корисні посилання
(ох, цей кайф, коли закриваєш купу вкладок в браузері…)
У Kubecost і подібних рішень є дуже корисна сторінка, де відображається статистика по Kubernetes Pods – скільки CPU/Memory вони використовують, скільки реквестів, лімітів, і які для них рекомендовані значення.
Додатково, щоб мати уяву про ефективність роботи Karpenter, я хочу мати дашборду в Grafana, яка буде відображати статистку по всім WorkerNode Kubernetes кластеру – ресурси CPU/Memory та кількість подів на них.
Тобто мета створення дашборди:
оцінювати ефективність Karpenter
оцінювати навантаження на кожній Worker Node
швидко побачити яка нода overcomitted (забагато requsted ресурсів подами)
швидко побачити на яких нодах запущені поди конкретного сервісу (у нас всі сервіси розбиті по неймпсейсам, створимо окремий фільтр на це)
Заодно трохи розберемося с Tables панелями, бо я ними давно не користувався, і щось підзабув, як для них готувати дані.
З чим будемо працювати:
AWS Elastic Kubertes Service (1.28)
Karpenter для менеджменту ЕС2 (v0.33.1)
VictoriaMetrics (v1.97.1)
Grafana (10.1.2)
До речі, у Grafana є чудова demo-версія, де можна погратись з дашбордами.
Планування
Зверху робимо таблицю, яка буде відображати загальну інформацію по WorkerNodes – CPU, Memory, Pods.
А під цією таблицею зробимо таблиці з інформацією по кожній Node та Pods на ній – і там вже буде інфа по CPU/Memory подів і їхнім реквестам.
Dashboard variables
Нам будуть потрібні фільтри по:
data source
іменам WorkerNodes
іменам Namespaces
Можна додати по кластеру – але в мене він наразі один, тому скіпаємо.
Data-source variable
По дата-сорсу – описував детальніше в Експорт існуючої dashboard та Data Source UID not found, але якщо стисло, то ідея полягає в тому, щоб не прив’язуватись до конкретного UID дата-сорса, а мати його в змінній – тоді можна легко переносити дашборду між інстансами Grafana.
Створюємо дашборду, переходимо в Settings > Variables, створюємо змінну для дата-сорса:
Ставимо Show on dashboard == Nothing, бо вона буде використовуватись тільки в панелях.
Тут можемо взяти метрику kube_pod_info, в якій є лейбла namespace.
Також включаємо Multi-value та Include All option:
Тут начебто все – можна починати робити таблички.
Nodes resources – CPU, Memory, Pods
Отже, перша таблиця буде відображати список всіх активних WorkerNodes та інформацію по ресурсам на ній.
Мені поки не актуальні дані по Persistent Volumes/AWS EBS, тому не додаю, але використовуючи загальну ідею це зробити досить просто. Аналогічно с нетворкінгом – поки не актуально, але також додається легко.
Створюємо нову панель, вибираємо тип Table:
В Panel options можемо використати змінну $node_name:
Колонка Instance Name
Далі нам потрібно задати такий собі “об’єднуючий селектор”.
Ідея роботи з Таблицями з multi queries полягає в тому, що у вас є загальна лейбла для всіх запитів, і по значенню цієї лейбли таблиця буду групувати дані в строках.
Отже, робимо першу колонку – тут будуть імена WorkerNodes, і по ним жеж будуть групуватись дані з інших запитів:
sum(karpenter_nodes_total_pod_requests{instance_memory!="", node_name=~"$node_name"}) by (node_name)
В Options запиту переключаємо Format на Table та Type на Instant – це потрібно буде робити для всіх запитів в таблицях:
Переключаємо Data source на ${datasource_vm}:
Тепер приберемо з таблиці Time та Value.
Справа в Options відкриваємо Add field override:
Вибираємо Field with name – Time, додаємо property Hide in table:
Аналогічно з Value – але трохи згодом, бо ім’я колонки зміниться, коли ми додамо інші запити.
Можемо відразу задати колір імен нод в цьому стовпчику.
Transformations
Аби зручно перейменовувати колонки в таблиці – додаємо Transformations > Orginize fileds:
І задаємо ім’я першої колонки:
Table cell color
Знов вибираємо Field with name > Cell options > Cell type > Colored text:
Тепер, коли маємо фіксоване ім’я колонки – повертаємось до Add field override і додаємо другий Override. Вибираємо Field with name – Node Name, і теж додаємо property Cell options > Cell type > Colored text:
Зараз колір береться з Tresholds. Аби перевизначити його – додаємо другий property – Standard options > Color scheme > Single color:
Data links
В мене є окрема дашборда з деталями по конкретній Worker Node, і було б зручно мати змогу перейти з цієї таблиці відразу на дашборду по ноді, тим більш обидві мають Dashboard variable $node_name.
В Override додаємо Data links, в URL використовуємо ${__data.fields["Node Name"]} (всі варіанти можна отримати по Ctrl+Space в полі URL):
Колонка Instance type
Наступним хочеться бачити тип інстансу.
Для цього використовуємо ту ж метрику karpenter_nodes_total_pod_requests, яка в лейблі instance_type має власне тип інстансу.
Робимо запит:
sum(karpenter_nodes_total_pod_requests{instance_memory!="", node_name=~"$node_name"}) by (node_name, instance_type)
В by (node_name) використовуємо наш “об’єднучий селектор” по імені ноди.
Не забуваємо про Format та Type нашого запиту.
Але тепер маємо запити у вигляді випадаючого списку замість того, щоб відобразити типи інстансів новою колонкою:
Переходимо до Transformations, додаємо Merge:
Ця трансформація об’єднає дані в таблиці по “селектору” – загальній лейблі node_name, і тепер маємо такі колонки:
Далі, прибираємо з таблиці колонки з Values – знов йдемо до Add field override > Field with name і додаємо Hide in table.
Іноді (часто) таблиця не оновлюється відразу – тому зверху справа тиснемо Refresh dashboard.
І маємо дві колонки – з іменами та типами інстансів:
Інформація по CPU
Почнемо з CPU, далі додамо пам’ять та поди по кожній ноді.
Колонка Node CPU total
Далі додаємо кількість vCPU на ноді – тут все аналогічно до типу інстансу, тільки лейбла instance_cpu:
sum(karpenter_nodes_total_pod_requests{instance_memory!=""}) by (node_name, instance_cpu)
Колонка Node CPU requested
Теж аналогічно, тільки в запиті робимо вибірку по лейблі resource_type="cpu" – тоді метрика нам поверне дані по кожній WorkerNode і загальній кількості CPU, яка була reqeusted всіма подами на цій ноді.
Не забуваємо про Orginize fields – задаємо імена колонкам:
Колонка Node CPU requested %
Тепер трохи більш цікаво: хочеться відобразити % CPU requested від загальної кількості.
Спочатку давайте впевнимось, що маємо правильні дані в метриці karpenter_nodes_total_pod_requests.
Тут маємо інформацію по всім requests всіх подів ноди + загальна інформація в Allocated resources.
В Allocated resources cpu == 1472 milicpu (або millicores), але це з урахуванням подів від DaemonSets – aws-node (50), ebs-csi-node (30), eks-pod-identity-agent (0), kube-proxy (20) і так далі. Загалом ці поди зареквестили 50+30+20+30+10+50 == 190 milicpu.
Метрика ж karpenter_nodes_total_pod_requests від Karpenter відображає всі реквести окрім DaemonSets – тож в ній дані будуть трохи менші, але в цілому картина має бути приблизно такою ж – 1452m в Allocated resources мінус 190m від DaemonSets, тобто реальні ворклоади зареквестили 1262 milicpu, або 0.631 від загальної кількості milicpu – 2.000, бо це t3.medium.
Повертаємось до дашборди, додаємо такий запит:
sum(
sum(karpenter_nodes_total_pod_requests{instance_memory!="", node_name=~"$node_name", resource_type="cpu"}) by (node_name)
/
sum(karpenter_nodes_allocatable{instance_memory!="", node_name=~"$node_name", resource_type="cpu"}) by (node_name)
) by (node_name) * 100
Тут з karpenter_nodes_total_pod_requests беремо загальну кількість requests від подів окрім DaemonSets і ділимо на загальну кількість vCPU на ноді – karpenter_nodes_allocatable{resource_type="cpu"}.
Отримуємо значення у 65% – в принципі, збігається з тим, що порахували вручну (0.631):
Тепер, додамо трохи краси – хочеться відобразити це значення шкалою.
Йдемо до Field override, вибираємо колонку Node CPU requested %, і спершу міняємо тип даних на проценти – Standard options > Unit > Percent 0-100:
Додаємо ще один property – міняємо тип на Gauge, і ще один property – Standard options > Max == 100:
Трохи підлаштуємо Tresholds – базовий буде червоний, тобто – якщо значення CPU Requested % низьке – то це погано, бо нода не використовується повністю. Трохи вище – жовтий, і максимум – зелений:
Інформація по Memory
Тут в принципі все аналогічно до того, як ми робили для CPU.
Node Memory total
Запит:
sum(karpenter_nodes_total_pod_requests{instance_memory!="", node_name=~"$node_name"}) by (node_name, instance_memory)
Дані в метриці у мегабайтах, тому додаємо Override > Standard options > Unit – megabytes:
Node Memory requested by Pods
Запит:
sum(karpenter_nodes_total_pod_requests{instance_memory!="", node_name=~"$node_name", resource_type="memory"}) by (node_name)
Тут у нас байти, тому знов додаємо Override:
Node Memory requested by Pods %
Запит:
sum(
sum(karpenter_nodes_total_pod_requests{instance_memory!="", node_name=~"$node_name", resource_type="memory"}) by (node_name)
/
sum(karpenter_nodes_allocatable{instance_memory!="", node_name=~"$node_name", resource_type="memory"}) by (node_name)
) by (node_name) * 100
І аналогічно до CPU % – робимо Gauge:
Інформація по Pods
Тут все схоже – скільки подів нода може мати максимум, скільки на ній зараз, і скільки % від максимуму зайнято.
Pods allocatable
Скільки подів максимум можна запустити на ноді:
sum(karpenter_nodes_allocatable{instance_memory!="", node_name=~"$node_name", resource_type="pods"}) by (node_name)
Pods allocated
Скільки подів запущено на ноді зараз.
Тут запит трохи інший, бо метрика karpenter_pods_state зараз має лейблу node замість node_name (можливо, пізніше пофіксять), тому використовуємо label_replace().
І вибираємо всі поди в статусі Running:
sum (label_replace(karpenter_pods_state{phase="Running", node=~"$node_name"}, "node_name", "$1", "node", "(.*)")) by (node_name)
Pods allocated %
Запит, теж з label_replace:
sum(
sum (label_replace(karpenter_pods_state{phase="Running", node=~"$node_name"}, "node_name", "$1", "node", "(.*)")) by (node_name)
/
sum(karpenter_nodes_allocatable{instance_memory!="", node_name=~"$node_name", resource_type="pods"}) by (node_name)
) by (node_name) * 100
І налаштовуємо шкалу, як робили для CPU та Memory:
Перевіряємо дані по використанню подів у AWS Console > EKS > Compute:
Нода ip-10-0-34-184.ec2.internal має 17 максимум, 11 запущених:
І на нашому графіку маємо ті ж самі дані:
Правда, чому AWS рахує 11 від 17 як 85% – не знаю, бо:
>>> 11/17*100
64.70588235294117
Тут у нас дані правильні теж.
І тепер все разом має такий вигляд:
Можемо переходити до наступної задачі – інформація по CPU/Memory подами по кожній WorkerNode.
Pods info tables
Що нам тут може бути цікавим?
cpu, memory usage – current та avgerage – as numbers
cpu, memory requested as number
cpu, memory used as % from Node’s total
cpu, memory used as % from requested
Імена подів та Namespaces на WorkerNodes
Почнемо з того, що створимо таблицю, в якій будуть виводитись імена подів (це буде наш “об’єднуючий селектор” для інших запитів) та імена відповідних неймспейсів.
Перший запит – імена подів:
sum(kube_pod_info{node=~"$node_name", namespace=~"$namespace", pod!~".*cron.*"}) by (pod)
Другий – неймспейси:
sum(kube_pod_info{node=~"$node_name", namespace=~"$namespace", pod!~".*cron.*"}) by (pod, namespace)
Аналогічно до попередньої таблиці – перемикаємо Data source на ${datasource_vm}, налаштовуємо Overrides і Transformations:
Тепер цікаве: хочеться мати окрему таблицю для кожної WorkerNode, яка вибрана в фільтрах.
Для цього в Panel Options включаємо опцію Repeat options і вибираємо нашу змінну $node_name.
З якогось дива не можна виставити Max per row == 1 в горизонтальному відображенні, тому, аби все було красиво, зробимо окрему колонку з таблицями під CPU і окрему під Memory, а в Repeate direction розмістимо їх Vertical:
Що це дуже зручно: значення $node_name у queries у кожній таблиці буде мати тільки ту WorkerNode, для якої відображається конкретно ця панель, а не всі ноди, які обрані у загальному фільтрі зверху. Тобто фільтр буде впливати тільки на кількість панелей, а не на запити по ресурсам подів всередині цих панелей.
Тепер у нас панелі виглядають так:
Pod CPU info
Перша колонка у нас буде відображати інформацію по CPU на нодах – ім’я поду, його неймспейс, скільки використовується зараз (в milicpu), скільки використовується в середньому (в milicpu), відсоток використання від загального vCPU на ноді, скільки под зареквестив, і скільки % від реквестів він використовує.
Pod CPU usage
Додаємо наступний запит – скільки под використовує ресурсів CPU:
sum(rate(container_cpu_usage_seconds_total{instance=~"$node_name", image!="", namespace=~"$namespace"}[5m])) by (pod) * 1000
Тут рахуємо per-second average rate для значення container_cpu_usage_seconds_total протягом останніх 5 хвилин по кожному контейнеру в поді, множимо на 1000, щоб перевести це значення у millicores.
Перевіримо значення з kubectl top pod:
$ kk -n kube-system top pod aws-node-q56z4
NAME CPU(cores) MEMORY(bytes)
aws-node-q56z4 4m 61Mi
І в панелі 3.67 millicores:
Це ми отримали поточне значення – давайте додамо average. Запит той самий, тільки з avg() замість sum():
Pod CPU use % from vCPU total
Додамо шкалу, яка буде відображати скільки % від загального CPU на ноді використовує кожен под:
sum(rate(container_cpu_usage_seconds_total{instance=~"$node_name", image!="", namespace=~"$namespace"}[5m])) by (pod) * 1000
Перевіряємо з kubectl:
$ kk -n prod-backend-api-ns describe pod backend-api-deployment-7d7969d69f-7r66t
...
Requests:
cpu: 512m
memory: 800Mi
...
І в панелі:
Pod CPU Requests %
І відобразимо, скільки % від загальної кількості vCPU ноди використовується кожним подом:
І вся борда тепер має такий вигляд:
Pod Mem info
Тут все аналогічно, тільки інші запити окрім перших двох – для імен подів і їхніх неймспейсів.
Pod name:
sum(kube_pod_info{node=~"$node_name", namespace=~"$namespace", pod!~".*cron.*"}) by (pod)
Namespace name:
sum(kube_pod_info{node=~"$node_name", namespace=~"$namespace", pod!~".*cron.*"}) by (pod, namespace)
Memory use
Current:
sum(container_memory_working_set_bytes{instance=~"$node_name", namespace=~"$namespace", container!="POD", container!=""}) by (pod)
Average:
avg(container_memory_working_set_bytes{instance=~"$node_name", namespace=~"$namespace", container!="POD", container!=""}) by (pod)
Не забуваємо перевіряти результати, аби потім, коли дашборда буде використовуватись, не виявилось, що вона відображає некоректні дані.
Глянемо пам’ять поду з kubectl top pod:
$ kk -n prod-backend-api-ns top pod backend-api-deployment-7d7969d69f-nslxk
NAME CPU(cores) MEMORY(bytes)
backend-api-deployment-7d7969d69f-nslxk 45m 574Mi
І порівняємо з даними в дашборді:
Окей, наче все вірно. Го далі.
Memory use %
Скільки % від загальної пам’яті на ноді використовує под:
Але хоча це робоче рішення, але краще обходитись без таких “грязних хаків”.
Натомість ми можемо просто перезібрати та перевстановити Yay – тоді він буде використовувати нову версію libalpm.
Встановлюємо пакети Git та base-devel:
$ sudo pacman -S git base-devel
Клонуємо репозиторій Yay, збираємо та встановлюємо його за допомогою makepkg, яка використає файл PKGBUILD з “інструкціями” по білду та інсталяції пакета.
А вже у PKGBUILD ми маємо флаг “GOFLAGS="${GOFLAGS} $(pacman -T 'libalpm.so=14-64')".
Отже. клонуємо, та запускаємо makepkg з опціями -s (--syncdeps – встановити залежності) та -i (--install – встановити зібраний пакет з pacman):
$ git clone https://aur.archlinux.org/yay.git
$ cd yay/
$ makepkg -si
І Yay тепер працює:
$ yay --help
Usage:
yay
yay <operation> [...]
yay <package(s)>
...
Що треба зробити: зараз на проекті ми в кожному репозиторії пишемо Workflow-файли окремо. Втім, оскільки поступово всі процеси уніфікуються – управління інфраструктурою через Terraform, та запуск сервісів в Kubernetes і деплой з Helm – то вирішили, що пора навести лад в GitHub Actions, і перестати писати “кожен для себе”.
Натомість в окремому репозиторії створимо Shared Workflow з набором Jobs, які будуть виконувати потрібні дії, і потім будемо ці Workflow включати в Wokflow проектів.
Але у Reusable Workflows виявилось кілька цікавих деталей.
Тож спочатку глянемо в чому різниця між Reusable Workflows та Composite Actions та для чого вони призначаються., а потім поглянемо на роботу з Reusable Workflows.
Порівняння Reusable Workflows та Composite Actions
Composite Actions
Composite Actions дозволяють скомбінувати кілька Steps в єдиний Action. Такі Step описуються в єдиному файлі, і можуть виконувати кілька різних runs або викликати інші Actions.
Ідеальне рішення, коли ви хочете використати послідовність Steps в кількох Jobs або Workflows.
Composite Actions дозволяє комбінувати кілька steps в одному Action, щоб потім у Workflow викликати їх всі як один Step
в Composite Actions не можна мати кілька Jobs
Job, яка викликає Composite Actions може мати інші Steps
Reusable Workflows
Reusable Workflows дозволяють перевикористати цілий Workflow з усіма його Jobs та Steps. Дають більше можливостей, бо включають в себе контексти, змінні оточення та секрети.
Ідеальне рішення, коли ви хочете використати цілий CI/CD пайплайн в кількох репозиторіях.
Далі будемо використовувати такі назви:
Reusable Workflow: workflow, який зберігається в окремому репозиторії та викликається для виконання іншим workflow
Caller Workflow: workflow, який викликає Reusable Workflow
Особливості Reusable Workflows:
Reusable Workflows не можуть викликати інші Reusable Workflows
Reusable Workflows мають досить детальні логи виконання – кожна Job та Step логується окремо
Reusable Workflows викликаються як Jobs, але така Job не може мати інших Steps
через це ви не можете використати $GITHUB_ENV, щоб передати values до Jobs та Steps у Caller Workflow, який викликає Reusable Workflow
ви можете використовувати різні версії одного Reusable Workflow через анотацію @REF з іменем бранча або git-тегом
Reusable Workflows та Composite Actions: Key differences
Reusable workflows
Composite actions
Can connect a maximum of four levels of workflows
Can be nested to have up to 10 composite actions in one workflow
Can use secrets
Cannot use secrets
Can use if: conditionals
Cannot use if: conditionals
Can be stored as normal YAML files in your project
Requires individual folders for each composite action
Can use multiple jobs
Cannot use multiple jobs
Each step is logged in real-time
Logged as one step even if it contains multiple steps
Створення Reusable Workflow
Зробимо тестові Workflow, щоб перевірити схему взагалі:
в репозиторії atlas-github-actions буде Reusable Workflow
в репозиторії atlas-test буде Caller Workflow
Створюємо репозиторій для наших Reusable Workflows – atlas-github-actions, і в ньому створюємо каталог .github/workflows з файлом test-reusable-workflow.yml:
name: Reusable Workflow
# trigger from other workflows
on:
workflow_call:
jobs:
test:
runs-on: ubuntu-latest
steps:
- name: "Test: print Hello"
run: echo "Hello, World!"
Зберігаємо, пушимо в GitHub.
Далі нам потрібно дозволити використання Workflows з цього репозиторію.
Переходимо в Setting > Actions, і внизу сторінки дозволяємо доступ з інших репозиторіїв організації:
Переходимо до Caller-репозиторія – atlas-test, також створюємо каталог .github/workflows з файлом test-caller-workflow.yml:
name: Caller Workflow
on:
# can be ran manually
workflow_dispatch:
jobs:
test:
# call the Reusable Workflow file
uses: <ORG_NAME>/atlas-github-actions/.github/workflows/test-reusable-workflow.yml@master
Пушимо, і запускаємо:
Тепер трохи подивимось на деталі того, як працювати з Reusable Workflows.
при використанні Actions сторонніх девелоперів – перевіряйте їх код, та використовуйте SHA hash замість Git-тегу (ніколи так не робив, але для зовсім Security – має сенс)
завжди налаштовуйте permissions для $GITHUB_TOKEN явно на рівні Workflow або Job, щоб не використовувати дефолтні дозволи
Reusable Workflow наслідує permissions з Job або Workflow, яка викликає Reusable Workflow
Тобто якщо ми в Caller Workflow задамо permissions.pull-request: write – то зможемо створювати коментарі в Pull Requests і з нашого Reusable Workflow.
GitHub Actions envs, vars, secrets та Reusable Workflow
або на рівні Repository та Organization Secrets – в Reusable Workflow доступні через secrets: inherit
Ми взагалі не можемо використовувати Environments в Caller Workflow та Job, яка викликає Reusable Workflow – див. Supported keywords for jobs that call a reusable workflow, тож всі vars та secrets, які задані конкретному Evnironment – ми в Reusable Workflow не побачимо.
Тобто в Caller Workflow не можна зробити щось типу:
...
jobs:
test:
# using 'environment' will fail
environment: test
uses: <ORG_NAME>/atlas-github-actions/.github/workflows/test-reusable-workflow.yml@master
...
Ну й давайте перевіримо що ми зможемо побачити в Caller Workflow, та в Reusable Workflow.
В репозиторії atlas-test з Caller Workflow додаємо Environment, і в ньому Environment secrets та Environment variables:
В тому ж репозиторії додаємо звичайні Repository secrets:
Та Repository variables:
В цьому ж репозиторії оновлюємо файл Caller Workflow – test-caller-workflow.yml:
на рівні Workflow додаємо env: CALLER_WORKFLOW_ENV
до Job з нашою Reusable Workflow:
додаємо передачу test-input в Reusable Workflow
додаємо передачу secrets: inherit
на рівні Workflow додаємо Job prints-envs
name: Caller Workflow
on:
# can be ran manually
workflow_dispatch:
env:
CALLER_WORKFLOW_ENV: "Caller Env String"
jobs:
test:
# call the Reusable Worfklow file
uses: <ORG_NAME>/atlas-github-actions/.github/workflows/test-reusable-workflow.yml@master
with:
test-input: "Test Input String"
secrets: inherit
prints-envs:
environment: test
runs-on: ubuntu-latest
steps:
# Can use Envs from the Workflow level
- name: "Test: print Caller Workflow Env"
run: echo ${{ env.CALLER_WORKFLOW_ENV }}
# can use Variables from the Workflow Environments level
- name: "Test: print Caller Repository Env Variable"
run: echo ${{ vars.CALLER_ENV_VAR }}
# can use Variables from the Reposiotiry level
- name: "Test: print Caller Repository Repo Variable"
run: echo ${{ vars.CALLER_REPO_VAR }}
# CAN'T use Secrets from the Workflow Environments level
- name: "Test: print Caller Env Secret"
run: echo ${{ secrets.CALLER_ENV_SECRET }}
# can use Secrets from the Reposiotiry level
- name: "Test: print Caller Repo Secret"
run: echo ${{ secrets.CALLER_REPO_SECRET }}
В репозиторії atlas-github-actions оновимо наш Reusable Workflow – файл test-reusable-workflow.yml.
Додаємо inputs та steps, в яких спробуємо вивести env, vars та secrets з Caller Workflow/Repository/Environment:
name: Reusable Workflow
# trigger from other workflows
on:
workflow_call:
inputs:
test-input:
required: true
type: string
jobs:
test:
runs-on: ubuntu-latest
steps:
- name: "Test: print Hello"
run: echo "Hello, World!"
# CAN'T use Envs from the Caller Workflow
- name: "Test: print Caller Workflow Env"
run: echo ${{ env.CALLER_WORKFLOW_ENV }}
# CAN'T use Variables from the Caller Workflow Environments level
- name: "Test: print Caller Repository Env Variable"
run: echo ${{ vars.CALLER_ENV_VAR }}
# can use Variables from the Caller Repository Variables
- name: "Test: print Caller Repository Repo Variable"
run: echo ${{ vars.CALLER_REPO_VAR }}
# CAN'T use Secrets from the Caller Workflow Environments Secrets
- name: "Test: print Caller Env Secret"
run: echo ${{ secrets.CALLER_ENV_SECRET }}
# can use Secrets from the Caller Reposiotiry
- name: "Test: print Caller Repo Secret"
run: echo ${{ secrets.CALLER_REPO_SECRET }}
# can use Inputs from the Caller Workflow
- name: "Test: print Caller Repo Input"
run: echo ${{ inputs.test-input }}
Передача Secrets
Додам про передачу Secrets:
Перший варіант – використати secrtes: inherit – тоді в Reusable Workflow будуть доступні всі змінні в Repository secrets та Orgznization secrets з Caller Workflow.
Крім того, в Reusable Workflow можна їх задати в env:
У пості GitHub Actions: деплой Dev/Prod оточень з Terraform я вже описував те, як можна реалізувати CI/CD для Terraform з GitHub Actions, але в тому рішенні є один суттєвий недолік: немає можливості зробити review змін перед тим, як їх застосувати з terraform apply.
GitHub Actions має можливість використання Reviewing deployments для approve/reject, проте ця можливість доступна тільки на GitHub Enterprise.
Тож як ми можемо вирішити цю проблему?
Звісно, ми взагалі можемо використати рішення на кшталт Atlatis або Gaia – але коли на проекті всього 4-5 репозиторіїв з Terraform, то такі утілити будуть трохи overkill.
Як варіант – продовжити використання GitHub Actions з hashicorp/setup-terraform, але робити terraform plan під час створення Pull Request, а його output виносити в коментарі до PR. Тоді перед тим, як підтвердити Merge – ми зможемо подивитись на те, які зміни будуть застосовані, і виконувати terraform apply після того, як feature/fix branch буде вмержено в master.
Хоча це теж не ідеальне рішення – бо між створенням Pull Request і виконанням Terraform Plan та мержем і виконанням Terraform Apply може пройти час, за який в інфраструктурі щось зміниться, і той Plan вже буде не актуальний – тож треба розібратись і з цим.
Отже, що будемо сьогодні робити:
створимо тестовий код Terraform
створимо два GitHub Actions Workflow:
test on Pull Request created
deploy on Pull Request merged
і подивимось на варіанти того, як можна вирішити проблему з неактуальним Terraform Plan
Підготовка
Створимо проект Terraform, потім підготуємо репозиторій GitHub.
Terraform – тестові ресурси
В тестовому репозиторії створюємо каталог terraform, і в ньому файл terraform.tf:
Трохи забігаючи наперед, і трохи “оффтопу”: вже коли робив terraform apply, то зловив помилку:
Error: creating S3 Bucket (atlas-test-bucket): operation error S3: CreateBucket, https response error StatusCode: 400, api error AuthorizationHeaderMalformed: The authorization header is malformed; the region ‘us-east-1’ is wrong; expecting ‘us-west-2’
Текст трохи вводить в оману, бо – при чому тут регіони? В provider "aws" у нас явно задано us-east-1, з AIM Role теж в порядку. Звідки взагалі береться us-west-2?
Якщо включити debug з export TF_LOG=debug, то бачимо, що дійсно:
А “лікується” це тим, що – як ми знаємо – ім’я корзини має ж бути унікальним на весь AWS Region, тобто якщо додати отой суфікс “ololo” – то все працює, як треба:
Тут все готово, можемо переходити до налаштувань GitHub Actions.
GitHub Actions – змінні оточення репозиторію
Для запуску Terraform в GitHub Actions нам потрібна одна змінна, в якій ми будемо передавати AWS IAM Role, за допомогою якої GitHub зможе виконувати дії в нашому AWS акаунті – див. Configuring OpenID Connect in Amazon Web Services.
Переходимо в Settings > Actions secrets and variables, переходимо в Variables:
І в принципі наразі це все, що нам потрібно – можемо починати створювати наші GitHub Actions Workflow.
GitHub Actions – створення Workflow
Для того, щоб Actions зміг запустити наш код Terrform, нам потрібно:
виконати checkout коду на GitHub Runner, на якому буде запускатись флоу: використаємо actions/checkout
виконати аутентифікацію в AWS: використаємо aws-actions/configure-aws-credentials – він виконає AssumeRole зі змінної TF_AWS_ROLE та створить змінні оточення з ключами та AWS_SESSION_TOKEN
запустити terraform init та інші: використаємо hashicorp/setup-terraform – він додасть сам Terraform
Тут в принципі можна використати той же підхід з окремими Actions для кожного step, як описувалось в Створення test-on-push Workflow, але зараз для наочності та простоти все опишемо прям у файлі workflow.
Workflow з Terraform Validate та Plan
Першим створимо Workflow, який буде запускатись при створенні PR та буде виконувати перевірки з terraform validate, tflint і т.д, а потім буде виконувати terraform plan, результат якого буде додавати в коментарі до Pull Request, який тригернув цей workflow.
В корні репозиторію створюємо каталог .github/workflows, і в ньому файл terraform-test-on-pr.yml:
name: "Terraform: test on PR open"
# set other jobs with the same 'group' in a queue
concurrency:
group: deploy-test
cancel-in-progress: false
on:
# can be ran manually
workflow_dispatch:
# run on pull-requests
pull_request:
types: [opened, synchronize, reopened, ready_for_review]
# only if changes were in the 'terraform' directory
paths:
- terraform/**
permissions:
# create OpenID Connect (OIDC) ID token
id-token: write
# allow read repository's content by steps
contents: read
# allow adding comments in a Pull Request
pull-requests: write
jobs:
terraform:
name: "Test: Terraform"
runs-on: ubuntu-latest
# to avoid using GitHub Runners time
timeout-minutes: 10
defaults:
run:
# run all steps in the 'terraform' directory
working-directory: ./terraform
steps:
# get repository's content
- name: "Setup: checkout"
uses: actions/checkout@v4
# setup 'env.AWS_*' variables to run Terraform
- name: "Setup: Configure AWS credentials"
uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: ${{ vars.TF_AWS_ROLE }}
role-session-name: github-actions-terraform
role-duration-seconds: 900
aws-region: us-east-1
# terraform formatting check
- name: "Test: Terraform fmt"
id: fmt
run: terraform fmt -check -no-color
# do not throw error, just warn
continue-on-error: true
# use TFLint to check the code
- name: "Setup: TFLint"
uses: terraform-linters/setup-tflint@v3
with:
tflint_version: v0.48.0
- name: "Test: Terraform linter"
run: tflint -f compact
shell: bash
# use official Action
- name: "Setup: Terraform"
uses: hashicorp/setup-terraform@v3
# get modules, configure backend
- name: "Test: Terraform Init"
id: init
run: terraform init -no-color
# verify whether a configuration is syntactically valid
- name: "Test: Terraform Validate"
id: validate
run: terraform validate -no-color
# create a Plan to see what will be changed
- name: "Test: Terraform Plan"
id: plan
run: terraform plan -no-color
В concurrency забороняємо одночасний запуск кількох workflows, див. Using concurrency, і це нам знадобиться далі, коли будемо робити terraform apply.
Для команд Terraform задаємо параметр -no-color, щоб потім в коментарях до Pull Request не мати зайвих символів:
Комітимо всі зміни, пушимо в репозиторій, якщо зміни робили в окремому бранчі – то мержимо в master, щоб GitHub Actions “побачив” новий Workflow файл, і маємо запущений Workflow:
Додавання Terraform Plan в коментарі до Pull request
Далі використаємо actions/github-script, який через GitHub API може додавати коментарі до нашого PR.
А в коментарі використаємо outputs з нашого steps.plan.
Пушимо зміни, чекаємо, поки виконається джоба (пам’ятаємо, що тригер у нас – зміни в каталозі terraform):
І маємо новий коментар в Pull Request:
Що тут ще можна “затюнити” – це замість створення нового коментаря в Pull Request оновлювати вже існуючий.
Для цього можна використати функцію github.rest.issues.listComments, за допомогою якої знайти всі коментарі comment.body.includes(‘Terraform Format and Style’), і якщо такий знайдеться – то виконати github.rest.issues.updateComment, а якщо ні – то github.rest.issues.createComment, як робили вище:
...
- name: "Misc: Post Terraform summary to PR"
uses: actions/github-script@v6
if: github.event_name == 'pull_request'
env:
PLAN: "${{ steps.plan.outputs.stdout }}"
with:
github-token: ${{ secrets.GITHUB_TOKEN }}
script: |
// 1. Retrieve existing bot comments for the PR
const { data: comments } = await github.rest.issues.listComments({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.issue.number,
})
const botComment = comments.find(comment => {
return comment.user.type === 'Bot' && comment.body.includes('Terraform Format and Style')
})
// 2. Prepare format of the comment
const output = `#### Terraform Format and Style 🖌\`${{ steps.fmt.outcome }}\`
#### Terraform Initialization ⚙️\`${{ steps.init.outcome }}\`
#### Terraform Validation 🤖\`${{ steps.validate.outcome }}\`
<details><summary>Validation Output</summary>
\`\`\`\n
${{ steps.validate.outputs.stdout }}
\`\`\`
</details>
#### Terraform Plan 📖\`${{ steps.plan.outcome }}\`
<details><summary>Show Plan</summary>
\`\`\`\n
${process.env.PLAN}
\`\`\`
</details>
*Pusher: @${{ github.actor }}, Action: \`${{ github.event_name }}\`, Workflow: \`${{ github.workflow }}\`*`;
// 3. If we have a comment, update it, otherwise create a new one
if (botComment) {
github.rest.issues.updateComment({
owner: context.repo.owner,
repo: context.repo.repo,
comment_id: botComment.id,
body: output
})
} else {
github.rest.issues.createComment({
issue_number: context.issue.number,
owner: context.repo.owner,
repo: context.repo.repo,
body: output
})
}
Хоча тут кому-як, бо може більш інформативно буде мати історію планів.
Окей.
Тепер ми маємо GitHub Actions Workflow, який виконує перевірки Terraform, і постить результат Plan в коментарі до Pull Request.
Єдине, що ще треба на увазі – це ліміт на кількість символів в коментарі до Pull Request – 65535 – і деякі Plan можуть не влізти.
Останнім нам треба додати Workflow, який буде виконувати Terraform Apply, коли PR змержено.
Workflow з Terraform Apply
Створюємо файл terraform-deploy-on-pr-merge.yml:
name: "Terraform: Apply on push to master"
# set other jobs with the same 'group' in a queue
concurrency:
group: deploy-test
cancel-in-progress: false
on:
# run on PR merge, e.g. 'push' changes to the 'master'
push:
branches:
- master
# only if changes were in the 'terraform' directory
paths:
- terraform/**
permissions:
# create OpenID Connect (OIDC) ID token
id-token: write
# allow read repository's content by steps
contents: read
jobs:
deploy:
name: "Deploy: Terraform"
runs-on: ubuntu-latest
# to avoid using GitHub Runners time
timeout-minutes: 30
defaults:
run:
# run all steps in the 'terraform' directory
working-directory: ./terraform
steps:
# get repository's content
- name: "Setup: checkout"
uses: actions/checkout@v4
# setup 'env.AWS_*' variables to run Terraform
- name: "Setup: Configure AWS credentials"
uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: ${{ vars.TF_AWS_ROLE }}
role-session-name: github-actions-terraform
role-duration-seconds: 900
aws-region: us-east-1
# use TFLint to check the code
- name: "Setup: TFLint"
uses: terraform-linters/setup-tflint@v3
with:
tflint_version: v0.48.0
- name: "Test: Terraform lint"
run: tflint -f compact
shell: bash
# use official Action
- name: "Setup: Terraform"
uses: hashicorp/setup-terraform@v3
# get modules, configure backend
- name: "Setup: Terraform Init"
id: init
run: terraform init -no-color
# verify whether a configuration is syntactically valid
- name: "Test: Terraform Validate"
id: validate
run: terraform validate -no-color
# create a Plan to use with the 'apply'
- name: "Deploy: Terraform Plan"
id: plan
run: terraform plan -no-color -out tf.plan
# deploy changes using Plan's file
- name: "Deploy: Terraform Apply"
id: apply
run: terraform apply -no-color tf.plan
Тут все майже те саме, що і попередньому workflow, тільки тригером буде push в master, а результат виконання terraform plan ми зберігаємо у файл tf.plan, і з нього ж потім виконуємо terraform apply.
Пушимо в репозиторій, мержимо – і маємо виконаний apply:
Добре. Виглядає начебто чудово?
Але є проблема.
What if? Outdated Terraform Plan
А проблема полягає в тому, що за той час, коли ми подивились результат terraform plan в коментарях до PR і до моменту, коли він буде змержений і виконаний – пройде якийсь час, за який в ресурсах можливі зміни – чи то хтось запустить deploy-джобу з іншого PR, чи хтось задеплоїть власні зміни зі своєї машини (у нас це поки що допускається).
І тоді той Plan, який ми бачили в результатах виконання workflow Terraform Test вже буде неактуальний.
Друге питання – що в terraform apply виконується не той Plan, який ми бачили в коментарях і який ми апрувнули, а новий, який генерується вже під час виконання деплою.
Тож що ми можемо зробити, щоб запобігти такому?
Terraform: “Saved plan is stale”
Як варіант – це зберігати результати виконання terraform plan в файл, і потім цей же файл передавати в terraform apply.
Тоді, якщо в Terraform State відбулися зміни, то файл з Plan, який ми передамо на apply, сфейлиться, див. Manual State Pull/Push.
Як можемо перевірити те, як воно працює (тобто фейлиться):
деплоїмо нашу корзину
додаємо тег
робимо terraform plan -out test.plan
додаємо ще один тег
робимо terraform apply
а потім ще раз terraform apply, але вже з файлу test.plan – симулюємо зміни, які відбулися між запуском наших workflow
$ terraform apply test.plan
Acquiring state lock. This may take a few moments...
╷
│ Error: Saved plan is stale
│
│ The given plan file can no longer be applied because the state was changed by another operation after the plan was created.
Чудово – наш деплой обламався.
GitHub Actions та Artifacts: передача файлу з Terraform Plan
Тепер спробуємо реалізувати механізм передачі plan-файлу між workflow. Спочатку зробимо його збереження в artifact.
Note: тут ще треба враховувати питання Security, бо в plan можуть бути конфіденційні дані. З іншого боку, якщо хтось отримав доступ до вашого CI – то ви й так маєте проблеми.
Save Plan output as an Artifact
Тож що нам треба зробити:
при створенні Pull Request виконуємо terraform plan -out name.tfplan
зберігаємо файл name.tfplan як артефакт
при мержі Pull Request завантажуємо цей name.tfplan на GitHub Runner
і виконуємо terraform apply name.tfplan
Виглядає наче досить просто? Якби ж то…
Почнемо з вигрузки артефакту після тесту – тут все дійсно просто.
Задля того, щоб при виконанні terraform apply взяти артефакт саме з цього PR – додамо в ім’я файлу номер PR.
Оновлюємо файл terraform-test-on-pr.yml:
...
# create a Plan to see what will be changed
# save it to the file with a PR number
- name: "Test: Terraform Plan"
id: plan
run: terraform plan -no-color -out env-test-${{ github.event.pull_request.number }}.tfplan
# save as an artifact to this workflow run
- name: Upload Terraform Plan
uses: actions/upload-artifact@v4
with:
name: env-test-${{ github.event.pull_request.number }}.tfplan
path: "terraform/env-test-${{ github.event.pull_request.number }}.tfplan"
# throw an error if we can't find the Plan's file
if-no-files-found: error
# replace if an existing one is found
overwrite: true
...
наш workflow з terraform apply запускається при івенті push, а не pull_request – і в контексті github у нас вже нема github.event.pull_request.number
Першу проблеми ми можемо вирішити за допомогою іншого Action – dawidd6/action-download-artifact, якому можна передати ім’я файлу іншого workflow, в якому буде потрібний артефакт.
А другу проблему можемо вирішити за допомогою Action jwalton/gh-find-current-pr, який виконує запит до GitHub API, і повертає номер PR, з якого був зроблений Merge.
Отже, оновлюємо наш terraform-deploy-on-pr-merge.yml – додаємо permissions.actions: read, permissions.pull-requests: read та два нових step – прибираємо Terraform Plan, отримуємо PR number, та оновлюємо Terraform Apply – передаємо файл з планом:
...
permissions:
# create OpenID Connect (OIDC) ID token
id-token: write
# allow read repository's content by steps
contents: read
# get PR number
pull-requests: read
# allow download artifacts
actions: read
...
# get a PR number used to make the 'push' when merging
- name: "Misc: get PR number"
uses: jwalton/gh-find-current-pr@master
id: findpr
with:
state: all
# download artifact witjh the Terraform Plan file of the 'Terraform Test' workflow
- name: "Misc: Download Terraform Plan"
uses: dawidd6/action-download-artifact@v3
with:
github_token: ${{secrets.GITHUB_TOKEN}}
# the Workflow to look for the artifact
workflow: terraform-test-on-pr.yml
# PR number used to generate the artifact and trigger this workflow
pr: ${{ steps.findpr.outputs.pr }}
# artifact's name
name: env-test-${{ steps.findpr.outputs.pr }}.tfplan
path: terraform/
# ensure we have the file in the workflow
check_artifacts: true
- name: "Deploy: Terraform Apply"
id: apply
run: terraform apply -no-color env-test-${{ steps.findpr.outputs.pr }}.tfplan
Все пушимо, мержимо – і маємо виконаний Terraform Apply з використанням файлу з Terraform Plan попереднього workflow:
Також ще один важливий нюанс – завдяки однаковому значенню в concurrency.group в обох Workflow – наш деплой завжди буде чекати виконання Plan, що корисно, коли PR створюється і тут же мержиться:
...
# set other jobs with the same 'group' in a queue
concurrency:
group: deploy-test
cancel-in-progress: false
...
Рішення виглядає цілком робочим, і перевірки з кількома одночасними PR пройшло. Хоча мене трохи напрягає те, що тут маємо “too many moving parts”, ще й покладаємось на thirdparty GitHub Actions.
Використання GitHub Branch protection rule
Є ще один варіант, і можна або використати тільки його – або варіант, писаний вище + цей: це задати Branch protection rule, де буде вимога в бранчі, з якого робиться PR, мати всі зміни, які вже є в master.
Якщо використовувати тільки такий підхід – то прибираємо steps з uploads/download артефакту, і натомість використовуємо terraform plan -out file.tfplan та terraform apply file.tfplan в одній джобі, як робили до рішення з artifacts.
Хоча при такому підході ви все одно покладаєтесь на те, що Plan, який буде створено під час виконання джоби буде == тому плану, який ви дивились в коментарях, бо “запобіжник” з “Saved plan is stale” тут не спрацює.
Втім, налаштувати Branch protection rule все одно буде корисно:
Включаємо “Require status checks to pass before merging” та “Require branches to be up to date before merging“:
Тоді, якщо в master бранч був зроблений merge з іншого бранчу – GitHub не дозволить виконати PR Merge, поки ви не оновите свій бранч, а це викличе ще один запуск workflow з Terraform Test і генерацію нового Plan, який буде додано в коментарі:
Клікаємо Update branch – запускається нова перевірка, генерується новий артефакт з новим Plan в артефакті (якщо комбінуємо обидва рішення):
І тепер можемо мержити, і деплоїти:
Готово.
P.S. І в будь-якому разі завжди майте S3 Bucket Versioning, щоб мати бекап ваших state-файлів.
Tracing (“трасування”) дозволяє відстежувати запити між компонентами, тобто, наприклад, при використанні AWS і у Kubernetes ми можемо прослідкувати весь шлях запиту від AWS Load Balancer – до Kubernetes Pod – і до DynamoDB або RDS.
Це допомагає нам як відстежувати проблеми з performance – де і які запити виконуються довго – так і мати більше інформації при виникненні проблем, наприклад, коли наш API віддає клієнтам 500 помилки, і нам треба знайти в якому саме компоненті системи виникає проблема.
Проте сьогодні ми будемо додавати саме X-Ray коллектор, який створить Kubernetes DaemonSet та Kubernetes Service, в який Kubernetes Pods зможуть слати дані, які ми потім зможемо побачити або в AWS Console X-Ray, або в Grafana.
AWS IAM
IAM Policy
Для доступу в AWS з подів з X-Ray нам потрібно створити IAM Role, яку ми потім будемо використовувати в ServiceAccount для X-Ray.
$ kk get pod -l app.kubernetes.io/name=aws-xray
NAME READY STATUS RESTARTS AGE
aws-xray-5n2kt 0/1 Pending 0 41s
aws-xray-6cwwf 1/1 Running 0 41s
aws-xray-7dk67 1/1 Running 0 41s
aws-xray-cq7xc 1/1 Running 0 41s
aws-xray-cs54v 1/1 Running 0 41s
aws-xray-mjxlm 0/1 Pending 0 41s
aws-xray-rzcsz 1/1 Running 0 41s
aws-xray-x5kb4 1/1 Running 0 41s
aws-xray-xm9fk 1/1 Running 0 41s
Та Kubernetes Service:
$ kk get svc -l app.kubernetes.io/name=aws-xray
NAME TYPE CLUSTER-IP EXTERNAL-IP PORT(S) AGE
aws-xray ClusterIP None <none> 2000/UDP,2000/TCP 77s
Перевірка та робота з X-Ray
Створення Python Flask HTTP App з X-Ray
Створимо сервіс на Python Flask, який буде відповідати на HTTP-запити і логувати X-ray ID (ChatGPT промт – “Create a simple Python App with AWS X-Ray SDK for Python to run in Kubernetes. Add X-Ray ID output to requests“):
from flask import Flask
from aws_xray_sdk.core import xray_recorder
from aws_xray_sdk.ext.flask.middleware import XRayMiddleware
import logging
app = Flask(__name__)
# Configure AWS X-Ray
xray_recorder.configure(service='SimpleApp')
XRayMiddleware(app, xray_recorder)
# Set up basic logging
logging.basicConfig(level=logging.INFO)
@app.route('/')
def hello():
# Retrieve the current X-Ray segment
segment = xray_recorder.current_segment()
# Get the trace ID from the current segment
trace_id = segment.trace_id if segment else 'No segment'
# Log the trace ID
logging.info(f"Responding to request with X-Ray trace ID: {trace_id}")
return f"Hello, X-Ray! Trace ID: {trace_id}\n"
if __name__ == '__main__':
app.run(debug=True, host='0.0.0.0', port=5000)
Створюємо requirements.txt:
flask==2.0.1
werkzeug==2.0.0
aws-xray-sdk==2.7.0
Додаємо Dockerfile:
FROM python:3.8-slim
COPY requirements.txt .
RUN pip install --force-reinstall -r requirements.txt
COPY app.py .
CMD ["python", "app.py"]
Збираємо Docker-образ – тут використовується репозиторій в AWS ECR:
$ kk get ingress
NAME CLASS HOSTS ADDRESS PORTS AGE
flask-app-ingress alb * k8s-default-flaskapp-25042181e0-298318111.us-east-1.elb.amazonaws.com 80 10m
Кожна WorkerNode в Kubernetes може мати обмежену кількість подів, і цей ліміт визначається трьома параметрами:
CPU: загальна кількість requests.cpu не може бути більше, ніж є CPU на Node
Memory: загальна кількість requests.memory не може бути більше, ніж є Memory на Node
IP: загальна кількість подів не може бути більшою, ніж є IP-адрес у ноди
І якщо перші два ліміти такі собі “soft” – бо ми можемо просто не задавати requests взагалі – то ліміт по кількості IP-адрес на ноді це вже “hard” ліміт, бо кожному поду, який запускається на ноді, потрібно видати власну адресу з пула Secondary IP його ноди.
А проблема полягає в тому, що ці адреси дуже часто використовуються ще до того, як на ноді закінчаться CPU або Memory – і в такому випадку ми опиняємось в ситуації, коли наша нода underutilized, тобто – ми могли б ще запустити на ній поди, але не можемо, бо для них нема вільних IP.
Наприклад, одна з наших нод t3.medium виглядає так:
В неї є вільні CPU, не вся пам’ять requested, але Pods Allocation вже 100% – бо до ноди t3.medium може бути додано 17 Secondary IP для подів, і вони всі вже зайняті.
Максимум Secondary IP на AWS EC2
Кількість же додаткових (secondary) IP на ЕС2 залежить від кількості ENI (Elastic network Interface) та кількості IP на кожен інтерфейс, і ці параметри залежать від типу EC2.
Тобто всього 18 адрес, але мінус по 1 Private IP роботу ENI самого інстансу – і для подів на такій ноді буде доступно 17 адрес.
Amazon VPC Prefix Assignment Mode
Щоб вирішити проблему з кількістью Secondary IP на EC2 можна використати VPC Prefix Assignment Mode – коли на інтерфейс підключається не окремий IP, а цілий блок /28, див. Assign prefixes to Amazon EC2 network interfaces.
Наприклад, ми можемо створити новий ENI і йому присвоїти CIDR (Classless Inter-Domain Routing) префікс:
$ aws --profile work ec2 create-network-interface --subnet-id subnet-01de26778bea10395 --ipv4-prefix-count 1
Перевіряємо цей ENI:
Єдиний момент, який варто мати на увазі це те, що VPC Prefix Assignment Mode доступний тільки для інстансів на AWS Nitro System – останнє покоління гіпервізорів AWS, на якому працюють інстанси T3, M5, C5, R5 і т.д. – див. Instances built on the Nitro System.
What is: CIDR /28
Кожна IPv4-адреса складається з 32 бітів, поділених на 4 октети (групи по 8 біт). Ці біти можуть бути представлені у двійковій системі (0 або 1) або у десятковій формі (значення між 0 та 255 для кожного октету). Ми будемо оперувати саме 0 та 1.
Маска підмережі /28 вказує на те, що перші 28 бітів IP-адреси зарезервовані для ідентифікації мережі – тоді 4 біти (32 всього мінус 28 зарезервованих) залишаються для визначення індивідуальних хостів в мережі:
Знаючи, що у нас є 4 вільні біти, а кожен біт може мати значення 0 або 1, ми можемо порахувати загальну кількість комбінацій: 2 в ступені 4, або 2×2×2×2=16 – тобто в мережі /28 може бути загалом 16 адрес, включаючи як адресу мережі (перший IP), так і broadcast адресу (останній IP), отже саме для хостів буде доступно 14 адрес.
Тож замість того, щоб на ENI підключати один Secondary IP – ми підключаємо відразу 16.
При цьому варто враховувати скільки ваша VPC Subnet зможе мати таких блоків, бо це буде визначати кільікість WorkerNodes, які ви зможете запустити.
Тут вже простіше використати утіліти накшалт ipcalc.
Наприклад, в мене Private Subnets мають префікс /20, і якщо всю цю мережу розбити на блоки по /28, то будемо мати 256 підмереж і 3584 адрес:
L-IPAM daemon (IPAMD): відповідає за створення та підключення ENI до EC2-інстансів, призначення блоків адрес до цих інтерфейсів та “прогрів” IP-префіксів для пришвидшення запуску подів (поговоримо далі)
CNI plugin: відповідає за налаштування мережевих інтерфейсів на ноді – як ethernet, та і віртуальних, і комунікує з IPAMD через RPC (Remote Procedure Call)
Для конфігурації виділення префіксів нодам та IP подам VPC CNI має три додаткові опції – WARM_PREFIX_TARGET, WARM_IP_TARGET та MINIMUM_IP_TARGET:
WARM_PREFIX_TARGET: скільки підключених /28 префіксів тримати “в запасі”, тобто вони будуть підключені до ENI, але адреси з них ще не використовуються
WARM_IP_TARGET: скільки мінімально IP адрес підключати при створенні ноди
MINIMUM_IP_TARGET: скільки мінімально IP адрес тримати “в запасі”
При використанні VPC Prefix Assignment Mode ви не можете задати всі три параметри в нуль – як мінімум або WARM_PREFIX_TARGET або WARM_IP_TARGET мають бути задані хоча б в 1.
Якщо заданий WARM_IP_TARGET та/або MINIMUM_IP_TARGET – вони будуть мати перевагу над WARM_PREFIX_TARGET, тобто значення з WARM_PREFIX_TARGET буде ігноруватись.
При використанні Prefix IP, адреси в префіксу мають бути суміжні, тобто в одному префіксу не можуть бути адреси “10.0.31.162” (блок 10.0.31.160/28) та “10.0.31.178” (блок 10.0.31.176/28).
Якщо сабнет активно використовується, і в ньому немає безперервного блоку адрес для виділння цілого префіксу, то ви отримаєте помилку:
failed to allocate a private IP/Prefix address: InsufficientCidrBlocks: The specified subnet does not have enough free cidr blocks to satisfy the request
Щоб запобігти цьому, можна використати функцію резервації блоків – VPC Subnet CIDR reservations для створення єдиного блоку, з якого потім будуть “нарізатись” блоки по /28. Такий блок не буде використовуватись для виділення Private IP для EC2, натомість VPC CNI буде створювати префікси саме з цієї “резервації”.
При цьому ви можете створити таку резервацію навіть якщо окремі IP в цьому блоку вже використовуються на EC2 – як тільки такі адреси звільняться, вони більше не будуть виділятись окремим інстансам EC2, а будуть зберігатись для формування префіксів /28.
Отже, якщо в мене є VPC Subnet з блоком /20 – я можу розбити її на два CIDR Reservation блоки по /21, і в кожному /21 блоці мати:
При використанні AWS Managed NodeGroups новий ліміт буде заданий автоматично.
В цілому, максимальна кількість подів буде залежати від типу інстансу і кількості vCPU на ньому – 110 подів на кожні 10 ядер (див. Kubernetes scalability thresholds). Але є ще ї ліміти, які задані самим AWS.
Наприклад для t3.nano з 2 vCPU це буде 34 поди – перевіримо скриптом max-pod-calculator.sh:
Деплоїмо, і Karpenter створює один NodeClaim з t3.small:
$ kk get nodeclaim
NAME TYPE ZONE NODE READY AGE
default-w7g9l t3.small us-east-1a False 3s
Пара хвилин – і поди на ньому запустились:
Тепер скейлимо Deployment до, наприклад, 50 подів:
$ kk scale deployment nginx-deployment --replicas=50
deployment.apps/nginx-deployment scaled
І все ще маємо один NodeClaim з тим же t3.small, але тепер на ньому запущено 50 подів:
Звісно, при такому підході треба завжди задавати Pod requests, щоб кожен под мав доступ до CPU та Memory – саме requests для нас тепер будуть лімітами на кількість подів на нодах.