В Kubernetes окрім метрик та логів з контейнерів ми маємо змогу отримувати інформацію про роботу компонентів за допомогою Kubernetes Events.
В евентах зазвичай зберігається інформація про статус подів (створення, Evict, kill, ready або not-ready статус подів), про WorkerNodes (статус серверів), про роботу Kubernetes Scheduler (неможливість запуску поду, тощо).
Типи Kubernetes Events
В цілому, всі ці евенти можна поділити на такі типи:
Failed events: коли виникає проблема з маніфестом або образом, з якого потрібно створити контейнер (ImagePullBackOff, CrashLoopBackOff)
Scheduler events: проблеми с запуском поду на WorkerNode, наприклад – коли Scheduler не може знайти ноду з достатніми ресурсами, щоб задовольнити Pod requests
Volume events: проблеми з підключенням PersistentVolume до поду (FailedAttachVolume, FailedMount)
Node events: проблеми в роботі WorkerNodes (NodeNotReady)
Kubernetes Events та kubectl
Отримати евенти можемо просто з kubectl – або зробивши kubectl describe pod <POD_NAME> чи kubectl decsribe deploy <DEPLOY_NAME>:
Або з kubectl get events, якому можна додати параметр --watch:
Також існує цікавий плагін podevents, який додає час евенту.
sloop – активний, система візуалізації евентів з можливістю фільтрації та пошуку
kspan – активний, створює OpenTelemetry Spans з евентів, які потім можна перевіряти в системах типу Jaeger
kubernetes-event-exporter – активний, вміє відправляти евенти, мабуть, в усе, що взагалі існує – і AWS SQS/SNS, і Opsgenie, і Slack, і Loki – можливо, він буде наступний в моєму кластері, коли поточне рішення стане недостатнім
Grafana Agent (Grafana Alloy) – теж вміє працювати з евентами, і писати їх у вигляді логів в Loki
Але, як на мене, то простіше всього їх мати у вигляді логів, і потім з Loki RecordingRules створювати метрики, а з них графіки в Grafana та/або алерти.
Для цього є дуже проста система max-rocket-internet/k8s-event-logger, яка слухає Kubernetes API, отримує всі евенти, і записує їх у вигляді лога в JSON.
$ kubectl -n ops-monitoring-ns get pods -l "app.kubernetes.io/name=k8s-event-logger"
NAME READY STATUS RESTARTS AGE
k8s-event-logger-5b548d6cc4-r8wkl 1/1 Running 0 68s
Чого прям ну дуже не вистачає в цій системі – це доступу до серверів по SSH, без якого почуваєшся… Ну, наче DevOps, а не Infrastructure Engineer. Короче – доступ по SSH іноді прям треба, але – сюрпрайз – Karpenter з коробки не дає можливості додати ключ на ноди, які він менеджить.
Хоча, здавалося б – в чому проблема в EC2NodeClass передати ключ, як це робиться в Terraform resource "aws_instance"з параметром key_name?
Але – ОК. Нема, то й нема. Можливо, додадуть пізніше.
Тож що будемо робити сьогодні – по черзі спробуємо всі три рішення, спочатку кожне будемо робити руками, потім дивитись як його додати в нашу автоматизацію, і потім вирішимо який варіант буде найпростішим.
Варіант 1: AWS Systems Manager Session Manager та SSH на EC2
AWS Systems Manager Session Manager використовується для менеджменту EC2-інстансів. Взагалі він вміє досить багато, наприклад – слідкувати за патчами і апдейтами для пакетів, які встановлені на інстансах.
Зараз він нас цікавить тільки як система, яка дозволить виконати SSH на Kubernetes WorkerNode.
Для роботи потребує SSM-агента, який по дефолту є на всіх інстансах з Amazon Linux AMI.
Знаходимо ноди, які створені Kaprneter (у нас є окрема лейбла для них):
$ kubectl get node -l created-by=karpenter
NAME STATUS ROLES AGE VERSION
ip-10-0-34-239.ec2.internal Ready <none> 21h v1.28.8-eks-ae9a62a
ip-10-0-35-100.ec2.internal Ready <none> 9m28s v1.28.8-eks-ae9a62a
ip-10-0-39-0.ec2.internal Ready <none> 78m v1.28.8-eks-ae9a62a
...
AWS CLI: TargetNotConnected when calling the StartSession operation
Пробуємо підключитись – і отримуємо помилку “TargetNotConnected“:
$ aws --profile work ssm start-session --target i-011b1c0b5857b0d92
An error occurred (TargetNotConnected) when calling the StartSession operation: i-011b1c0b5857b0d92 is not connected.
Або через AWS Console:
Але і тут маємо помилку підключення – “SSM Agent is not online“:
Помилка виникає через те, що:
або в IAM-ролі, яка підключена до інстансу, нема дозволу на SSM
або EC2 запущено в приватній мережі, і агент не може підключитись до зовнішнього ендпоінту
SessionManager та політики для IAM
Перевіряємо – знаходимо IAM Role:
І підключені до неї політики – про SSM нема нічого:
Редагуємо політику – поки що руками, потім зробимо в коді Terraform:
Підключаємо AmazonSSMManagedInstanceCore:
І за хвилину-дві пробуємо ще раз:
SessionManager та VPC Endpoint
Інша можлива причина проблем підключення SSM-агенту до AWS – нема доступу з інстансу до ендпоінтів SSM:
ssm.region.amazonaws.com
ssmmessages.region.amazonaws.com
ec2messages.region.amazonaws.com
Якщо сабнет приватний, і має ліміти на зовнішні підключення – то можливо треба створити VPC Endpoint для SSM.
Але після фіксу IAM при підключенні з робочої машини з AWS CLI маємо помилку “SessionManagerPlugin is not found“:
$ aws --profile work ssm start-session --target i-011b1c0b5857b0d92
SessionManagerPlugin is not found. Please refer to SessionManager Documentation here: http://docs.aws.amazon.com/console/systems-manager/session-manager-plugin-not-found
В модулі версії 20.0 ім’я параметру змінилось – iam_role_additional_policies => node_iam_role_additional_policies, але у нас поки що версія 19.21.0, і роль додається таким чином:
Для Terraform тут буде потрібно в модулі EKS додавати node_security_group_additional_rules для доступу по SSH, і для VPC створювати EC2 Instance Connect Endpoint, бо у нас VPC та EKS створюються окремо.
Втім, тут треба передавати SecurityGroup ID з кластеру, а кластер у нас створюється після VPC, тому виникає проблема “куриця-яйце”.
Взагалі з Instance Connect виглядає якось трохи більш складно, чим з SSM, бо більше змін в коді, ще й в різних модулях.
Втім – варіант робочий, і якщо ваша автоматизація дозволяє – то можна використовувати його.
Варіант 3: дідовський спосіб з SSH Public Key через EC2 User Data
Ну і самий старий і, можливо, простий варіант – це самому створити SSH-ключ, і додавати його публічну частину на EC2 при створені інстансу.
З недоліків тут те, що додавати таким чином багато ключів буде складно, та й взагалі EC2 User Data іноді може вилізти боком, але якщо потрібно додати тільки один ключ, якийсь “супер-адмін” на крайній випадок – то цілком валідний варіант.
$ ssh-keygen
Generating public/private ed25519 key pair.
Enter file in which to save the key (/home/setevoy/.ssh/id_ed25519): /home/setevoy/.ssh/atlas-eks-ec2
Enter passphrase (empty for no passphrase):
Enter same passphrase again:
Your identification has been saved in /home/setevoy/.ssh/atlas-eks-ec2
Your public key has been saved in /home/setevoy/.ssh/atlas-eks-ec2.pub
...
Публічну частину можемо зберігати в репозиторії – копіюємо її:
Далі трохи костилів: EC2NodeClass у нас створюється з Terraform через ресурс kubectl_manifest. Найпростіший варіант, який поки що прийшов в голову – це додати публічний ключ в variables, і потім використати в kubectl_manifest.
Пізніше мабуть перенесу такі ресурси в окремий Helm-чарт, і зроблю більш красиво.
Поки що створюємо нову змінну:
variable "karpenter_nodeclass_ssh" {
type = string
default = "ssh-ed25519 AAA***VMO setevoy@setevoy-wrk-laptop"
description = "SSH Public key for EC2 created by Karpenter"
}
AWS SessionManager: виглядає як найпростіший варіант з точку зору автоматизації, рекомендований самим AWS, але треба подумати, як робити той же scp через нього (хоча це начебто можливо через додаткові костилі – див. .SSH and SCP with AWS SSM)
AWS EC2 Instance Connect: прикольна фіча від Амазону, але якось більш геморно в автоматизації, тому не наш варіант
“дідовський” SSH: ну, старе – перевірене 🙂 але я не дуже люблю User Data, бо іноді може призвести до проблем з запуском інстансів; втім – теж простий з точки зору автоматиазції, і дає звичний SSH без додаткових тєлодвіженій
Повернемось до цієї теми ще раз, але цього разу на EC2 в AWS, без Kubernetes.
Отже, що треба – це запустити якийсь VPN-сервіс для проекту, або мати доступ до всяких Kubernetes API/Kubernetes WorkerNodes/AWS RDS у приватних мережах.
Вибір тут, в принципі, є – і AWS VPN, і ванільний OpenVPN, і багато іншого.
Але я Pritunl користувався вже в декількох проектах, він має приємний інтерфейс, основні можливості VPN доступні у Free версії – тож вай нот?
Що таке Pritunl
Фактично, Pritunl – це обгортка над звичайним OpenVPN сервером. Повністю сумісний, використовує однакові конфіги, і так далі.
Вміє в інтеграцію з AWS VPC – https://pritunl.com/vpc, але мені не дуже хочеться, щоб хтось автоматом міняв таблиці маршрутизації.
Сетап мережі в AWS у нас доволі простий, і поки що все можна менеджити самому – більше контролю, більше розуміння, що може піти не так.
Ну і плюс ця інтеграція наче доступна тільки в Enerprize – Pritunl Pricing.
У Pritunl дві основні концепції – Organization та Server:
Server описує конфіг для OpenVPN – порти, роути, DNS
Organization описує юзерів
Organization підключається до Server
Далі, юзер завантажує файл .ovpn, і підключається будь-яким VPN-клієнтом. Наскільки пам’ятаю, навіть дефолтний клієнт на macOS працював з ним без проблем.
Pritunl та Terraform
На попередньому проекті ми мали Pritunl в Kubernetes, але мені ця ідея якось не дуже подобається, бо, імхо, VPN має бути окремим сервісом.
Якщо говорити про Terraform, то є цікавий Pritunl Provider – але йому потрібен API ключ, який в Pritunl доступний тільки в Enerprize.
Ще є готовий код з Terraform тут – Pritunl VPN, але мені якось простіше підняти власну EC2 у власній VPC.
$ ssh-keygen
Generating public/private ed25519 key pair.
Enter file in which to save the key (/home/setevoy/.ssh/id_ed25519): /home/setevoy/.ssh/atlas-vpn
...
Публічну частину можемо зберігати в репозиторії – створюємо каталог, і копіюємо її:
Описуємо SecurityGroup – дозволяємо SSH тільки з цього IP, у vpc_id використовуємо local.vpc_out.vpc_id.
Додаємо порти – 80 для Let’s Encrypt, який використовується Pritunl, 443 – для доступу до його адмінки, тут знов тільки з мого IP, 10052 UPD – для клієнтів VPN:
Використовуючи data "aws_ami", знайдемо AWS AMI з Ubuntu.
Я спочатку пробував Pritunl запустити на Amazon Linux, але той yum і окремі репозиторії – це якась біда, на Ubuntu завелось без проблем:
data "aws_ami" "ubuntu" {
most_recent = true
filter {
name = "name"
values = ["ubuntu/images/hvm-ssd/ubuntu-*-22.04-amd64-server-*"]
}
filter {
name = "virtualization-type"
values = ["hvm"]
}
owners = ["099720109477"] # Canonical's official AWS account ID for Ubuntu AMIs
}
Але використовуючи data "aws_ami" майте на увазі, що коли вийде якась обнова, то AWS створить новий AMI, і при наступному запуску вашого Terraform-коду він підтягне новий ID, і запропонує перестворити відповідну EC2.
Аби мати одну і ту ж саму адресу, зробимо її окремим ресурсом:
resource "aws_eip" "vpn_eip" {
domain = "vpc"
}
AWS Route53 VPN record
Відразу створимо запис в DNS.
В variables.tf задаємо ID зони в Route53 та її ім’я:
variable "route53_ops_zone" {
type = object({
name = string
id = string
})
default = {
name = "ops.example.co"
id = "Z02***OYY"
}
}
І в main.tf описуємо сам запис:
resource "aws_route53_record" "vpn_dns" {
zone_id = var.route53_ops_zone.id
name = "vpn.${var.route53_ops_zone.name}"
type = "A"
ttl = 300
records = [aws_eip.vpn_eip.public_ip]
}
Тепер у нас буде запис виду “vpn.ops.example.co IN A <EC2_EIP>.
AWS EC2 та установка Pritunl
І, врешті-решт, описуємо сам EC2, використовуючи ресурси, які створили вище:
ami – беремо з data.aws_ami.amazon_linu
key_name – беремо з aws_key_pair.vpn_key.key_name
vpc_security_group_ids – з SG, яку створюємо тут же
subnet_id, де створювати EС2 – беремо з local.vpc_out.vpc_public_subnets_ids
Тут же відразу додаємо встановлення Pritunl – див. документацію [Other Providers] Ubuntu 22.04, але вона місцями крива, тому, можливо, краще зробити установку руками після створення інстансу.
Ну, або таки додати в user_data – принаймні на момент написання з кодом нижче це працювало.
У випадку проблем з EC2 user_data – перевіряйте лог /var/log/cloud-init.log, і спробуйте запустити скрипт вручну – він має бути у файлі типу /var/lib/cloud/instance/scripts/part-001.
Майте на увазі, що user_data викликається тільки при створенні інстансу:
Відкриваємо vpn.ops.example.co:443, на помилку ERR_CERT_AUTHORITY_INVALID поки не звертаємо уваги – Let’s Encrypt згенерить сертифікат після налаштувань Pritunl.
Передаємо setup-key, адресу MongoDB можна лишити по дефолту:
Чекаємо апдейту MongoDB:
Коли відкриється вікно логіна – на сервері виконуємо pritunl default-password:
Генеруємо новий пароль, яким будемо користуватись вже постійно:
$ pwgen 12 1
iBai1Aisheat
І задаємо основні параметри Pritunl – тут тільки логін/пароль та адреси:
Якщо забули новий пароль – можна скинути з pritunl reset-password.
Error getting LetsEncrypt certificate check the logs for more information.
При проблемах з Let’s Ecnrypt – перевіряємо лог /var/log/pritunl.log, там все буде:
root@ip-10-0-1-25:/home/ubuntu# tail -f /var/log/pritunl.log
File "/usr/lib/pritunl/usr/lib/python3.9/site-packages/pritunl/handlers/settings.py", line 1112, in settings_put
acme.update_acme_cert()
File "/usr/lib/pritunl/usr/lib/python3.9/site-packages/pritunl/acme.py", line 73, in update_acme_cert
cert = get_acme_cert(settings.app.acme_key, csr, cmdline)
File "/usr/lib/pritunl/usr/lib/python3.9/site-packages/pritunl/acme.py", line 45, in get_acme_cert
certificate = acmetiny.get_crt(
File "/usr/lib/pritunl/usr/lib/python3.9/site-packages/pritunl/acmetiny.py", line 138, in get_crt
raise ValueError("Challenge did not pass for {0}: {1}".format(domain, authorization))
ValueError: Challenge did not pass for vpn.ops.example.co: {'identifier': {'type': 'dns', 'value': 'vpn.ops.example.co'}, 'status': 'invalid', 'expires': '2024-06-07T13:32:30Z', 'challenges': [{'type': 'http-01', 'status': 'invalid', 'error': {'type': 'urn:ietf:params:acme:error:dns', 'detail': 'DNS problem: NXDOMAIN looking up A for vpn.ops.example.co - check that a DNS record exists for this domain; DNS problem: NXDOMAIN looking up AAAA for vpn.ops.example.co - check that a DNS record exists for this domain', 'status': 400}, 'url': 'https://acme-v02.api.letsencrypt.org/acme/chall-v3/357864308812/RHhMwA', 'token': 'KZLx4dUxDmow5uMvfJdwbgz5bY4HG0tTQOW2m4UvFBg', 'validated': '2024-05-31T13:32:30Z'}]}
acme_domain = "vpn.ops.example.co"
Домен новий – Let’s Encrypt про нього ще не знає.
Чекаємо кілька хвилин, і пробуємо ще раз.
Успішна реєстрація сертифікату в логах має виглядати так:
У випадку помилки “ERROR: Cannot open TUN/TAP dev /dev/net/tun: No such device” на Linux – спробуйте перезавантажитись. В мене ядро було оновлене, і давно не ребутався.
Перевіряємо локальні роути:
$ route -n
Kernel IP routing table
Destination Gateway Genmask Flags Metric Ref Use Iface
0.0.0.0 192.168.3.1 0.0.0.0 UG 600 0 0 wlan0
0.0.0.0 192.168.3.1 0.0.0.0 UG 1002 0 0 enp2s0f0
10.0.0.0 172.16.0.1 255.255.0.0 UG 0 0 0 tun0
172.16.0.0 0.0.0.0 255.255.255.0 U 0 0 0 tun0
...
Все гуд – в Інтернет, 0.0.0.0, ходимо старим маршрутом, через домашній роутер, а у VPC, 10.0.0.0 – через 172.16.0.1, наш VPN.
Спробуємо:
$ traceroute 1.1.1.1
traceroute to 1.1.1.1 (1.1.1.1), 30 hops max, 60 byte packets
1 _gateway (192.168.3.1) 1.617 ms 1.550 ms 1.531 ms
...
9 one.one.one.one (1.1.1.1) 17.265 ms 17.246 ms 18.600 ms
Окей, через домашній роутер.
І до якогось серверу в AWS VPC:
$ traceroute 10.0.42.95
traceroute to 10.0.42.95 (10.0.42.95), 30 hops max, 60 byte packets
1 172.16.0.1 (172.16.0.1) 124.407 ms 124.410 ms 124.417 ms
...
Через VPN.
І навіть SSH до інстансів в приватній мережі працює:
$ systemctl start pritunl-org.service
==== AUTHENTICATING FOR org.freedesktop.systemd1.manage-units ====
Authentication is required to start 'pritunl-org.service'.
Ще раз роути:
$ route -n
Kernel IP routing table
Destination Gateway Genmask Flags Metric Ref Use Iface
0.0.0.0 192.168.3.1 0.0.0.0 UG 100 0 0 enp2s0f0
0.0.0.0 192.168.3.1 0.0.0.0 UG 600 0 0 wlan0
0.0.0.0 192.168.3.1 0.0.0.0 UG 1002 0 0 enp2s0f0
10.0.0.0 172.16.0.1 255.255.0.0 UG 0 0 0 tun0
Все є.
Додаємо в автостарт:
$ systemctl enable pritunl-org.service
==== AUTHENTICATING FOR org.freedesktop.systemd1.manage-unit-files ====
Authentication is required to manage system service or unit files.
Authenticating as: root
Password:
==== AUTHENTICATION COMPLETE ====
Created symlink /etc/systemd/system/multi-user.target.wants/pritunl-org.service -> /etc/systemd/system/pritunl-org.service.
Хоча в заголовку цього поста я вказав “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: