Кожна WorkerNode в Kubernetes може мати обмежену кількість подів, і цей ліміт визначається трьома параметрами:
CPU: загальна кількість requests.cpu не може бути більше, ніж є CPU на Node
Memory: загальна кількість requests.memory не може бути більше, ніж є Memory на Node
IP: загальна кількість подів не може бути більшою, ніж є IP-адрес у ноди
І якщо перші два ліміти такі собі “soft” – бо ми можемо просто не задавати requests взагалі – то ліміт по кількості IP-адрес на ноді це вже “hard” ліміт, бо кожному поду, який запускається на ноді, потрібно видати власну адресу з пула Secondary IP його ноди.
А проблема полягає в тому, що ці адреси дуже часто використовуються ще до того, як на ноді закінчаться CPU або Memory – і в такому випадку ми опиняємось в ситуації, коли наша нода underutilized, тобто – ми могли б ще запустити на ній поди, але не можемо, бо для них нема вільних IP.
Наприклад, одна з наших нод t3.medium виглядає так:
В неї є вільні CPU, не вся пам’ять requested, але Pods Allocation вже 100% – бо до ноди t3.medium може бути додано 17 Secondary IP для подів, і вони всі вже зайняті.
Максимум Secondary IP на AWS EC2
Кількість же додаткових (secondary) IP на ЕС2 залежить від кількості ENI (Elastic network Interface) та кількості IP на кожен інтерфейс, і ці параметри залежать від типу EC2.
Тобто всього 18 адрес, але мінус по 1 Private IP роботу ENI самого інстансу – і для подів на такій ноді буде доступно 17 адрес.
Amazon VPC Prefix Assignment Mode
Щоб вирішити проблему з кількістью Secondary IP на EC2 можна використати VPC Prefix Assignment Mode – коли на інтерфейс підключається не окремий IP, а цілий блок /28, див. Assign prefixes to Amazon EC2 network interfaces.
Наприклад, ми можемо створити новий ENI і йому присвоїти CIDR (Classless Inter-Domain Routing) префікс:
$ aws --profile work ec2 create-network-interface --subnet-id subnet-01de26778bea10395 --ipv4-prefix-count 1
Перевіряємо цей ENI:
Єдиний момент, який варто мати на увазі це те, що VPC Prefix Assignment Mode доступний тільки для інстансів на AWS Nitro System – останнє покоління гіпервізорів AWS, на якому працюють інстанси T3, M5, C5, R5 і т.д. – див. Instances built on the Nitro System.
What is: CIDR /28
Кожна IPv4-адреса складається з 32 бітів, поділених на 4 октети (групи по 8 біт). Ці біти можуть бути представлені у двійковій системі (0 або 1) або у десятковій формі (значення між 0 та 255 для кожного октету). Ми будемо оперувати саме 0 та 1.
Маска підмережі /28 вказує на те, що перші 28 бітів IP-адреси зарезервовані для ідентифікації мережі – тоді 4 біти (32 всього мінус 28 зарезервованих) залишаються для визначення індивідуальних хостів в мережі:
Знаючи, що у нас є 4 вільні біти, а кожен біт може мати значення 0 або 1, ми можемо порахувати загальну кількість комбінацій: 2 в ступені 4, або 2×2×2×2=16 – тобто в мережі /28 може бути загалом 16 адрес, включаючи як адресу мережі (перший IP), так і broadcast адресу (останній IP), отже саме для хостів буде доступно 14 адрес.
Тож замість того, щоб на ENI підключати один Secondary IP – ми підключаємо відразу 16.
При цьому варто враховувати скільки ваша VPC Subnet зможе мати таких блоків, бо це буде визначати кільікість WorkerNodes, які ви зможете запустити.
Тут вже простіше використати утіліти накшалт ipcalc.
Наприклад, в мене Private Subnets мають префікс /20, і якщо всю цю мережу розбити на блоки по /28, то будемо мати 256 підмереж і 3584 адрес:
L-IPAM daemon (IPAMD): відповідає за створення та підключення ENI до EC2-інстансів, призначення блоків адрес до цих інтерфейсів та “прогрів” IP-префіксів для пришвидшення запуску подів (поговоримо далі)
CNI plugin: відповідає за налаштування мережевих інтерфейсів на ноді – як ethernet, та і віртуальних, і комунікує з IPAMD через RPC (Remote Procedure Call)
Для конфігурації виділення префіксів нодам та IP подам VPC CNI має три додаткові опції – WARM_PREFIX_TARGET, WARM_IP_TARGET та MINIMUM_IP_TARGET:
WARM_PREFIX_TARGET: скільки підключених /28 префіксів тримати “в запасі”, тобто вони будуть підключені до ENI, але адреси з них ще не використовуються
WARM_IP_TARGET: скільки мінімально IP адрес підключати при створенні ноди
MINIMUM_IP_TARGET: скільки мінімально IP адрес тримати “в запасі”
При використанні VPC Prefix Assignment Mode ви не можете задати всі три параметри в нуль – як мінімум або WARM_PREFIX_TARGET або WARM_IP_TARGET мають бути задані хоча б в 1.
Якщо заданий WARM_IP_TARGET та/або MINIMUM_IP_TARGET – вони будуть мати перевагу над WARM_PREFIX_TARGET, тобто значення з WARM_PREFIX_TARGET буде ігноруватись.
При використанні Prefix IP, адреси в префіксу мають бути суміжні, тобто в одному префіксу не можуть бути адреси “10.0.31.162” (блок 10.0.31.160/28) та “10.0.31.178” (блок 10.0.31.176/28).
Якщо сабнет активно використовується, і в ньому немає безперервного блоку адрес для виділння цілого префіксу, то ви отримаєте помилку:
failed to allocate a private IP/Prefix address: InsufficientCidrBlocks: The specified subnet does not have enough free cidr blocks to satisfy the request
Щоб запобігти цьому, можна використати функцію резервації блоків – VPC Subnet CIDR reservations для створення єдиного блоку, з якого потім будуть “нарізатись” блоки по /28. Такий блок не буде використовуватись для виділення Private IP для EC2, натомість VPC CNI буде створювати префікси саме з цієї “резервації”.
При цьому ви можете створити таку резервацію навіть якщо окремі IP в цьому блоку вже використовуються на EC2 – як тільки такі адреси звільняться, вони більше не будуть виділятись окремим інстансам EC2, а будуть зберігатись для формування префіксів /28.
Отже, якщо в мене є VPC Subnet з блоком /20 – я можу розбити її на два CIDR Reservation блоки по /21, і в кожному /21 блоці мати:
При використанні AWS Managed NodeGroups новий ліміт буде заданий автоматично.
В цілому, максимальна кількість подів буде залежати від типу інстансу і кількості vCPU на ньому – 110 подів на кожні 10 ядер (див. Kubernetes scalability thresholds). Але є ще ї ліміти, які задані самим AWS.
Наприклад для t3.nano з 2 vCPU це буде 34 поди – перевіримо скриптом max-pod-calculator.sh:
Деплоїмо, і Karpenter створює один NodeClaim з t3.small:
$ kk get nodeclaim
NAME TYPE ZONE NODE READY AGE
default-w7g9l t3.small us-east-1a False 3s
Пара хвилин – і поди на ньому запустились:
Тепер скейлимо Deployment до, наприклад, 50 подів:
$ kk scale deployment nginx-deployment --replicas=50
deployment.apps/nginx-deployment scaled
І все ще маємо один NodeClaim з тим же t3.small, але тепер на ньому запущено 50 подів:
Звісно, при такому підході треба завжди задавати Pod requests, щоб кожен под мав доступ до CPU та Memory – саме requests для нас тепер будуть лімітами на кількість подів на нодах.
є EKS оточення – наразі один кластер, але пізніше може бути декілька
є аплікейшени – API у бекенда, моніторинг у девопсів тощо
у кожного аплікейшена може бути один або декілька власних оточень – Dev, Staging, Prod
для аплікейшенів є AWS ALB, з яких треба збирати логи
Код Terraform для збору логів досить великий – aws_s3_bucket, aws_s3_bucket_public_access_block, aws_s3_bucket_policy, aws_s3_bucket_notification, ще й Lambda-функції.
При цьому ми маємо декілька проектів у різних команд, і у кожного проекту можуть бути декілька оточень – у когось тільки Ops або тільки Dev, у когось – Dev, Staging, Prod.
Тож, щоб не повторювати код в кожному проекті і мати змогу міняти якісь конфігурації в цій системі – вирішив винести цей код окремим модулем, а потім підключати його в проектах, і передавати необхідні параметри.
Але головна причина – це трохи mess при створені ресурсів для логування через декілька оточень – одне оточення це сам EKS-кластер, а інше оточення – це самі сервіси на кшталт моніторингу або Backend API
Тобто:
в рутовому модулі проекту маємо змінну для EKS-кластеру – $environment зі значенням ops/dev/prod (наразі маємо один кластер і, відповідно, один environment == "ops")
в модуль логів з рутового модулю будемо передавати іншу змінну – app_environments зі значеннями dev/staging/prod, плюс імена сервісів, команди тощо – це оточення самих сервісів/applications
Отже, в рутовому модулі, тобто в проекті, будемо викликати новий модуль ALB Logs в циклі для кожного значення з environment, а в середині модуля – в циклі створювати ресурси по кожному з app_environments.
Спочатку зробимо все локально в існуючому проекті, а потім винесемо новий модуль в GitHub-репозиторій, і підключимо його в проекті з репозиторію.
Створення модулю
В репозиторії проекту зараз маємо таку структуру файлів – тестувати будемо в проекті, який створює ресурси для моніторингу, але не принципово, просто тут вже налаштований backend і інші параметри для Terraform:
Виконуємо terraform init і перевіряємо з terraform plan:
Добре.
Далі в наш новий модуль треба додати декілька inputs (див. Terraform: модулі, Outputs та Variables) – щоб формувати ім’я корзини, і мати значення для app_environments.
Створюємо файл modules/alb-s3-logs/variables.tf:
variable "eks_env" {
type = string
description = "EKS environment passed from a root module (the 'environment' variable)"
}
variable "eks_version" {
type = string
description = "EKS version passed from a root module"
}
variable "component" {
type = string
description = "A component passed from a root module"
}
variable "application" {
type = string
description = "An application passed from a root module"
}
variable "app_environments" {
type = set(string)
description = "An application's environments"
default = [
"dev",
"prod"
]
}
resource "aws_s3_bucket" "alb_s3_logs" {
# ops-1-28-backend-api-dev-alb-logs
# <eks_env>-<eks_version>-<component>-<application>-<app_env>-alb-logs
for_each = var.app_environments
bucket = "${var.eks_env}-${var.eks_version}-${var.component}-${var.application}-${each.value}-alb-logs"
# to drop a bucket, set to `true` first
# apply
# then remove the block
force_destroy = false
}
Або можемо зробити краще – винести формування імен корзин в locals:
locals {
# ops-1-28-backend-api-dev-alb-logs
# <eks_env>-<eks_version>-<component>-<application>-<app_env>-alb-logs
bucket_names = { for env in var.app_environments : env => "${var.eks_env}-${var.eks_version}-${var.component}-${var.application}-${env}-alb-logs" }
}
resource "aws_s3_bucket" "alb_s3_logs" {
for_each = local.bucket_names
bucket = each.value
# to drop a bucket, set to `true` first
# run `terraform apply`
# then remove the block
# and run `terraform apply` again
force_destroy = false
}
Тут беремо кожний елемент зі списку app_environments, формуємо змінну env, і формуємо map[] з іменем bucket_names, де в key у нас буде значення з env, а в value – ім’я корзини.
Оновлюємо виклик модулю в проекті – додаємо передачу параметрів:
Далі додамо створення Lambda-функцій – на кожну корзину буде своя функція зі своїми змінними для лейбл в Loki.
Тобто для корзини “ops-1-28-backend-api-dev-alb-logs” ми створимо інстанс Promtail Lambda, у якої в змінних EXTRA_LABELS будуть значення “component=backend, logtype=alb, environment=dev“.
Для створення функцій нам потрібні нові змінні:
vpc_id: для Lambda Security Group
vpc_private_subnets_cidrs: для правил в Security Group – куди буде дозволено доступ
vpc_private_subnets_ids: для самих функцій – в яких сабнетах їх запускати
promtail_image: Docker image URL з AWS ECR, з якого буде створюватись Lambda
loki_write_address: для Promtail – куди слати логи
# connect to the atlas-vpc Remote State to get the 'outputs' data
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"
}
}
І потім в locals створюється об’єкт vpc_out з даними по VPC, і там жеж формується URL для Loki:
locals {
...
# get VPC info
vpc_out = data.terraform_remote_state.vpc.outputs
# will be used in Lambda Promtail 'LOKI_WRITE_ADDRESS' env. variable
# will create an URL: 'https://logger.1-28.ops.example.co:443/loki/api/v1/push'
loki_write_address = "https://logger.${replace(var.eks_version, ".", "-")}.${var.environment}.example.co:443/loki/api/v1/push"
}
Додаємо нові змінні в variables.tf нашого модуля:
...
variable "vpc_id" {
type = string
description = "ID of the VPC where to create security group"
}
variable "vpc_private_subnets_cidrs" {
type = list(string)
description = "List of IPv4 CIDR ranges to use in Security Group rules and for Lambda functions"
}
variable "vpc_private_subnets_ids" {
type = list(string)
description = "List of subnet ids when Lambda Function should run in the VPC. Usually private or intra subnets"
}
variable "promtail_image" {
type = string
description = "Loki URL to push logs from Promtail Lambda"
default = "492***148.dkr.ecr.us-east-1.amazonaws.com/lambda-promtail:latest"
}
variable "loki_write_address" {
type = string
description = "Loki URL to push logs from Promtail Lambda"
}
Створення security_group_lambda
Створюємо файл modules/alb-logs/lambda.tf, і почнемо з модуля security_group_lambda з модулю terraform-aws-modules/security-group/aws, який створить нам Security Group – вона у нас одна на всі функції для логування:
data "aws_prefix_list" "s3" {
filter {
name = "prefix-list-name"
values = ["com.amazonaws.us-east-1.s3"]
}
}
module "security_group_lambda" {
source = "terraform-aws-modules/security-group/aws"
version = "~> 5.1.0"
name = "${var.eks_env}-${var.eks_version}-loki-logger-lambda-sg"
description = "Security Group for Lambda Egress"
vpc_id = var.vpc_id
egress_cidr_blocks = var.vpc_private_subnets_cidrs
egress_ipv6_cidr_blocks = []
egress_prefix_list_ids = [data.aws_prefix_list.s3.id]
ingress_cidr_blocks = var.vpc_private_subnets_cidrs
ingress_ipv6_cidr_blocks = []
egress_rules = ["https-443-tcp"]
ingress_rules = ["https-443-tcp"]
}
В файлі main.tf проекту додаємо передачу нових параметрів в модуль:
В ній нам потрібно буде вказати allowed_triggers – ім’я корзини, з якої можна виконувати нотифікацію про створення в корзині нових об’єктів, і для кожної корзини ми хочемо створити окрему функцію з власними змінними для labels в Loki.
Для цього описуємо модуль module "promtail_lambda", де знов зробимо цикл по всім корзинам – як робили з aws_s3_bucket_public_access_block.
Але в параметрах функції нам потрібно передати поточне значення з app_environments – “dev” або “prod“.
Для цього ми можемо використати each.key, бо коли ми створюємо resource “aws_s3_bucket” “alb_s3_logs” з for_each = var.app_environments або for_each = local.bucket_names – то отримуємо обє’кт, в якому в key буде кожне значення з var.app_environments, а в value – деталі корзини.
Давайте глянемо як це виглядає.
В нашому модулі додамо output – можна прямо в файлі modules/alb-s3-logs/s3.tf:
output "buckets" {
value = aws_s3_bucket.alb_s3_logs
}
В рутовому модулі, в самому проекті в файлі main.tf – теж output, який використовує output модулю:
...
output "alb_logs_buckets" {
value = module.alb_logs_test.buckets
}
Тут в each.value.id ми будемо мати ім’я корзини, а в environment,${each.key}" – значення “dev” або “prod“.
Перевіряємо – terraform init && terraform plan:
Створення aws_s3_bucket_policy
Наступним ресурсом нам потрібна політика для S3, яка буде дозволяти писати ALB та читати нашій Lambda-функції.
Тут у нас будуть дві нові змінні:
aws_account_id: передамо з рутового модулю
elb_account_id: поки можемо задати дефолтне значення, бо ми тільки в одному регіоні
Додаємо в variables.tf модулю:
...
variable "aws_account_id" {
type = string
description = "AWS account ID"
}
variable "elb_account_id" {
type = string
description = "AWS ELB Account ID to be used in the ALB Logs S3 Bucket Policy"
default = 127311923021
}
Тут ми знову використовуємо each.key з наших корзин, де будемо мати значення “dev” або “prod“.
І, відповідно, можемо звернутись до кожного ресурсу module "promtail_lambda" – бо вони теж створюються в циклі – module.alb_logs_test.module.promtail_lambda["dev"].aws_lambda_function.this[0].
Додаємо передачу aws_account_id в корневому модулі:
$ kk get ingress example-ingress
NAME CLASS HOSTS ADDRESS PORTS AGE
example-ingress alb test-logs.ops.example.co k8s-opsmonit-examplei-8f89ccef47-1782090491.us-east-1.elb.amazonaws.com 80 39s
Перевіряємо зміст корзини:
$ aws s3 ls ops-1-28-backend-api-dev-alb-logs/AWSLogs/492***148/
2024-02-20 16:56:54 107 ELBAccessLogTestFile
Тестовий файл є – значить ALB може писати логи.
Робимо запити до ендпоінту:
$ curl -I http://test-logs.ops.example.co
HTTP/1.1 200 OK
За пару хвилин перевіряємо відповідну Lambda-функцію:
Виклики пошли, добре.
І перевіряємо логи в Loki:
Все працює.
Залишилось винести наш модуль в репозиторій, а потім використати в якомусь проекті.
Terraform модуль з GitHub репозиторію
Створюмо новий репозиторій, копіюємо в нього всю директорію модулю – alb-s3-logs:
Що я хочу, це зробити графіки по Load Balancers не з метрик CloudWatch – а генерувати їх з логів, по, по-перше – збір метрик з CloudWatch у Prometheus/VictoriaMetrics коштує грошей за запити до CloudWatch, по-друге – з логів ми можемо отримати набагато більше інформації, по-третє – метрики CloudWatch мають обмеження, які ми можемо обійти з логами.
Наприклад, якщо до одного лоад-балансеру підключено кілька доменів – то в метриках CloudWatch ми не побачимо на який саме хост йшли запити, а з логів таку інформацію можемо отримати.
Також деякі метрики не дають тієї картини, котру хочеться, як-от метрика ProcessedBytes, яка має значення по “traffic to and from clients“, але немає інформації по recieved та sent bytes.
Тож що будемо робити:
розгорнемо тестове оточення в Kubernetes – Pod та Ingress/ALB з логуванням в S3
налаштуємо збір логів з S3 через Lambda-функцію з Promtail
подивимось на поля в ALB Access Logs – які можуть бути нам цікаві
подумаємо, які графіки хотілося б мати для дашборди Графани і, відповідно, які метрики нам для цього можуть знадобитись
створимо запити для Loki, які будуть формувати дані з логу AWS LoadBalancer
і напишемо кілька Loki Recording Rules, які будуть нам генерувати потрібні метрики
Тестове оточення в Kubernetes
Нам потрібен Pod, який буде генерувати HTTP-відповіді, та Ingress, який створить AWS ALB, з якого ми будемо збирати логи.
Тут опишу кратко, бо в принципі все досить непогано описано у вищезгаданному пості – принаймні в мене вийшло вручну все це повторити ще раз для цього тестового оточення.
Створюємо корзину для логів:
$ aws --profile work s3api create-bucket --bucket eks-alb-logs-test --region us-east-1
$ kk apply -f ingress-svc-deploy.yaml
deployment.apps/nginx-demo-deployment created
service/nginx-demo-service created
ingress.networking.k8s.io/example-ingress created
Перевіряємо Ingress та Load Balancer:
$ kk get ingress example-ingress
NAME CLASS HOSTS ADDRESS PORTS AGE
example-ingress alb test-alb-logs-1.setevoy.org.ua k8s-opsmonit-examplei-8f89ccef47-1707845441.us-east-1.elb.amazonaws.com 80 2m40s
Перевіряємо з cURL, що все працює:
$ curl -I https://test-alb-logs.setevoy.org.ua
HTTP/1.1 200 OK
Є сенс додати Security Group, де закрити доступ усім крім вашого IP – щоб під час тестування бути впевненим в результатах.
Application Load Balancer: корисні поля в Access Log
Grafana Loki: логи Application Load Balancer та LogQL pattern
Знаючи існуючі поля ми можемо створити запит, який буде формувати Fields з записів в логах. А далі ці поля ми зможемо використовувати або для створення labels, або для побудови графіків.
(я все збираєсь спробувати VictoriaLogs, цікаво, чи вміє вона в Recodring Rules та генерацію метрик, як доберусь – напишу пост)
При роботі з Load Balancer логами в Loki враховуйте, що від моменту запиту до самої Loki дані дойдуть через 5 хвилин – і це окрім того, що ми бачимо дані з затримкою, створить ще деякі проблеми, які далі розберемо.
Використуємо парсер pattern, якому задамо імена полів, які хочемо створити: на кожне поле в логу зробимо окремий <field> в Loki:
Далі потрібно чимось виконувати запити до нашого Load Balancer, щоб генерувати логи, і мати змогу задавати конкретні параметри на кшалт кількості запитів в секунду.
Спробував декілька різних утиліт, наприклад Vegeta:
Перше, що приходить в голову для отримання per-second rate запитів – це використати функцію rate():
rate(log-range): calculates the number of entries per second
В rate() використовуємо наш stream selector {logtype="alb", component="devops"} (в мене ці лейбли задаються на Lambda Promtail) та парсер pattern, щоб створити поля зі значеннями, які потім будемо використовувати:
Щоб отримати загальний результат по всім знайденим записам – “огорнемо” наш запит в функцію sum():
Якщо ж ми хочемо робити метрику, яка буде в лейблах мати ім’я ALB та ім’я домену, щоб потім використовувати ці лейбли в фільтрах дашборди Grafana – то додаємо sum by (elb_id, domain):
І тепер маємо значення по кількості всіх унікальних пар elb_id, domain – 1 запит на секунду, як ми з задавали в wrk2 -c1 -t1 -R1.
Наче виглядає чудово?
Але давайте спробуємо цей запит використати в Loki Recording Rules та створити на його основі метрику.
Loki Recording Rule для метрик та проблема з AWS Load Balancer Logs EmitInterval
Додаємо запис для створення метрики aws:alb:requests:sum_by:rate:1m:
По-друге – чому ми в результаті маємо розриви в лінії?
AWS Load Balancer та logs EmitInterval
Давайте повернемось до Loki, і збільшемо інтервал для rate() до 5 хвилин:
І в результаті бачимо, що значення поступово знижується з 1 до нуля.
Чому?
Тому що логи з LoadBalancer до S3 збираються раз на 5 хвилин – див. Access log files:
Elastic Load Balancing publishes a log file for each load balancer node every 5 minutes
В Classic Load Balancer є можливість налаштувати частоту передачі логів до S3 з параметром EmitInterval – але в Application Load Balancer це значення фіксоване 5 хвилинами.
А як працює функція rate()?
Вона в момент виклику бере всі значення за заданий проміжок часу – останні 5 хвилин в нашому випадку, rate({...}[5m]) – і формує середнє значення за секунду:
calculates per second rate of all values in the specified interval
Тобто картина виходить така:
Тож коли ми викликаємо rate() в 12:28 – вона бере дані за проміжок 12:23-12:28, але на цей момент остані логи в нас є тільки до 12:25, а тому і значення, яке повертає rate() з кожною хвилиною зменшується.
Коли ж нові логи приходять до Loki – то rate() обробляє їх всіх, і лінія вирівнюється до значення 1.
Окей, тут більш-менш ясно-понятно.
А що з графіками в Prometheus/VictoriaMetrics, які ми будуємо з метрики aws:alb:requests:sum_by:rate:1m?
Тут картина ще цікавіша, бо Loki Ruler виконує запити раз на хвилину:
# How frequently to evaluate rules.
# CLI flag: -ruler.evaluation-interval
[evaluation_interval: <duration> | default = 1m]
Тобто, запит був виконаний у 12:46:42:
(час на сервері UTC, -2 години від часу в VictoriaMetrics VMUI, тому тут 10:46:42, а не 12:46:42)
І отримав значення “0.3” – бо нових логів ще було:
І навіть якщо ми в Recording Rule збільшемо час в rate() до 5 хвилин – ми все одно не тримаємо правильної картини, бо частина проміжку часу ще не буде мати логів.
Біда.
LogQL та offset
То що ми можемо зробити?
Я тут досить довго намагався знайти рішення, і пробував варіант з count_over_time, думав спробувати рішення з ruler_evaluation_delay_duration, як описано у Loki recording rule range query, і думав пробувати погратись з Ruler evaluation_interval, але придумалось набагато простіше і, мабуть, найбільш правильне – чому б не використати offset?
Не був впевнений, що LogQL його підтримує, але як виявилось – таки є:
З offset 5m ми “зсуваємо” початок нашого запиту: замість того, щоб брати дані за останню хвилину – ми беремо дані за 5 хвилин раніше + наша хвилина для rate(), і тоді в результаті ми вже маємо всі логи.
Це, звісно, трохи портить загальну картину, бо ми не отримаємо самі актуаільні дані, але ми і так маємо їх з затримкою в 5 хвилин, і для графіків та алертів по Load Balancer такі затримки не є критичними, тож, imho, рішення цілком валідне.
Додамо нову метрику aws:alb:requests:sum_by:rate:1m:offset:5m:
target_processing_time >= 0 використовуємо, бо деякі записи можуть мати значення -1:
This value is set to -1 if the load balancer can’t dispatch the request to a target
Фактично, avg_over_time() виконує sum_over_time() (сума з unwrap target_processing_time всіх значень за 1 хвилину) / на count_over_time() (загальна кількість записів з Log Selector {logtype="alb"}):
В принципі, ми можемо її використовувати і для нашого першого завдання – загальна кількість реквестів в секунду, просто робити sum() по всім отриманим кодам.
Аналогічно робимо, якщо треба мати метрики/графіки по кодам з TargetGroups, тільки замість sum by (elb_id, domain, elb_code) робимо sum by (elb_id, domain, target_code).
LogQL для received та transmitted bytes
Метрика CloudWatch ProcessedBytes має загальне значення для отримано/передано, а я хотів б бачити їх окремо.
Отже, зараз у нас виконується 2 запити в секунду.
Глянемо в лог на значення поля sent_bytes:
На кожен запит з кодом 200 ми відправляємо відповідь у 853 байти, тобто на два запити в секунду – 1706 байт/секунду.
В логах ми маємо поле client_ip зі значеннями типу “178.158.203.100:41426“, де 41426 – це локальний TCP-порт на моєму ноуті, який використовується в рамках цього підключення до destination Load Balancer.
Тож ми можемо взяти кількість унікальних client_ip в логах за секунду – і отримати кількість TCP-сессій, тобто підключень.
за допомогою count_over_time()[1s] ми беремо кількість записів в логах за останню секунду
отриманий результат “огортаємо” в sum by (elb_id, domain, client_ip) – тут client_ip використовуємо, щоб отримати унікальні значення для кожного Client_IP:Client_Port, і отримуємо кількість унікальних значень по полям elb_id, domain, client_ip
а потім ще раз огортаємо в sum by (elb_id, domain) – щоб отримати загальну кількість таких унікальних записів по кожному Load Balancer та Domain
Для перевірки запускаємо ще один інстанс wrk2, через пару хвилин ще один – і маємо 4 connection:
(спайки, мабуть додаткові підключеня, які робить wrk2 при запуску)
Або теж саме, але з rate() за 1 хвилину – щоб графік був більш рівномірний:
Ну і в принципі на цьому все. Маючі такі запити ми можемо побудувати борду.
Loki Recording Rules, Prometheus/VictoriaMetrics та High Cardinality
Окремо додам на тему створення labels: було б дуже прикольно зробити метрику, яка в своїх labels мала б, наприклад, target/Pod IP, або IP клієнтів.
Втім, це призведе до такого явища як “High Cardinality“:
Any additional log lines that match those combinations of label/values would be added to the existing stream. If another unique combination of labels comes in (e.g. status_code=”500”) another new stream is created.
Imagine now if you set a label for ip. Not only does every request from a user become a unique stream. Every request with a different action or status_code from the same user will get its own stream.
Doing some quick math, if there are maybe four common actions (GET, PUT, POST, DELETE) and maybe four common status codes (although there could be more than four!), this would be 16 streams and 16 separate chunks. Now multiply this by every user if we use a label for ip. You can quickly have thousands or tens of thousands of streams.
В нашому випадку ми генеруємо метрику, яку записуємо в TSDB VictoriaMetrics, і кожна додаткова лейбла з унікальним значенням буде створювати додаткові time-series.
Тепер нам в IDE треба вибрати board, тобто плату Arduino, з якою будемо працювати, але – “No ports discovered”
Ну, Linux жеж 🙂
“Нєльзя просто так взять, і…” (с)
У CLI аналогічно:
$ arduino-cli board list
No boards found.
Окей, а якщо..
Але ні – після ребуту системи теж нічого не змінилося.
Судячи з документації, має з’явитись новий девайс – /dev/ttyACM0 або /dev/ttyUSBx – проте в мене нічого. Та й lsusb не виводить ніяких нових девайсів.
Почав гуглити, знайшов ось цей тред, і подумав – може і в мене проблема з USB-кабелем? А як перевірити? Бо іншого кабелю під рукою нема.
Проте є ігровий ПК з Віндою – давайте спробуємо там.
Встановлюємо IDE на Вінду, і – о чудо!
Але ж працювати під Віндою не хочеться, тож пішов знову пробувати з Linux, переключив Arduino назад до ноута з Arch, і – о, знову чудо!
Feb 03 16:14:41 setevoy-wrk-laptop kernel: usb 4-2: new full-speed USB device number 5 using xhci_hcd
Feb 03 16:14:41 setevoy-wrk-laptop kernel: usb 4-2: New USB device found, idVendor=10c4, idProduct=ea60, bcdDevice= 1.00
Feb 03 16:14:41 setevoy-wrk-laptop kernel: usb 4-2: New USB device strings: Mfr=1, Product=2, SerialNumber=3
Feb 03 16:14:41 setevoy-wrk-laptop kernel: usb 4-2: Product: CP2102 USB to UART Bridge Controller
Feb 03 16:14:41 setevoy-wrk-laptop kernel: usb 4-2: Manufacturer: Silicon Labs
Feb 03 16:14:41 setevoy-wrk-laptop kernel: usb 4-2: SerialNumber: 0001
Feb 03 16:14:41 setevoy-wrk-laptop kernel: cp210x 4-2:1.0: cp210x converter detected
Feb 03 16:14:41 setevoy-wrk-laptop kernel: usb 4-2: cp210x converter now attached to ttyUSB0
Feb 03 16:14:41 setevoy-wrk-laptop mtp-probe[48870]: checking bus 4, device 5: "/sys/devices/pci0000:00/0000:00:08.1/0000:06:00.4/usb4/4-2"
Feb 03 16:14:41 setevoy-wrk-laptop mtp-probe[48870]: bus: 4, device: 5 was not an MTP device
Feb 03 16:14:42 setevoy-wrk-laptop mtp-probe[48931]: checking bus 4, device 5: "/sys/devices/pci0000:00/0000:00:08.1/0000:06:00.4/usb4/4-2"
Feb 03 16:14:42 setevoy-wrk-laptop mtp-probe[48931]: bus: 4, device: 5 was not an MTP device
І тепер є новий девайс в lsusb:
$ lsusb
...
Bus 004 Device 005: ID 10c4:ea60 Silicon Labs CP210x UART Bridge
...
// the setup function runs once when you press reset or power the board
void setup() {
// initialize digital pin LED_BUILTIN as an output.
pinMode(LED_BUILTIN, OUTPUT);
}
// the loop function runs over and over again forever
void loop() {
digitalWrite(LED_BUILTIN, HIGH); // turn the LED on (HIGH is the voltage level)
delay(1000); // wait for a second
digitalWrite(LED_BUILTIN, LOW); // turn the LED off by making the voltage LOW
delay(1000); // wait for a second
}
Підключаємо Arduino, клікаємо Upload:
В коді все наче досить просто:
виконуємо ініціалізацію LED_BUILTIN (13 pin на платі)
запускаємо цикл, в якому
подаємо живлення на LED_BUILTIN
чекаємо 1000 мілісекунд
відключаємо живлення
Тепер зберемо сам “девайс”, який буде нам блимкати лампочкою.
LED та резистор
Нам потрібен LED і резистор на 220 Ом.
Якщо з LED все зрозуміло, то з резистором я трохи покопався.
В наборі є кілька резисторів і таблиця відповідності кольорів – але я ну ніяк не міг розібрати які саме кольори на них (але нормально побачив, коли зробив цю фотку для цього посту 🙂 ):
Тож просто взяв мультиметр, та поміряв ним:
Коли ж вже це все зробив, то стала зрозуміла і ця схема:
Тож резистор на фотці має:
червоний
червоний
чорний
чорний
коричневий
Тобто відповідно до схеми це буде “2 2 0 х1 1%” – 220 Ом, начебто вірно?
Тепер спробуємо це все підключити.
Breadboard для Arduino
Дістаємо breadboard (“макетна плата”).
Під капотом вона має такі з’єднання:
І шини для підключення живлення та компонентів:
Отже, нам потрібно таке підключення:
Самому резистору сторона підключення не має значення – одним кінцем до шини, де у нас червоний провід, іншим кінцем туди, де буде лампа.
Сам LED довгою ніжкою підключаємо на сторону живлення (“+”), до резистора, короткою – до “землі”:
Далі – підключаємо на самій платі Arduino: чорний провід до GRD (Ground), червоний – до 13 pin – він у нас відповідає за роботу з LED (але з портами детальніше будемо розбиратись вже далі, див. Digital Pins):
Підключаємо Arduino до ноута (або підключаємо блок живлення), і…
Wow! It works! 🙂
Окей.
Тепер, як все працює – можна починати розбиратись з рештою, і пробувати щось вже більш складне.
Бути девопсом – то, звичайно круто – всі ці клауди, Терраформи, сесуріті і прочі дуже цікаві штуки.
Але я давно хотів спробувати і щось більш “реальне”, щось таке, щоб можна було потримати в руках, і цими ж руками зібрати.
В минулому році, коли думав про чергову підготовку до зими (див. Підготовка до зими 2023-2024: електрохарчування), знов згадав свою ідею мати вдома пожежну сигналізацію – бо на балконі стоять акумулятори. І, звісно, можна купити готові рішення від Ajax Systems, але ж можна і зробити самому!
Аналогічно з системою відеоспостереження – можна купити готові рішення (власне, я так і зробив) – а можна зібрати власний солюшон, і це було б набагато цікавіше.
Тож врешті-решт я таки вирішив почати знайомитись з Arduino. До того ж досвід роботи з мікроконтролерами може знадобитись, якщо доведеться мати справу з дронами (if you know what I mean 😉 ).
Ну і само собою – хіба ж можна займатись такими штуками, і не написати про на RTFM? 🙂 Тож додаю нову рубрику, і сподіваюсь, що буду її періодично оновлювати.
Arduino: the very beginning
Раніше я не мав справ ані з мікроконтролерами взагалі (хоча в універі наче були пари по ним), ані з Adruino, тому на самому початку для мене це був досить темний ліс: я знав, що це якась маленька плата, з якою можна робити якісь штуки.
Тож самим першим кроком було загуглити якось на кшталт “adruino що можна зробити” і, чесно кажучи, навіть здивувався – скільки ж всього є під цю платформу.
Тож з того, що можна зробити – і що було б корисно вдома, аби мати більше мотивації:
система відеоспостереження
сигналізація пожежі/потопу
система автополиву квітів
робот-пилосос
система управління акваріумом
Та безліч всього іншого, бо датчиків для Arduino – просто море.
Як познайомитись з Arduino?
Знову-таки – я ніколи з ним не мав справу, тож треба було якось почати освоюватись в новому для себе світі.
і стартовий набір Super Arduino Starter Kit від Keyestudio – купував на цьому сайті, і взагалі класний сайт з купою всього – і документація, і всякі плати/датчики/корпуси
Взагалі стартових наборів теж дуже багато, я вибирав по принципу “побільше всього відразу”. Враховуючи вартість в 2-3 тисячі гривень – можна собі дозволити брати “максимальний” набір (в мене ще не самий максимальний), бо краще відразу мати під рукою все, аніж почати щось робити, потім зрозуміти, що тобі не вистачає якоїсь деталі, і чекати, поки на пошту прийде замовлення.
Щодо книги – вона дійсно прям дуже зайшла. Розказується як для маленьких, з картинками, досить детально і з самого-самого початку – як раз те, що мені потрібно.
Ще, звісно, є купа матеріалів у всяких блогах, на Youtube тощо – але до них поки не добрався.
Насправді я тільки от сьогодні дістав цей набір із шафи, хоча почав читати книгу і купив набір ще восени, але потім якось те-це, якісь справи-робота, і трохи підзабив на це діло.
Тож час розпаковувати його – і пробувати щось сконектити.
Arduino Starter Kit
Список компонентів в моєму наборі прям дуже широкий:
5 * Синій світлодіод
5 * Червоний світлодіод
5 * Жовтий світлодіод
1 * RGB світлодіод
8 * Резистор 220 Ом
5 * Резистор 10 кОм
5 * Резистор 1кОм
1 * 10K потенціометр
1 * Зумер (активний)
1 * Зумер (пасивний)
4 * Великий кнопковий перемикач
2 * Датчик нахилу
3 * Фоторезистор
1 * Датчик полум’я
1 * Датчик температури LM35
1 * Регістр зсуву 74HC595N
1 * 7-сегментний світлодіодний 1x модуль
1 * 7-сегментний світлодіодний 4х модуль
1 * 8 * 8 Світлодіодна матриця
1 * 2×16 РК-дисплей
1 * ІЧ-приймач
1 * ІЧ-пульт дистанційного керування
1 * Серводвигун
1 * Кроковий модуль драйвера
1 * Кроковий двигун
1 * Модуль джойстика
1 * Релейний модуль
1 * Датчик руху PIR
1 * Аналоговий датчик газу
1 * Модуль акселерометра ADXL345
1 * Ультразвуковий датчик HC-SR04
1 * Модуль годинника реального часу на DS3231
1 * Датчик температури і вологості DHT11
1 * Датчик вологості грунту
1 * RFID-модуль RC522
1 * RFID-карта
1 * RFID-ключ
40 * Конектор
1 * Макетна плата
10 * Перемички мама-мама
30 * Перемички тато-тато
1 * 6-елементна AA акумуляторна батарея
1 * Кабель USB
Arduino controller
Власне сама “ардуінка”, контролер – головна плата:
Забігаючи наперед – для Arduino використовується Wiring – фреймворк зі спрощеним C++, але начебто можна писати і на самій С++ (буде привід згадати її).
Підключення Arduino до комп’ютера
Ну, що – спробуємо його включити?)
Живлення може бути прямо від USB:
Ваааау!)))
“It works!” (c)
Тепер можна починати щось робити.
Тож в наступній частині ми встановимо IDE на Linux, і заставимо нашу ардуінку блимкати LED.
В цілому проблем з ним поки не маємо, але в будь-якому разі потрібен його моніторинг, для чого Karpeneter “з коробки” надає метрики, які можемо використати в Grafana та Prometheus/VictoriaMetrics алертах.
Тож що будемо робити сьогодні:
додамо збір метрик до VictoriaMetrics
подивимось які метрики нам можуть бути корисні
додамо Grafana Dashboard для WorkerNodes + Karpenter
Взагалі пост вийшов більше про Grafana, ніж про Karpenter, але в графіках в основному використовуються метрики саме від Karpenter.
Окремо треба буде створити алерти – але це вже іншим разом. Маючи уяву про доступні метрики Karpenter та Prometheus-запити для графіків в Grafana проблем з алертами не має бути.
Деплоїмо, і для перевірки таргету відкриваємо порт до VMAgent:
$ kk port-forward svc/vmagent-vm-k8s-stack 8429
Перевіряємо таргети:
Для перевірки метрик відкриваємо порт до VMSingle:
$ kk port-forward svc/vmsingle-vm-k8s-stack 8429
І шукаємо дані по запиту {job="karpenter"}:
Корисні метрики Karpenter
Тепер глянемо, які саме метрики нам можуть бути корисні.
Але перед тим, як розбиратись з метриками – давайте проговоримо основні поняття в Karpenter (окрім очевидних типу Node, NodeClaim або Pod):
controller: компонент Karpenter, який віподвідає за певний аспект його роботи, наприклад, Pricing controller відповідає за перевірку вартості інстансів, а Disruption Controller відповідає за керування процесом зміни стану WorkerNodes
reconciliation (“узгодження”): процес, коли Karpenter виконує узгодження бажаного стану (desired state) и реального (current state), наприклад – при появі Pod, для якого нема вільних ресурсів на існуючих WorkerNodes, Karpenter створить нову Node, на якій зможе запустись Pod, і його статус стане Running – тоді reconciliation процес статусу цього поду завершиться
consistency (“когерентність” або “узгодженість”): процес внутрішнього контролю і забезпечення відповідності необхідним параметрам (наприклад, перевірка того, що створена WorkerNode має диск розміром саме 30 GB)
disruption: процес зміни WorkerNodes в кластері, наприклад перестворення WorkerNode (для заміни на інстанс з більшою кількістю CPU або Memory), або видалення існуючої ноди, на якій нема запущених Pods
interruption: випадки, коли EC2 буде зупинено у зв’язку з помилками на hardware, виключення інстансу (коли робиться Stop або Terminate instance), або у випадку зі Spot – коли AWS “відкликає” інстанс; ці евенти йдуть на віподвідну SQS, звідки їх отримує Karpenter, щоб запустити новий інстанс на заміну
provisioner: компонент, який аналізує поточні потреби кластера, такі як запити на створення нових Pod, визначає, які ресурси потрібно створити (WorkerNodes), і ініціює створення нових (взагалі, Provisioner був замінений на NodePool, але окремі метрики по ньому залишились)
Тут я зібрав тільки ті метрики, які мені вважаються найбільш корисними в даний момент, але варто самому передивитись документацію Inspect Karpenter Metrics і є трохи більше деталей у документації Datadog:
Controller:
controller_runtime_reconcile_errors_total: кількість помилок при оновленні WorkerNodes (тобто в роботі Disruption Controller при виконанні операцій по Expiration, Drift, Interruption та Consolidation) – корисно мати графік або алерт
controller_runtime_reconcile_total: загальна кількість таких операції – корисно мати уяву про активність Karpenter і, можливо, мати алерт, якщо це відбувається надто часто
Сonsistency:
karpenter_consistency_errors: виглядає як корисна метрика, але в мене вона пуста (принаймні поки що)
Disruption:
karpenter_disruption_actions_performed_total: загальна кількість дій по disruption (видалення/перестворення WorkerNodes), в лейблах метрик вказується disruption method – корисно мати уяву про активність Karpenter і, можливо, мати алерт, якщо це відбувається надто часто
karpenter_disruption_eligible_nodes: загальна кількість WorkerNodes для виконання disruption (видалення/перестворення WorkerNodes), в лейблах метрик вказується disruption method
karpenter_disruption_replacement_nodeclaim_failures_total: загальна кількість помилок при створенні нових WorkerNodes на заміну старим, в лейблах метрик вказується disruption method
Interruption:
karpenter_interruption_actions_performed: кількість дій за повідомленнями про EC2 Interruption (з SQS) – можливо має сенс, але в мене за тиждень збору метрик такого не траплялось
Nodeclaims:
karpenter_nodeclaims_created: загальна кількість створенних NodeClaims з лейблами по причині створення та відповідним NodePool
karpenter_nodeclaims_terminated: аналогічно, але по видаленним NodeClaims
Provisioner:
karpenter_provisioner_scheduling_duration_seconds: можливо, має сенс моніторити, бо якщо цей показник буде рости але буде надто великим – то це може бути ознакою проблем; проте, в мене за тиждень хістограма karpenter_provisioner_scheduling_duration_seconds_bucket незмінна
Nodepool:
karpenter_nodepool_limit: ліміт CPU/Memory NodePool, заданий в його Provisioner (spec.limits)
karpenter_nodepool_usage: використання ресурсів NodePool – CPU, Memory, Volumes, Pods
Nodes:
karpenter_nodes_allocatable: інформація по існуючим WorkerNodes – тип, кількість CPU/Memory, Spot/On-Demand, Availability Zone, etc
можна мати графік по кількості Spot/On-Demand інстансів
можна використовувати для отримання даних по доступних ресурсах ЦПУ/пам’яті – sum(karpenter_nodes_allocatable) by (resource_type)
karpenter_nodes_created: загальна кількість створенних нод
karpenter_nodes_terminated: загальна кількість видалених нод
karpenter_nodes_total_pod_limits: загальна кількість всіх Pod Limits (окрім DaemonSet) на кожній WorkerNode
karpenter_nodes_total_pod_requests: загальна кількість всіх Pod Requests (окрім DaemonSet) на кожній WorkerNode
Pods:
karpenter_pods_startup_time_seconds: час від сторення поду до його переходу в статус Running (сума по всім подам)
karpenter_pods_state: досить корисна метрика, бо в лейблах має статус поду, на якій він ноді запущений, неймспейс тощо
Cloudprovider:
karpenter_cloudprovider_errors_total: кількість помилок від AWS
karpenter_cloudprovider_instance_type_price_estimate: вартість інстансів по типам – можна на дашборді виводити вартість compute-потужностей кластеру
Створення Grafana dashboard
Для Grafana є готовий дашборд – Display all useful Karpenter metrics, але він якось зовсім не інформативний. Втім, з нього можна взяти деякі графіки та/або запити.
Зараз в мене є власна борда для перевірки статусу та ресурів по кожній окремій WorkerNode:
В графіках цієї борди є Data Links на дашборду з деталями по на дашборду з інформацією по конкретному Pod:
Графіки ALB внизу будуються з логів в Loki.
Тож що зробимо: нову борду, на якій будуть всі WorkerNodes, а в Data Links графіків цієї борди зробимо лінки на перший дашборд.
Тоді буде непогана навігація:
загальна борда по всім WorkerNodes з можливістю перейти на дашборду з більш детальною інформацією по конкретній ноді
на борді по конкретній ноді вже буде інформація по подах на цій ноді, і data links на борду по конкретному поду
Планування дашборди
Давайте поміркуємо, що саме ми б хотіли бачити на новій борді.
Фільтри/змінні:
мати змогу бачити всі WorkerNodes разом, або обрати одну чи кілька окремо
мати змогу бачити ресурси по конкретним Namespaces або Applications (в моєму випадку кожен сервіс має власний неймспейс, тому використаємо їх)
Далі, інформація по нодам:
загальна інформаця по нодам:
кількість нод
кількість подів
кількість ЦПУ
кількість Мем
spot vs on-deman ratio
вартість всіх нод за добу
відсотки від allocatable використано:
cpu – від pods requested
mem – від pods requested
pods allocation
реальне використання ресурсів – графіки по нодам:
CPU та Memory подами
кількість подів – процент від максимума на ноді
створено-видалено нод (by Karpenter)
вартість нод
процент EBS used
network – in/our byes/sec
По Karpenter:
controller_runtime_reconcile_errors_total – загальна кількість помилок
karpenter_provisioner_scheduling_duration_seconds – час створення подів
karpenter_cloudprovider_errors_total – загальна кількість помилок
Looks like a plan?
Поїхали творити.
Створення дашборди
Робимо нову борду, задаємо основні параметри:
Grafana variables
Нам потрібні дві змінні – по нодам і неймпспейсам.
Ноди можемо вибрати з karpenter_nodes_allocatable, неймспейси отримати з karpenter_pods_state.
Створюємо першу змінну – node_name, включаємо можливість вибору All або Multi-value:
Створюємо другу змінну – $namespace.
Щоб вибирати неймспейси тільки з обраних в фільтрі нод – додаємо можливість фільтру по $node_name яку створили вище і використовуємо регулярку “=~” – якщо нод буде обрано кілька:
Переходимо до графіків.
Кількість нод в кластері
Запит – використовуємо фільтр под обраним нодам:
count(sum(karpenter_nodes_allocatable{node_name=~"$node_name"}) by (node_name))
Частина ресурсів зайнята системою то ДемонСетами – вони у karpenter_nodes_allocatable не враховються. Можна перевірити запитом sum(karpenter_nodes_system_overhead{resource_type="cpu"}).
Тому можемо вивести або загальну кількість – karpenter_nodes_allocatable{resource_type="cpu"} + karpenter_nodes_system_overhead{resource_type="cpu"}, або тільки дійсно доступну для наших workloads – karpenter_nodes_allocatable{resource_type="cpu"}.
Так як тут ми хочемо бачити саме загальну кількість – то давайте використаємо суму:
Окрім нод створенних самим Karpenter у нас є окрема “дефолтна” нода, яка створються при створенні кластеру – для всяких controllers. Вона теж Spot (поки що), тож рахуймо і її.
Формула буде такою:
загальна сума нод = всі спот від karpenter + 1 дефолтна / на загальну кількість нод
Сам запит:
sum(count(sum(karpenter_nodes_allocatable{node_name=~"$node_name", nodepool!="", capacity_type="spot"}) by (node_name)) + 1) / count(sum(karpenter_nodes_allocatable{node_name=~"$node_name"}) by (node_name)) * 100
CPU requested – % від загального allocatable
Запит беремо з дефолтної борди Karpenter, трохи підпилюємо під свої фільтри:
Метрика controller_runtime_reconcile_errors_total включає в себе і контролери від VictoriaMetrcis, тож виключаємо їх через {container!~".*victoria.*"}:
По-перше – у AWS є дефолтні метрики білінгу від CloudWatch, але наш проект користується кредитами від AWS і ці метрики пусті.
Тому скористаємось метриками від Karpenter – karpenter_cloudprovider_instance_type_price_estimate.
Щоб відобразити вартість серверверів нам треба вибрати кожен тип інстансу які використовуються і потім порахувати загальну вартість по кожному типу і іх кількості.
Що ми маємо:
дефолта нода: з типом Spot, але створюється не Karpenter – можемо її ігнорувати
ноди, створені Karpenter: можуть бути або spot або on-demand, і можуть бути різних типів (t3.medium. c5.large, etc)
Спочатку нам треба отримати кількість нод по кожному типу:
count(sum(karpenter_nodes_allocatable) by (node_name, instance_type,capacity_type)) by (instance_type, capacity_type)
Отримуємо 4 spot, і один інстанс без лейбли capacity_type – бо це з дефолтної нод-групи:
Можемо його виключити з {capacity_type!=""} – він у нас один, без скейлінгу, можемо не враховувати, бо це тільки для CriticalAddons.
Для кращої картини візьмемо більший проміжок часу, бо там був ще t3.small:
Далі, у нас є метрика karpenter_cloudprovider_instance_type_price_estimate, використовуючи яку нам треба порахувати вартість всіх інстансів по кожному instance_type і capacity_type.
Запит буде виглядати так (дякую ChatGPT):
sum by (instance_type, capacity_type) (
count(sum(karpenter_nodes_allocatable) by (node_name, instance_type, capacity_type)) by (instance_type, capacity_type)
* on(instance_type, capacity_type) group_left
avg(karpenter_cloudprovider_instance_type_price_estimate) by (instance_type, capacity_type)
)
Тут:
“внутрішній” запит sum(karpenter_nodes_allocatable) by (node_name, instance_type, capacity_type): рахується сума всіх CPU, memory тощо для кожної комбінації node_name, instance_type, capacity_type
“зовнішній” count(...) by (instance_type, capacity_type): результат попереднього запиту рахуємо з count, щоб отримати кількість кожної комбінації – отримуємо кількіть WorkerNodes кожного instance_type та capacity_type
другий запит – avg(karpenter_cloudprovider_instance_type_price_estimate) by (instance_type, capacity_type): повертає нам середню ціну по кожному instance_type та capacity_type
використовуючи * on(instance_type, capacity_type): множимо кількість нод с запита номер 2 (count(...)) на результат с запита номер 3 (avg(...)) по співпадаючим комбінаціям метрик instance_type та capacity_type
і самий перший “зовнішній” запит sum by (instance_type, capacity_type) (...): повертає нам суму по кожій комбінації
В результаті маємо такий графік:
Отже, що ми тут маємо:
4 інстанси t3.medium та 2 t3.small
загальна вартість всіх t3.medium на годину виходить 0.074, всіх t3.small – 0.017
Для перевірки порахуємо вручну.
Спочатку по t3.small:
{instance_type="t3.small", capacity_type="spot"}
Виходить 0.008:
І по t3.medium:
{instance_type="t3.medium", capacity_type="spot"}
Виходить 0.018:
Тож:
4 інстанси t3.medium по 0.018 == 0.072 usd/година
2 інстанси t3.small по 0.008 == 0.016 usd/година
Все сходиться.
Залишилось все це зібрати разом, і вивести загальную вартість всіх серверів за 24 години – використаємо avg() і результат помножимо на 24 години:
avg(
sum by (instance_type, capacity_type) (
count(sum(karpenter_nodes_allocatable{capacity_type!=""}) by (node_name, instance_type, capacity_type)) by (instance_type, capacity_type)
* on(instance_type, capacity_type) group_left
avg(karpenter_cloudprovider_instance_type_price_estimate) by (instance_type, capacity_type)
)
) * 24
І в результаті все у нас зараз виглядає так:
Йдемо далі – до графіків.
CPU % use by Node
Тут вже використаємо дефолтні метрики від Node Exporter – node_cpu_seconds_total, але вони мають лейбли instance у вигляді instance="10.0.32.185:9100", а не node_name або node як у метриках від Karpenter (karpenter_pods_state{node="ip-10-0-46-221.ec2.internal"}).
Тож щоб їх застосувати node_cpu_seconds_total з нашою змінною $node_name – додамо нову змінну node_ip, яку будемо формувати з метрики kube_pod_info з фільтром по лейблі node, де використовуємо нашу стару змінну node_name – щоб вибирати поди тільки з обраних в фільтрах нод.
Додаємо нову змінну, поки для перевірки не вимикаємо “Show on dashboard“:
І тепер можемо створити графік с запитом:
100 * avg(1 - rate(node_cpu_seconds_total{instance=~"$node_ip:9100", mode="idle"}[5m])) by (instance)
Але в такому випадку instance нам поверне результати як “10.0.38.127:9100” – а ми всюди використовуємо “ip-10-0-38-127.ec2.internal“. До того ж ми не зможемо додати data links, бо друга панель використовує формат ip-10-0-38-127.ec2.internal.
Тож ми можемо використати label_replace(), і переписати запит так:
перший – метрика, над якою будемо виконувати трансформацію (результат rate(node_cpu_seconds_total))
другий – лейбла, над якою ми будемо виконувати трансформацію – instance
третій – новий формат value для лейбли – “ip-${1}-${2}-${3}-${4}.ec2.internal“
четвертий – ім’я лейбли, з якої ми будемо тримувати дані за допомогою regex
І останнім описуємо сам regex “(.*)\\.(.*)\\.(.*)\\.(.*):9100“, за яким треба отрмати кожен октет з IP 10.0.38.127, а потім кожен результат відповідно записати у ${1}-${2}-${3}-${4}.
Тут нам треба виконати запит між двома метриками – karpenter_pods_state та karpenter_nodes_allocatable:
(
sum by (node) (kube_pod_info{node=~"$node_name", created_by_kind!="Job"})
/
sum by (node) (kube_node_status_allocatable{node=~"$node_name", resource="pods"})
) * 100
Або ми можемо виключити нашу дефолтну ноду “ip-10-0-41-2.ec2.internal” і відобразити тільки ноди самого Карпентеру додавши вибірку по karpenter_nodes_allocatable{capacity_type!=""} – бо нам тут більше цікаво наскільки зайняті ноди, які створено самим Karpenter під наші аплікейшени.
Але для цього нам знабиться метрика karpenter_nodes_allocatable, в якій ми можемо перевірити наявність лейбли capacity_type – capacity_type!="".
Проте karpenter_nodes_allocatable має лейблу node_name а не node як в попередніх двух, тому ми знову можемо додати label_replace, і зробити такий запит:
(
sum by (node) (kube_pod_info{node=~"$node_name", created_by_kind!="Job"})
/
on(node) group_left
sum by (node) (kube_node_status_allocatable{node=~"$node_name", resource="pods"})
) * 100
and on(node)
label_replace(karpenter_nodes_allocatable{capacity_type!=""}, "node", "$1", "node_name", "(.*)")
Тут в and on(node) ми використовуємо лейблу node в результатах запиту зліва (sum by()) і в результаті справа, щоб зі списку нод в karpenter_nodes_allocatable{capacity_type!=""} (тобто всі ноди, окрім нашої “дефолтної”) вибрати тільки ті, які є в результатх першого запиту:
EBS use % by Node
Тут вже простіше:
sum(kubelet_volume_stats_used_bytes{instance=~"$node_name", namespace=~"$namespace"}) by (instance)
/
sum(kubelet_volume_stats_capacity_bytes{instance=~"$node_name", namespace=~"$namespace"}) by (instance)
* 100
Nodes created/terminated by Karpenter
Для відображення активності автоскейлінгу додамо графік з двома запитами:
increase(karpenter_nodes_created[1h])
Та:
- increase(karpenter_nodes_terminated[1h])
Тут в функції increase() перевіряємо наскільки змінилося значення за годину:
А щоб позбутися цих “сходинок” – можемо додатково загорнути результат у функцію avg_over_time():
Grafana dashboard: фінальний результат
І все разом у нас тепер виглядає так:
Додавання Data links
Останім кроком буде додавання Data Links на графіки: потрібно додати лінку на іншу дашборду, по конкретній ноді.
Ця борда має такий URL: https://monitoring.ops.example.co/d/kube-node-overview/kubernetes-node-overview?var-node_name=ip-10-0-41-2.ec2.internal
Де в var-node_name=ip-10-0-41-2.ec2.internal задається ім’я ноди, по якій треба вивести дані:
Тож відкриваємо графік, знаходимо Data links:
Задаємо ім’я та URL – список всіх полів можна отримати по Ctrl+Space:
__field візьме дані з labels.node з результату запиту в панелі:
І сформує посилання у вигляді “https://monitoring.ops.example.co/d/kube-node-overview/kubernetes-node-overview?var-node_name=ip-10-0-38-110.ec2.internal“.
Ще з дуже цікавих новинок останнього re:Invent – це EKS Pod Identities: нова можливість керувати доступами подів до ресурсів AWS.
The current state: IAM Roles for Service Accounts
До цього ми використовували модель IAM Roles for Service Accounts, IRSA, де для того, щоб якомусь поду дати доступ до, наприклад, S3, ми створювали IAM Role з відповідною IAM Policy, налаштовували її Trust Policy – щоб дозволити виконувати AssumeRole тільки з відповідвідного кластеру, потім створювали Kubernetes ServiceAccount, в annotations якого вказували ARN цієї ролі.
За такою схемою ми мали декілька “error prone” моментів:
найбільш розповсюджена проблема, з якою і я стикався прям ну дуже багато раз – помилки в Trust Policy, де треба було вказувати OIDC кластеру
помилки в самому ServiceAccount, де можна було помилитись в ARN ролі
Проте тепер EKS Pod Identities дозволяє нам один раз створити IAM Role, ніяк її не обмежувати конкретним кластером, і підключати цю роль до подів (знову-таки – через ServiceAccount) прямо з AWS CLI, AWS Console чи через AWS API (Terraform, CDK, etc).
Як це виглядає:
в EKS додаємо новий контроллер – Amazon EKS Pod Identity Agent add-on
створюємо IAM Role, в Trust Policy якої тепер використовуємо Principal: pods.eks.amazonaws.com
і з AWS CLI, AWS Console чи через AWS API підключаємо цю роль напряму до потрібного ServiceAccount
Го тестити!
Створення IAM Role
Переходимо в IAM, створюємо роль.
В Trusted entity type вибираємо EKS і новий тип – EKS – Pod Identity:
В Permissions візьмемо вже існуючу політику на S3ReadOnly:
Задаємо ім’я ролі, і як раз тут і бачимо нову Trust Policy:
І давайте порівняємо її з Trust Policy для IRSA ролі:
Набагато простіше, а значить – менше варіантів для помилок, і взагалі простіше менеджити. До того ж, ми більше не зав’язані на Cluster OIDC Provider.
До речі, з EKS Pod Identities ми можемо використовувати і role session tags.
Окей, йдемо далі.
Amazon EKS Pod Identity Agent add-on
Переходимо до нашого кластеру, встановлюємо новий компонент – Amazon EKS Pod Identity Agent add-on:
Чекаємо хвилину – готово:
І поди цього контролера:
$ kk -n kube-system get pod | grep pod
eks-pod-identity-agent-d7448 1/1 Running 0 91s
eks-pod-identity-agent-m46px 1/1 Running 0 91s
eks-pod-identity-agent-nd2xn 1/1 Running 0 91s
Підключення IAM Role до ServiceAccount
Переходимо в Access, і клікаємо Create Pod Identity Association:
Вибираємо роль, яку створили вище.
Далі задаємо ім’я неймспейсу – або вибираємо зі списку існуючих, або вказуємо нове.
Аналогічно з ім’ям ServiceAccount – можна задати вже створенний SA, можно задати нове ім’я:
$ kubectl apply -f iam-sa.yaml
namespace/ops-iam-test-ns created
serviceaccount/ops-iam-test-sa created
pod/ops-iam-test-pod created
І пробуємо доступ:
$ kk -n ops-iam-test-ns exec -ti ops-iam-test-pod -- bash
bash-4.2# aws s3 ls
2023-02-01 11:29:34 amplify-staging-112927-deployment
2023-02-02 15:40:56 amplify-dev-174045-deployment
...
Але якщо ми спробуємо іншу операцію, на яку ми не підключали політику, наприклад – EKS, то отримаємо 403:
bash-4.2# aws eks list-clusters
An error occurred (AccessDeniedException) when calling the ListClusters operation: User: arn:aws:sts::492***148:assumed-role/EKS-Pod-Identities-test-TO-DEL/eks-atlas-eks--ops-iam-te-cc662c4d-6c87-44b0-99ab-58c1dd6aa60f is not authorized to perform: eks:ListClusters on resource: arn:aws:eks:us-east-1:492***148:cluster/*
Проблеми?
Наразі я бачу одну потенційну не проблему, але питання, яке варто мати на увазі: якщо раніьше ми налаштовували доступ на рівні сервісу, то з EKS Pod Identities це робиться на рівні управління кластером.
Тобо: в мене є сервіс, Backend API. В нього є власний репозиторій, в якому є каталог terrafrom, в якому створюються необхідні IAM-ролі.
Далі, є каталог helm, в якому маємо маніфест з ServiceAccount, в якому в анотаціях через змінні передається ARN цієї IAM ролі.
І на цьому все – мені (точніше – CI/CD пайплайну, який виконує деплой) потрібен доступ тільки до IAM, потрібен доступ в EKS на створення Ingress, Deployment та ServiceAccount.
Але тепер треба буде думати як давати доступ ще й до EKS на рівні AWS, бо треба буде виконувати додаткову операцію в AWS API на Create Pod Identity Assosiaction.
Тобто тепер ми можемо створювати графіки та/або алерти не тільки з дефолтних метрик самого CloudWatch – але й за допомогою конекторів для CloudWatch підключити збір метрик з Amazon Managed Service for Prometheus, звичайного Prometheus, Amazon OpenSearch Service, Amazon RDS для MySQL та PostgreSQL, CSV файлів з S3 бакетів і навіть з Microsoft Azure Monitor.
Виглядає це прям дуже круто, бо тепер можна буду переосмислили взагалі всю свою концепцію побудови моніторингу и observability на проекті.
Підключення метрик з vanilla Prometheus
Є Prometheus на голому EC2 інстансі, на якому відкрито порт 9090 – спочатку спробуємо тут, потім глянемо на VictoriaMetrcis в EKS.
Переходимо в CloudWatch Metrics, тепер маємо нову вкладку Multi source query:
Вибираємо Prometheus:
Задаємо параметри.
Поля логін-пароль обов’язкові, тож навіть якщо Prometheues не потребує аутентифікації – задаємо тут якісь значення.
Нижче можна налаштувати параметри мережі. Наприклад, якщо Prometheus доступний тільки всередені VPC – то тут можемо вибрати VPC і сабнети:
Клілкаємо Create data source – в CloudWatch запустить створення CloudFormation стеку, в якому створить Lambda-функції, які власне і будуть збирати дані з нашого дата-сорсу:
Повертаємось до CloudWatch, де тепер маємо новий дата-сорс:
Отже, поговоримо про дуже гучний запуск Amazon Q – нової системи від AWS, яка має допомогти нам, інженерам і не тільки, в роботі.
Сам Amazon його називає “AI-powered assistant”, по факту для нас, як інженерів, це просто chatbot, з яким ми можемо поговорити, щоб отримати допомогу у розв’язанні якихось проблем або для отримання рекомендації для налаштування сервісу. Для бізнесу ж він може мабуть багато іншого, але нам він цікавий, як асистент по роботі з AWS.
Під капотом Amazon Q використовує Amazon Bedrock, тож щоб краще розуміти що таке Q – давайте глянемо і на Bedrock.
Amazon Bedrock
Bedrock запустили у квітні 2023, але чомусь він не привернув до себе такої уваги, хоча сервіс дуже цікавий, і я постараюся написати про нього окремо.
Отже, AWS Bedrock – це managed сервіс від AWS, який дозволяє будувати ваші АІ-powered сервіси, використовуючи Foundation Models (FM) від Amazon, Мета, Amazon, Stability AI, та інших:
Для роботи з FL Bedrock надає єдиний API, і вам не потрібно будувати ніякої інфраструктури для запуску моделей.
Крім того, ви можете розширяти базу знань Bedrock за рахунок власних баз знань (“Knowledge base“). При цьому ваші дані не будуть об’єднані з самим FM, тобто повністю зберігається всяка privacy (включаючи підтримку стандартів GDPR, HIPAA).
Тож Amazon Q – це система поверх Bedrock (я поки не знайшов інформації, яка саме модель використовується, але, мабуть, Titan – бо це система від самого Amazon).
По факту це чат-бот, з яким ми можемо поговорити, і який вже нам доступний в AWS Console справа, де ми звикли бачити розділ допомоги:
Хоча місцями Q не може відповісти навіть на прості питання 🙂
При цьому і сам Q, і Bedrock заточені під приватність даних, тому начебто можна спокійно (нєт) підключати їх до корпоративних даних – наприклад, до Git-репозиторію, або Atlassian Confluence, і тоді Q при формуванні відповіді буде використовувати дані з цих джерел для формування відповідей.
Чому “нєт?” Бо поки система в Preview, і про неї вже пишуть, що:
Knowledge baseQ is “experiencing severe hallucinations and leaking confidential data,”
в сервісах Amazon – наприклад, CodeWhisperer (аналог GitHub Copilot?)
Amazon Q vs ChatGPT
Мабуть, чи найперше питання, яке приходить в голову.
Знову-таки, чисто моє IMHO:
Amazon Q – це про бізнес: якщо ChatGPT це так би мовити “AI-чатбот загального призначення”, то Q може тісно інтегруватися з вашим бізнесом – вашими даними, користувачами тощо
Amazon Q – це про безпеку даних: нам обіцяють дуже потужні інструменти по обмеженню доступів: наприклад, якщо до Q буде підключена Confluence, то при формуванні відповіді на запит юзера будуть перевірятись його права в Confluence, і не будуть використані дані, до яких він там не має доступу (цікаво, як це реалізовано і як воно буде працювати, але поки просто маємо на увазі)
Amazon Q – це про інтеграцію:
модель, яка використовується для відповідей навчалася на даних самого AWS, яких за 17 років існування набралось багато – в тому числі мабуть і якісь дані, котрих зазвичай нема у відкритому доступі, тому по ідеї – Q в деяких моментах може давати більш точні відповіді (знову-таки – може не відразу, поки воно ще в Preview)
Q інтегрований з самими сервісами AWS, і ви можете використовувати його прямо в QuickSighe, або, як вже казалось, в самій AWS Console
(оці три пункта звучать так, наче Amazon мені заплатили за цей пост :-D)
Amazon Q pricing
Як завжди у AWS – “всьо сложно” 🙂
По-перше, поки система в Preview – більша частина можливостей безкоштовні.
По-друге – доступні будуть два різних плана: Business і Builder.
Якщо коротко, то Business – це більше про якісь маркетингові штуки на кшталт чат-бота для допомоги співробітникам у вирішення якихось питань, або такий собі “internal Google” – але з доступом до внутрішніх баз даних.
А от Builder, судячи з документації – це як раз те, що буде цікаво нам, як інженерам, бо саме в цьому плані буде доступ до “over 17 years’ worth of AWS knowledge and experience building in the cloud, including best practices, well-architected patterns“.
Спробуємо потраблшутити з Amazon Q – створимо EC2, зламаємо нетворкінг, і спитаємо Q.
Створюємо інстанс, дозволяємо SSH:
Перевіряємо – доступ є:
$ ssh 16.170.217.131
The authenticity of host '16.170.217.131 (16.170.217.131)' can't be established.
...
Далі, редагуємо SecurityGroup – видаляємо доступ SSH:
І питаємо Amazon Q:
На що він відповідає, що може спробувати проаналізувати проблему з AWS VPC Reachability Analyzer. Спробуємо – переходимо за посиланням, і:
Ну, я все ж очікував, що Q прям зможе проаналузвати конфіг нетворкінгу EC2, і побачить, що там проблема в самій SecurityGroup. Але це я багато, мабуть, хочу)
Втім вже те, що він так інтегрується з системами типу VPC Reachability Analyzer – непогано, і далі, сподіваюсь, буде ще краще.
Проходимо аутентифікацію – клікаємо Use free with AWS Builder ID:
Відкриється вікно в браузері, там підтверджуємо код, і вказуємо свою пошту – можна будь-яку, не обов’язкового ту, яка використовується в AWS акаунті:
На пошту буде відправлено листа з кодом – підтверджуємо, і задаємо собі пароль:
Дозволяємо доступ:
І тепер маємо підключений Amazon Q в нашому VSCode:
Задаємо питання – і він просканує відкритий у редакторі код, і на основі нього видасть відповідь:
Але питання розуміє не завжди коректно: в цілому він має підтримувати контекст розмови і враховувати відкритий код, але ось маємо відкритий код Terraform і пов’язане питання перед цим, а він дає відповідь про AWS Console:
Але якщо трохи перебудувати питання – то він повернув більш валідну відповідь:
На доповіді на самому re:Invent показували демо роботи з IDE, і там був приклад, як Q допомагає створювати код для AWS CDK та Python, і там виглядає прям дуже круто, бо я влітку цього року намучився з ChatGPT, який постійно видавав приклади для старої версії CDK або старих версій бібліотек (див. AWS: CDK – створення EKS з Python та загальні враження від CDK).
Amazon Q Application
Ми також можемо створити Application в AWS Console, і там використати власні дата-сорси, на основі яких Q буде формувати відповіді.
Плюс, там жеж можемо створити і веб-інтерфейс для юзерів:
Створюємо:
Далі налаштовується Retriever – яким чином Q буде отримувати дані з дата-сорса:
І останнім налаштовується вже сам дата-сорс через вибір конекторів – а їх тут дуже багато:
Візьмемо Web crawler:
Тут вже налаштовується і аутентифікація – і саме тут вочевидь будуть перевірятись user permissions – те, про що я вписав на початку, що Amazon Q не дасть інформації юзеру, якщо він не має до неї доступу в самому дата-сорсі:
І решта налаштувань – там і VPC, і IAM, параметри синхронізації тощо:
Робимо синхронізацію з нашим дата-сорсом:
І далі вже можемо відкрити веб-інтерфейс, і початитись з ботом:
А якщо клікнути Deploy web experience – то треба буде налаштовувати SAML:
І воно наче і має працювати без деплою, в Web preview режимі, але в мене воно просто зависало на запиті:
Можливо тому, що не закінчена синхронізація з дата-сорсом, але процес синхронізації з RTFM затягнувся на кілька годин, хоча тут всього-то близько 1500 постів, плюс всякі медіафайли типу скріншотів. Мабуть, вже не дочекаюсь її завершення, але якщо після закінчення синхронізації запрацює – то цю частину тут оновлю.
Висновки та враження від Amazon Q
Я не сказав би, що сервіс прям “producation ready” – але він дійсно ще в Preview, а тому багато чого буде допилюватись/фікситись/апгрейдитись, і тут все ще є над чим попрацювати, бо наразі вона виглядає трохи сирою – Amazon явно поспішали випустити її хоча б в Preview, бо вся ця “гонка Generative AI” і вот ето вот всьо.
Але в цілому система виглядає доволі перспективною, особливо, як вона навчиться толком допомагати траблшутити якісь проблеми в AWS.
Ну і головне – її дійсно можна буде використовувати різними бізнесами, інтегруючи зі своїми системами та базами даних, і не переживати за всякий privacy complience – а з ChartGPT це зараз дуже велика проблема.
Що маємо: є у нас Kubernetes cluster, на якому скейлінгом WorkerNodes займається Karpenter, який для NodePool має параметр disruption.consolidationPolicy=WhenUnderutilized, тобто він буде намагитись “ущільніти” розміщеня подів на нодах так, щоб максимально ефективно використати ресурси CPU та Memory.
В цілому все працює, але це призводить до того, що досить часто перестворюються WorkerNodes, а це викликає “переселення” наших Pods на інші ноди.
Тож задача зараз зробити так, щоб скейлінг і процес consolidation не викликав перебоїв в роботі наших сервісів.
Загалом це тема не стільки про сам Karpenter, скільки про забезпечення стабільності роботи подів в Kubernetes загалом, але зараз я детально зайнявся цим питанням саме через Karpenter, тому будемо трохи говорити і про нього.
Karpenter Disruption Flow
Щоб краще розуміти, що відбувається з нашими подами, давайте коротко глянемо як Karpenter виводить з пулу WorkerNode. Див. Termination Controller.
Після того, як Karpenter виявив, що є ноди, які треба термінейтити, він:
ставить на такую ноду taintkarpenter.sh/disruption:NoSchedule щоб Kubernetes не створював нових подів на цій ноді
при необхідності створює нову ноду, на яку буде переносити поди з ноди, яка буде виведена з роботи (або викорстає ноду яка вже є, якщо вона може прийняти додаткові поди відповідно до їх requests)
після того, як з ноди всі поди окрім DaemonSets видалені, Karpenter видаляє відповідний NodeClaim
видаляє finalizer ноди, що дозволяє Kubernetes виконати видалення цієї ноди
Kubernetes Pod Eviction Flow
І коротко процес того, як сам Kubernetes виконує “виселення” поду:
API Server отримує Eviction request і виконує перевірку – чи можна цей под виселити (наприклад – чи не порушить його видалення обмежень якогось PodDisruptionBudget)
відмічає ресурс цього поду на видалення
kubelet починає процес gracefully shut down – тобто відправляє сигнал SIGTERM
Kubernetes видаляє IP цього поду зі списку ендпоінтів
якщо под не закінчив роботи на протязі заданого – то kubelet відправляє сигнал SIGKILL, щоб вбити процес негайно
kubelet відправляє сигнал API Server, що под можна видаляти зі списку об’єктів
Тож що ми можемо зробити з подами, щоб наш сервіс працював незалежно від роботи Karpenter і взагалі стабільно і “бєз єдіного разрива” (с) ?
мати мінімум по 2 поди на критичних сервісах
мати Pod Topology Spread Constraints, щоб Pods розміщались на різних WorkerNodes – тоді якщо вбивається одна нода з одним подом – інший под на іншій ноді залишиться живим
мати PodDisruptionBudget, щоб мінімум 1 под був завжди живий – це не дасть Karpenter виконати evict всіх подів відразу, бо він слідкує за додтриманням PDB
і щоб гарантовано не дати виконати Pod Eviction – можемо задати поду анотацію karpenter.sh/do-not-disrupt – тоді Karpenter буде ігнорувати таки поди (і, відповідно, ноди, на яких буде запущено такий под)
Kubernetes Deployment replicas
Саме просте і очевидне рішення – це мати як мінімум 2 одночасно працюючих поди.
Хоча це не гарантує, що Kubernetes не виконає їхній eviction одночасно, але це мінімальна умова для подальших дій.
Тож або виконуємо руками kubectl scale deployment --replicas=2, або оновлюємо поле replicas в Deployment/StatefulSets/ReplicaSet (див. Workload Resources):
Більш детально описував у Pod Topology Spread Constraints, але якщо коротко, то ми можемо задати правила розміщення Kubernetes Pod так, щоб вони знаходились на різних WorkerNodes. Таким чином коли Karpenter захоче вивести одну ноду з роботи – то у нас залишиться под на іншій ноді.
Проте ніхто не завадить Karpenter-у виконати drain обох нод відразу, тож і це не є 100% гарантією, але це друга умова для забезпечення стабільності роботи нашого сервісу.
Крім того, з Pod Topology Spread Constraints, ми можемо задати розміщення подів у різних Availabilty Zones, що є фактичного must have опцією при побудові High-Availabiltiy архітектури.
Тож додаємо до нашого деплойменту topologySpreadConstraints:
За допомогою PodDisruptionBudget ми можемо задати правило на мінімальну кількість доступних або максимальну кількість недоступних подів. Значення може бути як у виді числа, так і у виді відсотка від загальної кількості подів в replicas для Deployment/StatefulSets/ReplicaSet.
У випадку з Deployment в якому маємо два поди і який має topologySpreadConstraints по різним WorkerNodes це дасть гарантію того, що Karpenter не виконає Node Drain двох WorkerNdoes одночасно. Натомість він “переселить” спочатку один под, вб’є його ноду, а потім повторить процес для іншої ноди.
$ kk get pdb
NAME MIN AVAILABLE MAX UNAVAILABLE ALLOWED DISRUPTIONS AGE
nginx-demo-pdb 50% N/A 1 21s
Анотація karpenter.sh/do-not-disrupt
Окрім налаштувань на стороні Kubernetes, ми можемо явно задати заборону на видаленя поду самому Karpenter через додавання анотації karpenter.sh/do-not-disrupt (раніше, до Beta, це були анотації karpenter.sh/do-not-evict та karpenter.sh/do-not-consolidate).
Це може знадобитись наприклад для подів, які мають бути запущені в одному екземплярі (як VictoriaMetircs VMSingle instance), і які небажано зупиняти.
Для цього в template цього поду додаємо annotation: