Pritunl: запуск VPN в AWS на EC2 з Terraform
0 (0)

31 Травня 2024

Колись вже трохи писав про Pritunl – Pritunl: запуск VPN в Kubernetes.

Повернемось до цієї теми ще раз, але цього разу на 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.

І ще нагуглив такий готовий модуль – AWS VPN (Pritunl) Terraform Module, виглядає наче робочим рішенням.

Проте будемо робити більш дідовським методом:

  • є звичайна AWS VPC з кількома приватними сабнетами
  • у публічному сабнеті з Terraform запустимо звичайний EC2
  • через AWS EC2 user_data встановимо і запустимо Pritunl
  • і вручну налаштуємо йому юзерів, сервери, роути

Роутинг має бути таким: всі пакети, які йдуть у VPC – відправляються через VPN, а решта – через звичайне підключення.

Terraform: створення EC2

Отже, спершу нам потрібно запустити EC2, на якій буде працювати Pritunl.

Для цього EC2 нам потрібні AWS AMI, SSK Key, Security Group, VPC ID, і відразу створимо AWS Route53 запис.

Отримання VPC ID

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

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

У цьому outputs маємо VPC ID та ID публічних сабнетів:

$ terraform output
...
vpc_id = "vpc-0fbaffe234c0d81ea"
...
vpc_public_subnets_cidrs = tolist([
  "10.0.0.0/20",
  "10.0.16.0/20",
])
vpc_public_subnets_ids = [
  "subnet-01de26778bea10395",
  "subnet-0efd3937cadf669d4",
]

І далі цей data resource використовуємо у locals:

locals {
  # get VPC info
  vpc_out = data.terraform_remote_state.vpc.outputs
}

Хоча в принципі теж можна зробити просто з data "aws_vpc".

EC2 SSH Key

Використовуємо key_pair.

Створюємо сам ключ:

$ 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
...

Публічну частину можемо зберігати в репозиторії – створюємо каталог, і копіюємо її:

$ mkdir ssh
$ cp /home/setevoy/.ssh/atlas-vpn.pub ssh/

Описуємо ресурс aws_key_pair:

resource "aws_key_pair" "vpn_key" {
  key_name   = "atlas-vpn-key"
  public_key = file("${path.module}/ssh/atlas-vpn.pub")
}

AWS Secuirty Group

Знаходимо домашній/робочий IP:

$ curl ifconfig.me
178.***.***.52

Описуємо SecurityGroup – дозволяємо SSH тільки з цього IP, у vpc_id використовуємо local.vpc_out.vpc_id.

Додаємо порти – 80 для Let’s Encrypt, який використовується Pritunl, 443 – для доступу до його адмінки, тут знов тільки з мого IP, 10052 UPD – для клієнтів VPN:

resource "aws_security_group" "allow_ssh" {
  name        = "allow_ssh"
  description = "Allow SSH inbound traffic"
  vpc_id      = local.vpc_out.vpc_id

  ingress {
    description = "SSH Arseny home"
    from_port   = 22
    to_port     = 22
    protocol    = "tcp"
    cidr_blocks = ["178.***.***.52/32"]
  }

  ingress {
    description = "Pritunl Admin Arseny home"
    from_port   = 443
    to_port     = 443
    protocol    = "tcp"
    cidr_blocks = ["178.***.***.52/32"]
  }

  ingress {
    description = "Pritunl Lets Encrypt"
    from_port   = 80
    to_port     = 80
    protocol    = "tcp"
    cidr_blocks = ["0.0.0.0/0"]
  }    

  ingress {
    description = "Pritunl VPN port"
    from_port   = 10052
    to_port     = 10052
    protocol    = "udp"
    cidr_blocks = ["0.0.0.0/0"]
  } 

  egress {
    from_port   = 0
    to_port     = 0
    protocol    = "-1"
    cidr_blocks = ["0.0.0.0/0"]
  }

  tags = {
    Name = "${var.project_name}-${var.environment}-allow_ssh"
  }
}

AWS AMI

Використовуючи 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.

Тому, можливо, краще просто знайти AMI ID вручну і занести в variables. Див. Find an AMI та Amazon EC2 AMI Locator.

AWS Elastic IP

Аби мати одну і ту ж саму адресу, зробимо її окремим ресурсом:

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 викликається тільки при створенні інстансу:

resource "aws_instance" "vpn" {
  ami           = data.aws_ami.ubuntu.id
  instance_type = var.vpn_ec2_instance_type
  key_name      = aws_key_pair.vpn_key.key_name

  vpc_security_group_ids = [aws_security_group.allow_ssh.id]
  subnet_id     = local.vpc_out.vpc_public_subnets_ids[0]

  user_data = <<-EOF
              #!/bin/bash
              echo 'deb http://repo.pritunl.com/stable/apt jammy main' > /etc/apt/sources.list.d/pritunl.list
              echo 'deb https://repo.mongodb.org/apt/ubuntu jammy/mongodb-org/6.0 multiverse' > /etc/apt/sources.list.d/mongodb-org-6.0.list
              apt-key adv --keyserver hkp://keyserver.ubuntu.com --recv 7568D9BB55FF9E5287D586017AE645C0CF8E292A
              wget -qO - https://www.mongodb.org/static/pgp/server-6.0.asc | sudo apt-key add -
              apt update
              apt --assume-yes upgrade
              apt -y install wireguard wireguard-tools
              ufw disable
              apt -y install pritunl mongodb-org
              systemctl enable mongod pritunl
              systemctl start mongod pritunl
              EOF

  tags = {
    Name = "Pritunl VPN"
  }
}

Додаємо підключення Elastic IP до цього інстансу:

resource "aws_eip_association" "vpn_eip_assoc" {
  instance_id   = aws_instance.vpn.id
  allocation_id = aws_eip.vpn_eip.id
}

Terraform Outputs

Додамо outputs, аби потім простіше було шукати всякі ID:

output "vpn_ec2_id" {
  value = aws_instance.vpn.id
}

output "vpn_eip" {
  value = aws_eip.vpn_eip.public_ip
}

output "aws_ami_id" {
  value = data.aws_ami.ubuntu.id
}

output "vpn_dns" {
  value = aws_route53_record.vpn_dns.name
}

Робимо terraform init, terraform plan, terraform apply:

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

Outputs:

ec2_public_ip = "3.83.69.105"
vpn_ec2_id = "i-0ea1407cb7ff8690f"

Перевіряємо інстанс:

Перевіряємо SSH до нього:

$ ssh -i ~/.ssh/atlas-vpn [email protected]
...
[ec2-user@ip-10-0-3-26 ~]$ sudo -s
[root@ip-10-0-3-26 ec2-user]#

Перевіряємо сам Pritunl на сервері:

root@ip-10-0-1-25:/home/ubuntu# systemctl status pritunl
● pritunl.service - Pritunl Daemon
     Loaded: loaded (/etc/systemd/system/pritunl.service; enabled; vendor preset: enabled)
     Active: active (running) since Fri 2024-05-31 13:04:08 UTC; 55s ago
   Main PID: 3812 (pritunl)
      Tasks: 19 (limit: 2328)
     Memory: 99.7M
        CPU: 1.318s
     CGroup: /system.slice/pritunl.service
             ├─3812 /usr/lib/pritunl/usr/bin/python3 /usr/lib/pritunl/usr/bin/pritunl start
             └─4174 pritunl-web

May 31 13:04:08 ip-10-0-1-25 systemd[1]: Started Pritunl Daemon.

Тепер можна переходити до його налаштування.

Pritunl inital setup

Документація – Configuration.

Підключаємось на EC2, виконуємо pritunl setup-key:

root@ip-10-0-1-25:/home/ubuntu# pritunl setup-key
074d9be70f1944d7a77374cca09ff8dc

Відкриваємо vpn.ops.example.co:443, на помилку ERR_CERT_AUTHORITY_INVALID поки не звертаємо уваги – Let’s Encrypt згенерить сертифікат після налаштувань Pritunl.

Передаємо setup-key, адресу MongoDB можна лишити по дефолту:

Чекаємо апдейту MongoDB:

Коли відкриється вікно логіна – на сервері виконуємо pritunl default-password:

root@ip-10-0-1-25:/home/ubuntu# pritunl default-password
[local][2024-05-31 13:12:41,687][INFO] Getting default administrator password
Administrator default password:
  username: "pritunl"
  password: "1rueBHeV9LIj"

І логінимось:

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

$ 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 про нього ще не знає.

Чекаємо кілька хвилин, і пробуємо ще раз.

Успішна реєстрація сертифікату в логах має виглядати так:

[INFO] Found domains: vpn.ops.example.co
[INFO] Getting directory...
[INFO] Directory found!
[INFO] Registering account...
[INFO] Registered!
[INFO] Creating new order...
[INFO] Order created!
[INFO] Verifying vpn.ops.example.co...
[INFO] vpn.ops.example.co verified!
[INFO] Signing certificate...
[INFO] Certificate signed!
[INFO] Settings changed, restarting server...

Створення Pritunl Organization та юзерів

Додаємо організацію – через неї будемо групувати юзерів, бо Groups в безплатний версії недоступні:

Додаємо юзера:

Email, Pin – опціональні, зараз не потрібні:

Створення Pritunl Server та роути

Див. Server configuration.

Переходимо до Servers, додаємо новий:

В DNS Server задаємо адресу DNS нашої VPC.

В Port задаємо порт, який відкривали на AWS EC2 SecurityGroup.

Virtual Network – пул, з якого будуть виділятись адреси клієнтам. Я тут використовую 172.*, бо простіше відрізнити від решти – дома 192.*, VPC 10.*.

Підключаємо створену раніше Організацію:

Стартуємо сервер:

Налаштуємо роути – щоб через VPN йшли запити тільки в VPC:

І видаляємо дефолтний роут в 0.0.0.0/0:

Linux OpenVPN – підключення до серверу

Переходимо в Users, клікаємо Download profile:

Розпаковуємо його:

$ tar xfpv test-user.tar 
org-all_test-user_org-all-serv.ovpn

І підключаємося за допомогою звичайного OpenVPN клієнта:

$ sudo openvpn --config org-all_test-user_org-all-serv.ovpn

У випадку помилки “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 до інстансів в приватній мережі працює:

$ ssh -i test-to-del.pem [email protected]
...
ubuntu@ip-10-0-45-127:~$

Чудово.

Linux Systemd та Pritunl/OpenVPN autostart

Давайте зробимо, аби конект був постійно.

Створюємо каталог:

$ sudo mkdir /etc/pritunl-client

Переносимо конфіг:

$ sudo mv org-all_test-user_org-all-serv.ovpn /etc/pritunl-client/work.ovpn

Пишемо самий простий файл /etc/systemd/system/pritunl-org.service:

[Unit]
Description=Pritunl Work

[Service]
Restart=always
WorkingDirectory=/etc/pritunl-client/

ExecStart=/usr/bin/openvpn --config work.ovpn

ExecStop=killall openvpn

[Install]
WantedBy=multi-user.target

І перевіряємо:

$ 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.

Готово.

Loading

Renovate: GitHub та Helm Charts versions management
0 (0)

29 Травня 2024

Depndabot (див. Dependabot: GitHub та Terraform versions management) цікавий тим, що досить швидко і просто конфігуриться, але те, що він досі не вміє в Helm-чарти (хоча feature request був відкритий ще в 2018) робить його трохи useless для нас.

Отже, натомість давайте глянемо на Renovate, який прям дуже цінується всіма, хто має справу з менеджментом версій.

Що Renovate вміє?

  • як і Dependabot, може запускатись з майже будь-яким хостингом – GitHub, Gitlab, Bitbucket тощо
  • можемо запускати як self-hosted на власних GitHub Actions Runner
  • можемо запускати в Kubernetes

Вміє перевіряти прям безліч систем – Terraform, Helm, Kubernetes manifest – перевіряти images і їхні апдейти, Dockerfiles. Див. Supported Managers.

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

Для GitHub найпростіший шлях інтеграції – через Renovate GitHub App.

Хоча в заголовку цього поста я вказав “Helm Charts”, але з коробки і з дефолтними параметрами Renovate виконає перевірку просто всього, що є в репозиторії і має якісь versions та dependencides.

Ну і коли я писав, що Dependabot “швидко і просто конфігуриться“, то у випадку з Renovate це взагалі фактично робиться в кілька кліків і працює просто з коробки.

Підключення Renovate до GitHub

Переходимо на сторінку Renovate GitHub App, клікаємо Install, вибираємо в які репозиторії його підключити.

Я поки для тестів додам тільки в один репозиторій з нашим моніторингом де маємо Terraform та Helm:

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

Реєструємось на https://developer.mend.io – далі тут будуть дашборди з деталями перевірок:

Переходимо до репозиторію, і маємо відкритий Pull Request для ініціалізації Renovate:

Ііі… В принципі – це все 🙂

Налаштування Renovate

В цьому PR маємо створений файл renovate.json з мінімальним конфігом:

Крім того, Renovate відразу визначив, які пакети в цьому репозиторії є:

І відразу визначає, що треба оновити:

А на сторінці репозиторію на developer.mend.io побачимо всі деталі перевірки:

Тепер можемо додати трохи свої параметрів, яких прям дуже багато, бо Renovate дозволяє дуже гнучко налаштувати ваші перевірки – див. всі на Configuration Options.

Наприклад, додамо розклад запуску, лейбли та будемо PR відразу асайнити на мене:

{
  "$schema": "https://docs.renovatebot.com/renovate-schema.json",
  "extends": [
    "config:recommended"
  ],
  "labels": ["dependencies"],
  "assignees": ["arseny-zinchenko"]
}

По дефолту, Renovate має ліміт у 2 PR на годину. Аби збільшити цей ліміт – у файлі renovate.json додаємо prHourlyLimit:

{
  "$schema": "https://docs.renovatebot.com/renovate-schema.json",
  "extends": [
    "config:recommended"
  ],
  "labels": ["dependencies"],
  "assignees": ["arseny-zinchenko"],
  "prHourlyLimit": 10
}

Зберігаємо, пушимо і мержимо цей PR:

І маємо відкриті PR:

Деталі по конкретному PR:

Renovate Dependency Dashboard та GitHub Issues

Окремо можемо включити створення Issues для всіх PR, які буде створювати Renovate.

Переходимо в Settings репозиторію – включаємо Issues:

Тепер, коли Renovate буде відкривати PR з апдейтами – він створить GitHub Issue з деталями по апдейту:

 

Ну в цілому на цьому все.

“It (just) works!” (c)

Можливо, потім додам ще якісь деталі по конфігурації, коли буду налаштовувати інші репозиторії.

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

Loading

Dependabot: GitHub та Terraform versions management
0 (0)

29 Травня 2024

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

Робити це вручну, звісно, можна – але тільки до якоїсь межі, бо врешті-решт ви просто фізично не зможете моніторити та оновлювати все.

Для автоматизації таких процесів існує багато рішень, але найчастіше зустрічаються два – 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, в якому додає деталі по апдейту
  • Profit!

Тож що будемо робити:

  • маємо GitHub репозиторій для моніторингу
  • в ньому маємо Terraform
  • налаштуємо перевірку і створення PR

Документація – Dependabot quickstart guide, Configuration options for the dependabot.yml file.

Див. також Supported repositories and ecosystems – які саме системи підтримує Dependabot.

Dependabot та Terraform

Що можемо моніторити з Dependabot в контексті Terraform – це версії провайдерів та модулів.

Наприклад, маємо два файли – versions.tf, де задаються версії провайдерів, і файл lambda.tf, де використовуємо кілька модулів – terraform-aws-modules/security-group/aws, terraform-aws-modules/lambda/aws і інші:

Тепер, щоб Dependabot почав моніторити версії в них – створюємо каталог .github, і в ньому файл dependabot.yml:

У файлі задаємо параметри:

version: 2
updates:
  - package-ecosystem: "terraform"
    directory: "/terraform"
    schedule:
      interval: "daily"
      time: "09:00"
      timezone: "Europe/Kyiv"
    assignees:
    - arseny-zinchenko
    reviewers:
    - arseny-zinchenko
    open-pull-requests-limit: 10

В принципі, тут все зрозуміло з назв параметрів:

  • package-ecosystem: так як цей конфіг для Terraform, то вказуємо його
  • directory: файли Terraform у директорії terraform в корні репозиторію
  • schedule: розклад перевірок – при цьому при першому додаванні файлу dependabot.yml він запустить перевірку відразу, і є можливість запускати вручну
  • assignees та reviewers: відразу створюємо PR на мене
  • open-pull-requests-limit: по дефолту Dependabot відкриває максимум 5 PR, можна збільшити за допомогою цього параметру

Пушимо в репозиторій, і перевіряємо статус:

В репозиторії переходимо в Insights > Dependency graph > Dependabot, і бачимо, що перевірка запустилась:

За хвилину маємо відкриті пул-реквести:

При цьому, в коментах він додає трохи деталей по апдейту – Release notes, Changelog, etc:

Правда, чомусь не всюди.

Наприклад, апдейт для модулю Lambda створився без деталей:

А от Renovate робить це набагато краще.

Dependabot та GitHub Secrets

Ще один нюанс – це сікрети, які доступні Dependabot.

У нас при PR зі змінами в директорії terraform запускається GitHub Actions Workflow, який виконує перевірки з Terraform (див. GitHub Actions: деплой Terraform з review запланованих змін).

Цей workflow знаходиться в окремому репозиторії, і для доступу до нього у викликаючий worfklow передається GitHub Deploy Key через GitHub Actions Secrets.

В GitHub Actions джобі, яка запустилась від Dependabot, цей степ сфейлився:

Хоча сам workflow передає всі сікрети через secrets: inherit:

...
jobs:

  terraform-test:
    # call the Reusable Workflow file
    uses: ORG_NAME/atlas-github-actions/.github/workflows/call-terraform-check-and-plan.yml@master
    with:
      aws-iam-role: ${{ vars.AWS_IAM_ROLE }}
      aws-env: ${{ vars.AWS_ENV }}
      pr-num: ${{ github.event.pull_request.number }}
      environment: ops
      slack-channel: '#cicd-devops'      
    secrets:
      inherit

Але для 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 без необхідності самому підписуватись на всі репозиторії.

Хоча, ще раз – Renovate дійсно краще. Див. Renovate: GitHub та Helm Charts versions management.

Loading

Helm: UPGRADE FAILED: another operation (install/upgrade/rollback) is in progress
0 (0)

24 Травня 2024

Іноді під час деплою 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 – і все працює.

Loading

AWS: VPC Flow Logs, NAT Gateways, та Kubernetes Pods – детальний обзор
0 (0)

1 Травня 2024

Маємо відносно великі витрати на 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
    • приватні для Kubernetes Worker Nodes
    • окремі сабнети для баз даних
    • окремі сабнети для Kubernetes Control Plain

Створення VPC для кластеру описано у Terraform: створення EKS, частина 1 – VPC, Subnets та Endpoints.

AWS NAT Gateway Pricing

Документація – Amazon VPC pricing.

Отже, коли ми використовуємо NAT Gateway, то платимо за:

  1. за кожну годину роботи NAT Gateway
  2. за гігабайти, які він оброблює

Година роботи 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 до інтернету.

Якщо відобразити це схемою, то вона буде виглядати так:

  1. Ініціація запиту з EC2: сервіс на EC2 з Private IP 10.0.1.5 генерує запит до External Server з IP 203.0.113.5
    1. ядро операційної системи 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
  2. Маршрутизація пакета: мережевий інтерфейс на EC2 включений в Private Subnet, і має Route Table, яка підключена до цього сабнету
    1. ядро операційної системи визначає, що 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
  3. Обробка пакета NAT Gateway: пакет приходить на мережевий інтерфейс NAT GW, який має адресу 10.0.0.220
    1. NAT Gateway зберігає запис про походження пакету з IP 10.0.1.5:10099 => 203.0.11.443 у своїй NAT-таблиці
    2. 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, і розберемося з записами, які він створює.

Див. The Network Address Translation Table.

Налаштування AWS VPC Flow Logs

Див. також AWS: VPC Flow Logs – знайомство та аналітика з CloudWatch Logs Insights.

VPC Flow Logs можна налаштувати вручну в панелі AWS:

Або, якщо використовуєте Terraform модуль terraform-aws-modules/vpc, то задати параметри в ньому:

...
module "vpc" {
  source  = "terraform-aws-modules/vpc/aws"
  version = "~> 5.5.2"

  name = local.env_name
  cidr = var.vpc_params.vpc_cidr

  ...

  enable_flow_log = var.vpc_params.enable_flow_log

  create_flow_log_cloudwatch_log_group = true
  create_flow_log_cloudwatch_iam_role  = true

  flow_log_max_aggregation_interval         = 60
  flow_log_cloudwatch_log_group_name_prefix = "/aws/${local.env_name}-flow-logs/"
  flow_log_log_format = "$${region} $${vpc-id} $${az-id} $${subnet-id} $${instance-id} $${interface-id} $${flow-direction} $${srcaddr} $${dstaddr} $${srcport} $${dstport} $${pkt-srcaddr} $${pkt-dstaddr} $${pkt-src-aws-service} $${pkt-dst-aws-service} $${traffic-path} $${packets} $${bytes} $${action}"
  #flow_log_cloudwatch_log_group_class       = "INFREQUENT_ACCESS"

}
...

Виконуємо terraform apply, і маємо логи у VPC з власним форматом:

VPC Flow Logs – формат

У flow_log_log_format описується формат того, як лог буде записаний, а саме – які поля в ньому будуть.

Я завжди використовую custom format з додатковою інформацією, бо дефолтний формат може бути недостатньо інформативним, особливо про роботі через NAT Gateways.

Всі поля є у документації Logging IP traffic using VPC Flow Logs.

Для 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.

Бо насправді витрати на логування трафіку досить помітні.

Наприклад, у невеликій VPC, де в Kubernetes крутиться наш Backend API, моніторинг (див. VictoriaMetrics: створення Kubernetes monitoring stack з власним Helm-чартом) та кілька інших сервісів, після включення VPC Flow Logs вартість CloudWatch почала виглядати так:

Тож майте це на увазі.

VPC Flow Logs в CloudWatch Logs vs AWS S3

Зберігання логів в CloudWatch Logs буде дорожчим – але дає можливість виконувати запити у CloudWatch Logs Insights.

Крім того, як на мене, то налаштування збору логів до Grafana Loki простіше через CloudWatch Subscription Filters, аніж робити через S3 – просто менше головної болі з IAM.

Про Loki та S3 – див. Grafana Loki: збираємо логи AWS LoadBalancer з S3 за допомогою Promtail Lambda.

Про Loki та CloudWatch – див. Loki: збір логів з CloudWatch Logs з використанням Lambda Promtail.

Втім, поки що тримаю Flow Logs в CloudWatch Logs, а як закінчу розбиратись з тим, звідки йде трафік – то подумаю про використання S3, і звідти вже буду збирати до Grafana Loki.

VPC Flow Logs та Log Insights

Окей – отже, маємо налаштовані VPC Flow Logs в CloudWatch Logs.

Що нас особливо цікавить – це трафік через NAT Gateway.

Використовуючи кастомний формат логів – в Logs Insights можемо зробити такий запит:

parse @message "* * * * * * * * * * * * * * * * * * *" 
| as region, vpc_id, az_id, subnet_id, instance_id, interface_id, 
| flow_direction, srcaddr, dstaddr, srcport, dstport, 
| pkt_srcaddr, pkt_dstaddr, pkt_src_aws_service, pkt_dst_aws_service, 
| traffic_path, packets, bytes, action 
| filter (dstaddr like "10.0.5.175") | stats sum(bytes) as bytesTransferred by interface_id, flow_direction, srcaddr, srcport, dstaddr, dstport, pkt_srcaddr, pkt_dstaddr, pkt_src_aws_service, pkt_dst_aws_service, bytes
| sort bytesTransferred desc
| limit 10

Тут ми робимо фільтр по запитам, які у dstaddr мають Private IP нашого NAT Gateway:

Іноді pkt_src_aws_service або pkt_dst_aws_service не вказані, і тоді не дуже зрозуміло що за трафік.

Можна перевірити на сайті https://ipinfo.io – там може бути вказане ім’я хоста, і тоді ясно, що це, наприклад, S3-ендпоінт:

Flow Logs ingress vs egress

Ми знаємо, що це ingress – це вхідний трафік (RX, Received), а egress – вихідний трафік (TX, Transmitted).

Але вхідний та вихідний до чого? VPC, Subnet або ENI – Elastic Network Interface?

Читаємо документацію Logging IP traffic using VPC Flow Logs:

  • 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 – “поточна” маршрутизація:
    • адреса “пункту призначення” пакета у вихідному трафіку, або
    • адреса мережевого інтерфейсу для вхідного трафіку
  • pkt-srcaddr: “оригінальна” адреса появи пакету
  • pkt-dstaddr: “оригінальна” адреса “пункту призначення” пакету

Аби краще зрозуміти ці поля і взагалі структуру записів у Flow Logs – давайте розглянемо кілька прикладів з документації.

Flow Logs та приклади записів

Отже, маємо EC2 інстанс в приватній мережі, який робить запити до якогось зовнішнього сервісу через NAT Gateway.

Що ми побачимо в логах?

Приклади взяті з документації Traffic through a NAT gateway, і додав трохи схем, аби візуально було простіше зрозуміти.

Дивитись будемо на реальні дані:

  • маємо EC2 інстанс в приватному сабнеті:
    • Elastic Network Interface: eni-0467f85cabee7c295
    • Private IP: 10.0.36.132
  • маємо NAT Gateway:
    • Elastic Network Interface: eni-0352f8c82da6aa229
    • Private IP: 10.0.5.175
    • Public IP: 52.54.3.183

На EC2 запущено curl в циклі з запитом на 1.1.1.1:

root@ip-10-0-36-132:/home/ubuntu# watch -n 1 curl https://1.1.1.1

Формат VPC Flow Log той самий, що був вище, а для перевірки в CloudWatch Logs Insights будемо використовувати такий запит:

parse @message "* * * * * * * * * * * * * * * * * * *" 
| as region, vpc_id, az_id, subnet_id, instance_id, interface_id, 
| flow_direction, srcaddr, dstaddr, srcport, dstport, 
| pkt_srcaddr, pkt_dstaddr, pkt_src_aws_service, pkt_dst_aws_service, 
| traffic_path, packets, bytes, action 
| filter (interface_id = "eni-0352f8c82da6aa229" AND srcaddr = "10.0.36.132") | stats sum(bytes) as bytesTransferred by instance_id, interface_id, flow_direction, srcaddr, dstaddr, pkt_srcaddr, pkt_dstaddr
| sort bytesTransferred desc

Тут робимо виборку по записам з мережевого інтерфейсу 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:

  1. з Elastic Network Interface на самому EC2
  2. з Elastic Network Interface на NAT Gateway

В цьому прикладі ми бачимо запис з інтерфейсу NAT Gateway, бо:

  • поле instace-id пусте (NAT GW хоч і є EC2, але це все ж Amazon-managed сервіс)
  • flow-directioningress, пакет прийшов на інтерфейс NAT Gateway
  • в полі dstaddr бачимо Private IP нашого NAT GW
  • і поле pkt-dstaddr не співпадає з dstaddr – в pkt-dstaddr у нас адреса “кінцевого отримувача”, а пакет прийшов на dstaddr – NAT Gateway

Від NAT Gateway до Remote Server

В другому прикладі бачимо запис про пакет, який було відправлено з NAT Gateway до Remote Server:

  • flow-directionegress, пакет відправлено з інтерфейсу NAT Gateway
  • srcaddr та pkt-srcaddr однакові
  • dstaddr та pkt-dstaddr однакові

Від Remote Server до NAT Gateway

Далі – наш Remote Server відправляє відповідь до нашого NAT Gateway:

  • flow-directioningress, пакет прийшов на інтерфейс NAT Gateway
  • srcaddr та pkt-srcaddr однакові
  • dstaddr та pkt-dstaddr однакові

Від Remote Server через NAT Gateway до EC2

Запис про пакет від Remote Server до нашого EC2 через NAT Gateway:

  • flow-directionegress, пакет відправлено з інтерфейсу 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-directionegress, бо запис с інтерфейсу EC2, який відправляє пакет до Remote Server
  • srcaddr та pkt-srcaddr однакові, з Private IP цього EC2
  • поля dstaddr та pkt-dstaddr – теж однакові, з адресою Remote Server

Від Remote Server до EC2

Відправка пакета з Remote Server до EC2:

  • instance_id не пустий
  • flow-directioningress, бо запис с інтерфейсу 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?

Тут ситуація ще цікавіша, бо маємо різні типи мережевої комунікації:

  • Worker Node to Pod
  • Worker Node to ClusterIP
  • Pod to ClusterIP Service
  • Pod to Pod на одній Worker Node
  • Pod to Pod на різних Worker Node
  • Pod to External Server

Поди мають IP адреси з пулу VPC CIDR, і ці IP підключаються до WorkerNode як Secondary Private IP (або беруться з підключених префіксів /28 у випадку з VPC CNI Prefix Assignment Mode – див. AWS: VPC Prefix та максимальна кількість подів на Kubernetes WorkerNodes).

При комунікації 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.

Документація – SNAT for Pods.

Давайте перевіримо.

Трафік з Pod до Pod, та VPC Flow Logs

Запустимо два поди. Додамо їм antiAffinity та topologyKey (див. Kubernetes: Pods та WorkerNodes – контроль розміщення подів на нодах), аби вони запустились на двох різних WorkerNodes:

apiVersion: v1
kind: Pod
metadata:
  name: ubuntu-pod1
  labels:
    app: ubuntu-app
    pod: one
spec:
  containers:
  - name: ubuntu-container1
    image: ubuntu
    command: ["sleep"]
    args: ["infinity"]
    ports:
    - containerPort: 80
  affinity:
    podAntiAffinity:
      requiredDuringSchedulingIgnoredDuringExecution:
      - labelSelector:
          matchLabels:
            app: ubuntu-app
        topologyKey: "kubernetes.io/hostname"
---
apiVersion: v1
kind: Pod
metadata:
  name: ubuntu-pod2
  labels:
    app: ubuntu-app
    pod: two
spec:
  containers:
  - name: ubuntu-container2
    image: ubuntu
    command: ["sleep"]
    args: ["infinity"]
    ports:
    - containerPort: 80
  affinity:
    podAntiAffinity:
      requiredDuringSchedulingIgnoredDuringExecution:
      - labelSelector:
          matchLabels:
            app: ubuntu-app
        topologyKey: "kubernetes.io/hostname"

Деплоїмо, і на першому встановлюємо curl, а на другому – NGINX.

Тепер маємо:

  • ubuntu-pod1:
    • Pod IP: 10.0.46.182
    • WorkerNode IP: 10.0.42.244
  • ubuntu-pod2:
    • Pod IP: 10.0.46.127
    • WorkerNode IP: 10.0.39.75

На другому стартуємо NGINX, і з першого поду запускаємо curl в циклі на IP другого поду:

root@ubuntu-pod1:/# watch -n 1 curl 10.0.46.127

І за хвилину перевіряємо Flow Logs с запитом:

parse @message "* * * * * * * * * * * * * * * * * * *" 
| as region, vpc_id, az_id, subnet_id, instance_id, interface_id, 
| flow_direction, srcaddr, dstaddr, srcport, dstport, 
| pkt_srcaddr, pkt_dstaddr, pkt_src_aws_service, pkt_dst_aws_service, 
| traffic_path, packets, bytes, action 
| filter (dstaddr = "10.0.46.127" AND dstport = 80) | stats sum(bytes) as bytesTransferred by instance_id, interface_id, flow_direction, srcaddr, dstaddr, dstport, pkt_srcaddr, pkt_dstaddr
| sort bytesTransferred desc

В srcaddr у нас Primary Private IP з WorkerNode, на якій запущено ubuntu-pod-1, а в pkt_srcaddr – IP самого Pod, який робить запити.

Трафік з Pod до External Server через NAT Gateway, та VPC Flow Logs

Тепер, нічого не міняючи, запустимо з того ж ubuntu-pod1 curl на 1.1.1.1, і подивимось логи:

В першому записі бачимо:

  • eni-0352f8c82da6aa229 – інтерфейс NAT Gateway
  • flow-directioningress, інтерфейс отримав пакет
  • srcaddr 10.0.42.244 – адреса WorkerNode, де запущений ubuntu-pod1
  • dstaddr 10.0.5.175 – пакет для NAT Gateway
  • pkt_dstaddr 1.1.1.1 – і пакет призначається для Remote Server

Далі, у другому записі:

  • той же мережевий інтерфейс, NAT GW
  • але вже egress – пакет вийшов з інтерфейсу
  • srcaddr 10.0.5.175 – пакет з NAT GW

І третій запис:

  • інстанс i-023f37c7aad6fc69d – там, де наш Pod
  • трафік egress – пакет вийшов з інтерфейсу
  • srcaddr 10.0.42.244 – пакет з Private IP цієї WorkerNode
  • і dstaddr 1.1.1.1 – пакет для Remote Server

Але ми ніде не бачимо IP самого Kubernetes Pod.

Kubernetes Pod, hostNetwork: true та VPC Flow Logs

Давайте передеплоїмо ubuntu-pod1 з hostNetwork: true:

apiVersion: v1
kind: Pod
metadata:
  name: ubuntu-pod1
  labels:
    app: ubuntu-app
    pod: one
spec:
  hostNetwork: true
  containers:
  - name: ubuntu-container1
    image: ubuntu
    command: ["sleep"]
    args: ["infinity"]
    ports:
    - containerPort: 80
  affinity:
    podAntiAffinity:
      requiredDuringSchedulingIgnoredDuringExecution:
      - labelSelector:
          matchLabels:
            app: ubuntu-app
        topologyKey: "kubernetes.io/hostname"

Деплоїмо, і перевіряємо IP самого Pod та IP його WorkerNode:

$ kubectl describe pod ubuntu-pod1
Name:             ubuntu-pod1
...
Node:             ip-10-0-44-207.ec2.internal/10.0.44.207
...
Status:           Running
IP:               10.0.44.207
...

Обидва 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.

Див. AWS_VPC_K8S_CNI_EXTERNALSNAT та AWS_VPC_K8S_CNI_EXCLUDE_SNAT_CIDRS.

І, відповідно, в логах ми будемо бачити реальні IP наших подів.

Оновлюємо конфігурацію VPC CNI:

$ kubectl set env daemonset -n kube-system aws-node AWS_VPC_K8S_CNI_EXTERNALSNAT=true

Повертаємо конфіг для ubuntu-pod-1 без hostNetwork: true, передеплоїмо, і глянемо логи з таким запитом:

parse @message "* * * * * * * * * * * * * * * * * * *" 
| as region, vpc_id, az_id, subnet_id, instance_id, interface_id, 
| flow_direction, srcaddr, dstaddr, srcport, dstport, 
| pkt_srcaddr, pkt_dstaddr, pkt_src_aws_service, pkt_dst_aws_service, 
| traffic_path, packets, bytes, action 
| filter (srcaddr = "10.0.37.171" OR pkt_srcaddr = "10.0.37.171") | stats sum(bytes) as bytesTransferred by instance_id, interface_id, flow_direction, srcaddr, dstaddr, pkt_srcaddr, pkt_dstaddr
| sort bytesTransferred desc

Маємо два записи:

Перший запис – з інтерфейсу 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-корзин.

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

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

(ох, цей кайф, коли закриваєш купу вкладок в браузері…)

Loading

Grafana: AWS EC2 resources та Kubernetes Pods requests
0 (0)

19 Квітня 2024

У 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, бо вона буде використовуватись тільки в панелях.

WorkerNodes variable

Додаємо змінну, в якій будуть всі воркер-ноди.

У нас Карпентер, тому можемо взяти karpenter_nodes_total_pod_requests, і з регуляркою .*"(.*)".* вирізати тільки імена нод:

Включаємо Multi-Value та Include All option:

Namespaces variable

Тут можемо взяти метрику 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)

Я тут використовую instance_memory!="", бо є окрема default WorkerNode для CriticalAddons, яка створюється зі звичайної AWS AutoSacaling Managed Node Group, а не з Karpenter NodeClaim (див. Terraform: створення EKS, частина 2 – EKS кластер, WorkerNodes та IAM).

В 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.

Виконуємо kubectl describe node ip-10-0-32-219.ec2.internal:

Тут маємо інформацію по всім 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)
    /
    sum(karpenter_nodes_allocatable{instance_memory!="", node_name=~"$node_name", resource_type="cpu"})
) * 100

Налаштовуємо Tresholds:

Налаштовуємо Overrides:

Pod CPU Requests

Далі – Pod CPU requests.

Спочатку скільки под requested в milicpu:

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 %

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

(
  sum(container_memory_working_set_bytes{instance=~"$node_name", namespace=~"$namespace", container!="POD", container!=""}) by (pod)
  /
  sum(karpenter_nodes_allocatable{instance_memory!="", node_name=~"$node_name", resource_type="memory"})
) * 100

На ноді t3.medium маємо 3.469 доступної пам’яті (мінус всякі резервації):

Под backend-api-deployment-7d7969d69f-nslxk використовує 575, це буде:

>>> 575/3469*100
16.57

І в нашій панелі бачимо 17.4% – норм, плюс-мінус сходиться.

Memory requsted

Запит:

sum(container_memory_working_set_bytes{instance=~"$node_name", namespace=~"$namespace", container!="POD", container!=""}) by (pod)

Вже дивились з kubectl describe pod:

$ kk -n prod-backend-api-ns describe pod backend-api-deployment-7d7969d69f-7r66t
...
    Requests:
      cpu:      512m
      memory:   800Mi
...

І в панелі:

Memory requsted %

Скільки % від requested пам’яті на ноді використовує под:

(
  sum(container_memory_working_set_bytes{instance=~"$node_name", namespace=~"$namespace", container!="POD", container!=""}) by (pod)
  /
  sum(kube_pod_container_resource_requests{job="kube-state-metrics", node=~"$node_name", namespace=~"$namespace", resource="memory"}) by (pod)
) * 100

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

В принципі, на цьому все.

Можна ще погратись, наприклад – додати Stats панелі з інформацією по загальній кількості CPU/Mem/Pods в кластері типу такого:

Але в мене це є в іншій дашборді (див. Karpenter: моніторинг та Grafana dashboard для Kubernetes WorkerNodes).

Loading

Arch Linux: фікс помилки “yay: error while loading shared libraries: libalpm.so.13”
0 (0)

17 Березня 2024

Після оновлення Arch Linux, yay почав видавати помилку:

yay: error while loading shared libraries: libalpm.so.13: cannot open shared object file: No such file or directory

Спершу, ддавайте знайдемо файл бібліотеки libalpm:

$ sudo find / -type f -name "*.so*" | grep libalpm
/usr/lib/libalpm.so.14.0.0

Окей – він є, але версії 14 –  libalpm.so.14, а Yay хоче стару, 13.

Перший варіант пофіксити – це тупо зробити symlink /usr/lib/libalpm.so.14.0.0 на файл /usr/lib/libalpm.so.13:

$ sudo ln -s /usr/lib/libalpm.so.14.0.0 /usr/lib/libalpm.so.13

Але хоча це робоче рішення, але краще обходитись без таких “грязних хаків”.

Натомість ми можемо просто перезібрати та перевстановити 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)>
...

Готово.

Loading

GitHub Actions: використання Reusable Workflows – нюанси роботи
0 (0)

13 Березня 2024

В пості GitHub Actions: деплой Dev/Prod оточень з Terraform я вже трохи торкався теми Reusable Workflows та Composite Actions – прийшов час трохи більше з нею ознайомитись.

Що треба зробити: зараз на проекті ми в кожному репозиторії пишемо 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.

Гарний приклад роботи з ними є в тому GitHub Actions: деплой Dev/Prod оточень з TerraformСтворення Composite Action “terraform-init”.

Ідеальне рішення, коли ви хочете використати послідовність 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-тегом

Див. також Limitations.

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.

Permissions

Чудовий пост на тему GitHub Actions Permisssions і Security взагалі – GitHub Actions Workflow Permissions.

Якщо в двох словах:

  • при використанні 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

У нас є три типи данних, але з різними “рівнями”:

  • env context:
    • задається на рівні Workflow/Job/Step – в Reusable Workflow не передаються
  • vars context:
    • задається або на рівні GitHub Actions Environments – в Reusable Workflow не передаються
    • або на рівні Repository та Organization Variables – в Reusable Workflow доступні без додаткових дій
  • secrets context:
    • задається або на рівні GitHub Actions Environments – в 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:

...
on:
  workflow_call:
    inputs:
      test-input:
        required: true
        type: string  

env:
  reusable_wf_local_secret: ${{ secrets.CALLER_REPO_INHERITED_SECRET }}

jobs:
  
  test:
...

Другий варіант – замість використання secrets: inherit передавати конкретний Secret:

...
  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:
      REUSAVBLE_WF_SECRET_NAME: ${{ secrets.CALLER_WF_SECRET_NAME }}
...

В такому випадку в Reusable Workflow REUSAVBLE_WF_SECRET_NAME має бути заданий в разом з inputs:

...
on:
  workflow_call:
    secrets:
      REUSABLE_WF_SECRET_NAME:
        required: false
    inputs:
      test-input:
        required: true
        type: string 
...

Тоді далі в Reusable Workflow його можна використовувати як ${{ secrets.REUSABLE_WF_SECRET_NAME }}.

Все пушимо в репозиторії, і запускаємо Workflow.

В Job, яка викликає Reusable Workflow частини даних нема:

В Job, яка викликається напряму в Caller Workflow всі дані є:

GitHub Context

Коли Reusable Workflows викликається з Caller Workflow, github контекст завжди буде мати дані з Caller Workflow.

Наприклад, в Reusable Workflow додамо відображення імені репозиторію:

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!"

      ...

      - name: "Test: print Repository Name from the github context"
        run: echo ${{ github.repository }}

І маємо ім’я atlas-test – репозиторій з Caller Workflow:

Тепер можна починати робити Worfklow для Terraform та Helm, але це вже зовсім інша історія.

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

Loading

GitHub Actions: деплой Terraform з review запланованих змін
0 (0)

7 Березня 2024

У пості 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 {
  backend "s3" {
    bucket         = "tf-state-backend-atlas-test"
    key            = "atlas-test.tfstate"
    region         = "us-east-1"
    encrypt        = true
  }
}

terraform {
  required_version = "~> 1.5"

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

provider "aws" {
  region = "us-east-1"
  default_tags {
    tags = {
      component  = "devops"
      created-by = "terraform"
      environment = "test"
      project     = "atlas-test"
    }
  }
}

Для тесту будемо створювати S3-бакет – додаємо main.tf:

resource "aws_s3_bucket" "example" {
  bucket = "atlas-test-bucket-ololo"

  tags = {
    Name = "atlas-test-bucket-ololo"
  }
}

Error: creating S3 Bucket (atlas-test-bucket): operation error S3: CreateBucket – AuthorizationHeaderMalformed

Трохи забігаючи наперед, і трохи “оффтопу”: вже коли робив 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, то бачимо, що дійсно:

http.response.header.server=AmazonS3 http.response.header.x_amz_bucket_region=us-west-2″.

А “лікується” це тим, що – як ми знаємо – ім’я корзини має ж бути унікальним на весь AWS Region, тобто якщо додати отой суфікс “ololo” – то все працює, як треба:

http.response.header.server=AmazonS3 http.response.header.x_amz_bucket_region=us-east-1

Схоже, що чи то сам AWS, чи Terraform намагається зробити корзину в іншому регіоні, якщо в заданому така вже у когось є.

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

Робимо cd terraform/ && terraform fmt && terraform init && terraform plan:

Тут все готово, можемо переходити до налаштувань 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:

В Repository variables додаємо нову змінну TF_AWS_ROLE – вона буде використовуватись в aws-actions/configure-aws-credentials:

І в принципі наразі це все, що нам потрібно – можемо починати створювати наші 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 не мати зайвих символів:

Додаємо .gitignore:

**/.terraform/*

Див. повний приклад в Terraform.gitignore.

Комітимо всі зміни, пушимо в репозиторій, якщо зміни робили в окремому бранчі – то мержимо в master, щоб GitHub Actions “побачив” новий Workflow файл, і маємо запущений Workflow:

Додавання Terraform Plan в коментарі до Pull request

Далі використаємо actions/github-script, який через GitHub API може додавати коментарі до нашого PR.

А в коментарі використаємо outputs з нашого steps.plan.

Додаємо ще один step:

...
      # generate comment to the Pull Request using 'steps.plan.outputs'
      - 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: |
            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 }}\`*`;
      
            github.rest.issues.createComment({
              issue_number: context.issue.number,
              owner: context.repo.owner,
              repo: context.repo.repo,
              body: output
            })

Тут:

  • виконуємо step якщо github.event_name == 'pull_request'
  • створюємо змінну оточення PLAN зі змістом із steps.plan.outputs.stdout
  • запускаємо actions/github-script, якому:
    • передаємо GITHUB_TOKEN для аутентифікації (використає permissions.pull-requests: write)
    • передаємо аргумент script, в якому:
      • створюємо const output, де ми формуємо текст для коментаря
      • викликаємо функцію github.rest.issues.createComment, яка:

Мене трохи засмутило, що викликається github.rest.issues.createComment – бо чому issues, коли ми маємо справу з Pull Request?

Але в документації Sending requests to the GitHub API сказано, що “issues and PRs are treated as one concept by the Octokit client“.

І в документації самого клієнта:

You can use the REST API to create comments on issues and pull requests. Every pull request is an issue, but not every issue is a pull request.

При потребі, можна отримати всі дані з контекстів так:

...
      - name: Dump Job Context
        env: 
          JOB_CONTEXT: ${{toJson(github)}}
        run: echo "$JOB_CONTEXT"

      - name: View context attributes
        uses: actions/github-script@v7
        with:
          script: console.log(context)  
...

Окей.

Пушимо зміни, чекаємо, поки виконається джоба (пам’ятаємо, що тригер у нас – зміни в каталозі 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.

Як можемо перевірити те, як воно працює (тобто фейлиться):

  1. деплоїмо нашу корзину
  2. додаємо тег
  3. робимо terraform plan -out test.plan
  4. додаємо ще один тег
  5. робимо terraform apply
  6. а потім ще раз terraform apply, але вже з файлу test.plan – симулюємо зміни, які відбулися між запуском наших workflow

Отже – маємо корзину:

resource "aws_s3_bucket" "example" {
  bucket = "${var.project_name}-bucket-ololo"

  tags = {
    Name = "${var.project_name}-bucket"
  }
}

Деплоїмо її з terraform apply:

$ terraform apply
...
aws_s3_bucket.example: Creating...
aws_s3_bucket.example: Creation complete after 3s [id=atlas-test-bucket-ololo]

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

Додаємо тег:

resource "aws_s3_bucket" "example" {
  bucket = "${var.project_name}-bucket-ololo"

  tags = {
    Name = "${var.project_name}-bucket"
    NewTag = "NewTag"
  }
}

Виконуємо terraform plan, результат зберігаємо у файл:

$ terraform plan -out=test.plan
...
Plan: 0 to add, 1 to change, 0 to destroy.

Saved the plan to: test.plan

Додаємо ще один тег:

resource "aws_s3_bucket" "example" {
  bucket = "${var.project_name}-bucket-ololo"

  tags = {
    Name = "${var.project_name}-bucket"
    NewTag = "NewTag"
    NewTag2 = "NewTag2"
  }
}

Деплоїмо без test.plan:

$ terraform apply
...
aws_s3_bucket.example: Modifying... [id=atlas-test-bucket-ololo]
aws_s3_bucket.example: Modifications complete after 3s [id=atlas-test-bucket-ololo]

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

А тепер пробуємо задеплоїти з файлу:

$ 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

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

  1. при створенні Pull Request виконуємо terraform plan -out name.tfplan
  2. зберігаємо файл name.tfplan як артефакт
  3. при мержі Pull Request завантажуємо цей name.tfplan на GitHub Runner
  4. і виконуємо 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
...

(задаємо тут в path каталог terraform/, бо “upload-artifact action does not use the working-directory setting”, див. No files were found with the provided path: build. No artifacts will be uploaded.)

Пушимо, і перевіряємо джобу:

Use Terraform Plan’s Artifact for the Terraform Apply

Далі, нам треба цей файл використати в іншому workflow, де відбувається деплой – і тут маємо дві проблеми:

  1. офіційний actions/download-artifact не підтримує завантаження файлів з інших workflow
  2. наш 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-файлів.

Loading

Kubernetes: tracing запитів з AWS X-Ray та Grafana data source
0 (0)

2 Березня 2024

Tracing (“трасування”) дозволяє відстежувати запити між компонентами, тобто, наприклад, при використанні AWS і у Kubernetes  ми можемо прослідкувати весь шлях запиту від AWS Load Balancer – до Kubernetes Pod – і до DynamoDB або RDS.

Це допомагає нам як відстежувати проблеми з performance – де і які запити виконуються довго – так і мати більше інформації при виникненні проблем, наприклад, коли наш API віддає клієнтам 500 помилки, і нам треба знайти в якому саме компоненті системи виникає проблема.

В AWS для трейсінгу існує є сервіс X-Ray, куди ми можемо відправляти дані за допомогою AWS X-Ray SDK for Python або AWS Distro for OpenTelemetry Python (або інших мов, але тут будемо говорити про Python).

AWS X-Ray до кожного запиту додає унікальний X-Ray ID і дозволяє будувати картину повного “маршруту” запиту.

Окрім X-Ray, в Kubernetes ми можемо трейсити за допомогою таких рішень як Jaeger або Zipkin, і потім будувати картину в Grafana Tempo.

Інше рішення – використовувати X-Ray Daemon, який ми можемо запустити в Kubernetes, і додати в Grafana плагін X-Ray. Див. приклади в Introducing the AWS X-Ray integration with Grafana.

Крім того, AWS Distro for OpenTelemetry теж працює з Trace ID, сумісними з AWS X-Ray – див. AWS Distro for OpenTelemetry and AWS X-Ray та Collecting traces from EKS with ADOT.

Проте сьогодні ми будемо додавати саме 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.

Ми все ще користуємось старим варіантом додавання IAM Role через ServiceAccounts, див. Kubernetes: ServiceAccount з AWS IAM Role для Kubernetes Pod, хоча нещодавно AWS анонсували Amazon EKS Pod Identity Agent add-on – див. AWS: EKS Pod Identities – заміна IRSA? Спрощуємо менеджмент IAM доступів.

Отже, створюємо IAM Policy з дозволами для запису в X-Ray:

{
    "Version": "2012-10-17",
    "Statement": [
        {
            "Effect": "Allow",
            "Action": [
                "xray:PutTraceSegments",
                "xray:PutTelemetryRecords"
            ],
            "Resource": [
                "*"
            ]
        }
    ]
}

Зберігаємо:

IAM Role

Далі додаємо IAM Role, яку зможе використовувати Kubernetes ServiceAccount.

Знаходимо Identity provider нашого EKS-кластеру:

Переходимо в IAM Role, додаємо нову роль.

В Trusted entity type вибираємо Web Identity, і в Web identity вибираємо Identity provider нашого EKS, в Audience – AWS STS:

Підключаємо створену вище політику:

Зберігаємо:

Запуск X-Ray Daemon в Kubernetes

Використаємо Helm-чарт okgolove/aws-xray.

Створюємо файл x-ray-values.yaml – див. дефолтні значення у values.yaml:

serviceAccount:
  annotations: 
    eks.amazonaws.com/role-arn: arn:aws:iam::492***148:role/XRayAccessRole-test
xray:
  region: us-east-1
  loglevel: prod

Додаємо репозиторій:

$ helm repo add okgolove https://okgolove.github.io/helm-charts/

Встановлюємо чарт в кластер, який створить DaemonSet і Service:

$ helm -n ops-monitoring-ns install aws-xray okgolove/aws-xray -f x-ray-values.yaml

Перевіряємо поди:

$ 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:

$ docker build -t 492***148.dkr.ecr.us-east-1.amazonaws.com/x-ray-test .

Логінимось в ECR:

$ aws ecr get-login-password --region us-east-1 | docker login --username AWS --password-stdin 492***148.dkr.ecr.us-east-1.amazonaws.com

Пушимо образ:

$ docker push 492***148.dkr.ecr.us-east-1.amazonaws.com/x-ray-test

Запуск Flask App в Kubernetes

Створюємо маніфест з Kubernetes Deployment, Service та Ingress.

Для Ingress включаємо логування в AWS S3 бакет – з нього логи будуть збиратись до  Grafana Loki, див. Grafana Loki: збираємо логи AWS LoadBalancer з S3 за допомогою Promtail Lambda.

Для Deployment задаємо змінну оточення AWS_XRAY_DAEMON_ADDRESS, в якій вказуємо Kubernetes Service нашого X-Ray Daemon:

apiVersion: apps/v1
kind: Deployment
metadata:
  name: flask-app
spec:
  replicas: 2
  selector:
    matchLabels:
      app: flask-app
  template:
    metadata:
      labels:
        app: flask-app
    spec:
      containers:
      - name: flask-app
        image: 492***148.dkr.ecr.us-east-1.amazonaws.com/x-ray-test
        ports:
        - containerPort: 5000
        env: 
          - name: AWS_XRAY_DAEMON_ADDRESS
            value: "aws-xray.ops-monitoring-ns.svc.cluster.local:2000"
          - name: AWS_REGION
            value: "us-east-1"
---
apiVersion: v1 
kind: Service
metadata:
  name: flask-app-service
spec:
  selector:
    app: flask-app
  ports:
    - protocol: TCP
      port: 80
      targetPort: 5000
---
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
  name: flask-app-ingress
  annotations:
    alb.ingress.kubernetes.io/scheme: "internet-facing"
    alb.ingress.kubernetes.io/target-type: "ip"
    alb.ingress.kubernetes.io/listen-ports: '[{"HTTP": 80}]'
    alb.ingress.kubernetes.io/load-balancer-attributes: access_logs.s3.enabled=true,access_logs.s3.bucket=ops-1-28-devops-monitoring-ops-alb-logs
spec:
  ingressClassName: alb
  rules:
  - http:
      paths:
      - path: /
        pathType: Prefix
        backend:
          service:
            name: flask-app-service
            port:
              number: 80

Деплоїмо, та перевіряємо Ingress/ALB:

$ 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

Робимо запит до ендпоінту:

$ curl k8s-default-flaskapp-25042181e0-298318111.us-east-1.elb.amazonaws.com
Hello, X-Ray! Trace ID: 1-65e1d287-5fc6f0f34b4fb2120da8bbec

І бачимо X-Ray ID. Чудово.

Його ж бачимо в логах Load Balancer:

І в самому X-Ray:

Правда, я все ж очікував, що Load Balancer теж буде в мапі запиту – але ні.

Grafana X-Ray data source

Додаємо новий Data source:

Налаштовуємо доступ до AWS – тут просто з ACCESS та SECRET ключами (див. документацію X-Ray):

І тепер маємо новий data source в Explore:

Та новий тип візуалізації – Traces:

І десь вже окремим постом мабуть опишу побудову реальної дашборди з використанням X-Ray.

Loading