Nexus: запуск в Kubernetes та налаштування PyPi caching repository
0 (0)

11 Грудня 2024

У нас в Kubernetes запускаються GitHub Runner для білда і деплоя нашого Backend API, див. GitHub Actions: запуск Actions Runner Controller в Kubernetes.

Але з часом ми звернули увагу, що на NAT Gateway бігає якось забагато трафіку – див. VictoriaLogs: дашборда в Grafana з AWS VPC Flow Logs – мігруємо з Grafana Loki.

Проблема: трафік на AWS NAT Gateway

Коли почали перевіряти, то виявили цікаву деталь:

Тут через NAT GW пройшло 40.8 гігабайт даних за годину, з них 40.7 – Ingress.

З цих 40 GB в топі три Remote IP, кожен з яких передав нам майже по 10 GB трафіку (табличка зліва внизу на скріні вище).

В топі Remote IP у нас:

Remote IP       Value     Percent
------------------------------
20.60.6.4       10.6 GB	  28%
20.150.90.164   9.79 GB	  26%
20.60.6.100     8.30 GB	  22%
185.199.111.133 2.06 GB	  5%
185.199.108.133 1.89 GB	  5%
185.199.110.133 1.78 GB	  5%
185.199.109.133 1.40 GB	  4%
140.82.114.4    805  MB	  2%
146.75.28.223   705  MB	  2%
54.84.248.61    267  MB	  1%

А в топі по трафіку в Kubernetes – у нас чотири Kubernetes Pods IP:

Source IP        Pod IP      Value	Percent
-----------------------------------------------
20.60.6.4     => 10.0.43.98  1.54 GB	14%
20.60.6.100   => 10.0.43.98  1.49 GB	14%
20.60.6.100   => 10.0.42.194 1.09 GB	10%
20.150.90.164 => 10.0.44.162 1.08 GB	10%
20.60.6.4     => 10.0.44.208 1.03 GB	9%

І всі ці IP належать до подів з GitHub Runners, а “kraken” в імені – це як раз ті раннери для білдів і деплоїв нашого проекту “kraken“, бекенду:

Далі – цікавіше: якщо перевірити IP https://20.60.6.4 – то побачимо цікавий hostname:

*.blob.core.windows.net???

Шта? Дуже здивувався, бо у нас білдиться Python, і ніяких бібліотек від Mifcrosoft нема. Але потім з’явилась ідея: через те, що ми використовуємо кешування PiP і Docker в GitHub Actions для білдів Backend API, то скоріш за все це саме GitHub storage і є, і саме з нього ми ці кеши тягнемо в Kubernetes.

Аналогічна перевірка 185.199.111.133 та 140.82.114.4 нам показує *.github.io, а 54.84.248.61 – це вже athena.us-east-1.amazonaws.com.

Отже, що вирішили зробити – це запустити в Kubernetes локальне кешування з Sonatype Nexus, і його використовувати як проксі для PyPi.org і для Docker Hub images.

Про Docker caching поговоримо наступного разу, а сьогодні:

  • протестуємо Nexus локально з Docker на робочій машині
  • запустимо Nexus в Kubernetes з Helm-чарту
  • налаштуємо і перевіримо роботу PyPi cache для білдів
  • і подивимось на результати

Nexus: тестування локально з Docker

Запускаємо Nexus:

$ docker run -ti --rm --name nexus -p 8081:8081 sonatype/nexus3

Чекаємо кілька хвилин, бо Nexus на Java, тому стартує довго.

Отримуємо пароль адміна:

$ docker exec -ti nexus cat /nexus-data/admin.password
6221ad20-0196-4771-b1c7-43df355c2245

В браузері переходимо на http://localhost:8081, логінимось:

Якщо не зробили в Setup wizard, то заходимо в Security > Anonymous access, дозволяємо підключатись без аутентифікації:

Додавання репозиторію pypi (proxy)

Переходимо в Settings > Repositories, клікаємо Create repository:

Вибираємо тип pypi (proxy):

Створюємо репозиторій:

  • Name: pypi-proxy
  • Remote storage: https://pypi.org
  • Blob store: default

Знизу клікаємо Create repository.

Перевіримо які дані у нас зараз в default Blob storage – заходимо в контейнер Nexus:

$ docker exec -ti nexus bash                          
bash-4.4$

І дивимось каталог /nexus-data/blobs/default/content/ – зараз тут пусто:

bash-4.4$ ls -l /nexus-data/blobs/default/content/
total 8
drwxr-xr-x 3 nexus nexus 4096 Nov 27 11:02 directpath
drwxr-xr-x 2 nexus nexus 4096 Nov 27 11:02 tmp

Перевірка Nexus PyPi cache

Тепер перевіримо чи наш проксі-кеш працює.

Знаходимо IP контейнера з Nexus:

$ docker inspect nexus | jq '.[].NetworkSettings.IPAddress'
"172.17.0.2"

Запускаємо ще один контейнер з Python:

$ docker run -ti --rm python bash
root@addeba5d307c:/# 

І виконуємо pip install --index-url http://172.17.0.2:8081/repository/pypi-proxy/simple setuptools --trusted-host 172.17.0.2

root@addeba5d307c:/# time pip install --index-url http://172.17.0.2:8081/repository/pypi-proxy/simple  setuptools  --trusted-host 172.17.0.2
Looking in indexes: http://172.17.0.2:8081/repository/pypi-proxy/simple
Collecting setuptools
  Downloading http://172.17.0.2:8081/repository/pypi-proxy/packages/setuptools/75.6.0/setuptools-75.6.0-py3-none-any.whl (1.2 MB)
     ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ 1.2/1.2 MB 81.7 MB/s eta 0:00:00
Installing collected packages: setuptools
Successfully installed setuptools-75.6.0
...
real    0m2.595s
...

Бачимо, що виконався Downloading, і це зайняло 2.59 секунди.

Глянемо, що у нас тепер в default Blob storage в Nexus:

bash-4.4$ ls -l /nexus-data/blobs/default/content/
total 20
drwxr-xr-x 3 nexus nexus 4096 Nov 27 11:02 directpath
drwxr-xr-x 2 nexus nexus 4096 Nov 27 11:21 tmp
drwxr-xr-x 3 nexus nexus 4096 Nov 27 11:21 vol-05
drwxr-xr-x 3 nexus nexus 4096 Nov 27 11:21 vol-19
drwxr-xr-x 3 nexus nexus 4096 Nov 27 11:21 vol-33

Вже якісь дані з’явились, ок.

Тестуємо pip ще раз – спочатку видалимо встановлений пакет:

root@addeba5d307c:/# pip uninstall setuptools

І встановлюємо його ще раз, але тепер додаємо --no-cache-dir, аби не використовувати локальний кеш в контейнері:

root@5dc925fe254f:/# time pip install --no-cache-dir --index-url http://172.17.0.2:8081/repository/pypi-proxy/simple setuptools --trusted-host 172.17.0.2
Looking in indexes: http://172.17.0.2:8081/repository/pypi-proxy/simple
Collecting setuptools
  Downloading http://172.17.0.2:8081/repository/pypi-proxy/packages/setuptools/75.6.0/setuptools-75.6.0-py3-none-any.whl (1.2 MB)
     ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ 1.2/1.2 MB 942.9 MB/s eta 0:00:00
Installing collected packages: setuptools
Successfully installed setuptools-75.6.0
...
real    0m1.589s

Тепер часу зайняло 1.52 секунди замість 2.59.

Окей – наче все працює?

Давайте запустимо Nexus в Kubernetes.

Запуск Nexus в Kubernetes

Є такий чарт – stevehipwell/nexus3.

Можна написати маніфести самому, можна спробувати цей чарт.

Що нам може бути цікаво з вальюсів чарту:

  • config.anonymous.enabled: працювати Nexus буде локально в Kubernetes з доступом тільки по ClusterIP, тому поки це в PoC і чисто для кешу PiP – можна без аутентифікації
  • config.blobStores: поки можна залишити як є, але пізніше, можливо, підключити окремий EBS або AWS Elastic File System, див. також persistence.enabled
  • config.job.tolerations та nodeSelector: якщо треба ранити на окремій ноді, див. Kubernetes: Pods та WorkerNodes – контроль розміщення подів на нодах
  • config.repos: відразу через values створити репозиторії
  • ingress.enabled: не наш кейс, але можливість є
  • metrics.enabled: потім можна буде подивитись на моніторинг

Спочатку давайте встановимо з дефолтними параметрами, потім накидаємо власні values.

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

$ helm repo add stevehipwell https://stevehipwell.github.io/helm-charts/
"stevehipwell" has been added to your repositories

Створюємо окремий неймспейс ops-nexus-ns:

$ kk create ns ops-nexus-ns
namespace/ops-nexus-ns created

Встановлюємо чарт:

$ helm -n ops-nexus-ns upgrade --install nexus3 stevehipwell/nexus3

Запускався він хвилин 5 – я вже думав дропати чарт, і писати самому, але врешті-решт таки стартанув – Java, шо поробиш.

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

$ kk -n ops-nexus-ns get all
NAME           READY   STATUS    RESTARTS   AGE
pod/nexus3-0   4/4     Running   0          6m5s

NAME                TYPE        CLUSTER-IP       EXTERNAL-IP   PORT(S)    AGE
service/nexus3      ClusterIP   172.20.160.147   <none>        8081/TCP   6m5s
service/nexus3-hl   ClusterIP   None             <none>        8081/TCP   6m5s

NAME                      READY   AGE
statefulset.apps/nexus3   1/1     6m6s

Додавання Admin user password

Створимо Kubernetes Secret з паролем:

$ kk -n ops-nexus-ns create secret generic nexus-root-pass --from-literal=password=p@ssw0rd
secret/nexus-root-pass created

Пишемо файл nexus-values.yaml, в якому задаємо ім’я Kubernetes Secret і ключ з паролем, заодно включаємо Anonymous Access:

rootPassword:
  secret: nexus-root-password
  key: password

  config:  
    enabled: true
    anonymous:
      enabled: true

Додавання репозиторію в Nexus через Helm chart values

Тут трохи довелось робити “методом тика”, але завелось.

Отже, в values.yaml чарту сказано: “Repository configuration; based on the REST API (API reference docs require an existing Nexus installation and can be found at **Administration** under _System_ → _API_) but with `format` & `type` defined in the object.

Подивимось специфікацію Nexus API – які поля передаються в API request:

А що по формату?

Поля Format і Type можемо глянути в якомусь існуючому репозиторії:

Описуємо репозиторій і інші потрібні параметри – в мене все раз наразі виглядає так:

rootPassword:
  secret: nexus-root-password
  key: password
  
persistence:
  enabled: true
  storageClass: gp2-retain

resources:
  requests:
    cpu: 1000m
    memory: 1500Mi  

config:  
  enabled: true
  anonymous:
    enabled: true
  repos:
    - name: pip-cache
      format: pypi
      type: proxy
      online: true
      negativeCache:
        enabled: true
        timeToLive: 1440
      proxy:
        remoteUrl: https://pypi.org
        metadataMaxAge: 1440
        contentMaxAge: 1440
      httpClient:
        blocked: false
        autoBlock: true
        connection: 
        retries: 0
        useTrustStore: false
      storage:
        blobStoreName: default
        strictContentTypeValidation: false

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

Деплоїмо:

$ helm -n ops-nexus-ns upgrade --install nexus3 stevehipwell/nexus3 -f nexus-values.yml

У випадку помилок типу “Could not create repository“:

$ kk -n ops-nexus-ns logs -f nexus3-config-9-2cssf
Configuring Nexus3...
Configuring anonymous access...
Anonymous access configured.
Configuring blob stores...
Configuring scripts...
Script 'cleanup' updated.
Script 'task' updated.
Configuring cleanup policies...
Configuring repositories...
ERROR: Could not create repository 'pip-cache'.

Перевіряємо логи – Nexus хоче передачу майже всіх полів, в данному випадку не вистачало config.repos.httpClient.contentMaxAge:

nexus3-0:nexus3 2024-11-27 12:34:16,818+0000 WARN  [qtp554755438-84] admin org.sonatype.nexus.siesta.internal.resteasy.ResteasyViolationExceptionMapper - (ID af473d22-3eca-49ea-adb9-c7985add27e7) Response: [400] '[ValidationErrorXO{id='PARAMETER strictContentTypeValidation', message='must not be null'}, ValidationErrorXO{id='PARAMETER negativeCache', message='must not be null'}, ValidationErrorXO{id='PARAMETER metadataMaxAge', message='must not be null'}, ValidationErrorXO{id='PARAMETER contentMaxAge'[]ust not be null]arg0.httpClient]ntMaxAge]]TypeValidation]TER httpClient', message='must not be null'}]'; mapped from: [PARAMETER]

Під часу деплою, коли ми задаємо параметр config.enabled=true, чарт запускає ще один Kubernetes Pod, який власне виконує конфігурацію Nexus.

Перевіримо доступ і репозиторій – відкриваємо собі доступ:

$ kk -n ops-nexus-ns port-forward pod/nexus3-0 8082:8081
Forwarding from 127.0.0.1:8082 -> 8081
Forwarding from [::1]:8082 -> 8081

Заходимо на http://localhost:8082/#admin/repository/repositories:

Ресурсів, особливо Memory, Nexus хоче багато, бо знов-таки – Java:

Тому є сенс в values відразу виставити requests.

Перевірка Nexus в Kubernetes

Запускаємо Pod з Python:

$ kk run pod --rm -i --tty --image python bash
If you don't see a command prompt, try pressing enter.
root@pod:/# 

Знаходимо Kubernetes Service для Nexus:

$ kk -n ops-nexus-ns get svc
NAME        TYPE        CLUSTER-IP       EXTERNAL-IP   PORT(S)    AGE
nexus3      ClusterIP   172.20.160.147   <none>        8081/TCP   78m
nexus3-hl   ClusterIP   None             <none>        8081/TCP   78m

Знов запускаємо pip install:

root@pod:/# time pip install --index-url http://nexus3.ops-nexus-ns.svc:8081/repository/pip-cache/simple setuptools --trusted-host nexus3.ops-nexus-ns.svc
Looking in indexes: http://nexus3.ops-nexus-ns.svc:8081/repository/pip-cache/simple
Collecting setuptools
  Downloading http://nexus3.ops-nexus-ns.svc:8081/repository/pip-cache/packages/setuptools/75.6.0/setuptools-75.6.0-py3-none-any.whl (1.2 MB)
     ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ 1.2/1.2 MB 86.3 MB/s eta 0:00:00
Installing collected packages: setuptools
Successfully installed setuptools-75.6.0
...

real    0m3.958s

Встановило setuptools-75.6.0 за 3.95 секунди.

Перевіримо в http://localhost:8082/#browse/browse:pip-cache:

Видаляємо setuptools з нашого поду:

root@pod:/# pip uninstall setuptools

І встановлюємо ще раз, знов з --no-cache-dir:

root@pod:/# time pip install --no-cache-dir --index-url http://nexus3.ops-nexus-ns.svc:8081/repository/pip-cache/simple setuptools --trusted-host nexus3.ops-nexus-ns.svc
Looking in indexes: http://nexus3.ops-nexus-ns.svc:8081/repository/pip-cache/simple
Collecting setuptools
  Downloading http://nexus3.ops-nexus-ns.svc:8081/repository/pip-cache/packages/setuptools/75.6.0/setuptools-75.6.0-py3-none-any.whl (1.2 MB)
     ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ 1.2/1.2 MB 875.9 MB/s eta 0:00:00
Installing collected packages: setuptools
Successfully installed setuptools-75.6.0
..

real    0m2.364s

Тепер це зайняло 2.364s.

Залишилось оновити GitHub Workflows – відключити там всякі кеші, і додати використання Nexus.

GitHub та результати по AWS NAT Gateway трафіку

На Workflow детально зупинятись не буду, бо це у кожного своє, але якщо кратко, то відключаємо кешування PiP:

...
    - name: "Setup: Python 3.10"
      uses: actions/setup-python@v5
      with:
        python-version: "3.10"
        # cache: 'pip'
        check-latest: "false"
        # cache-dependency-path: "**/*requirements.txt"
...

Це збереже близько 540 мегабайт на завантаженні архіву з кешем.

Далі у нас є step, який виконує pip install через виклик make:

...
    - name: "Setup: Dev Dependencies"
      id: setup_dev_dependencies
      #run: make dev-python-requirements
      run: make dev-python-requirements-nexus
      shell: bash
...

А в Makefile я зробив нову таску, аби можна було швидко повернути на старий конфіг:

...
dev-python-requirements:
  python3 -m pip install --no-compile -r dev-requirements.txt

dev-python-requirements-nexus:
  python3 -m pip install --index-url http://nexus3.ops-nexus-ns.svc:8081/repository/pip-cache/simple --no-compile -r dev-requirements.txt --trusted-host nexus3.ops-nexus-ns.svc
...

У Workflow відключаємо всякі кеши типу actions/cache:

..
    # - name: "Setup: Get cached api-generator images"
    #   id: api-generator-cache
    #   uses: actions/cache@v4
    #   with:
    #     path: ~/_work/api-generator-cache
    #     key: api-generator-cache
...

Ну і порівняємо результати.

Білд зі старим конфігом, без Nexus і з кешами GitHub – трафік Kubernetes Pod раннера, який цей білд виконував:

3.55 гігабайт  трафіку, білд-деплой зайняли 4 хвилини 11 секунд часу.

І ця сама GitHub Actions джоба, але вже зі змерженими змінами і використанням Nexus і без GitHub caching.

В логах бачимо, що пакети дійсно беруться з Nexus:

Трафік:

329 мегабайт, білд-деплой зайняли 4 хвилини 20 секунд часу.

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

Що буде зробити далі – це подивитись як Nexus можна моніторити, які в нього є метрики і які з них можна зробити алерти, і далі додати ще Docker кеш, бо доволі часто стикаємось з лімітами Docker Hub – “429 Too Many Requests – Server message: toomanyrequests: You have reached your pull rate limit. You may increase the limit by authenticating and upgrading“.

Loading

VictoriaLogs: дашборда в Grafana з AWS VPC Flow Logs – мігруємо з Grafana Loki
0 (0)

4 Грудня 2024

В попередньому пості – AWS: VPC Flow Logs – логи до S3 та Grafana dashboard з Loki ми створили дашборду в Grafana, яка відображає статистику використання NAT Gateway.

Що саме нас там цікавило – це які Kubernetes Pods використовують найбільше байт, бо це напряму впливає на наші AWS Costs.

І все наче добре з цією бордою, окрім одного – Loki не здатна обробити “сирі” логи і побудувати графіки більше ніж за 30 хвилин, максимум – 1 година, і то вже частина візуалізацій не прогружаються, хоча я намагався її затюнити – див. Grafana Loki: оптимізація роботи – Recording Rules, кешування та паралельні запити.

Тому я вирішив спробувати такий же підхід – з S3 для VPC Flow Logs, Lambda та Promtail – але вже з VictoriaLogs, тим більш з версії 0.8.0 VictoriaLogs Grafana data source вже завели кращу підтримку запитів, і тепер можна будувати візуалізації без Grafana Transformations.

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

  • швиденько покажу Terraform код, який створює S3 для VPC Flow Logs і AWS Lambda з Promtail, який шле дані до VictoriaLogs
  • створимо нову Grafana dashboard з VictoriaLogs datasource, і перенесемо запити з Loki та її LogQL до VictoriaLogs та LogsQL

Нагадаю з попереднього поста що ми маємо в нашому сетапі:

  • знаємо CIDR приватних сабнетів для Kubernetes Pods
    • у нас використовується тільки одна мережа в us-east-1a AvailabilityZone – 10.0.32.0/20
  • знаємо Elastic Network Interface ID нашого NAT Gateway – він у нас один, тому тут все просто
  • в логах маємо поля pkt_src_addr та pkt_dst_addr, по яким можемо вибирати трафік тільки з/до Kubernetes Pods

Також варто глянути інші пости по цій темі:

Terraform

S3 та Promtail Lambda

Детально тут розписувати не буду, бо в коді наче достатньо коментарів, які описують кожен ресурс. Просто приклад того, як таке можна зробити. Крім того, першу версію модуля описував в Terraform: створення модулю для збору логів AWS ALB в Grafana Loki, але тут трохи перероблений варіант аби мати можливість налаштування і Loki і VictoriaLogs, і не тільки логи ALB, але і VPC Flow Logs.

Отже, як це реалізував я:

  • репозиторій atlas-tf-modules: модулі Terraform, в якому є код для створення S3 бакетів, Lambda, нотифікацій і пермішенів
  • репозиторій atlas-monitoring: код Terraform та Helm-чарт нашого моніторинга, де створюються необхідні ресурси – RDS, різні додаткові S3-бакети, сертифікати AWS ACM, та викликається модуль з atlas-tf-modules/alb-s3-logs для налаштування збору логів з S3 бакетів

Почнемо з самого модуля alb-s3-logs для S3 та Lambda. Про Terraform та модулі писав в Terraform: модулі, Outputs та Variables.

Структура файлів в alb-s3-logs:

$ tree alb-s3-logs/
alb-s3-logs/
|-- README.md
|-- lambda.tf
|-- outputs.tf
|-- s3.tf
`-- variables.tf

Створення S3 buckets

Файл s3.tf – створення бакетів:

# define S3 bucket names from parameteres passed from a calling/root module in the 'atlas-monitoring' repository
locals {

  # ops-1-30-devops-ingress-ops-alb-loki-logs
  # "ops"     "1-30"        "devops"    "ingress"     "ops"     "alb"         "loki"       "logs"
  # <aws_env>-<eks_version>-<component>-<application>-<app_env>-<aws_service>-<logger_type>-logs

  # ops-1-30-devops-vpc-ops-flow-loki-logs
  # "ops"     "1-30"        "devops"    "vpc"         "ops"     "flow"        "loki"       "logs"
  # <aws_env>-<eks_version>-<component>-<application>-<app_env>-<aws_service>-<logger_type>-logs
  logs_bucket_names = { for env in var.app_environments : env => "${var.aws_env}-${var.eks_version}-${var.component}-${var.application}-${env}-${var.aws_service}-${var.logger_type}-logs" }
}

resource "aws_s3_bucket" "s3_logs" {
  for_each = local.logs_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 = true
}

# remove logs older than 30 days
resource "aws_s3_bucket_lifecycle_configuration" "bucket_config" {
  for_each = aws_s3_bucket.s3_logs

  bucket = each.value.id

  rule {
    id     = "logs"
    status = "Enabled"

    expiration {
      days = 30
    }
  }
}

# block S3 bucket public access
resource "aws_s3_bucket_public_access_block" "s3_logs_backend_acl" {
  for_each = aws_s3_bucket.s3_logs

  bucket = each.value.id

  block_public_acls       = true
  block_public_policy     = true
  ignore_public_acls      = true
  restrict_public_buckets = true
}

# using the 'var.aws_service == "alb"', attach the S3 bucket Policy to buckets for ALB Logs only
resource "aws_s3_bucket_policy" "s3_logs_alb" {
  for_each = {
    for key, bucket_name in aws_s3_bucket.s3_logs :
    key => bucket_name if var.aws_service == "alb"
  }

  bucket = each.value.id

  policy = jsonencode({
    Version = "2012-10-17"
    Statement = [
      {
        Sid = "RegionELBLogsWrite"
        Effect = "Allow"
        Principal = {
          AWS = "arn:aws:iam::${var.elb_account_id}:root"
        }
        Action = "s3:PutObject"
        Resource = "arn:aws:s3:::${each.value.id}/AWSLogs/${var.aws_account_id}/*"
      },
      {
        Sid = "PromtailLambdaLogsGet"
        Effect = "Allow"
        Principal = {
          AWS = module.logs_promtail_lambda[each.key].lambda_role_arn
        }
        Action = "s3:GetObject"
        Resource = "arn:aws:s3:::${each.value.id}/*"
      }
    ]
  })
}

# using the 'var.aws_service == "flow"', attach attach the S3 bucket Policy to buckets for VPC Flow Logs only
resource "aws_s3_bucket_policy" "s3_logs_flow" {
  for_each = {
    for key, bucket_name in aws_s3_bucket.s3_logs :
    key => bucket_name if var.aws_service == "flow"
  }

  bucket = each.value.id

  policy = jsonencode({
    Version = "2012-10-17"
    Statement = [
      {
        Sid = "VPCFlowLogsDeliveryWrite",
        Effect = "Allow",
        Principal = {
          Service = "delivery.logs.amazonaws.com"
        },
        Action = "s3:PutObject",
        Resource = "arn:aws:s3:::${each.value.id}/AWSLogs/${var.aws_account_id}/*",
        Condition = {
          StringEquals = {
            "aws:SourceAccount": "${var.aws_account_id}",
            "s3:x-amz-acl": "bucket-owner-full-control"
          },
          ArnLike = {
            "aws:SourceArn": "arn:aws:logs:us-east-1:${var.aws_account_id}:*"
          }
        }
      },
      {
        Sid = "VPCFlowLogsAclCheck",
        Effect = "Allow",
        Principal = {
          Service = "delivery.logs.amazonaws.com"
        },
        Action = "s3:GetBucketAcl",
        Resource = "arn:aws:s3:::${each.value.id}",
        Condition = {
          StringEquals = {
            "aws:SourceAccount": "${var.aws_account_id}"
          },
          ArnLike = {
            "aws:SourceArn": "arn:aws:logs:us-east-1:${var.aws_account_id}:*"
          }
        }
      },
      {
        Sid = "PromtailLambdaLogsGet"
        Effect = "Allow"
        Principal = {
          AWS = module.logs_promtail_lambda[each.key].lambda_role_arn
        }
        Action = "s3:GetObject"
        Resource = "arn:aws:s3:::${each.value.id}/*"
      }      
    ]
  })
}

# send notifications to a Lambda function with Promtail when a new object is created in the S3 bucket
resource "aws_s3_bucket_notification" "s3_logs_lambda_notification" {
  for_each = aws_s3_bucket.s3_logs

  bucket = each.value.id

  lambda_function {
    lambda_function_arn = module.logs_promtail_lambda[each.key].lambda_function_arn
    events              = ["s3:ObjectCreated:*"]
    filter_prefix       = "AWSLogs/${var.aws_account_id}/"
  }
}

Створення Lambda функцій з Promtail

Файл lambda.tf:

# to allow network connections from S3 buckets IP range
data "aws_prefix_list" "s3" {
  filter {
    name   = "prefix-list-name"
    values = ["com.amazonaws.us-east-1.s3"]
  }
}

# allow connections from S3 and from/to VPC Private Subnets to access Loki and VictoriaLogs
module "logs_security_group_lambda" {
  source  = "terraform-aws-modules/security-group/aws"
  version = "~> 5.2.0"

  # 'ops-1-30-loki-lambda-sg'
  name        = "${var.aws_env}-${var.eks_version}-lambda-${var.logger_type}-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"]
}

# S3 buckets names:

  # ops-1-30-devops-ingress-ops-alb-loki-logs
  # "ops"     "1-30"        "devops"    "ingress"     "ops"     "alb"         "loki"       "logs"
  # <aws_env>-<eks_version>-<component>-<application>-<app_env>-<aws_service>-<logger_type>-logs

  # ops-1-30-devops-vpc-ops-flow-loki-logs
  # "ops"     "1-30"        "devops"    "vpc"         "ops"     "flow"        "loki"       "logs"
  # <aws_env>-<eks_version>-<component>-<application>-<app_env>-<aws_service>-<logger_type>-logs

module "logs_promtail_lambda" {
  source  = "terraform-aws-modules/lambda/aws"
  version = "~> 7.16.0"
  # key: 'ops'
  # value:  'ops-1-30-devops-vpc-ops-flow-loki-logs'
  for_each = aws_s3_bucket.s3_logs

  # build Lambda function name like 'ops-1-30-devops-vpc-ops-flow-loki-logs-logger'
  function_name = "${each.value.id}-${var.logger_type}-logger"
  description   = "Promtail instance to collect logs from S3"

  create_package = false
  # https://github.com/terraform-aws-modules/terraform-aws-lambda/issues/36
  publish = true

  # an error when sending logs from Flow Logs S3:
  # 'Task timed out after 3.05 seconds'
  timeout = 60

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

  # component=devops, logtype=alb, environment=ops, logger_type=loki
  # component=devops, logtype=flow, environment=ops, logger_type=loki
  environment_variables = {
    EXTRA_LABELS             = "component,${var.component},logtype,${var.aws_service},environment,${each.key},logger_type,${var.logger_type}"
    KEEP_STREAM              = "true"
    OMIT_EXTRA_LABELS_PREFIX = "true"
    PRINT_LOG_LINE           = "true"
    WRITE_ADDRESS            = var.logger_write_address
  }

  vpc_subnet_ids         = var.vpc_private_subnets_ids
  vpc_security_group_ids = [module.logs_security_group_lambda.security_group_id]
  attach_network_policy  = true

  # writing too many logs
  # see in CloudWatch Metrics by the 'IncomingBytes' metric
  # to save CloudWatch Logs costs, decrease the logs number
  # set to 'INFO' for debugging
  logging_application_log_level = "FATAL"
  logging_system_log_level = "WARN"
  logging_log_format = "JSON"

  # allow calling the Lambda from an S3 bucket
  # bucket name: ops-1-28-backend-api-dev-alb-logs
  allowed_triggers = {
    S3 = {
      principal  = "s3.amazonaws.com"
      source_arn = "arn:aws:s3:::${each.value.id}"
    }
  }
}

Виклик модуля atlas-tf-modules з коду моніторинга

Далі описуємо ресурси в коді Terraform в репозиторії atlas-monitoring – файл logs.tf.

Тут створюється три модулі:

  • Load Balancers logs в Loki
  • VPC Flow Logs в Loki
  • VPC Flow Logs в VictoriaLogs
/*

Collect ALB Logs to Loki module

S3:

- will create an aws_s3_bucket for each app_environments[]:
  # bucket names:
  # '<eks_env>-<eks_version>-<component>-<application>-<app_env>-alb-logs'
  # i.e:
  # 'ops-1-28-backend-api-dev-alb-logs'
- will create an aws_s3_bucket_policy with Allow for each Lambda
- will create an aws_s3_bucket_notification with Push event on each s3:ObjectCreated to each Lambda

Lambda:

- will create a security_group_lambda with Allow 443 from VPC CIDR
- will create a Lambda with Promtail for each aws_s3_bucket

*/

module "vpc_flow_logs_loki" {
  # create the module for each EKS cluster by its version
  # for_each = var.eks_versions
  for_each = toset(["1-30"])
  source = "[email protected]:ORG-NAME/atlas-tf-modules//alb-s3-logs?ref=master"
  # for local development
  # source = "/home/setevoy/Work/atlas-tf-modules//alb-s3-logs"

  # ops-1-30-devops-ingress-ops-alb-loki-logs
  # "ops"     "1-30"        "devops"    "ingress"     "ops"     "alb"         "loki"       "logs"
  # <aws_env>-<eks_version>-<component>-<application>-<app_env>-<aws_service>-<logger_type>-logs

  # ops-1-30-devops-vpc-ops-flow-loki-logs
  # "ops"     "1-30"        "devops"    "vpc"         "ops"     "flow"        "loki"       "logs"
  # <aws_env>-<eks_version>-<component>-<application>-<app_env>-<aws_service>-<logger_type>-logs

  # 'ops'
  aws_env          = var.aws_environment
  # '1-30'
  eks_version = each.value
  # by team: 'backend', 'devops'
  component        = "devops"
  application      = "vpc"
  app_environments = ["ops"]
  aws_service = "flow"
  logger_type = "loki"

  vpc_id                    = local.vpc_out.vpc_id
  vpc_private_subnets_cidrs = local.vpc_out.vpc_private_subnets_cidrs
  vpc_private_subnets_ids   = local.vpc_out.vpc_private_subnets_ids
  # 'https://loki.monitoring.1-30.ops.example.co:443/loki/api/v1/push'
  logger_write_address        = "https://loki.monitoring.${each.value}.ops.example.com:443/loki/api/v1/push"
  aws_account_id            = data.aws_caller_identity.current.account_id
}

module "vpc_flow_logs_vmlogs" {
  # create the module for each EKS cluster by its version
  # for_each = var.eks_versions
  for_each = toset(["1-30"])
  source = "[email protected]:ORG-NAME/atlas-tf-modules//alb-s3-logs?ref=master"
  # for local development
  # source = "/home/setevoy/Work/atlas-tf-modules//alb-s3-logs"

  # ops-1-30-devops-ingress-ops-alb-loki-logs
  # "ops"     "1-30"        "devops"    "ingress"     "ops"     "alb"         "loki"       "logs"
  # <aws_env>-<eks_version>-<component>-<application>-<app_env>-<aws_service>-<logger_type>-logs

  # ops-1-30-devops-vpc-ops-flow-loki-logs
  # "ops"     "1-30"        "devops"    "vpc"         "ops"     "flow"        "loki"       "logs"
  # <aws_env>-<eks_version>-<component>-<application>-<app_env>-<aws_service>-<logger_type>-logs

  # 'ops'
  aws_env          = var.aws_environment
  # '1-30'
  eks_version = each.value
  # by team: 'backend', 'devops'
  component        = "devops"
  application      = "vpc"
  app_environments = ["ops"]
  aws_service = "flow"
  logger_type = "vmlogs"

  vpc_id                    = local.vpc_out.vpc_id
  vpc_private_subnets_cidrs = local.vpc_out.vpc_private_subnets_cidrs
  vpc_private_subnets_ids   = local.vpc_out.vpc_private_subnets_ids
  # create log streams by the 'logtype,environment,logger_type' fields
  # see https://docs.victoriametrics.com/victorialogs/keyconcepts/#stream-fields
  logger_write_address        = "https://vmlogs.monitoring.${each.value}.ops.example.com:443/insert/loki/api/v1/push?_stream_fields=logtype,environment,logger_type"
  aws_account_id            = data.aws_caller_identity.current.account_id
}

# ../../atlas-load-balancers/helm/templates/external-ingress-alb.yaml:    
# alb.ingress.kubernetes.io/load-balancer-attributes: access_logs.s3.enabled=true,access_logs.s3.bucket=ops-1-30-devops-ingress-ops-alb-logs
# two ALB are using this buckets for their logs - the External, 'ops-external-ingress', and the Internal one, 'ops-internal-ingress'
# both are in the 'ops-common-alb-ns' Namespace
module "single_ingress_alb_logs_loki" {
  # create the module for each EKS cluster by its version
  # for_each = var.eks_versions
  for_each = toset(["1-30"])
  source = "[email protected]:ORG-NAME/atlas-tf-modules//alb-s3-logs?ref=master"
  # for local development
  # source = "/home/setevoy/Work/atlas-tf-modules//alb-s3-logs"

  # ops-1-30-devops-ingress-ops-alb-loki-logs
  # "ops"     "1-30"        "devops"    "ingress"     "ops"     "alb"         "loki"       "logs"
  # <aws_env>-<eks_version>-<component>-<application>-<app_env>-<aws_service>-<logger_type>-logs

  # ops-1-30-devops-vpc-ops-flow-loki-logs
  # "ops"     "1-30"        "devops"    "vpc"         "ops"     "flow"        "loki"       "logs"
  # <aws_env>-<eks_version>-<component>-<application>-<app_env>-<aws_service>-<logger_type>-logs

  # 'ops'
  aws_env          = var.aws_environment
  # '1-30'
  eks_version = each.value
  component        = "devops"
  application      = "ingress"
  app_environments = ["ops"]
  aws_service = "alb"
  logger_type = "loki"

  vpc_id                    = local.vpc_out.vpc_id
  vpc_private_subnets_cidrs = local.vpc_out.vpc_private_subnets_cidrs
  vpc_private_subnets_ids   = local.vpc_out.vpc_private_subnets_ids
  # 'https://loki.monitoring.1-30.ops.example.co:443/loki/api/v1/push'
  logger_write_address        = "https://loki.monitoring.${each.value}.ops.example.com:443/loki/api/v1/push"
  aws_account_id            = data.aws_caller_identity.current.account_id
}

З цим наче все.

Модуль VPC та Flow Logs

В модулі terraform-aws-modules/vpc/aws є підтримка Flow Logs, але там можна задати тільки один flow_log_destination_arn, в якому в мене зараз Grafana Loki – S3-бакет ops-1-30-devops-vpc-ops-flow-loki-logs:

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

  ...

  enable_flow_log = var.vpc_params.enable_flow_log

  # Default: "cloud-watch-logs"
  flow_log_destination_type = "s3"

  # disalbe to use S3
  create_flow_log_cloudwatch_log_group = false
  create_flow_log_cloudwatch_iam_role  = false

  # ARN of the CloudWatch log group or S3 bucket
  # disable if use 'create_flow_log_cloudwatch_log_group' and the default 'flow_log_destination_type' value (cloud-watch-logs)
  flow_log_destination_arn = "arn:aws:s3:::ops-1-30-devops-vpc-ops-flow-loki-logs"

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

  vpc_flow_log_tags = {
    "Name" = "flow-logs-s3-to-loki"
  }
}

Аби писати відразу в два S3 бакета – просто додаємо ресурс aws_flow_log.

VPC Flow Logs пишуться з custom format:

resource "aws_flow_log" "vpc_flow_vmlogs" {
  vpc_id               = module.vpc.vpc_id
  log_destination      = "arn:aws:s3:::ops-1-30-devops-vpc-ops-flow-vmlogs-logs"
  log_destination_type = "s3"
  traffic_type         = "ALL"
  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}"
  tags = {
    "Name" = "flow-logs-s3-to-vmlogs"
  }
}

Крім того, я ще вручну створив Flow Logs з destination в CloudWatch Logs аби перевіряти дані в Loki та VictoriaLogs.

Створення Grafana dashboard

NAT Gateway Total processed

Першим у нас йде відображення загальної статистики по тому, скільки через NAT Gateway пройшло трафіку – і від Kubernetes Pods в інтернет, і з інтернету до Kubernetes Pods.

Запит в Loki

В Loki запит виглядає так:

sum (
    sum_over_time(
        ({logtype="flow", logger_type="loki"} | pattern `<region> <vpc_id> <az_id> <subnet_id> <instance_id> <interface_id> <flow_direction> <src_add> <dst_addr> <src_port> <dst_port> <pkt_src_addr> <pkt_dst_addr> <pkt_src_aws_service> <pkt_dst_aws_service> <traffic_path> <packets> <bytes> <action>`
        | interface_id="eni-0352f8c82da6aa229"
        | pkt_src_addr=ip("10.0.32.0/20") OR pkt_dst_addr=ip("10.0.32.0/20")
        | pkt_src_addr=~"${kubernetes_pod_ip}"
        | pkt_dst_addr=~"${remote_svc_ip}"
        | unwrap bytes
        | __error__=""
        )[$__range]
    )
)

Тут:

  • рахуємо sum_over_time() за період, вибраний в Grafana dashboard – $__range
  • рахуємо трафік або від Kubernetes Pods – pkt_src_addr=ip("10.0.32.0/20"), або навпаки до Kubernetes Pods – pkt_dst_addr=ip("10.0.32.0/20")
  • рахуємо по полю bytesunwrap bytes

З таким запитом маємо такі дані:

kubernetes_pod_ip та remote_svc_ip – змінні в Grafana dashboard аби мати можливість перевірки даних по конкретним адресам:

Запит з VictoriaLogs

Тепер нам треба перевести цей запит в формат LogsQL для VictoriaLogs.

Виглядати він буде так:

_time:$__range {logtype=flow, environment=ops, logger_type=vmlogs} seq("eni-0352f8c82da6aa229", "ACCEPT")
| extract "<region> <vpc_id> <az_id> <subnet_id> <instance_id> <interface_id> <flow_direction> <src_addr> <dst_addr> <src_port> <dst_port> <pkt_src_addr> <pkt_dst_addr> <pkt_src_aws_service> <pkt_dst_aws_service> <traffic_path> <packets> <bytes> <action>"
  | filter
      interface_id:="eni-0352f8c82da6aa229"
      action:="ACCEPT"
      (pkt_src_addr:ipv4_range("10.0.32.0/20") OR pkt_dst_addr:ipv4_range("10.0.32.0/20"))
      pkt_src_addr:~"${kubernetes_pod_ip}"
      pkt_dst_addr:~"${remote_svc_ip}"   
  | stats sum(bytes) bytes_total

Підтримку змінної Grafana $__range завезли тільки вчора в версії датасорсу 0.9.0, тому оновіться.

Тут ми:

  • вибираємо дані за _time:$__range
  • в {logtype=flow, environment=ops, logger_type=vmlogs} використовуємо log stream selector з лейблами, які задаються в Lambda Promtail під час запису логів – /insert/loki/api/v1/push?_stream_fields=logtype,environment,logger_type
  • seq("eni-0352f8c82da6aa229", "ACCEPT") – використовуємо Sequence filter – вибираємо тільки записи з інтерфейсу NAT Gateway і ACCEPT аби пришвидшити виконання запиту (див. коментар від Olexandr Valialkin тут>>>)
  • з extract формуємо поля із записів в логу
  • з filter вибираємо інтерфейсу NAT Gateway, ACCEPT, і як і в запиті Loki – фільтруємо трафік або від Kubernetes Pods з IPv4 range filterpkt_src_addr:ipv4_range("10.0.32.0/20"), або навпаки до Kubernetes Pods – pkt_dst_addr:ipv4_range("10.0.32.0/20") (зверніть увагу, що умови OR заключені в дужки)
  • і в кінці з stats рахуємо суму по полю bytes, а результат пишемо в поле bytes_total

Перевірка з CloudWatch Logs Insights

Аби мати можливість перевірити дані в Loki і VictoriaLogs, VPC зараз пише ще й в CloudWatch Logs.

Зробимо такий запит в 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 ((isIpv4InSubnet(pkt_srcaddr,"10.0.32.0/20") OR isIpv4InSubnet(pkt_dstaddr,"10.0.32.0/20") )) | stats sum(bytes) as bytes_total

В результаті маємо 8547192734 байт:

Що в форматі SI (див. Binary prefixes) дає нам 1.87 гігабайт – рахуємо з калькулятором:

$ bc
scale=2
8547192734/1000/1000/1000
8.54

В Loki у нас було 7.56 GiB, в VictoriaLogs – 8.66 GiB.

Інколи ті ж самі дані між Loki, VictoriaLogs та CloudWatch можуть відрізнятись, тим більш при виборках всього за 30 хвилин, бо самі Flow Logs пишуться з різницею в кілька хвилин.

Наприклад, в бакеті Loki останній об’єкт створено в 13:06:50:

А в VMLogs – в 13:05:29:

Перевірка з Cost Explorer

Ще можна перевірити дані в Cost Explorer.

Вибираємо Service == EC2-Other, Usage type == NatGateway-Bytes (GB):

За минулу добу маємо 129 гігабайт трафіку через NAT Gateway.

Якщо ми в Grafana (нарешті ми це можемо зробити, бо є VictoriaLogs) зробимо range в 24 години – то побачимо в “NAT Gateway Total processed” 135 гігабайт:

Плюс-мінус сходиться, бо Cost Explorer рахує не останні 24 години, як в Grafana, а за попередню добу, крім того, там використовується UTC (+00:00) time zone.

NAT Gateway Total OUT та IN processed

Далі, хочеться бачити розподілення трафіку – від Kubernetes Pods в інтернет, та з інтернету до Kubernetes Pods.

Згадаємо, що ми маємо в записах для пакетів, які проходять через NAT Gateway – розбирали в Трафік з Pod до External Server через NAT Gateway, та VPC Flow Logs:

  • по полю interface_id фільтруємо тільки ті записи, які були зроблені з інтерфейсу NAT Gateway
  • якщо пакет йде від Kubernetes Pod в інтернет – то в полі pkt_src_addr буде IP цього Pod
  • якщо пакет йде з інтернету до Kubernetes Pod –  то в полі pkt_dst_addr буде IP цього Pod

Запити Loki

Тому аби порахувати байти з інтернету – до Kubernetes Pods ми можемо зробити такий запит в Loki з sum_over_time() та $__range, аби вибрати дані за 30 хвилин, а в pkt_dst_addr=ip("10.0.32.0/20") вибираємо IP тільки VPC Private Subnet, яка використовується для Kubernetes Pods:

sum (
    sum_over_time(
        ({logtype="flow", logger_type="loki"} | pattern `<region> <vpc_id> <az_id> <subnet_id> <instance_id> <interface_id> <flow_direction> <src_add> <dst_addr> <src_port> <dst_port> <pkt_src_addr> <pkt_dst_addr> <pkt_src_aws_service> <pkt_dst_aws_service> <traffic_path> <packets> <bytes> <action>`
        | interface_id="eni-0352f8c82da6aa229"
        | pkt_dst_addr=ip("10.0.32.0/20")
        | pkt_src_addr=~"${kubernetes_pod_ip}"
        | pkt_dst_addr=~"${remote_svc_ip}"
        | unwrap bytes
        | __error__=""
        )[$__range]
    )
)

Аби запит швидше оброблювався, внизу в Options можна поставити Type == Instant.

І аналогічно, але рахуємо від Kubernetes Pod в інтернет:

sum (
    sum_over_time(
        ({logtype="flow", logger_type="loki"} | pattern `<region> <vpc_id> <az_id> <subnet_id> <instance_id> <interface_id> <flow_direction> <src_add> <dst_addr> <src_port> <dst_port> <pkt_src_addr> <pkt_dst_addr> <pkt_src_aws_service> <pkt_dst_aws_service> <traffic_path> <packets> <bytes> <action>`
        | interface_id="eni-0352f8c82da6aa229"
        | flow_direction="ingress"
        | pkt_src_addr=ip("10.0.32.0/20")
        | pkt_src_addr=~"${kubernetes_pod_ip}"
        | pkt_dst_addr=~"${remote_svc_ip}"
        | unwrap bytes
        | __error__=""
        )[$__range]
    )
)

Запити VictoriaLogs

Запити для VictoriaLogs будуть виглядати так – з інтернету до Kubernetes Pods:

_time:$__range {logtype=flow, environment=ops, logger_type=vmlogs} seq("eni-0352f8c82da6aa229", "ACCEPT")
| extract "<region> <vpc_id> <az_id> <subnet_id> <instance_id> <interface_id> <flow_direction> <src_addr> <dst_addr> <src_port> <dst_port> <pkt_src_addr> <pkt_dst_addr> <pkt_src_aws_service> <pkt_dst_aws_service> <traffic_path> <packets> <bytes> <action>"
  | filter
      interface_id:="eni-0352f8c82da6aa229"
      action:="ACCEPT"
      pkt_dst_addr:ipv4_range("10.0.32.0/20")
      pkt_src_addr:~"${kubernetes_pod_ip}"
      pkt_dst_addr:~"${remote_svc_ip}"        
  | stats sum(bytes) bytes_total

З Kubernetes Pods в інтернет:

_time:$__range {logtype=flow, environment=ops, logger_type=vmlogs} seq("eni-0352f8c82da6aa229", "ACCEPT")
| extract "<region> <vpc_id> <az_id> <subnet_id> <instance_id> <interface_id> <flow_direction> <src_addr> <dst_addr> <src_port> <dst_port> <pkt_src_addr> <pkt_dst_addr> <pkt_src_aws_service> <pkt_dst_aws_service> <traffic_path> <packets> <bytes> <action>"
  | filter
      interface_id:="eni-0352f8c82da6aa229"
      action:="ACCEPT"
      pkt_src_addr:ipv4_range("10.0.32.0/20")
      pkt_src_addr:~"${kubernetes_pod_ip}"
      pkt_dst_addr:~"${remote_svc_ip}"  
  | stats sum(bytes) bytes_total

І всі три панелі разом:

NAT Gateway Total processed bytes/sec

Крім Stat панелей хотілося б бачити і історичну картину – в який час як мінявся трафік.

Запит Loki

В Loki все просто – просто використовуємо функцію rate():

sum (
    rate(
        ({logtype="flow", logger_type="loki"} | pattern `<region> <vpc_id> <az_id> <subnet_id> <instance_id> <interface_id> <flow_direction> <src_add> <dst_addr> <src_port> <dst_port> <pkt_src_addr> <pkt_dst_addr> <pkt_src_aws_service> <pkt_dst_aws_service> <traffic_path> <packets> <bytes> <action>`
        | interface_id="eni-0352f8c82da6aa229"
        | pkt_dst_addr=ip("10.0.32.0/20")
        | pkt_src_addr=~"${kubernetes_pod_ip}"
        | pkt_dst_addr=~"${remote_svc_ip}"
        | unwrap bytes
        | __error__=""
        )[$__interval]
    )
)

В Options і в rate() використовуємо інтервал 5 хвилин, в Standart Options > Unit bytes/sec(SI), в результаті маємо 7.25 МБ/с в 12:20:

Запит VictoriaLogs

А з VictoriaLogs трохи цікавіше, бо з коробки вона не має функції rate() (але обіцяють скоро додати).

Крім того, є ще один нюанс:

  • Loki рахує дані “назад”, тобто – точка на графіку в 12:25 а rate() бере попередні 5 хвилин – [5m] з Options, які передаються в $__interval
  • в VictoriaLogs графік буде відображатись на момент виконання запиту

Аби порахувати per-second rate наших bytes – можемо використати  math pipe.

Отже, запит буде таким:

_time:$__range {logtype=flow, environment=ops, logger_type=vmlogs} seq("eni-0352f8c82da6aa229", "ACCEPT")
| extract "<region> <vpc_id> <az_id> <subnet_id> <instance_id> <interface_id> <flow_direction> <src_addr> <dst_addr> <src_port> <dst_port> <pkt_src_addr> <pkt_dst_addr> <pkt_src_aws_service> <pkt_dst_aws_service> <traffic_path> <packets> <bytes> <action>"
  | filter
      interface_id:="eni-0352f8c82da6aa229"
      action:="ACCEPT"
      pkt_dst_addr:ipv4_range("10.0.32.0/20")
      pkt_src_addr:~"${kubernetes_pod_ip}"
      pkt_dst_addr:~"${remote_svc_ip}"        
  | stats sum(bytes) sum_bytes
  | math (sum_bytes / ($__interval/1s)) as bytes_total

Тут:

  • в stats sum(bytes) рахуємо суму байт за інтервал, заданий в Options (5 хвилин), результат зберігаємо як sum_bytes
  • далі з math рахуємо суму байт з sum_bytes за кожен інтервал на графіку, і їх ділимо на кількість секунд в обраному $__interval

Тут у нас 8.30 МБ/с в 12:20. Плюс-мінус схоже. Можна вже зовсім заморочитись з перевіркою, і порахувати вручну з логів – але прям супер-точні цифри тут не дуже важливі, цікавить саме тренд, тому ОК.

Взагалі, при побудові саме графіків можна не прописувати _time:$__range, бо це виконується в самій VMLogs “під капотом”, але тут нехай буде для ясності.

Kubernetes Pods IN From IP

Наступним відобразимо топ Kubernetes Pods IP по отриманому з інтернету трафіку.

Запит Loki

Для Loki використовуємо sum_over_time() за $__range, у нас в дашборді це 30 хвилин:

topk(5,
  sum by (pkt_src_addr) (
    sum_over_time(
      (
        {logtype="flow", logger_type="loki"}
        | pattern `<region> <vpc_id> <az_id> <subnet_id> <instance_id> <interface_id> <flow_direction> <src_addr> <dst_addr> <src_port> <dst_port> <pkt_src_addr> <pkt_dst_addr> <pkt_src_aws_service> <pkt_dst_aws_service> <traffic_path> <packets> <bytes> <action>`
        | interface_id="eni-0352f8c82da6aa229"
        | action="ACCEPT"
        | pkt_dst_addr=ip("10.0.32.0/20")
        | pkt_src_addr=~"${remote_svc_ip}"
        | pkt_dst_addr=~"${kubernetes_pod_ip}"
        | unwrap bytes
        | __error__=""
      )[$__range]
    )
  )
)

Див. Grafana Data links – дуже корисна штука.

Запит VictoriaLogs

І аналогічний запит для VictoriaLogs буде виглядати так:

_time:$__range {logtype=flow, environment=ops, logger_type=vmlogs} seq("eni-0352f8c82da6aa229", "ACCEPT")
| extract "<region> <vpc_id> <az_id> <subnet_id> <instance_id> <interface_id> <flow_direction> <src_addr> <dst_addr> <src_port> <dst_port> <pkt_src_addr> <pkt_dst_addr> <pkt_src_aws_service> <pkt_dst_aws_service> <traffic_path> <packets> <bytes> <action>"
  | filter 
    interface_id:="eni-0352f8c82da6aa229"
    action:=ACCEPT
    pkt_dst_addr:ipv4_range("10.0.32.0/20")
    pkt_dst_addr:~"${kubernetes_pod_ip}"
    pkt_src_addr:~"${remote_svc_ip}"    
  | stats by (pkt_src_addr) sum(bytes) sum_bytes
  | sort by (sum_bytes) desc limit 10

VictoriaLogs поки не підтримує Options для Legend і повертає результат просто в JSON.

Тому, аби все було красиво і без зайвих даних – можемо додати Transformations > Rename fields by regex, в якому з регуляркою .*addr="(.*)".* “виріжемо” тільки IP-адреси:

І що ми маємо:

  • в Loki у нас в топі 20.150.90.164 з 954 МБ
  • в VictoriaLogs в топі 20.150.90.164 з 954 МБ

І цілому дані схожі, хоча в Loki трохи відрізняється сортування, знов-таки – через невелику затримку. Ну і topk() в Loki працює трохи дивно, я колись намагався покопати цей момент, але забив. В VictoriaLogs limit працює краще (хоча теж є баг, далі побачимо).

Давайте перевіримо IP 20.150.90.164 в 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 (isIpv4InSubnet(pkt_dstaddr,"10.0.32.0/20"))) | stats sum(bytes) as bytes_total by pkt_srcaddr
| sort bytes_total desc

Дані в VictoriaLogs більш схожі на правду, але в цілому обидві системи виводять дані правильно.

Знов-таки, якщо брати більший проміжок часу (чого ми не можемо зробити з Loki, але можемо в VictoriaLogs) – то дані в CloudWatch Logs та VictoriaLogs будуть ще більш точні.

Kubernetes Pods IN From IP bytes/sec

Тут аналогічно тому, як ми робили для панельки “NAT Gateway Total IN processed” – аби мати історичну картину по трафіку.

Запит Loki

topk(5,
  sum by (pkt_src_addr) (
    rate(
      (
        {logtype="flow", logger_type="loki"}
        | pattern `<region> <vpc_id> <az_id> <subnet_id> <instance_id> <interface_id> <flow_direction> <src_addr> <dst_addr> <src_port> <dst_port> <pkt_src_addr> <pkt_dst_addr> <pkt_src_aws_service> <pkt_dst_aws_service> <traffic_path> <packets> <bytes> <action>`
        | interface_id="eni-0352f8c82da6aa229"
        | action="ACCEPT"
        | pkt_dst_addr=ip("10.0.32.0/20")
        | pkt_src_addr=~"${remote_svc_ip}"
        | pkt_dst_addr=~"${kubernetes_pod_ip}"
        | unwrap bytes
        | __error__=""
      )[$__interval]
    )
  )
)

Запит VictoriaLogs

_time:$__range {logtype=flow, environment=ops, logger_type=vmlogs} seq("eni-0352f8c82da6aa229", "ACCEPT")
| extract "<region> <vpc_id> <az_id> <subnet_id> <instance_id> <interface_id> <flow_direction> <src_addr> <dst_addr> <src_port> <dst_port> <pkt_src_addr> <pkt_dst_addr> <pkt_src_aws_service> <pkt_dst_aws_service> <traffic_path> <packets> <bytes> <action>"
  | filter
      interface_id:="eni-0352f8c82da6aa229"
      action:="ACCEPT"
      pkt_dst_addr:ipv4_range("10.0.32.0/20")
      pkt_drc_addr:~"${kubernetes_pod_ip}"
      pkt_src_addr:~"${remote_svc_ip}"
  | stats by (pkt_src_addr) sum(bytes) bytes_total
  | sort by (bytes_total) desc
  | math (bytes_total / ($__interval/1s)) as bytes_total

Теж плюс-мінус дані схожі.

Але тут знов є проблема з topk() в Loki – бо задано ліміт в топ-5 результатів, а виводить 11.

В VictoriaLogs також є проблема з limit, наприклад задамо | sort by (bytes_total) desc limit 5:

І в результаті маємо не топ-5 IP, а просто 5 точок на графіку.

Говорив з девелоперами VictoriaMetrics – кажуть, що схоже на баг, завів їм GitHub Issue, подивимось, що буде в найближчих релізах з багфіксами.

Kubernetes Pods IN by IP and Port

Залишилось відобразити інформацію по IP і портам – буває корисно при визначені сервісу, який генерує трафік – див. pkt_src_aws_service, pkt_dst_aws_service та визначення сервісу.

Запит Loki

Використовуємо тип візуалізації Table і такий запит:

topk(10,
  sum by (pkt_src_addr, src_port, pkt_dst_addr, dst_port) (
    sum_over_time(
      (
        {logtype="flow", logger_type="loki"}
        | pattern `<region> <vpc_id> <az_id> <subnet_id> <instance_id> <interface_id> <flow_direction> <src_addr> <dst_addr> <src_port> <dst_port> <pkt_src_addr> <pkt_dst_addr> <pkt_src_aws_service> <pkt_dst_aws_service> <traffic_path> <packets> <bytes> <action>`
        | interface_id="eni-0352f8c82da6aa229"
        | action="ACCEPT"
        | pkt_dst_addr=ip("10.0.32.0/20")
        | pkt_src_addr=~"${remote_svc_ip}"
        | pkt_dst_addr=~"${kubernetes_pod_ip}"
        | unwrap bytes
        | __error__=""
      )[$__range]
    )
  )
)

В Fields override задаємо Unit поля Value в bytes(SI), і для кожної колонки – власний Data link.

Змінити заголовки колонок і сховати поле Time можемо в Transformations:

Запит VictoriaLogs

Запит буде таким:

_time:$__range {logtype=flow, environment=ops, logger_type=vmlogs} seq("eni-0352f8c82da6aa229", "ACCEPT")
| extract "<region> <vpc_id> <az_id> <subnet_id> <instance_id> <interface_id> <flow_direction> <src_addr> <dst_addr> <src_port> <dst_port> <pkt_src_addr> <pkt_dst_addr> <pkt_src_aws_service> <pkt_dst_aws_service> <traffic_path> <packets> <bytes> <action>"
  | filter
      interface_id:="eni-0352f8c82da6aa229"
      action:="ACCEPT"
      pkt_dst_addr:ipv4_range("10.0.32.0/20")
      pkt_dst_addr:~"${kubernetes_pod_ip}"
      pkt_src_addr:~"${remote_svc_ip}"
  | stats by (pkt_src_addr, src_port, pkt_dst_addr, dst_port) sum(bytes) bytes_total
  | sort by (bytes_total) desc limit 10

Через те, що VictoriaLogs повертає (поки що) результати в JSON – то додамо трансформацію Extract fields.

В Filter fields by name як і для Loki – прибираємо колонку Time.

А в Organize fields by name – міняємо заголовки колонок і робимо сортування колонок:

Фінальний результат та перформанс Loki vs VictoriaLogs

Результат в VictoriaLogs за 12 (!) годин:

І ресурси:

$ kk top pod atlas-victoriametrics-victoria-logs-single-server-0 
NAME                                                  CPU(cores)   MEMORY(bytes)   
atlas-victoriametrics-victoria-logs-single-server-0   2754m        34Mi

Результат в Loki за 30 хвилин:

І ресурси:

$ kk top pod -l app.kubernetes.io/name=loki,app.kubernetes.io/component=read
NAME                        CPU(cores)   MEMORY(bytes)   
loki-read-89fbb8f5c-874xq   683m         402Mi           
loki-read-89fbb8f5c-8qpdw   952m         399Mi           
loki-read-89fbb8f5c-qh8dg   848m         413Mi

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

Залишилось дочекатись мінорних апдейтів по датасорсу і самій VictoriaLogs.

Що далі?

А далі все ж хочеться мати поля з Kubernetes Pods IP та адреси зовнішніх ресурсах в полях логів, а не парсити їх в дашборді самою VictoriaLogs – тоді буде можливість взагалі робити виборки за кілька днів або може навіть тижнів.

Для цього підсказали ідею із vector.dev – збирати нею логи з S3, там виконувати трансформації і додавати поля, а потім вже писати ці логи в VictoriaLogs.

Скоріш за все, як буде час, спробую, бо виглядає дуже цікавим рішенням.

Loading

Підготовка до зими 2024-2025: частина 2 – готуємо оселю до блекаутів
0 (0)

29 Листопада 2024

Хотілося б написати про щось айтішне, тим більш є зараз цікава тема, і не одна – але доведеться написати про трохи більш актуальні речі.

Отже, на сьогодні, 29 листопада, маємо таку картину по відключенням:

Пєчалька, звісно, але я готувався заздалегіть, тому не все так погано.

Про що буде мова сьогодні:

  • електроживлення квартири: як в мене працює опалення/холодильник, освітлення, інтернет
  • безпека: як себе убезпечити від пожежі
  • вода: тут просто – тримаємо запас

Звісно, багато залежить від дому/квартири. Якщо у вас взагалі приватний будинок – то ставимо генератор, і не знаємо біди.

Але якщо це багатоквартирний будинок – то тут вже питання.

І теж саме стосується опалення і води: на щастя в мене в ЖК опалення газове, котел, плюс ще я водопостачання у ЖК власне, а не від загальної мережі.

Це вже не перший пост на цю тему:

Електроживлення квартири

Коли я готувався до цієї зими, то довго не міг вирішити – що ж брати? Зекономити, і взяти просто акумулятори + ДБЖ, чи купити зарядну станцію від “народних умільців”, або психанути – та купити EcoFlow?

Власне в пості Підготовка до зими 2024-2025: ДБЖ, інвертори, та акумулятори саме про це і йде мова, і трохи розібравшись з темою я вирішив не економити, і просто купив EcoFlow.

В мене дві квартири – одна однокімнатна, як “офіс”, і друга трикімнатна – для родини.

Вдома, тобто у “сімейній” квартирі я поставив станцію EcoFlow DELTA Pro 3600 Вт⋅год з LiFePO4:

При роботі котла опалення + холодильника споживається близько 280 Вт/годину, і цієї станції має вистачити на 12-15 годин роботи. А враховуючи швидкість зарядки EcoFlow в пару годин – цілком нормальний варіант:

В “офісі” ж в мене стоїть EcoFlow Delta Max 2000 2016Wh, і на резерв – саморобна станція, куплена на OLX (не рекомендую, але працює з 2023):

АВР – Автомат Введення Резерву

Якщо “офіс” в мене однокімнатна квартира, і там я обійшовся кабелями з балкону, які йдуть на кухню і в робочу кімнату, то в 3к квартирі це було б, по-перше, дуже не зручно – десятиметрові кабеля через всю квартиру, по-друге – там хотілося мати нормальне освітлення і роботу розеток, тим більш вдома є дитина.

Тому ще влітку я встановив там АВР, який автоматично переключає живлення квартири на EcoFlow, коли в загальній мережі пропадає напруга.

Виглядає це ось так:

Тут зверху – звичайний ЗУБР з автоматами, на нього заходить лінія живлення з будинку.

А під ним – вже сам АВР: з розетки в ньому йде кабель живлення до EcoFlow, а з EcoFlow ще один назад до АВР, і вже від нього живиться квартира.

Це просто чудова штука, рекомендую, хоча обійшлася вона мені в 16.000 гривень. Якщо комусь треба – то пишіть в Telegram, дам контакт майстра (до речі, служить в ППО Київської області).

Холодильник – режим Eco Friendly (LG)

Але при таких відключеннях, як сьогодні, коли світла не буде майже добу – доводиться вже економити, і тут я вперше спробував функцію Eco Friendly в холодильнику LG:

Вона обмежує споживання електроенергії, і дуже відчутно – замість ~120 Вт/г холодильник забирає близько 60-80.

При цьому він підвищує температуру в морозильнику з -20 до -15, і в самому холодильнику з +3 до +7.

Крім того, в холодильники купив Акумулятор холоду Кемпінг IcePack, аби довше підтримувати холод всередині.

Котел опалення: термостат

Ще одна дуже корисна штука, яка прям маст хев всім, у кого опалення газовим котлом – це термостат, я собі брав Computherm Q7 RF:

Тут на стіні приймач, і з нього йдуть команди на сам котел:

По-перше, такий термостат програмується на різні режими в різні години доби: на ніч можна поставити 18-20 градусів, на ранок – 21-22, вдень, поки нікого немає вдома – знов 18, а ввечері, перед поверненням всіх додому – знов до 21-22.

Це відчутно економить витрати електроенергії на роботу котла опалення, який теж їсть немало – 100-120 Вт/г. Ну і рахунок за газ буде трохи меншим.

Ноутбуки та павербанки

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

Наприклад, в мене ще з 2023 є станції Kseon 168 від Kseonics Technology на 160.00 mAh, до яких через Автомобільний зарядний пристрій Baseus можна підключити Type-C, а його до ноутбука:

Або використати звичайний павербанк, який зможе видати 30+ ват потужності, наприклад в мене є парочка 30000 mah 65W 6A Baseus PowerBank, або ось такі китайські:

До них ще докупав пару маленьких інверторів на 150 ват – телевізор від них працює без проблем.

Загалом моя “колекція” павербанок виглядає так:

Інтернет

Благо, в Україні вже давно багато де є GPON, і в моєму селі, на щастя, теж, тому ще в кінці 2022 я собі підключив таку оптику.

Єдине, що для неї треба – це живити сам ONU (медіаконвертор) та роутер.

Для дому я купив ДБЖ для роутеру UPS DC1018P (на AliExpress можна взяти рази в два дешевше, ніж на Rozetka, але і чекати довго, і якщо прийде Укрпоштою – то ну його до біса), тримає 8-10 годин:

А в офісі ONU живиться або від павербанки:

Або від Step4Net UPS-18W, і такий жеж стоїть в кімнаті для роутера – але його вистачає години на 4 роботи:

Освітлення

Для квартири ще в минулі роки купив лампи на акумуляторах Yeelight Xiaomi з магнітами:

Вони з датчиком руху, класна штука.

Така ж висить над дверима:

Також в квартирі і на сходах в парадному розвісив ось такі світильники – Motion Sensor LED Night Light:


Ще класний варіант LED-стрічка, наприклад – така, але я (поки що) не брав, бо вистачає того, що є.

Пожежна безпека

Я прям дуже боюся, що все це щастя загориться, тим більш в мене з 2022 досі на балконі стоять звичайні AGM-акумулятори, тому подумав про безпеку.

Вогнегасники

Першим ще в 2022 купив звичайні порошкові вогнегасники ВП-3 – брав відразу дві штуки, бо не факт, що з першого разу вийде правильно його використати:

Автоматичні вогнегасники

А в цьому році пішов далі, і поставив автоматичні вогнегасники AFO Fire Ball:

Вони всередині заповнені порошком, який розкидується навкруги, коли до вогнегасника доходить полум’я.

Ось тут є ефектне відео того, як це працює.

Датчики диму

Ще купив датчики диму CoVi Security, які вміють слати альорти на мобільний телефон:

На телефоні виглядає якось так:

Тестив, підпалюючи тряпку – працює.

Вода та водопостачання

Тут мені дуже повезло з ЖК, бо у нас власні скважини з насосами. Для них є окремий генератор, але він працює 2 рази на добу по 3-4 години, і буває, що не вмикають зовсім.

Тому в будь-якому випадку тримаю вдома запас води технічної, 2 бутилі по 20 літрів, і питної, теж 2х20 літрів.

На додачу ще в минулі роки купував ось такі рукомийники Litolan:

Ну і останнє, що теж купував ще в 2022 – це звичайний чайник на плиту, аби не садити EcoFlow з електрочайником (тим більш тоді ще EcoFlow в мене не було):

Начебто все розказав з цікавого-корисного.

Чекаю включення світла, аби зарядити станції)

Loading

Kubernetes: єдиний AWS Load Balancer для різних Kubernetes Ingress
0 (0)

19 Листопада 2024

Подивились ми на наші витрати на AWS Load Balancers, і подумали, що треба трохи це діло привести в порядок.

Чого хочеться: мати один LoadBalancer, і через нього роутити запити на різні Kubernetes Ingresses та Services в різних Namespaces.

Перше, що спало на думку – це або додавати в Kubernetes кластер якийсь Service Mesh типу Istio або Linkerd, або додавати Nginx Ingress Controller, а перед ним – AWS ALB.

Але в UkrOps Slack мені нагадали, що AWS Load Balancer Controller, який ми використовуємо в нашому кластері AWS Elastic Kubernetes Service, вже давно вміє таке робити за допомогою IngressGroup.

Тож давайте подивимось як це працює, і як таку схему можна додати на існуючі Ingress ресурси.

Тест Load Balancer Controller IngressGroup

Отже, ідея доволі проста: в маніфесті Kubernetes Ingress ми задаємо ще один атрибут – group.name, і по ньому Load Balancer Controller визначає до якого AWS LoadBalancer цей Ingress належить.

Потім він використовуючи spec.hosts в Ingress визначає hostnames і на LoadBalancer будує роутинг до необхідних Target Groups.

Давайте спробуємо на простому прикладі.

Спочатку створюємо звичайну схему з окремими Ingress/ALB – описуємо маніфест з Namespace, Deployment, Service та Ingress:

apiVersion: v1
kind: Namespace
metadata:
  name: test-app-1-ns
---
apiVersion: apps/v1
kind: Deployment
metadata:
  name: app-1-deploy
  namespace: test-app-1-ns
spec:
  replicas: 1
  selector:
    matchLabels:
      app: app-1-pod
  template:
    metadata:
      labels:
        app: app-1-pod
    spec:
      containers:
        - name: app-1-container
          image: nginx
          ports:
            - containerPort: 80
---
apiVersion: v1
kind: Service
metadata:
  name: app-1-service
  namespace: test-app-1-ns
spec:
  selector:
    app: app-1-pod
  ports:
    - protocol: TCP
      port: 80
      targetPort: 80
  type: ClusterIP
---
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
  name: app-1-ingress
  namespace: test-app-1-ns
  annotations:
    alb.ingress.kubernetes.io/scheme: internet-facing
    alb.ingress.kubernetes.io/target-type: ip
    alb.ingress.kubernetes.io/listen-ports: '[{"HTTP":80}]'
spec:
  ingressClassName: alb
  rules:
    - host: app-1.ops.example.com
      http:
        paths:
          - path: /
            pathType: Prefix
            backend:
              service:
                name: app-1-service
                port:
                  number: 80

І такий же, тільки з app-2.

Деплоїмо:

$ kk apply -f .
namespace/test-app-1-ns created
deployment.apps/app-1-deploy created
service/app-1-service created
ingress.networking.k8s.io/app-1-ingress created
namespace/test-app-2-ns created
deployment.apps/app-2-deploy created
service/app-2-service created
ingress.networking.k8s.io/app-2-ingress created

Перевіряємо Ingress та його LoadBalancer для app-1:

$ kk -n test-app-1-ns get ingress
NAME            CLASS   HOSTS                   ADDRESS                                                                  PORTS   AGE
app-1-ingress   alb     app-1.ops.example.com   k8s-testapp1-app1ingr-9375bc68bc-376038977.us-east-1.elb.amazonaws.com   80      33s

Тут ADDRESS – “k8s-testapp1-app1ingr-9375bc68bc-376038977“.

Перевіряємо для app-2:

$ kk -n test-app-2-ns get ingress
NAME            CLASS   HOSTS                   ADDRESS                                                                   PORTS   AGE
app-2-ingress   alb     app-2.ops.example.com   k8s-testapp2-app2ingr-0277bbb198-1743964934.us-east-1.elb.amazonaws.com   80      64s

Тут ADDRESS – “k8s-testapp2-app2ingr-0277bbb198-1743964934“.

Відповідно, в AWS маємо два Load Balancers:

Тепер до обох Ingress  додаємо анотацію alb.ingress.kubernetes.io/group.name: test-app-alb:

...
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
  name: app-1-ingress
  namespace: test-app-1-ns
  annotations:
    alb.ingress.kubernetes.io/group.name: test-app-alb
    alb.ingress.kubernetes.io/scheme: internet-facing
...
...
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
  name: app-2-ingress
  namespace: test-app-2-ns
  annotations:
    alb.ingress.kubernetes.io/group.name: test-app-alb
    alb.ingress.kubernetes.io/scheme: internet-facing
...

Деплоїмо ще раз:

$ kk apply -f .
namespace/test-app-1-ns unchanged
deployment.apps/app-1-deploy unchanged
service/app-1-service unchanged
ingress.networking.k8s.io/app-1-ingress configured
namespace/test-app-2-ns unchanged
deployment.apps/app-2-deploy unchanged
service/app-2-service unchanged
ingress.networking.k8s.io/app-2-ingress configured

Та перевіряємо Ingresses та їхні адреси тепер.

У app-1 – це “k8s-testappalb-95eaaef0c8-2109819642“:

$ kk -n test-app-1-ns get ingress
NAME            CLASS   HOSTS                 ADDRESS                                                            PORTS   AGE
app-1-ingress   alb     app-1.ops.example.com   k8s-testappalb-95eaaef0c8-2109819642.us-east-1.elb.amazonaws.com   80      6m19s

У app-2 – теж “k8s-testappalb-95eaaef0c8-2109819642“:

$ kk -n test-app-2-ns get ingress
NAME            CLASS   HOSTS                 ADDRESS                                                            PORTS   AGE
app-2-ingress   alb     app-2.ops.example.com   k8s-testappalb-95eaaef0c8-2109819642.us-east-1.elb.amazonaws.com   80      6m48s

І в AWS у нас тепер один Load Balancer:

Який має два Listerner Rules, які в залежності від hostname в Ingress будуть редіректити запити до потрібних Target Groups:

IngressGroups – ліміти та реалізація в Production

При використанні такої схеми треба мати на увазі, що деякі параметри LoadBalancer не можуть задаватись в різних Ingress.

Наприклад, якщо один Ingress має анотацію alb.ingress.kubernetes.io/tags: "component=devops", а другий Ingress намагається задати тег component=backend, то Load Balancer Controller не задеплоїть такі зміни, і повідомить про конфлікт, наприклад:

aws-load-balancer-controller-7647c5cbc7-2stvx:aws-load-balancer-controller {"level":"error","ts":"2024-09-25T10:50:23Z","msg":"Reconciler error","controller":"ingress","object":{"name":"ops-1-30-external-alb"},"namespace":"","name":"ops-1-30-external-alb","reconcileID":"1091979f-f349-4b96-850f-9e7203bfb8be","error":"conflicting tag component: devops | backend"}
aws-load-balancer-controller-7647c5cbc7-2stvx:aws-load-balancer-controller {"level":"error","ts":"2024-09-25T10:50:44Z","msg":"Reconciler error","controller":"ingress","object":{"name":"ops-1-30-external-alb"},"namespace":"","name":"ops-1-30-external-alb","reconcileID":"19851b0c-ea82-424c-8534-d3324f4c5e60","error":"conflicting tag environment: ops | prod"}

Аналогічно до параметрів на кшталт alb.ingress.kubernetes.io/load-balancer-attributes: access_logs.s3.enabled=true,access_logs.s3.bucket=some-bucket-name, або параметри SecurityGroups.

А от з TLS все простіше: для кожного Ingress в його annotations з alb.ingress.kubernetes.io/certificate-arn можна передати ARN сертифікату з AWS Certificates Manager, і вони будуть налаштовані у Listener certificates for SNI:

Тому я принаймні поки що зробив так:

  • створив окремий GitHub репозиторій
  • в ньому Helm- чарт
  • в цьому чарті два маніфести для двох Ingress – один з типом internal, другий – internet-facing, і задав там всякі дефолтні параметри

Наприклад:

apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
  name: ops-external-ingress
  annotations:
    kubernetes.io/ingress.class: alb
    alb.ingress.kubernetes.io/group.name: ops-1-30-external-alb
    alb.ingress.kubernetes.io/scheme: internet-facing
    alb.ingress.kubernetes.io/target-type: ip
    alb.ingress.kubernetes.io/listen-ports: '[{"HTTP": 80}, {"HTTPS": 443}]'
    alb.ingress.kubernetes.io/certificate-arn: arn:aws:acm:us-east-1:492***148:certificate/88a5ccc0-e729-4fdb-818c-c41c411a3e3e
    alb.ingress.kubernetes.io/tags: "environment=ops,component=devops,Name=ops-1-30-external-alb"
    alb.ingress.kubernetes.io/actions.ssl-redirect: '{"Type": "redirect", "RedirectConfig": { "Protocol": "HTTPS", "Port": "443", "StatusCode": "HTTP_301"}}'
    alb.ingress.kubernetes.io/load-balancer-attributes: access_logs.s3.enabled=true,access_logs.s3.bucket=ops-1-30-devops-ingress-ops-alb-logs
    alb.ingress.kubernetes.io/actions.default-action: >
      {"Type":"fixed-response","FixedResponseConfig":{"ContentType":"text/plain","StatusCode":"200","MessageBody":"It works!"}}
spec:
  ingressClassName: alb
  defaultBackend:
    service:
      name: default-action
      port: 
        name: use-annotation

В defaultBackend задаємо дію, коли запит приходить на hostname, для якого нема окремого Listener – тут просто відповідаємо “It works!” з кодом 200.

А далі вже в Ingress проектів налаштовуються їхні параметри, наприклад Grafana:

$ kk -n ops-monitoring-ns get ingress atlas-victoriametrics-grafana -o yaml
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
  annotations:
    # the Common ALB group name
    alb.ingress.kubernetes.io/group.name: ops-1-30-external-alb
    ## TLS certificate from AWS ACM
    alb.ingress.kubernetes.io/certificate-arn: arn:aws:acm:us-east-1:492***148:certificate/88a5ccc0-e729-4fdb-818c-c41c411a3e3e
    # sucess codes for Target Group health checks
    alb.ingress.kubernetes.io/success-codes: "302"
    alb.ingress.kubernetes.io/target-type: ip
    kubernetes.io/ingress.class: alb
    ...
  name: atlas-victoriametrics-grafana
  namespace: ops-monitoring-ns
  ...
spec:
  rules:
  - host: monitoring.1-30.ops.example.com
    http:
      paths:
      - backend:
          service:
            name: atlas-victoriametrics-grafana
            port:
              number: 80
        path: /
        pathType: Prefix

І через alb.ingress.kubernetes.io/group.name: ops-1-30-external-alb вони “підключаються” до нашого “дефолтного” LoadBalancer.

Працюємо на такій схемі вже кілька тижнів – поки політ нормальний.

Моніторинг

Ще з важливих нюансів – це моніторинг, бо дефолтні метрики CloudWatch, наприклад по помилкам 502/503/504 створюються на весь LoadBalancer.

Але в нашому випадку ми взагалі відмовились від метрик CloudWatch (ще й платити за кожен запит GetData на отримання метрик в CloudWatch Exporter або Yet Another Cloudwatch Exporter).

Натомість ми всі Access логи лоад-балансерів збираємо до Loki, а далі вже з її Recording Rules генеруємо метрики, де в лейблах маємо ім’я домену при запиті на який помилка виникла:

...
        - record: aws:alb:requests:sum_by:elb_http_codes_by_uri_path:5xx:by_pod_ip:rate:1m
          expr: |
            sum by (pod_ip, domain, elb_code, uri_path, user_agent) (
                rate(
                    {logtype="alb"} 
                        | pattern `<type> <date> <elb_id> <client_ip> <pod_ip> <request_processing_time> <target_processing_time> <response_processing_time> <elb_code> <target_code> <received_bytes> <sent_bytes> "<request>" "<user_agent>" <ssl_cipher> <ssl_protocol> <target_group_arn> "<trace_id>" "<domain>" "<chosen_cert_arn>" <matched_rule_priority> <request_creation_time> "<actions_executed>" "<redirect_url>" "<error_reason>" "<target>" "<target_status_code>" "<_>" "<_>"`
                        | domain=~"(^.*api.challenge.example.co|lightdash.example.co)"
                        | elb_code=~"50[2-4]"
                        | regexp `.*:443(?P<uri_path>/[^/?]+).* HTTP`
                        [1m] offset 5m
                )
            )
...

Див. Grafana Loki: збираємо логи AWS LoadBalancer з S3 за допомогою Promtail Lambda та Grafana Loki: LogQL та Recoding Rules для метрик з логів AWS Load Balancer.

Ще з цікавого почитати можна тут – A deeper look at Ingress Sharing and Target Group Binding in AWS Load Balancer Controller.

Loading

AWS: VPC Flow Logs – логи до S3 та Grafana dashboard з Loki
0 (0)

16 Листопада 2024

В продовження теми AWS: VPC Flow Logs, NAT Gateways, та Kubernetes Pods – детальний обзор.

Там ми розбирали роботу з VPC Flow Logs в цілому, і дізнались, як ми можемо отримувати інформацію про трафік з/до Kubernetes Pods.

Але при використанні Flow Logs з CloudWatch Logs є одна проблема – це вартість.

Наприклад, коли ми включаємо Flow Logs і вони пишуться до CloudWatch Logs, то навіть у невеликому проекті з невеликим трафіком кости на CloudWatch Logs виглядають так – 23-го жовтня включив, 8 листопада відключив:

Тому замість використання CloudWatch Logs ми можемо зробити інакше: Flow Logs писати до AWS S3 бакета, а звідти забирати з Promtail і писати в Grafana Loki, див. Grafana Loki: збираємо логи AWS LoadBalancer з S3 за допомогою Promtail Lambda. А вже маючи логи в Loki – мати і алерти з VMAlert/Alertmanager, і дашборди в Grafana.

Головна проблема, яку ми зараз хочемо вирішити за допомогою VPC Flow Logs – це визначити, хто шле багато трафіку через NAT Gateway, бо це теж з’їдає наші гроші.

Друга задача – це надалі мати загальну картину і якісь алерти по трафіку.

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

  • створимо AWS S3 для логів
  • створимо Lambda-функцію з інстансом Promtail, який буде писати логи з бакета до Grafana Loki
  • подивимось що ми маємо в логах цікавого, і що корисного там може бути для нас по трафіку
  • створимо Grafana Dashboard

Наша інфраструктура

Спочатку трохи подивимось на наш проект.

Маємо:

Terraform та VPC Flow Logs до S3

Створення самих корзин та Lambda-функції описано в Terraform: створення модулю для збору логів AWS ALB в Grafana Loki, тому тут детально зупинятись не буду.

Взагалі модуль створювався для збору логів AWS Load Balancers, тому в іменах буде зустрічатись “alb” – потім треба буде його переписати аби імена бакетів та функцій передавати параметром.

Єдиний момент, який треба мати на увазі: VPC Flow Logs пише багато даних, тому варто додати більший таймаут для Lambda, бо частина записів втрачалась через помилку Lambda “Task timed out after 3.05 seconds“.

...
module "promtail_lambda" {
  source  = "terraform-aws-modules/lambda/aws"
  version = "~> 7.8.0"
  # key: dev
  # value:  ops-1-28-backend-api-dev-alb-logs
  for_each = aws_s3_bucket.alb_s3_logs

  # <aws_env>-<eks_version>-<component>-<application>-<app_env>-alb-logs-logger
  # bucket name: ops-1-28-backend-api-dev-alb-logs
  # lambda name: ops-1-28-backend-api-dev-alb-logs-loki-logger
  function_name = "${each.value.id}-loki-logger"
  description   = "Promtail instance to collect logs from ALB Logs in S3"

  create_package = false
  # https://github.com/terraform-aws-modules/terraform-aws-lambda/issues/36
  publish = true

  # an error when sending logs from Flow Logs S3:
  # 'Task timed out after 3.05 seconds'
  timeout = 60
...

Отже, ми маємо AWS S3 бакет, маємо Lambda, яка з цього бакету бути отримувати повідомлення про появу нових об’єктів, а потім Promtail з цієї Lambda-функції відправляє логи до інстансу Loki через Internal LoadBalancer:

При передачі логів до Loki Promtail додає кілька нових лейбл – component=vpc-flow-logs, logtype=alb, environment=ops. Далі ми зможемо їх використовувати в метриках та Grafana dashboards.

logtype=alb то знов-таки модуль писався під логи ALB, і це треба буде змінити

Тепер нам треба налаштувати Flow Logs для нашої VPC.

В модулі terraform-aws-modules/vpc/aws для цього є кілька параметрів:

...
  enable_flow_log = var.vpc_params.enable_flow_log

  # Default: "cloud-watch-logs"
  flow_log_destination_type = "s3"

  # disalbe to use S3
  create_flow_log_cloudwatch_log_group = false
  create_flow_log_cloudwatch_iam_role  = false

  # ARN of the a CloudWatch log group or an S3 bucket
  # disable if use 'create_flow_log_cloudwatch_log_group' and the default 'flow_log_destination_type' value (cloud-watch-logs)
  flow_log_destination_arn = "arn:aws:s3:::ops-1-30-vpc-flow-logs-devops-ops-alb-logs"

  # set 60 to use more detailed recoring
  flow_log_max_aggregation_interval         = 600
  # when use CloudWatch Logs, set this prefix
  flow_log_cloudwatch_log_group_name_prefix = "/aws/${local.env_name}-flow-logs/"

  # set custom log format for more detailed information
  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_destination_type: замість дефолтного cloud-watch-logs задаємо s3
  • create_flow_log_cloudwatch_log_group та create_flow_log_cloudwatch_iam_role: відключаємо створення ресурсів для CloudWatch Logs
  • flow_log_destination_arn: задаємо ARN корзини, в яку будуть писатись логи
  • flow_log_log_format: створюємо власний формат, аби мати більше інформації, в тому числі з IP подів в Kubernetes, див. VPC Flow Log – Custom format

Виконуємо terraform apply, перевіряємо нашу VPC:

І через 10 хвилин перевіряємо логи в Grafana Loki:

Чудово – логи пішли.

Далі до запиту в Loki додаємо парсер pattern, аби сформувати поля в записах:

{logtype="alb", component="vpc-flow-logs"} | pattern `<region> <vpc_id> <az_id> <subnet_id> <instance_id> <interface_id> <flow_direction> <src_add> <dst_addr> <src_port> <dst_port> <pkt_src_addr> <pkt_dst_addr> <pkt_src_aws_service> <pkt_dst_aws_service> <traffic_path> <packets> <bytes> <action>`

Окей. Тепер, маючи логи і поля, можемо подумати про метрики з Loki Recording Rules, Grafana dashboard та алерти в Alertmanager.

Аналіз запису VPC Flow Logs з NAT Gateway

Спочатку давайте глянемо весь трафік через NAT Gateway – додаємо фільтр по interface_id="eni-0352f8c82da6aa229":

{logtype="alb", component="vpc-flow-logs"} | pattern `<region> <vpc_id> <az_id> <subnet_id> <instance_id> <interface_id> <flow_direction> <src_add> <dst_addr> <src_port> <dst_port> <pkt_src_addr> <pkt_dst_addr> <pkt_src_aws_service> <pkt_dst_aws_service> <traffic_path> <packets> <bytes> <action>`
| interface_id="eni-0352f8c82da6aa229"

Що ми тут бачимо?

Наприклад, в першому записі:

Тут:

  • ingress: пакет вийшов до інтерфейсу NAT Gateway
  • 52.46.154.111: це src_addr – якийсь remote server
  • 10.0.5.175: це dst_addr – приватний IP нашого NAT Gateway
  • 443: це src_port – звідки прийшов пакет
  • 18779: це dst_port – куди прийшов пакет
  • 52.46.154.111 та 10.0.5.175:  це pkt_src_addr та pkt_dst_addr відповідно, значення такі ж, як і в5432 src_addr та dst_addr – тобто трафік явно “чисто NAT”, як розбирали в Від Remote Server до NAT Gateway
  • AMAZON: сервіс, від якого пакет отримано (але про це трохи далі)
  • pkt_dst_aws_service та traffic_path: пусті
  • 105: кількість пакетів
  • 113390: кількість байт
  • ACCEPT: пакет пройшов через Security Group/WAF

А в наступному запису бачимо dst_port 5432 – тут трафік явно до PostgreSQL RDS.

NAT Gateway та traffic_path

З цікавих моментів, які можна побачити в логах.

По-перше – це traffic_path. Іноді в логах, які пов’язані в NAT Gateway можна побачити “8”, тобто “Through an internet gateway” – див. Available fields.

Чому Internet Gateway? Бо трафік приходить з приватної мережі на NAT Gateway, але далі в інтернет він йде вже через Internet Gateway – див. One to Many: Evolving VPC Design.

pkt_src_aws_service, pkt_dst_aws_service та визначення сервісу

Щодо адрес не з нашої мережі, тобто якихось зовнішніх сервісів. В полях pkt_src_aws_service та pkt_dst_aws_service часто можна побачити запис типу “EC2” або “AMAZON” – але нам це нічого не каже.

Навіть більше – технічна підтримка самого AWS не змогла сказати що ж то за сервіси, на які йдуть пакети.

Але тут є хак: якщо в src_port/dst_port ми бачимо порт 443 – то можна просто відкрити IP в браузері, де ми отримаємо помилку SSL, і в помилці буде ім’я сервісу, на який це сертифікат видано.

Наприклад, вище ми бачили, що pkt_src_aws_service == AMAZON. Якщо відкрити https://52.46.154.111 – то побачимо що саме на цьому IP:

Аналогічно будуть записи типу monitoring.us-east-1.amazonaws.com для AWS CloudWatch або athena.us-east-1.amazonaws.com для AWS Athena.

Створення Grafana dashboard

Тепер давайте пробувати створити Grafana dashboard.

Планування

Отже, головна мета – це мати уяву про трафік, який  проходить через AWS NAT Gateway.

Що ми знаємо та маємо?

  • знаємо CIDR приватних сабнетів для Kubernetes Pods
  • знаємо Elastic Network Interface ID, Public IP та Private IP для NAT Gateway – він у нас один, тому тут все просто
  • в логах маємо IP подів Kubernetes та якихось зовнішніх ресурсів
  • в логах маємо напрямок трафіку через інтерфейс NAT Gateway – IN/OUT (ingress/egress, або RX/TX – Recieved та Transmitted)

Що ми б хотіли бачити на дашборді?

  • загальний об’єм трафіку, який пройшов через NAT Gateway і за який ми заплатити
  • загальний об’єм трафіку NAT Gateway за напрямком – ingress/egress
  • сервіси в Kubernetes, які генерують найбільший трафік
  • AWS сервіси та зовнішні ресурси, які генерують трафік – для цього маємо поля pkt-src-aws-service та pkt-dst-aws-service
  • дія з пакетами ACCEPT та REJECT – може бути корисним, якщо є AWS Web Application Firewall, або вам цікаві спрацювання VPC Network Access List
  • Availability Zones для визначення cross-AZ трафіку – але це наразі не в нашому випадку, бо у нас все в одній зоні
  • traffic-path – може бути корисним для визначення якого типу трафік йде – всередині VPC, через VPC Endpoint тощо (хоча особисто я не став це використовувати в дашборді)

NAT Gateway total traffic processed

Отримати суму всього трафіку за період часу ми можемо таким запитом:

sum (
    sum_over_time(
        ({logtype="alb", component="vpc-flow-logs", __aws_s3_vpc_flow="fl-01e52787e4495dfee"} | pattern `<region> <vpc_id> <az_id> <subnet_id> <instance_id> <interface_id> <flow_direction> <src_add> <dst_addr> <src_port> <dst_port> <pkt_src_addr> <pkt_dst_addr> <pkt_src_aws_service> <pkt_dst_aws_service> <traffic_path> <packets> <bytes> <action>`
        | interface_id="eni-0352f8c82da6aa229"
        | unwrap bytes
        | __error__=""
        )[$__range]
    )
)

Використовуємо [$__range], аби взяти проміжок часу, який задано в Grafana dashboard. В sum_over_time рахуємо всі bytes за цей час, і “загортаємо” все в sum(), аби мати просто цифру.

Для панелі “Total traffic processed” можна взяти тип візуалізації Stat, використати Unit з Bytes(IEC), і виглядати це буде так:

Маємо тут 5.8 GB за 15 хвилин.

Зараз маю для перевірки Flow Logs в CloudWatch, де можемо зробити такий запит для порівняння:

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 like "eni-0352f8c82da6aa229") | stats sum(bytes) as bytesTransferred
| sort bytesTransferred desc
| limit 10

Тут у нас за ті ж 15 хвилин 4835737572 байт, тобто такі ж 4.8 гігабайта.

Окей – загальний трафік маємо.

Давайте додамо відображення Egress та Ingress.

NAT Gateway Egress та Ingress traffic processed – Stat

Тут все аналогічно, тільки додаємо фільтр | flow_direction="egress" або “ingress” відповідно:

sum (
    sum_over_time(
        ({logtype="alb", component="vpc-flow-logs", __aws_s3_vpc_flow="fl-01e52787e4495dfee"} | pattern `<region> <vpc_id> <az_id> <subnet_id> <instance_id> <interface_id> <flow_direction> <src_add> <dst_addr> <src_port> <dst_port> <pkt_src_addr> <pkt_dst_addr> <pkt_src_aws_service> <pkt_dst_aws_service> <traffic_path> <packets> <bytes> <action>`
        | interface_id="eni-0352f8c82da6aa229"
        | flow_direction="egress"
        | unwrap bytes
        | __error__=""
        )[$__range]
    )
)

Так як панель у нас з типом Stat, де просто відображається цифра – то задля зменшення навантаження на Grafana та Loki в Options є сенс поставити Type == Instant.

Змінні дашборди – $kubernetes_pod_ip та $remote_svc_ip

По-перше – нам цікавий трафік саме з/до Kubernetes Pods, бо майже всі наші сервіси живуть там.

По-друге – хочеться мати можливість вибрати дані тільки по обраним pkt_src_addr та pkt_dst_addr – це може бути або Kubernetes Pod, або якийсь зовнішній сервіс – в залежності від ingress/egress трафіку.

Так як ми оперуємо з “сирими” записами в логах, а не метриками з лейблами – то ми не можемо просто взяти значення з полів, тому я додав дві змінні з типом Textbox, в які можна внести IP вручну:

А далі ми можемо ці змінні додати в усі наші запити з регуляркою pkt_src_addr=~"${kubernetes_pod_ip}", аби запит спрацьовував, якщо в змінній не задано жодного значення:

sum (
    sum_over_time(
        ({logtype="alb", component="vpc-flow-logs", __aws_s3_vpc_flow="fl-01e52787e4495dfee"} | pattern `<region> <vpc_id> <az_id> <subnet_id> <instance_id> <interface_id> <flow_direction> <src_add> <dst_addr> <src_port> <dst_port> <pkt_src_addr> <pkt_dst_addr> <pkt_src_aws_service> <pkt_dst_aws_service> <traffic_path> <packets> <bytes> <action>`
        | interface_id="eni-0352f8c82da6aa229"
        | flow_direction="egress"
        | pkt_src_addr=~"${kubernetes_pod_ip}"
        | pkt_dst_addr=~"${remote_svc_ip}"        
        | unwrap bytes
        | __error__=""
        )[$__range]
    )
)

Loki Recording Rules, поля, High Cardinality issue, та performance

Чому ми оперуємо з “сирими” записами в логах, а не метриками з лейблами?

Можна було б створити Recording Rule для Loki по типу такого:

...
        - record: aws:nat:egress_bytes:sum:15m
          expr: |
            sum (
                sum_over_time(
                    ({logtype="alb", component="vpc-flow-logs", __aws_s3_vpc_flow="fl-01e52787e4495dfee"} | pattern `<region> <vpc_id> <az_id> <subnet_id> <instance_id> <interface_id> <flow_direction> <src_add> <dst_addr> <src_port> <dst_port> <pkt_src_addr> <pkt_dst_addr> <pkt_src_aws_service> <pkt_dst_aws_service> <traffic_path> <packets> <bytes> <action>`
                    | interface_id="eni-0352f8c82da6aa229"
                    | flow_direction="egress"
                    | pkt_src_addr=ip("10.0.32.0/20")
                    | unwrap bytes
                    | __error__=""
                    )[15m]
                )
            )
...

Але якщо з даними типу “total processed bytes” це нормальний варіант, то далі, коли ми будемо створювати панелі з інформацією по IP, у нас буде проблема в тому, як ці IP зберігати в метриках.

Якщо ми будемо значення з pkt_src_addr та pkt_dst_addr заносити в лейбли метрики – то це призведе до того, що Loki буде створювати окремий набір блоків даних (chunks) на кожний унікальний набір лейбл.

А так як IP в VPC у нас багато, а зовнішніх IP може бути ще більше – то можемо отримати мільйони блоків даних, що вплине і на вартість зберігання даних в AWS S3, і на перформанс самої Loki та VictoriaMetrics або Prometheus, бо їм доведеться всі ці дані завантажувати про виконанні запитів в Grafana. Див. Loki Recording Rules, Prometheus/VictoriaMetrics та High Cardinality.

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

Тому тут варіант або змиритись з high cardinality issue і не дотримуватись best practices – або використовувати “сирі логи” в дашбордах.

При роботі з сирими логами в Loki та Grafana ми, звісно, обмежуємо себе, бо на запитах за відносно великий проміжок часу – наприклад, кілька годин – Loki починає жрати ресурси, як дурна, див. колонку CPU – майже 4 ядра зайняті повністю:

Можливо, я все ж спробую створити Recording Rules з IP в лейблах, подивитись як це вплине на систему. Поки у нас невеликий стартап і мало трафіку – це ще може бути варіантом. Але на великих обсягах такого краще не робити.

Іншим рішенням може бути спробувати використати VictoriaLogs (див. VictoriaLogs: знайомство, запуск в Kubernetes, LogsQL та Grafana), яка набагато краще може з цим працювати, і, скоріш за все, на наступному тижні я буду перероблювати цю схему саме з VictoriaLogs, тим більш сама VictoriaLogs вже отримала свій перший реліз, а скоро і її Grafana datasource вже буде офіційно додано до Grafana.

Крім того, до VictoriaLogs вже завели і підтримку Recording Rules, і алерти – див. vmalert.

NAT Gateway та traffic processed – графіки

На додачу до простих Stat панелей може бути корисним створити графіки – аби мати уявлення про те, як якісь зміни впливали на трафік.

Тут запит може бути аналогічним, тільки замість sum_over_time() використаємо rate(), в Options використовуємо Type == Range, а в Standart Options > Unit задаємо “bytes/sec”:

sum (
    rate(
        ({logtype="alb", component="vpc-flow-logs", __aws_s3_vpc_flow="fl-01e52787e4495dfee"} | pattern `<region> <vpc_id> <az_id> <subnet_id> <instance_id> <interface_id> <flow_direction> <src_add> <dst_addr> <src_port> <dst_port> <pkt_src_addr> <pkt_dst_addr> <pkt_src_aws_service> <pkt_dst_aws_service> <traffic_path> <packets> <bytes> <action>`
        | interface_id="eni-0352f8c82da6aa229"
        | unwrap bytes
        | __error__=""
        )[15m]
    )
)

Для rate() беремо період 15 хвилин, бо логи у нас пишуться раз на 10 хвилин – дефолтне значення для flow_log_max_aggregation_interval в модулі terraform-aws-modules/vpc/aws.

Сервіси в Kubernetes, які генерують найбільший трафік

Наступним хочеться бачити IP з Kubernetes Pods, які генерують трафік.

Тут можемо створити візуалізацію з типом Pie chart і таким запитом:

topk (5,
  sum by (pkt_src_addr) (
    sum_over_time(
      (
        {component="vpc-flow-logs", __aws_s3_vpc_flow="fl-01e52787e4495dfee"}
        | pattern `<region> <vpc_id> <az_id> <subnet_id> <instance_id> <interface_id> <flow_direction> <src_addr> <dst_addr> <src_port> <dst_port> <pkt_src_addr> <pkt_dst_addr> <pkt_src_aws_service> <pkt_dst_aws_service> <traffic_path> <packets> <bytes> <action>`
        | interface_id="eni-0352f8c82da6aa229"
        | action="ACCEPT"
        | pkt_src_addr=ip("10.0.32.0/20")
        | pkt_src_addr=~"${kubernetes_pod_ip}"
        | pkt_dst_addr=~"${remote_svc_ip}"
        | unwrap bytes
        | __error__=""
      )[$__range]
    )
  )
)

Використовуємо ip() з CIDR нашої приватної мережі, аби вибрати записи тільки з IP наших Pods (див. Matching IP addresses), і topk(5), аби відобразити тільки ті Pods, які генерують найбільше трафіку.

В результаті маємо таку картину:

В топі у нас IP 10.0.44.66 – глянемо, що за сервіс:

$ kk get pod -A -o wide | grep -w 10.0.44.66
dev-backend-api-ns    backend-celery-workers-deployment-b68f88f74-rzq4j    ... 10.0.44.66 ...

Є такий Kubernetes Pod, окей. Тепер маємо уяву хто шле багато трафіку.

Grafana Data links

Аби швидко отримати інформацію що за IP, та до якого Kubernetes Pod він належить – можемо додати Grafana Data Links.

Наприклад, в мене є окрема дашборда, де по Pod IP можна отримати всю інформацію про нього.

Тоді можемо створити Data link з полем ${__field.labels.pkt_src_addr}:

І дашборда по IP “10.0.44.66”:

Всі доступні поля для Data links можна отримати з Ctrl+Space.

Або замість (чи на додачу) Pie chart можемо створити звичайний графік, аби мати “історичну картину”, як ми це робили для NAT Gateway Total traffic:

topk(5,
  sum by (pkt_src_addr) (
    rate(
      (
        {component="vpc-flow-logs", __aws_s3_vpc_flow="fl-01e52787e4495dfee"}
        | pattern `<region> <vpc_id> <az_id> <subnet_id> <instance_id> <interface_id> <flow_direction> <src_addr> <dst_addr> <src_port> <dst_port> <pkt_src_addr> <pkt_dst_addr> <pkt_src_aws_service> <pkt_dst_aws_service> <traffic_path> <packets> <bytes> <action>`
        | interface_id="eni-0352f8c82da6aa229"
        | action="ACCEPT"
        | pkt_src_addr=ip("10.0.32.0/20")
        | pkt_src_addr=~"${kubernetes_pod_ip}"
        | pkt_dst_addr=~"${remote_svc_ip}"
        | unwrap bytes
        | __error__=""
      )[15m]
    )
  )
)

Remote services, які генерують найбільший трафік

По Kubernetes Pods інформацію отримали – давайте глянемо, звідки до нас приходить найбільше трафіку.

Тут все аналогічно, тільки фільтр робимо по pkt_dst_addr=ip("10.0.32.0/20") – тобто вибираємо всі записи, де пакет йде ззовні на NAT Gateway і потім до наших Pods:

topk(10,
  sum by (pkt_src_addr) (
    sum_over_time(
      (
        {component="vpc-flow-logs", __aws_s3_vpc_flow="fl-01e52787e4495dfee"}
        | pattern `<region> <vpc_id> <az_id> <subnet_id> <instance_id> <interface_id> <flow_direction> <src_addr> <dst_addr> <src_port> <dst_port> <pkt_src_addr> <pkt_dst_addr> <pkt_src_aws_service> <pkt_dst_aws_service> <traffic_path> <packets> <bytes> <action>`
        | interface_id="eni-0352f8c82da6aa229"
        | action="ACCEPT"
        | pkt_dst_addr=ip("10.0.32.0/20")
        | pkt_src_addr=~"${remote_svc_ip}"
        | pkt_dst_addr=~"${kubernetes_pod_ip}"
        | unwrap bytes
        | __error__=""
      )[$__range]
    )
  )
)

А в Data Links можемо використати сервіс https://ipinfo.io і поле pkt_src_addr:

Сервіси в Kubernetes, які генерують найбільший трафік – таблиця з портами

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

Чому окремо – бо тут ми робимо запит з великою вибіркою по декільком полям, і через це Loki доведеться тягнути додаткові дані. Тому на запитах за великий проміжок часу нехай краще не прогрузиться одна табличка – але будуть графіки.

В табличку можемо додати відображення портів – буде корисно при визначенні сервісу.

Створюємо візуалізацію з типом Table і таким запитом:

topk(10,
  sum by (pkt_src_addr, src_port, pkt_dst_addr, dst_port, pkt_dst_aws_service) (
    sum_over_time(
      (
        {component="vpc-flow-logs", __aws_s3_vpc_flow="fl-01e52787e4495dfee"}
        | pattern `<region> <vpc_id> <az_id> <subnet_id> <instance_id> <interface_id> <flow_direction> <src_addr> <dst_addr> <src_port> <dst_port> <pkt_src_addr> <pkt_dst_addr> <pkt_src_aws_service> <pkt_dst_aws_service> <traffic_path> <packets> <bytes> <action>`
        | interface_id="eni-0352f8c82da6aa229"
        | action="ACCEPT"
        | pkt_src_addr=ip("10.0.32.0/20")
        | pkt_src_addr=~"${kubernetes_pod_ip}"
        | pkt_dst_addr=~"${remote_svc_ip}"
        | unwrap bytes
        | __error__=""
      )[$__range]
    )
  )
)

В Options задаємо Type == Instant:

Додаємо Transformations:

  • Filter fields by name: тут прибираємо Time
  • Organize fields by name: міняємо заголовки колонок

Значення для Standard options > Unit та Data Links задаємо через Fields override, бо для кожної колонки у нас будуть власні параметри:

Grafana dashboard: фінальний результат

І все разом в мене поки що вийшло ось так:

Якщо не зважати не проблеми з перформансом Loki при використанні raw logs – то наче непогано. Вже дуже допомогло визначити зайвий трафік, наприклад – багато трафіку йшло від Athena, тому додамо VPC Endpoint для неї, аби не ганяти цей трафік через NAT Gateway.

Далі, мабуть, таки спробую варіант з Recording Rules для Loki, і точно буду пробувати писати логи до VictoriaLogs, і робити графіки та алерти через неї.

Loading

GitHub Actions: запуск Actions Runner Controller в Kubernetes
0 (0)

24 Вересня 2024

Ми користуємось GitHub Actions для деплоїв, і врешті-решт прийшли до того, що хочеться запускати Runners на своїх серверах в Kubernetes, бо:

  • self-hosted GitHub Runners дешевші – фактично, платимо тільки за сервери, на яких запускаються джоби
  • нам потрібно запускати SQL migrations на AWS RDS в приватних сабнетах
  • перформанс – ми можемо використовувати будь-який тип AWS EC2 та типи дисків, і не обмежувати себе в CPU/Memory та IOPS

Загальна документація – About self-hosted runners.

GitHub для цього має окремий контролер – Actions Runner Controller (ARC), який ми власне і будемо використовувати.

Запускати будемо на AWS Elastic Kubernetes Service v1.30, Karpenter 1.0 для скейлінгу EC2, та AWS Elastic Container Registry для Docker images.

У self-hosted раннерів є Usage limits, але навряд чи ми з ними зіткнемося.

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

  • з Helm встановимо Actions Runner Controller та Scale Set з Runners для тестового репозиторію, подивимось, як воно все взагалі працює
  • створимо окремий Karpenter NodePool з taints для запуску Runners на окремих інстансах
  • спробуємо реальні білди і деплої для нашого Backend API, подивимось, які будуть помилки
  • створимо власний Docker image для раннерів
  • спробуємо в роботі Docker in Docker mode
  • створимо окремий Kubernetes StorageClass з high IOPS, і подивимось, як це вплине на швидкість білдів-деплоїв

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

Аутентифікація в GitHub

Документація – Authenticating to the GitHub API.

Тут є два варіанти – більш кошерний для production через GitHub App, або через персональний токен.

З GitHub App виглядає як більш правильне рішення, але ми невеликий стартап, і через персональний токен буде простіше – тому поки зробимо так, а “потім” (с) при потребі зробимо вже “як треба”.

Переходимо до свого профайлу, клікаємо Settings > Developer settings > Personal access tokens, клікаємо Generate new token (classic):

Self-hosted runners поки будемо використовувати тільки для одного репозиторію, тому задаємо права тільки на repo:

Expiration було б добре задати, але це PoC (який потім, як завжди, піде в production), тому поки ОК – нехай буде вічний.

Створюємо Kubernetes Namespace для раннерів:

$ kk create ns ops-github-runners-ns
namespace/ops-github-runners-ns created

Створюємо в ньому Kubernetes Secret з токеном:

$ kk -n ops-github-runners-ns create secret generic gh-runners-token --from-literal=github_token='ghp_FMT***5av'
secret/gh-runners-token created

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

$ kk -n ops-github-runners-ns get secret -o yaml
apiVersion: v1
items:
- apiVersion: v1
  data:
    github_token: Z2h***hdg==
  kind: Secret
...

Запуск Actions Runner Controller з Helm

Actions Runner Controller складається з двох частин:

  • gha-runner-scale-set-controller: власне сам контролер – його Helm-чарт створить необхідні Kubernetes CRD та запустить поди контролера
  • gha-runner-scale-set: відповідає за запуск Kubernetes Pods з GitHub Action Runners

Крім того, ще є легасі-версія – actions-runner-controller, але ми її використовувати не будемо.

Хоча в документації Scale Sets Controller теж називається Actions Runner Controller, і при цьому є ще й легасі Actions Runner Controller… Трохи плутає, майте на увазі, що частина нагуглених прикладів/документації може бути саме про легасі-версію.

Документація – Quickstart for Actions Runner Controller, або повний варіант – Deploying runner scale sets with Actions Runner Controller.

Встановлення Scale Set Controller

Зробимо окремий Namespace для контролера:

$ kk create ns ops-github-controller-ns
namespace/ops-github-controller-ns created

Встановлюємо чарт – там values доволі простий, ніяких апдейтів робити не треба:

$ helm -n ops-github-controller-ns upgrade --install github-runners-controller \
> oci://ghcr.io/actions/actions-runner-controller-charts/gha-runner-scale-set-controller

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

$ kk -n ops-github-controller-ns get pod
NAME                                                           READY   STATUS    RESTARTS   AGE
github-runners-controller-gha-rs-controller-5d6c6b587d-fv8bz   1/1     Running   0          2m26s

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

$ kk get crd | grep github
autoscalinglisteners.actions.github.com                     2024-09-17T10:41:28Z
autoscalingrunnersets.actions.github.com                    2024-09-17T10:41:29Z
ephemeralrunners.actions.github.com                         2024-09-17T10:41:29Z
ephemeralrunnersets.actions.github.com                      2024-09-17T10:41:30Z

Встановлення Scale Set для Runners

Кожен Scale Set (ресурс AutoscalingRunnerSet) відповідає за конкретні Runners, які ми будемо використовувати через runs-on в workflow-файлах.

Задаємо дві змінні оточення – потім це передамо через власний файл values:

  • INSTALLATION_NAME: ім’я runners (в values задається через runnerScaleSetName)
  • GITHUB_CONFIG_URL: URL GitHub Organization або репозиторію в форматі https://github.com/<ORG_NAME>/<REPO_NAME>
$ INSTALLATION_NAME="test-runners"
$ GITHUB_CONFIG_URL="https://github.com/***/atlas-test"

Встановлюємо чарт, передаємо githubConfigUrl та githubConfigSecret – тут у нас вже є створений секрет, використовуємо його:

$ helm -n ops-github-runners-ns upgrade --install test-runners \
> --set githubConfigUrl="${GITHUB_CONFIG_URL}" \
> --set githubConfigSecret=gh-runners-token \
> oci://ghcr.io/actions/actions-runner-controller-charts/gha-runner-scale-set

Ще раз перевіряємо поди в неймспейсі контролера – має додатись новий, з ім’ям test-runners-*-listener – він буде відповідати за запуск подів з ранерами для групи “test-runners“:

$ kk -n ops-github-controller-ns get pod
NAME                                                           READY   STATUS    RESTARTS   AGE
github-runners-controller-gha-rs-controller-5d6c6b587d-fv8bz   1/1     Running   0          8m38s
test-runners-694c8c97-listener                                 1/1     Running   0          40s

А створюється він з AutoscalingListeners:

$ kk -n ops-github-controller-ns get autoscalinglisteners 
NAME                             GITHUB CONFIGURE URL                            AUTOSCALINGRUNNERSET NAMESPACE   AUTOSCALINGRUNNERSET NAME
test-runners-694c8c97-listener   https://github.com/***/atlas-test   ops-github-runners-ns            test-runners

Перевіряємо поди в неймспейсі з самими ранерами – тут поки що пусто:

$ kk -n ops-github-runners-ns get pod
No resources found in ops-github-runners-ns namespace.

Власне, для початку на цьому і все – можна починати запускати джоби. А там по ходу діла будемо дивитись “де впало”, і додавати конфігурації.

Тест з GitHub Actions Workflow

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

В тестовому репозиторії створюємо файл .github/workflows/test-gh-runners.yml.

В runs-on задаємо ім’я нашого пулу раннерів – test-runners:

name: "Test GitHub Runners"

concurrency:
  group: github-test
  cancel-in-progress: false

on:
  workflow_dispatch:
      
permissions:
  # allow read repository's content by steps
  contents: read

jobs:

  aws-test:
    name: Test EKS Runners
    runs-on: test-runners
    steps:

      - name: Test Runner
        run: echo $HOSTNAME

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

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

$ kk -n ops-github-runners-ns get pod
NAME                              READY   STATUS    RESTARTS   AGE
test-runners-p7j9h-runner-xhb94   1/1     Running   0          6s

Цей жеж раннер і відповідний Runner Scale Set буде в Settings > Actions > Runners:

І джоба завершилась:

Окей – воно працює. Що далі?

  • треба створити Karpenter NodePool з серверів виключно під GitHub Runners
  • треба задати requests на поди
  • треба подивитись як раннери зможуть білдити Docker-образи

Створення Karpenter NodePool

Створимо окремий Karpenter NodePool з taints, аби на цих EC2 запускались тільки поди з GitHub Runners (див. Kubernetes: Pods та WorkerNodes – контроль розміщення подів на нодах):

apiVersion: karpenter.sh/v1
kind: NodePool
metadata:
  name: github1abgt
spec:
  weight: 20
  template:
    metadata:
      labels:
        created-by: karpenter
        component: devops
    spec:
      taints:
        - key: GitHubOnly
          operator: Exists
          effect: NoSchedule
      nodeClassRef:
        group: karpenter.k8s.aws
        kind: EC2NodeClass
        name: defaultv1a
      requirements:
        - key: karpenter.k8s.aws/instance-family
          operator: In
          values: ["c5"]
        - key: karpenter.k8s.aws/instance-size
          operator: In
          values: ["large", "xlarge"]
        - key: topology.kubernetes.io/zone
          operator: In
          values: ["us-east-1a"]
        - key: karpenter.sh/capacity-type
          operator: In
          values: ["spot", "on-demand"]
  # total cluster limits
  limits:
    cpu: 1000
    memory: 1000Gi
  disruption:
    consolidationPolicy: WhenEmptyOrUnderutilized
    consolidateAfter: 600s
    budgets:
      - nodes: "1"

Типи інстансів тут не тюнив, скопіював з NodePool нашого Backend API – потім подивимось, скільки ресурсів раннери будуть використовувати для роботи.

Ще є сенс потюнити disruptionconsolidationPolicy, consolidateAfter та budgets. Наприклад, якщо всі девелопери працюють за одною таймзоною – то WhenEmptyOrUnderutilized робити вночі, а вдень видаляти тільки по WhenEmpty, і задати вищий consolidateAfter, аби нові джоби не чекали зайвого часу на створення EC2. Див. Karpenter: використання Disruption budgets.

Автоматизація Helm deploy Scale Set

Тут варіантів кілька:

  • можемо використати Terraform resource helm_release
  • можемо створити власний чарт, і в ньому встановлювати чарти GitHub Runners через Helm Dependency

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

Я скоріш за все заміксую схему:

  • сам контролер буде встановлюватись з Terraform коду, який розгортає весь Kubernetes кластер – там встановлюються інші контролери типу ExternalDNS, ALB Ingress Controller, etc
  • для створення Scale Sets з пулами раннерів під кожен репозиторій зроблю окремий Helm chart в окремому репозиторії, і в ньому у templates/ додам конфіг-файли для кожного пула раннерів
    • але поки це ще в PoC – то Scale Sets буде встановлюватись з Makefile який виконує helm install -f values.yaml

Створюємо власний values.yaml, задаємо runnerScaleSetName, requests та tolerations до tains з нашого NodePool:

githubConfigUrl: "https://github.com/***/atlas-test"
githubConfigSecret: gh-runners-token

runnerScaleSetName: "test-runners"

template:
  spec:
    containers:
      - name: runner
        image: ghcr.io/actions/actions-runner:latest
        command: ["/home/runner/run.sh"]    
        resources:
          requests:
            cpu: 1
            memory: 1Gi
    tolerations:
      - key: "GitHubOnly"
        effect: "NoSchedule"  
        operator: "Exists"

Додамо простенький Makefile:

deploy-helm-runners-test:
  helm -n ops-github-runners-ns upgrade --install test-eks-runners -f test-github-runners-values.yaml oci://ghcr.io/actions/actions-runner-controller-charts/gha-runner-scale-set

Деплоїмо:

$ make deploy-helm-runners-test

Перевіряємо чи змінився конфіг цього пулу раннерів:

$ kk -n ops-github-runners-ns describe autoscalingrunnerset test-runners
Name:         test-runners
Namespace:    ops-github-runners-ns
...
API Version:  actions.github.com/v1alpha1
Kind:         AutoscalingRunnerSet
...
Spec:
  Github Config Secret:   gh-runners-token
  Github Config URL:      https://github.com/***/atlas-test
  Runner Scale Set Name:  test-runners
  Template:
    Spec:
      Containers:
        Command:
          /home/runner/run.sh
        Image:  ghcr.io/actions/actions-runner:latest
        Name:   runner
        Resources:
          Requests:
            Cpu:             1
            Memory:          1Gi
      Restart Policy:        Never
      Service Account Name:  test-runners-gha-rs-no-permission
      Tolerations:
        Effect:    NoSchedule
        Key:       GitHubOnly
        Operator:  Exists

Аби перевірити, що буде використовуватись новий Karpenter NodePool – запускаємо тестовий білд, і перевіряємо NodeClaims:

$ kk get nodeclaim | grep git
github1abgt-dq8v5    c5.large    spot       us-east-1a                                 Unknown   20s

ОК, інстанс створюється, і под з раннером теж:

$ kk -n ops-github-runners-ns get pod
NAME                              READY   STATUS              RESTARTS   AGE
test-runners-6s8nd-runner-2s47n   0/1     ContainerCreating   0          45s

Тут все працює.

Білд Backend API

А тепер давайте спробуємо запустити білд і деплой нашого бекенду з реальним кодом і GitHub Actions Workflows.

Створюємо новий values для нового пула раннерів:

githubConfigUrl: "https://github.com/***/kraken"
githubConfigSecret: gh-runners-token

runnerScaleSetName: "kraken-eks-runners"
...

Деплоїмо новий Scale Set:

$ helm -n ops-github-runners-ns upgrade --install kraken-eks-runners \
> -f kraken-github-runners-values.yaml \
> oci://ghcr.io/actions/actions-runner-controller-charts/gha-runner-scale-set

Редагуємо workflow проекту – міняємо runs-on: ubuntu-latest на runs-on: kraken-eks-runners:

...
jobs:
  eks_build_deploy:
    name: "Build and/or deploy backend"
    runs-on: kraken-eks-runners
...

Запускаємо білд, новий Pod створився:

$ kk -n ops-github-runners-ns get pod
NAME                                    READY   STATUS    RESTARTS   AGE
kraken-eks-runners-pg29x-runner-xwjxx   1/1     Running   0          11s

І білд пішов:

Але тут жеж впав з помилками, що не може знайти make та git:

GitHub Runners image та “git: command not found”

Перевіримо вручну – запускаємо ghcr.io/actions/actions-runner:latest локально:

$ docker run -ti ghcr.io/actions/actions-runner:latest bash
To run a command as administrator (user "root"), use "sudo <command>".
See "man sudo_root" for details.

runner@c8aa7e25c76c:~$ make
bash: make: command not found
runner@c8aa7e25c76c:~$ git
bash: git: command not found

Ну, те, що нема make – ще якось можна зрозуміти. Але на GitHub Runners не додати “в коробку” git?

Ось в цій GitHub Issue люди теж дивуються такому рішенню.

Але ок… Маємо, що маємо. Що ми можемо зробити – це створити власний образ, де за базу будемо брати ghcr.io/actions/actions-runner, і встановлювати все, що нам необхідно для щастя.

Див. Software installed in the ARC runner image. Також є інші образи, не від GitHub – Runners, але я їх не пробував.

Отже, наш базовий образ GitHub Runners використовує Ubuntu 22.04, тому можемо з apt встановити всі потрібні пакети.

Описуємо Dockerfile – я тут вже додав і AWS CLI, і кілька пакетів для Python:

FROM ghcr.io/actions/actions-runner:latest

RUN sudo curl https://raw.githubusercontent.com/helm/helm/main/scripts/get-helm-3 | sudo bash

RUN sudo apt update && \
  sudo apt -y install git make python3-pip awscli python3-venv

Але ще можливі warnings типу такого:

WARNING: The script gunicorn is installed in ‘/home/runner/.local/bin’ which is not on PATH.
Consider adding this directory to PATH or, if you prefer to suppress this warning, use –no-warn-script-location.

Тому в Dockerfile додав PATH:

FROM ghcr.io/actions/actions-runner:latest

ENV PATH="$PATH:/home/runner/.local/bin"

RUN sudo curl https://raw.githubusercontent.com/helm/helm/main/scripts/get-helm-3 | sudo bash

RUN sudo apt update && \
  sudo apt -y install git make python3-pip awscli python3-venv

Створюємо репозиторій в AWS ECR:

Білдимо образ:

$ docker build -t 492***148.dkr.ecr.us-east-1.amazonaws.com/github-runners/kraken -f Dockefile.kraken .

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

$ aws --profile work 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/github-runners/kraken

Міняємо image в нашому values:

...
runnerScaleSetName: "kraken-eks-runners"

template:
  spec:
    containers:
      - name: runner
        image: 492***148.dkr.ecr.us-east-1.amazonaws.com/github-runners/kraken:latest
        command: ["/home/runner/run.sh"]  
...

Деплоїмо, запускаємо білд – і маємо нову проблему.

Помилка “Cannot connect to the Docker daemon” та Scale Set containerMode “Docker in Docker”

Тепер виникає проблема з Docker:

docker: Cannot connect to the Docker daemon at unix:///var/run/docker.sock. Is the docker daemon running?.

Бо під час білду нашого бекенду запускається ще один Docker-контейнер для генерації OpenAPI docs.

Тому в нашому випадку нам потрібно використати Docker in Docker (хоча дуже не люблю цю схему).

Документація GitHub – Using Docker-in-Docker mode.

У Scale Sets для цього є окремий параметр containerMode.type=dind.

Додаємо в наш values:

...
runnerScaleSetName: "kraken-eks-runners"

containerMode:
  type: "dind"

template:
  spec:
...

Деплоїмо Helm, і тепер маємо два контейнери в поді з раннером – один сам runner, інший – dind:

==> New container [kraken-eks-runners-trb9h-runner-klfxk:dind]
==> New container [kraken-eks-runners-trb9h-runner-klfxk:runner]

Запускаємо білд, і… Маємо нову помилку 🙂

Привіт DinD та Docker volumes.

Помилка виглядає так:

Error: ENOENT: no such file or directory, open ‘/app/openapi.yml’

Docker in Docker та Docker volumes

Виникає вона через те, що в коді API створюється директорія в /tmp, в якій генерується файл openapi.yml, з якого потім генерується HTML з документацією:

...
def generate_openapi_html_definitions(yml_path: Path, html_path: Path):
    print("Running docker to generate HTML")
    
    app_volume_path = Path(tempfile.mkdtemp())
    (app_volume_path / "openapi.yml").write_text(yml_path.read_text())

    if subprocess.call(
        [
            "docker",
            "run",
            "-v",
            f"{app_volume_path}:/app",
            "--platform",
            "linux/amd64",
            "-e",
            "yaml_path=/app/openapi.yml",
            "-e",
            "html_path=/app/openapi.html",
            "492***148.dkr.ecr.us-east-1.amazonaws.com/openapi-generator:latest",
        ]
    ):
...

Тут Path(tempfile.mkdtemp()) створює нову директорію в /tmp – але це виконується всередині контейнера kraken-eks-runners-trb9h-runner-klfxk:runner, а docker run -v f"{app_volume_path}:/app" запускається всередині контейнера kraken-eks-runners-trb9h-runner-klfxk:dind.

Давайте просто глянемо на маніфест поду:

$ kk -n ops-github-runners-ns describe autoscalingrunnerset kraken-eks-runners
...
  Template:
    Spec:
      Containers:
        ...
        Image:    492***148.dkr.ecr.us-east-1.amazonaws.com/github-runners/kraken:0.8
        Name:     runner
        ...
        Volume Mounts:
          Mount Path:  /home/runner/_work
          Name:        work
        ...
        Image:    docker:dind
        Name:     dind
        ...
        Volume Mounts:
          Mount Path:  /home/runner/_work
          Name:        work
        ...

Тобто, у обох контейнерів є спільний каталог /home/runner/_work, який створюється на хості/EC2, і маунтиться в Kubernetes Pod до обох Docker-контейнерів.

А каталог /tmp в контейнері runner – “локальний” для нього, і недоступний для контейнера з dind.

Тому як варіант – просто створювати новий каталог для файлу openapi.yml всередині /home/runner/_work:

...
    # get $HONE, fallback to the '/home/runner'
    home = os.environ.get('HOME', '/home/runner')
    # set app_volume_path == '/home/runner/_work/tmp/'
    app_volume_path = Path(home) / "_work/tmp/"

    # mkdir recursive, exist_ok=True in case the dir already created by openapi/asyncapi
    app_volume_path.mkdir(parents=True, exist_ok=True)
    (app_volume_path / "openapi.yml").write_text(yml_path.read_text())
...

Або зробити ще краще – на випадок, якщо білд буде запускатись на GitHub hosted Runners, то додати перевірку того, на якому саме раннері запущена джоба, і відповідно вибирати де створювати каталог.

В values нашого Scale Set додаємо змінну RUNNER_EKS:

...
template:
  spec:
    containers:
      - name: runner
        image: 492***148.dkr.ecr.us-east-1.amazonaws.com/github-runners/kraken:0.8
        command: ["/home/runner/run.sh"]
        env:
        - name: RUNNER_EKS
          value: "true"
...

А в коді – перевірку цієї змінної, і в залежності від неї задаємо каталог app_volume_path:

...
    # our runners will have the 'RUNNER_EKS=true'
    if os.environ.get('RUNNER_EKS', '').lower() == 'true':
        # Get $HOME, fallback to the '/home/runner'
        home = os.environ.get('HOME', '/home/runner')

        # Set app_volume_path to the '/home/runner/_work/tmp/'
        app_volume_path = Path(home) / "_work/tmp/"

        # mkdir recursive, exist_ok=True in case the dir already created by openapi/asyncapi
        app_volume_path.mkdir(parents=True, exist_ok=True)
    # otherwize if it's a GitHub hosted Runner without the 'RUNNER_EKS', use the old code
    else:
        app_volume_path = Path(tempfile.mkdtemp())

    (app_volume_path / "openapi.yml").write_text(yml_path.read_text())
...

Запускаємо білд ще раз – і тепер все працює:

Помилка “Access to the path ‘/home/runner/_work/_temp/_github_home/.kube/cache’ is denied”

Ще іноді виникає проблема, коли в кінці білда-деплоя джоба завершується з повідомленням “Error: The opeation was canceled“:

В логах раннера при цьому є і причина – він не може видалити директорію _github_home/.kube/cache:

...
kraken-eks-runners-wwn6k-runner-zlw7s:runner [WORKER 2024-09-20 10:55:23Z INFO TempDirectoryManager] Cleaning runner temp folder: /home/runner/_work/_temp
kraken-eks-runners-wwn6k-runner-zlw7s:runner [WORKER 2024-09-20 10:55:23Z ERR  TempDirectoryManager] System.AggregateException: One or more errors occurred. (Access to the path '/home/runner/_work/_temp/_github_home/.kube/cache' is denied.)
kraken-eks-runners-wwn6k-runner-zlw7s:runner [WORKER 2024-09-20 10:55:23Z ERR  TempDirectoryManager]  ---> System.UnauthorizedAccessException: Access to the path '/home/runner/_work/_temp/_github_home/.kube/cache' is denied.
kraken-eks-runners-wwn6k-runner-zlw7s:runner [WORKER 2024-09-20 10:55:23Z ERR  TempDirectoryManager]  ---> System.IO.IOException: Permission denied
...

І дійсно, якщо перевірити каталог /home/runner/_work/_temp/_github_home/ з контейнера runner – то він туди доступу не має:

runner@kraken-eks-runners-7pd5d-runner-frbbb:~$ ls -l /home/runner/_work/_temp/_github_home/.kube/cache
ls: cannot open directory '/home/runner/_work/_temp/_github_home/.kube/cache': Permission denied

Але доступ є з контейнера з dind, який цей каталог і створює:

/ # ls -l /home/runner/_work/_temp/_github_home/.kube/cache
total 0
drwxr-x---    3 root     root            78 Sep 24 08:36 discovery
drwxr-x---    3 root     root           313 Sep 24 08:36 http

При цьому створює його від root, хоча решта каталогів – від юзера 1001:

/ # ls -l /home/runner/_work/_temp/
total 40
-rw-r--r--    1 1001     1001            71 Sep 24 08:36 79b35fe7-ba51-47fc-b5a2-4e4cdf227076.sh
drwxr-xr-x    2 1001     1001            24 Sep 24 08:31 _github_workflow
...

А 1001 – це юзер runner з контейнера runner:

runner@kraken-eks-runners-7pd5d-runner-frbbb:~$ id runner
uid=1001(runner) gid=1001(runner) groups=1001(runner),27(sudo),123(docker)

Цікаво, що помилка виникає не постійно, а час від часу, хоча в самому workflow нічного не міняється.

Каталог .kube/config створюється з action bitovi/github-actions-deploy-eks-helm, який виконує aws eks update-kubeconfig з власного Docker-контейнера, і запускається від рута, бо запускається в Docker in Docker.

З варіантів приходить в голову два рішення:

  • або просто додати костиль у вигляді додаткової команди chown -r 1001:1001 /home/runner/_work/_temp/_github_home/.kube/cache в кінці деплою (хоча можна таким жеж костилем просто видаляти директорію)
  • або змінити GITHUB_HOME в іншу директорію – тоді aws eks update-kubeconfig буде створювати .kube/cache в іншому місці, і контейнер з runner зможе виконати Cleaning runner temp folder

Хоча я все одно не розумію, чому Cleaning runner temp folder виконується не кожного разу, і, відповідно, це “плаваючий баг”. Подивимось далі, як воно буде в роботі.

Підключення High IOPS Volume

Одна з причин, чому ми хочемо перейти на власні раннери – це пришвидшити білди-деплої.

Але велику частку часу займаються команди типу docker load && docker save.

Тому хочеться спробувати підключити AWS EBS з високим IOPS, бо дефолтний gp2 має 100 IOPS на кожен GB розміру – див. Amazon EBS volume types.

Створюємо новий Kubernetes StorageClass:

apiVersion: storage.k8s.io/v1
kind: StorageClass
metadata:
  name: gp3-iops
provisioner: kubernetes.io/aws-ebs
parameters:
  type: gp3
  iopsPerGB: "16000"
  throughput: "1000"
allowVolumeExpansion: true
volumeBindingMode: WaitForFirstConsumer

У values нашого пула раннерів додаємо блок volumes, де перевизначаємо параметри для диска work, який по дефолту створюється з emptyDir: {} – задаємо новий storageClassName:

githubConfigUrl: "https://github.com/***/kraken"
githubConfigSecret: gh-runners-token

runnerScaleSetName: "kraken-eks-runners"

containerMode:
  type: "dind"

template:
  spec: 
    containers:
      - name: runner
        image: 492***148.dkr.ecr.us-east-1.amazonaws.com/github-runners/kraken:0.9
        command: ["/home/runner/run.sh"]
        env:
        - name: RUNNER_EKS
          value: "true"
        resources:
          requests:
            cpu: 2
            memory: 4Gi
    volumes:
      - name: work
        ephemeral:
          volumeClaimTemplate:
            spec:
              accessModes: [ "ReadWriteOnce" ]
              storageClassName: "gp3-iops"
              resources:
                requests:
                  storage: 10Gi

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

Помилка “Access to the path ‘/home/runner/_work/_tool’ is denied”

Дивимось логи раннерів, і бачимо, що:

kraken-eks-runners-gz866-runner-nx89n:runner [RUNNER 2024-09-24 10:15:40Z ERR  JobDispatcher] System.UnauthorizedAccessException: Access to the path ‘/home/runner/_work/_tool’ is denied.

На документацію Error: Access to the path /home/runner/_work/_tool is denied я вже натикався, коли вище шукав рішення з помилкою “Access to the path ‘/home/runner/_work/_temp/_github_home/.kube/cache’ is denied“, ось воно і знадобилось.

Додаємо ще один initContainer, в якому виконуємо chown:

...
template:
  spec:
    initContainers:
    - name: kube-init
      image: ghcr.io/actions/actions-runner:latest
      command: ["sudo", "chown", "-R", "1001:123", "/home/runner/_work"]
      volumeMounts:
        - name: work
          mountPath: /home/runner/_work  
    containers:
      - name: runner
        image: 492***148.dkr.ecr.us-east-1.amazonaws.com/github-runners/kraken:0.9
        command: ["/home/runner/run.sh"]
...

І тепер все працює.

Порівняємо результати.

Джоба “Build and/or deploy backend” займала 9 хвилин:

А стало 6 хвилин:

В цілому на цьому поки все.

Не скажу, що все прям працює з коробки, трохи повозитись 100% треба буде – але працює. Будемо пробувати переводити всі білди на свої раннери.

Loading

VictoriaMetrics Cloud: інтеграція з AWS Data Firehose для CloudWatch метрик
0 (0)

22 Вересня 2024

Про саму VictoriaMetrics Cloud напишу окремо, а зараз хочу перевірити як можна писати CloudWatch Metrcis через AWS Firehose до VictoriaMetrics Cloud.

Власне, сам сервіс AWS Data Firehose дозволяє передачу потокових даних з різних джерел до сервісів Amazon на кшталт AWS S3, Redshift, OpenSearch, або до зовнішніх – Datadog, New Relic, і т.д.

Нещодавно VictoriaMetrics запустила (поки що в Beta) власну підтримку AWS Data Firehose, і тепер ми можемо стрімити дані до VictoriaMetrics Cloud.

Приємна особливість цього сетапу, що нам фактично не треба самим запускати якісь сервери або експортери для збору метрик – все повністю agentless та serverless, бо Data Firehose – це AWS Managed сервіс, який просто працює, а VictoriaMetrics Cloud працює повністю на інфраструктурі VictoriaMetrics, і не потребує від нас якихось особливих налаштувань.

Ще з цікавих моментів, це те, що CloudWatch віддає метрики а VictoriaMetrics приймає їх в форматі OpenTelemetry, хоча при бажанні у VictoriaMetrics можна їх конвертувати в формат Prometheus.

Власне, що будемо робити:

  • налаштуємо AWS Data Firehose Stream для передачі даних до VictoriaMetrics Cloud
  • налаштуємо CloudWatch Metrics Stream для передачі метрик в цей Firehose Stream

VictoriaMetrics Cloud Authentification

Перше, що потрібно зробити – це отримати URL ендпоінту, на який будуть відправлятись дані.

У VictoriaMetrics Cloud маємо створений Deployment (див. Creating deployments), в Overview якого маємо параметр Access Endpoint:

Друге – це отримати Access Token (див. Start writing and reading data).

Переходимо до вкладки Access, де маємо токен з правами read-write:

Тепер маємо дві частини, які будемо використовувати в AWS Firehose:

  • HTTP Endpoint URL: https://gw-c7-2b.cloud.victoriametrics.com
  • Bearer Access Token: ccbd4c8e-db49-463f-9813-371a09e549b6

З CloudWatch до VictoriaMetrics будемо писати в форматі OpenTelemetry, тому повний ендпоінт буде з URI /opentelemetry/api/v1/pushhttps://gw-c7-2b.cloud.victoriametrics.com/opentelemetry/api/v1/push.

Створення AWS Data Firehose Stream

Тут все доволі просто: нам потрібно задати Source, тобто – звідки і які дані будуть йти, і вказати Destination – куди ці дані відправляти.

При необхідності можна з AWS Lambda робити трансформації, але у випадку з метриками CloudWatch це не обов’язково.

Отже, переходимо до Amazon Data Firehose, клікаємо Create Firehose stream:

В Source вибираємо Direct PUT:

В Destination – HTTP Endpoint:

Задаємо Firehose stream name:

В Destination settings – вказуємо HTTP endpoint URL, який отримали в VictoriaMetrics Cloud + /opentelemetry/api/v1/push:

Токен аутентифікації задаємо в Access key у форматі “Bearer TOKEN_VALUE“:

Опціонально – включаємо GZIP.

Firehose потребує налаштування Backup storage для даних, які не зміг відправити до Destination – див. Handle data delivery failures.

Задаємо ім’я AWS S3 бакету:

Зберігаємо новий стрім – він готовий приймати дані.

Cloudwatch Metrics to AWS Data Firehose

Документація – Custom setup with Firehose.

Переходимо до CloudWatch > Metrcis > Streams, клікаємо Create metric stream:

Вибираємо Custom setup with Firehose, вибираємо створений вище стрім:

При необхідності – можна вибрати формат, але дефолтний OpenTelemetry 1.0 підтримується:

Вибираємо які саме метрики хочемо відправляти – всі, або тільки обрані:

Останнім задаємо ім’я стріма:

Перевіряємо, що Status == Running:

Перевірка Firehose Stream

Тепер маємо CloudWatch Metrcis Stream, який пише метрики до Firehose Stream, який потім відправляє їх до HTTP Endpoint у VictoriaMetrcis Cloud.

Чекаємо хвилин 5, і спершу перевіряємо метрики в CloudWatch Metrcis Stream:

Якщо тут метрики є, то переходимо до Firehose Stream > Monitoring, де маємо побачити, що дані йдуть до VictoriaMetrics Cloud:

При проблемах з відправкою даних – дивимось вкладку Destination error logs:

Також можна перевірити вкладку Monitoring в VictoriaMetrics – на графіку Ingestion rate мають бути запити з {type="opentelemetry"}:

VictoriaMetrics Explore та метрики CloudWatch

Включаємо Autocomplete – і маємо отримати список метрик, які приходять з AWS CloudWatch:

І далі можемо вже робити запити, наприклад використовуючи лейблу __name__:

sum({__name__="amazonaws.com/AWS/EC2/CPUUtilization"}) by (Namespace, cloud.region)

А аби переключити формат метрик з OpenTelemetry на Prometheus – переходимо до Settings > Advanced Settings, і додаємо параметр -opentelemetry.usePrometheusNaming:

Готово.

Loading

Karpenter: використання Disruption budgets
0 (0)

17 Вересня 2024

Disruption budgets з’явились в версії 0.36, і виглядає як дуже цікавий інструмент для того, аби обмежити Karpenter в перестворенні WorkerNodes.

Наприклад в моєму випадку ми не хочемо, аби EC2 вбивались в робочі часи по США, бо там у нас клієнти, а тому зараз маємо consolidationPolicy=whenEmpty, аби запобігти “зайвому” видаленню серверів та Pods на них.

Натомість з Disruption budgets ми можемо налаштувати політики таким чином, що в один період часу будуть дозволені операції з WhenEmpty, а в інший – WhenEmptyOrUnderutilized.

Див. також Kubernetes: забезпечення High Availability для Pods – бо при використанні Karpenter навіть при налаштованих Disruption budgets необхідно мати відповідно налаштовані поди з Topology Spread та PodDisruptionBudget.

Типи Karpenter Disruption

Документація – Automated Graceful Methods.

Спочатку глянемо, в яких випадках Disruption взагалі відбувається:

  • Drift: виникає, коли є різниця між створеними конфігураціями NodePools або EC2NodeClass та існуючими WorkerNodes – тоді Karpenter почне перестворювати EC2 аби привести їх у відповідність до заданих параметрів
  • Interruption: якщо Karpenter отримує AWS Event, що інстанс буде виключено, наприклад – якщо це Spot
  • Consolidation: якщо маємо налаштування Consolidation на WhenEmptyOrUnderutilized або WhenEmpty, і Karpenter переносить наші Pods на інші WorkerNodes
    • у нас Karpenter 1.0, тому полісі WhenEmptyOrUnderutilized, для 0.37 це WhenUnderutilized

Karpenter Disruption Budgets

За допомогою Disruption budgets ми можемо дуже гнучко налаштувати в який час і які операції Karpenter може проводити, і задати ліміт на те, скільки WorkerNodes одночасно будуть видалятись.

Документація – NodePool Disruption Budgets.

Формат конфігурації доволі простий:

budgets:
- nodes: "20%"
  reasons: 
  - "Empty"
  schedule: "@daily"
  duration: 10m

Тут ми задаємо:

  • дозволити видалення WorkerNodes для 20% від загальної кількості
  • для операції, коли Disruption викликаний умовою WhenEmpty
  • виконуємо це кожен день
  • на протязі 10 хвилин

Параметри тут можуть мати значення:

  • nodes: в процентах або просто кількості нод
  • reasons: Drifted, Underutilized або Empty
  • schedule: розклад, за яким правило застосовується, в UTC (інші таймзони поки не підтримуються), див. Kubernetes Schedule syntax
  • duration: і скільки часу правило діє, наприклад – 1h15m

При цьому не обов’язково задавати всі параметри.

Наприклад, ми можемо описати два таких бюджети:

- nodes: "25%"
- nodes: "10"

Тоді у нас постійно будуть працювати обидва правила, і перше обмежує кількість нод в 25% від загальної кількості, а друге – не більше як 10 інстансів – якщо у нас більш ніж 40 серверів.

Також, Budgets можна комбінувати, і якщо їх задано кілька – то ліміти будуть братись по найбільш суворому.

В першому прикладі ми застосовуємо правило на 20% нод і умові WhenEmpty, а решту часу будуть працювати дефолтні правила disruption – тобто, 10% від загальної кількості серверів із заданою consolidationPolicy.

Тому можемо записати правило так:

budgets:
- nodes: "20%"
  reasons: 
  - "Empty"
  schedule: "@daily"
  duration: 10m
- nodes: 0

Тут останнє правило працює постійно, і буде таким собі запобіжником: ми забороняємо все, але дозоляємо виконувати disruption за політикою WhenEmpty на протязі 10 хвилин раз на добу починаючи з 00:00 UTC.

Приклад Disruption Budgets

Повертаючись до моєї задачі:

  • маємо Backend API в Kubernetes на окремому NodePool, а наші клієнти в основному з США, тому ми хочемо мінімізувати down-скейлінг WorkerNodes в робочий час по США
  • для цього ми хочемо заблокувати всі операції по WhenUnderutilized в період робочого часу по Central Time USA
    • в schedule Karpenter використовує зону UTC, тому початок робочого дня по Central Time USA 9:00 – це 15:00 UTC
  • операції з WhenEmpty дозволимо в будь-який час, але тільки по 1 WorkerNode одночасно
  • Drift – аналогічно, бо коли я деплою зміни – то хочу побачити результат відразу

Фактично, нам потрібно задати два бюджети:

  • по Underutilized – забороняємо все з понеділка по п’ятницю на протязі 9 годин починаючи з 15:00 по UTC
  • по Empty та Drifted – дозволяємо в будь-який час, але тільки по 1 ноді, а не дефолтні 10%

Тоді наш NodePool буде виглядати так:

apiVersion: karpenter.sh/v1
kind: NodePool
metadata:
  name: backend1a
spec:
  template:
    metadata:
      labels:
        created-by: karpenter
        component: devops
    spec:
      taints:
        - key: BackendOnly
          operator: Exists
          effect: NoSchedule
      nodeClassRef:
        group: karpenter.k8s.aws
        kind: EC2NodeClass      
        name: defaultv1a
      requirements:
        - key: karpenter.k8s.aws/instance-family
          operator: In
          values: ["c5"]
        - key: karpenter.k8s.aws/instance-size
          operator: In
          values: ["large", "xlarge"]
        - key: topology.kubernetes.io/zone
          operator: In
          values: ["us-east-1a"]
        - key: karpenter.sh/capacity-type
          operator: In 
          values: ["spot", "on-demand"]
  # total cluster limits 
  limits:
    cpu: 1000
    memory: 1000Gi
  disruption:
    consolidationPolicy: WhenEmptyOrUnderutilized
    consolidateAfter: 600s
    budgets:
      - nodes: "0"                   # block all
        reasons:
        - "Underutilized"            # if reason == underutilized
        schedule: "0 15 * * mon-fri" # starting at 15:00 UTC during weekdays
        duration: 9h                 # during 9 hours
      - nodes: "1"                   # allow by 1 WorkerNode at a time
        reasons:
        - "Empty"
        - "Drifted"

Деплоїмо, перевіряємо NodePool:

$ kk describe nodepool backend1a   
Name:         backend1a
...
API Version:  karpenter.sh/v1
Kind:         NodePool
...
Spec:
  Disruption:
    Budgets:
      Duration:  9h
      Nodes:     0
      Reasons:
        Underutilized
      Schedule:  0 15 * * mon-fri
      Nodes:     1
      Reasons:
        Empty
        Drifted
    Consolidate After:     600s
    Consolidation Policy:  WhenEmptyOrUnderutilized
...

І в логах бачимо, що спрацював Disruption по WhenUnderutilized:

karpenter-55b845dd4c-tlrdr:controller {"level":"INFO","time":"2024-09-16T10:48:26.777Z","logger":"controller","message":"disrupting nodeclaim(s) via delete, terminating 1 nodes (2 pods) ip-10-0-42-250.ec2.internal/t3.small/spot","commit":"62a726c","controller":"disruption","namespace":"","name":"","reconcileID":"db2233c3-c64b-41f2-a656-d6a5addeda8a","command-id":"1cd3a8d8-57e9-4107-a701-bd167ed23686","reason":"underutilized"}
karpenter-55b845dd4c-tlrdr:controller {"level":"INFO","time":"2024-09-16T10:48:27.016Z","logger":"controller","message":"tainted node","commit":"62a726c","controller":"node.termination","controllerGroup":"","controllerKind":"Node","Node":{"name":"ip-10-0-42-250.ec2.internal"},"namespace":"","name":"ip-10-0-42-250.ec2.internal","reconcileID":"f0815e43-94fb-4546-9663-377441677028","taint.Key":"karpenter.sh/disrupted","taint.Value":"","taint.Effect":"NoSchedule"}
karpenter-55b845dd4c-tlrdr:controller {"level":"INFO","time":"2024-09-16T10:50:35.212Z","logger":"controller","message":"deleted node","commit":"62a726c","controller":"node.termination","controllerGroup":"","controllerKind":"Node","Node":{"name":"ip-10-0-42-250.ec2.internal"},"namespace":"","name":"ip-10-0-42-250.ec2.internal","reconcileID":"208e5ff7-8371-442a-9c02-919e3525001b"}

Готово.

Loading

VictoriaLogs: знайомство, запуск в Kubernetes, LogsQL та Grafana
0 (0)

5 Вересня 2024

VictoriaLogs – відносно нова система для збору та аналізу логів, схожа на Grafana Loki, але – як і VictoriaMetrics в порівнянні з “ванільним” Prometheus – менш вибаглива до ресурсів CPU/Memory.

Особисто я користуюсь Grafana Loki років 5, але до неї іноді буває дуже багато питань – і по документації, і по загальній складності системи, бо багато компонентів, і по перформансу – бо як я її не тюнив (див. Grafana Loki: оптимізація роботи – Recording Rules, кешування та паралельні запити), але іноді на відносно невеликих запитах Grafana повертає 504 від Loki Gateway, і я, якщо чесно, вже втомився з цим розбиратись.

Ну а оскільки у нас сам моніторинг побудований на VictoriaMetrics, і до VictoriaLogs вже “завезли” підтримку Grafana data source – то прийшов час спробувати її в роботі, і порівняти з Grafana Loki.

Чого у VictoriaLogs поки що нема:

  • підтримки AWS S3 бекенду – але обіцяють зробити в листопаді 2024 (до того, ж якоюсь “магічною” автоматизацією – коли старі дані з локального диску автоматично будуть перенесені до відповідного S3)
  • поки що нема аналога Loki RecordingRules – коли з логів створюємо звичайні метрики, їх записуємо в VictoriaMetrics/Prometheus, а потім робимо алерти в VMAlert та дашборди в Grafana, але знов-таки скоро має бути – жовтень-листопад 2024
  • Grafana data source теж ще в Beta, тому є складності з побудовою графіків в Grafana

І прям біда з всякими ChatGPT для генерації запитів – але про це поговоримо далі.

Документація – як завжди у VictoriaMetrcis чудова – VictoriaLogs.

Ще про останні апдейти VictoriaLogs говорили на мітапі VictoriaMetrics Meetup June 2024 – VictoriaLogs Update.

Цікаві скріншоти з бенчмарками VictoriaLogs vs ELK vs Grafana Loki – Benchmark for VictoriaLogs.

Roadmap по VictoriaLogs – тут>>>.

Тож що будемо сьогодні робити:

  • запустимо VictoriaLogs в Kubernetes
  • подивимось на можливості її LogsQL
  • підключимо Grafana data source
  • подивимось, як можна створити дашборду в Grafana

VictoriaLogs Helm chart

Деплоїти будемо з Helm-чарта vm/victoria-logs-single.

Такоє є підтримка в VictoriaMetrics Operator (див. VLogs).

Ми на проекті використовуємо власний чарт для нашого моніторингу (див. VictoriaMetrics: створення Kubernetes monitoring stack з власним Helm-чартом), в якому через Helm dependency встановлюється чарт victoria-metrics-k8s-stack + всякі додаткові сервіси типу Promtail, k8s-event-logger etc. В цей же чарт додамо victoria-logs-single.

Для початку зробимо все руками, спочатку з якимись дефолтними values, потім подивимось, що воно нам встановить в Kubernetes і як воно працює – а потім будемо додавати в автоматизацію.

В чарті VictoriaLogs є можливість відразу запустити Fluetbit DaemonSet, але в нас вже є Promtail, тому будемо використовувати його.

Всі values є в документації до чарту, а з того, що може бути цікаве зараз:

  • extraVolumeMounts та extraVolumes: можемо створити власний окремий persistentVolume з AWS EBS, та підключати його до VictoriaLogs
  • persistentVolume.enabled та persistentVolume.storageClassName: або можемо просто вказати, що його треба створювати, і при потребі задати власний storageClass з ReclaimPolicy retain
  • ingress: в моєму випадку частина логів пишеться з AWS Lambda (див. Grafana Loki: збираємо логи AWS LoadBalancer з S3 за допомогою Promtail Lambda), тому потрібно буде створювати AWS ALB з типом Internal

Встановлення чарту

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

$ helm repo add vm https://victoriametrics.github.io/helm-charts/
$ helm repo update

Встановлюємо чарт в окремий Kubernetes Namespace ops-test-vmlogs-ns:

$ helm -n ops-test-vmlogs-ns upgrade --install vlsingle vm/victoria-logs-single

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

$ kk get pod
NAME                                     READY   STATUS    RESTARTS   AGE
vlsingle-victoria-logs-single-server-0   1/1     Running   0          36s

І глянемо на ресурси:

$ kk top pod
NAME                                     CPU(cores)   MEMORY(bytes)   
vlsingle-victoria-logs-single-server-0   1m           3Mi             

3 мегабайти пам’яті 🙂

Забігаючи наперед – після підключення запису логів з Promtail до VictoriaLogs ресурсів буде використовуватись не набагато більше.

Відкриваємо доступ до UI:

$ kk -n ops-test-vmlogs-ns port-forward svc/vlsingle-victoria-logs-single-server 9428

В браузері заходимо на http://localhost:9428.

Як і інші сервіси від VictoriaMetrics – попадаємо на сторінку з усіма необхідними посиланнями:

Переходимо на  http://localhost:9428/select/vmui/ – поки що тут пусто:

Додамо відправку логів з Promtail.

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

До VictoriaLogs можна писати логи в форматі Elasticsearch, ndjson або Loki – див. Data ingestion.

Власне нас цікавить саме Loki, і логи ми пишемо з Promtail. Приклад конфігурації Promtail для VictoriaLogs див. у Promtail setup.

У нас Promtail встановлюється з його власного чарту, який створює Kubernetes Secret з promtail.yml.

Оновлюємо values чарту, в config.clients додаємо ще один URL – в моєму випадку він буде неймспейсом з ops-test-vmlogs-ns.svc, бо VictoriaLogs запущена в іншому неймспейсі, ніж Loki:

...
promtail:
  config:
    clients:
      - url: http://atlas-victoriametrics-loki-gateway/loki/api/v1/push
      - url: http://vlsingle-victoria-logs-single-server.ops-test-vmlogs-ns.svc:9428/insert/loki/api/v1/push
...

Деплоїмо зміни, чекаємо рестарту подів з Promtail, і ще раз перевіряємо логи в VictoriaLogs:

VictoriaLogs Log Streams

Під час запису логів до VictoriaLogs ми можемо задати додаткові параметри – див. HTTP parameters.

З того, що може бути цікавим зараз – це спробувати створити власні Log Stream, аби по ним потім робити фільтрацію логів для більш швидкої їх обробки. див. Stream fields.

Якщо лог-стрім не заданий – то VictoriaLogs пише все в один дефолтний стрім {}, як ми бачили на скріні вище.

Наприклад, у нас в кластері всі аплікейшени розбиті по власним Kubernetes Namespaces – dev-backend-api-ns, prod-backend-api-ns, ops-monitoring-ns і т.д.

Давайте створимо окремий стрім на кожен неймспейс – до url додаємо ?_stream_fields=namespace:

...
  config:
    clients:
      - url: http://atlas-victoriametrics-loki-gateway/loki/api/v1/push
      - url: http://vlsingle-victoria-logs-single-server.ops-test-vmlogs-ns.svc:9428/insert/loki/api/v1/push?_stream_fields=namespace
...

Деплоїмо, і тепер маємо окремі стріми на кожен неймспейс:

VictoriaLogs vs Loki: ресурси CPU/Memory

Давайте просто глянемо на ресурси, які зараз в мене споживають всі поди для роботи Loki:

$ kk -n ops-monitoring-ns top pod | grep loki
atlas-victoriametrics-loki-chunks-cache-0                         2m           824Mi          
atlas-victoriametrics-loki-gateway-6bd7d496f5-9c2fh               1m           12Mi            
atlas-victoriametrics-loki-results-cache-0                        1m           32Mi            
loki-backend-0                                                    50m          202Mi           
loki-backend-1                                                    8m           214Mi           
loki-backend-2                                                    12m          248Mi           
loki-canary-gzjxh                                                 1m           15Mi            
loki-canary-h9d6s                                                 1m           17Mi            
loki-canary-hkh4f                                                 2m           17Mi            
loki-canary-nh9mf                                                 2m           16Mi            
loki-canary-pbs4x                                                 1m           17Mi            
loki-read-55bcffc9fb-7j4tg                                        12m          255Mi           
loki-read-55bcffc9fb-7qtns                                        45m          248Mi           
loki-read-55bcffc9fb-s7rpq                                        10m          244Mi           
loki-write-0                                                      42m          262Mi           
loki-write-1                                                      27m          261Mi           
loki-write-2                                                      26m          258Mi           

Та ресурси VictoriaLogs:

$ kk top pod
NAME                                     CPU(cores)   MEMORY(bytes)   
vlsingle-victoria-logs-single-server-0   2m           14Mi            

При тому, що пишеться однакова кількість логів.

Так – в Loki зараз є пачка RecordingRules, так – є пара дашборд в Grafana, які виконують запити напряму до Loki для графіків, але ж ну камон! Це небо і земля!

Можливо, це ще й мої криві руки, які не змогли нормально затюнити Loki – проте VictoriaLogs зараз запущена взагалі без всякого тюнингу.

LogsQL

Окей – маємо інстанс VictoriaLogs, маємо логи, які в неї пишуться.

Давайте спробуємо “покверяти” і розібратися з LogsQL взагалі, та трохи порівняти з LogQL від Loki.

Документація по LogsQL для VictoriaLogs – тут>>>.

Запити ми можемо робити з VM UI, з CLI та з Grafana – див. Querying.

Запити з HTTP API

У VictoriaLogs дуже приємний API, з яким можна отримати всі необхідні дані.

Наприклад, для пошуку по логам за допомогою curl можемо зробити запит до /select/logsql/query, а потім через unix pipe передати до jq.

Все ще маємо запущений kubectl port-forward, робимо запит з пошуком всіх логів зі словом “error“:

$ curl -s localhost:9428/select/logsql/query -d 'query=error' | head | jq
{
  "_time": "2024-09-02T12:23:40.890465823Z",
  "_stream_id": "0000000000000000195443555522d86dcbf56363e06426e2",
  "_stream": "{namespace=\"staging-backend-api-ns\"}",
  "_msg": "[2024-09-02 12:23:40,890: WARNING/ForkPoolWorker-6] {\"message\": \"Could not execute transaction\", \"error\": \"TransactionCanceledException('An error occurred (TransactionCanceledException) when calling the TransactWriteItems operation: Transaction cancelled, please refer cancellation reasons for specific reasons [None, None, ConditionalCheckFailed]')\", \"logger\": \"core.storage.engines.dynamodb_transactions\", \"level\": \"warning\", \"lineno\": 124, \"func_name\": \"_commit_transaction\", \"filename\": \"dynamodb_transactions.py\", \"pid\": 2660, \"timestamp\": \"2024-09-02T12:23:40.890294\"}",
  "app": "backend-celery-workers",
  "component": "backend",
  "container": "backend-celery-workers-container",
  "filename": "/var/log/pods/staging-backend-api-ns_backend-celery-workers-deployment-66b879bfcc-8pw52_46eaf32d-8956-4d44-8914-7f2afeda41ad/backend-celery-workers-container/0.log",
  "hostname": "ip-10-0-42-56.ec2.internal",
  "job": "staging-backend-api-ns/backend-celery-workers",
  "logtype": "kubernetes",
  "namespace": "staging-backend-api-ns",
  "node_name": "ip-10-0-42-56.ec2.internal",
  "pod": "backend-celery-workers-deployment-66b879bfcc-8pw52",
  "stream": "stderr"
}
...

І в результаті маємо всі поля та Log Stream, який задали вище – по полю Namespace.

Ще з цікавих ендпоінтів – можливість отримати всі стріми, в логах яких є ключове слово, наприклад:

$ curl -s localhost:9428/select/logsql/streams -d "query=error" | jq
{
  "values": [
    {
      "value": "{namespace=\"ops-monitoring-ns\"}",
      "hits": 5012
    },
    {
      "value": "{namespace=\"staging-backend-api-ns\"}",
      "hits": 542
    },
...

Запити з VM UI

Тут все просто – пишемо запит в полі Log queiry, отримуємо результат.

Результат можемо сформувати в форматі Group by, Table та JSON – його ми вже бачили в HTTP API.

В форматі Group by результат виводиться по кожному стріму:

А в форматі Table – колонками по іменам полей з логів:

Синтаксис LogsQL

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

Але давайте глянемо хоча б основні, аби мати уяву що ми можемо робити.

Самий простий приклад запитів з LogsQL ми вже бачили – просто по слову “error“.

Аби виконати пошук по фразі – “загортаємо” її в лапки:

Сортування

Важливий нюанс – результати повертаються у довільному порядку з метою покращення перформансу, тому рекомендується використовувати sort pipe по полю _time:

_time:5m error | sort by (_time)

Comments

Дуже прикольно, що ми в запити можемо додавати коментарі, наприклад:

_time:5m | app:="backend-api" AND namespace:="prod-backend-api-ns" # this is a comment
| unpack_json | keep path, duration, _msg, _time # and an another one comment
| stats by(path) avg(duration) avg_duration | path:!"" | limit 10

Оператори

В LogsQL вони називаються Logical filter – AND, OR, NOT.

Наприклад, використати AND можемо так – шукаємо запис, в якому є строка “Received request” та ID “dada85f9246d4e788205ee1670cfbc6f“:

"Received request" AND "dada85f9246d4e788205ee1670cfbc6f"

Або зробити пошук по “Received request” тільки зі стриму namespace="prod-backend-api-ns":

"Received request" AND _stream:{namespace="prod-backend-api-ns"}

Або по полю pod:

"Received request" AND pod:="backend-api-deployment-98fcb6bcb-w9j26"

При чому оператор AND можна не задавати явно, тобто запит:

"Received request" pod:="backend-api-deployment-98fcb6bcb-w9j26"

Буде відпрацьований аналогічно попередньому.

Але в прикладах далі я все ж буду додавати AND  для ясності.

Фільтри

Будь-який запит LogsQL має містити хоча б один фільтр.

Коли ми робимо запит на кшталт “Received request” – то фактично ми використовуємо фільтр Phrase filter, який за замовченням застосовується до поля _msg.

А в запиті _stream:{namespace="prod-backend-api-ns"} ми використовуємо Stream filter.

Інші цікаві фільтри:

  • Time filter – можна задати проміжок часу в хвилинах/годинах або датах
  • Day та Week range filter – або виконати пошук по конкретним датам чи дням тижня
  • Prefix filter – пошук по неповному слову або фразі
  • Case-insensitive filter – пошук без урахування регістру
  • Regexp filter – регулярні вирази в пошуку
  • IPv4 range filter – це прям кілер-фіча – готовий фільтрі для IP-адрес

Давайте швиденько глянемо кілька прикладів.

Time filter

Вибрати всі записи за останню хвилину:

"Received request" AND _time:1m

Або за 1.09.2024:

"Received request" AND _time:2024-09-01

Або за проміжок часу – 30-го серпня по 2 вересня включно:

"Received request" AND _time:[2024-08-30, 2024-09-02]

Або без записів за 2024-08-30 – тобто, починаючи з 31-го числа – міняємо [ на (:

"Received request" AND _time:(2024-08-30, 2024-09-02]

Day range filter

Фільтри по годинам дня.

Наприклад, всі записи між 14:00 і 18:00 сьогодні:

"Received request" AND _time:day_range[14:00, 18:00]

Аналогічно до Time filter – використовуємо () та [] аби включити або виключити початок чи кінець range.

Week range filter

Подібний до Day range filter, але по днях тижня:

"Received request" AND _time:week_range[Mon, Fri]

Prefix filter

За допомогою “*” вказуємо, що нам потрібні всі логи, які починаються з фрази “ForkPoolWorker-1” – тобто, всі воркери з 1, 12, 19 і т.д:

"ForkPoolWorker-1"*

Аналогічно можемо використовувати цей фільтр для значень в полях записів.

Наприклад, вибрати всі записи, де поле container має значення “backend-celery“:

app:"backend-celery-"*

Або ж використати Substring filter:

app:~"backend-celery"

Regexp filter

Пошук з регуляркою, також можна комбінувати з Substring filter.

Наприклад, знайти всі записи з “Received request” АБО “ForkPoolWorker“:

~"Received request|ForkPoolWorker"

Pipes

Ще цікава можливість в LogsQL – використання pipes, через які можна виконувати додаткові операції.

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

З LogsQL це можна зробити за допомогою | copy або | rename:

  • є поле logtype: kubernetes
  • хочемо його зробити source: kubernetes

Виконуємо такий запит:

~"ForkPoolWorker" | rename logtype as source

Інші цікаві pipes:

  • delete pipe: видалити поле з результатів
  • extract pipe: створити нове поле зі значенням із записів в логах
  • field_names pipe: поверне всі поля з додавання кількості записів
  • fields pipe: повернути в результатах тільки обрані поля
  • filter pipe: фільтрувати результати з додатковими умовами
  • limit pipe: вивести тільки зазначені кількість результатів (див. також top)
  • math pipe: виконати математичні операції
  • offset pipe: теж прикольна штука – зробити “зміщення” на кількість результатів
  • pack_json pipe: “запакувати” всі поля з результатів в JSON (див. також pack_logfmt та unpack_json)
  • replace pipe: замінити слово/фразу в результатах на інше (маскувати паролі)
  • sort pipe: операції сортування в результатах
  • stats pipe: вивести статистику

Я вже не буду тут описувати приклади, бо в цілому вони – і багато іншого – є в документації, але давайте глянемо приклад запиту для Loki, і спробуємо переписати його для VictoriaLogs – і там як раз спробуємо pipes в ділі.

Приклад: Loki to VictoriaLogs query

Є у нас такий запит для Loki RecordingRules:

- record: eks:pod:backend:api:path_duration:avg
  expr: |
    topk (10,
        avg_over_time (
            {app="backend-api"} | json | regexp "https?://(?P<domain>([^/]+))" | line_format "{{.path}}: {{.duration}}"  | unwrap duration [5m]
        ) by (domain, path, node_name)
    )

З логів Kubernetes Pods нашого бекенду створює метрику eks:pod:backend:api:path_duration:avg, в якій відображає середній час відповіді по ендпоінтам.

В ньому маємо:

  • вибираємо логи з лог-стріма app="backend-api"
  • логи пишуться в JSON, тому використовуємо json парсер
  • потім з regex parser створюємо поле domain зі значенням після “https://
  • з line_format отримуємо поля path та duration
  • з unwrap “витягуємо” значення з duration
  • рахуємо середнє значення з duration за допомогою оператора avg_over_time() за останні 5 хвилин, групуючи по полям domain, path, node_name – вони потім використовуються в алертах і графіках Grafana
  • збираємо інформацію по топ-10 записів

Як ми можемо щось схоже зробити з VictoriaLogs та LogsQL?

Почнемо з фільтра по полю:

app:="backend-api"

Отримуємо всі записи з подів цієї апки.

Пам’ятаємо, що можемо використати тут регулярку, і задати фільтр як app:~"backend" – тоді будуть результати з app="backend-celery-workers", app="backend-api" і т.д.

Можна додати фільтр по стріму – тільки з продакшена:

_stream:{namespace="prod-backend-api-ns"} AND app:="backend-api"

Або просто:

namespace:="prod-backend-api-ns" AND app:="backend-api"

В наших метриках Loki неймспейс не використовується, бо фільтри в алертах і Grafana використовують ім’я домену з поля domain, але тут для приклада най буде.

Далі нам треба створити поля domain, path та duration.

Тут можна використати або unpack_json – або extract.

unpack_json розпарсить JSON, і створить поля для запису з кожного ключа в JSON:

  • в документації до unpack_json говориться, що краще використовувати extract pipe
  • якщо використовувати його, то запит був би | extract '"duration": <duration>,'

Але нам всі поля не потрібні – тому можемо дропнути всі, і з фільтром keep залишити тільки duration, _msg та _time:

Далі, нам потрібно створити поле domain. Але просто взяти key url який створив unpack_json із {"url": "http://api.app.example.co/coach/notifications?limit=0" ...} нам не підходить, бо потрібен тільки домен – без строки “/coach/notifications?limit=0“.

Можемо додати фільтр extract_regexpextract_regexp "https?://(?P<domain>([^/]+))":

Тепер, маючи всі три поля, можемо використати stats by() і avg по полю duration:

А аби прибрати з результатів {"path":"","domain":"","avg(duration)":"NaN"} – додаємо фільтр path:!"".

Тепер весь запит буде:

app:="backend-api" | unpack_json | keep path, duration, _msg, _time | extract_regexp "https?://(?P<domain>([^/]+))" | stats by(path, domain) avg(duration) | path:!""

Останнім додаємо ліміт в останні 5 хвилин – _time:5m, і виводимо тільки топ-10 результатів.

Я тут приберу domain і додам фільтр по namespace, аби простіше було порівняти з результатами в Loki.

Результат avg(duration) будемо писати в нове поле avg_duration.

Тепер весь запит буде таким:

_time:5m | app:="backend-api" AND namespace:="prod-backend-api-ns" | unpack_json | keep path, duration, _msg, _time | stats by(path) avg(duration) avg_duration | path:!"" | limit 10

Результат:

Замість limit можна використати top pipe – бо limit просто обмежує кількість запитів, а top обмежує саме по значенню поля:

_time:5m | app:="backend-api" AND namespace:="prod-backend-api-ns" | unpack_json | keep path, duration, _msg, _time | stats by(path) avg(duration) avg_duration | path:!"" | top 10 by (path, duration)

І можемо додати sort(), а умову path:!"" винести перед викликом stats(), аби швидше оброблювався запит:

_time:5m | app:="backend-api" AND namespace:="prod-backend-api-ns" | unpack_json | keep path, duration, _msg, _time | path:!"" | stats by(path) avg(duration) avg_duration | sort by (_time, avg_duration) | top 10 by (path, avg_duration)

Порівняємо його з результатом з Loki, наприклад – API-ендпоінт /sprint-planning/backlog/challenges в результатах VictoriaLogs у нас тут має значення 160.464981 мілісекунд.

Виконуємо аналогічний запит в Loki:

topk (10,
    avg_over_time (
        {app="backend-api", namespace="prod-backend-api-ns"} | __error__="" | json | line_format "{{.path}}: {{.duration}}"  | unwrap duration [5m]
    ) by (path)
)

Все сходиться.

ChatGPT, Gemini, Claude та LogsQL (але Perplexity!)

Спробував з ними переписувати запити з Loki LogQL на VictoriaMetrics LogsQL – тут все дуже пєчально.

ChatGPT взагалі прям дуже глючить, і видає оператори типу SELECT, яких взагалі нема:

Gemini трохи краще, принаймні з більш-менш реальними операторами – але все одного не той випадок, коли можна просто скопіювати і використати:

І Claude – аналогічно до ChatGPT, нічого не знає – але пропонує “щось подібне”:

А от Perplexity відповів майже вірно:

Тільки спутав порядок – by() має бути після stats().

Helm, VictoriaLogs та Grafana data source

Репозиторій та документація – victorialogs-datasource.

VictoriaLogs sub-chart installation

Давайте відразу сюди ж додамо VictoriaLogs. Нагадаю, що у нас весь стек моніторинг встановлюється з нашого власного чарту, в якому через Helm dependency додаються victoria-metrics-k8s-stack, k8s-event-logger, aws-xray і т.д.

Видаляємо встановлений вручну чарт:

$ helm -n ops-test-vmlogs-ns uninstall vlsingle

В файлі Chart.yaml описуємо ще один dependency:

apiVersion: v2
name: atlas-victoriametrics
description: A Helm chart for Atlas Victoria Metrics kubernetes monitoring stack
type: application
version: 0.1.1
appVersion: "1.17.0"
dependencies:
- name: victoria-metrics-k8s-stack
  version: ~0.25.0
  repository: https://victoriametrics.github.io/helm-charts
- name: victoria-metrics-auth
  version: ~0.6.0
  repository: https://victoriametrics.github.io/helm-charts
- name: victoria-logs-single
  version: ~0.6.0
  repository: https://victoriametrics.github.io/helm-charts  
...

Оновлюємо сабчарти:

$ helm dependency build

Оновлюємо свої values – додамо постійний сторейдж:

...
victoria-logs-single:
  server:
    persistentVolume:
      enabled: true
      storageClassName: gp2-retain
      size: 3Gi # default value, to update later
...

Деплоїмо, і перевіряємо сервіс для VictoriaLogs:

$ kk get svc | grep logs
atlas-victoriametrics-victoria-logs-single-server      ClusterIP   None             <none>        9428/TCP                     2m32s

Редагуємо конфіг Promtail – задаємо новий URL:

...
promtail:
  config:
    clients:
      - url: http://atlas-victoriametrics-loki-gateway/loki/api/v1/push
      - url: http://atlas-victoriametrics-victoria-logs-single-server:9428/insert/loki/api/v1/push?_stream_fields=namespace
...

Підключення Grafana data source

Трохи довелось повозитись з values для Grafana, але в результаті вийшло так:

...
  grafana:
    enabled: true

    env:
      GF_PLUGINS_ALLOW_LOADING_UNSIGNED_PLUGINS: "victorialogs-datasource"
    ...
    plugins:
      - grafana-sentry-datasource
      - grafana-clock-panel
      - grafana-redshift-datasource
      - https://github.com/VictoriaMetrics/victorialogs-datasource/releases/download/v0.4.0/victorialogs-datasource-v0.4.0.zip;victorialogs-datasource
    additionalDataSources:
      - name: Loki
        type: loki
        access: proxy
        url: http://atlas-victoriametrics-loki-gateway:80
        jsonData:
          maxLines: 1000
          timeout: 3m
      - name: VictoriaLogs
        type: victorialogs-datasource
        access: proxy
        url: http://atlas-victoriametrics-victoria-logs-single-server:9428

Версію шукаємо на сторінці Releases, зараз остання v0.4.0.

І зверніть увагу, що версія в URL задається два рази – /releases/download/v0.4.0/victorialogs-datasource-v0.4.0.zip.

Деплоїмо, при необхідності рестартимо поди з Grafana (якщо не використовуємо щось по типу Reloader), і перевіряємо датасорси:

Пробуємо з Explore:

Все працює.

Grafana dashboards та Time series visualization

З візуалізацією в Grafana поки що не все чудово, бо потрібно додавати transformations, аби Grafana panel коректно відобразила дані.

Довелось додавати їх як мінімум чотири:

  • Extract fields: результат від VictoriaLogs ми отримуємо в JSON, і з цією трансформацією з нього витягуємо всі поля
  • Convert field type: поле ‘duration’ в JSON приходить в string, тому його треба змінити на Number
  • Sort by: сортуємо по полю ‘avg_duration’
  • Prepare time series: для конвертації результатів в формат, який зрозуміє Time series visualization panel

Без цього будемо мати помилки типу “Data is missing a number field“, “Data is missing a time field” або “Data outside time range“.

Налаштування трансформацій:

Запит для графіка такий:

app:="backend-api" namespace:="prod-backend-api-ns" | unpack_json | keep path, duration, _msg, _time | path:!"" | stats by(_time:1m, path) avg(duration) avg_duration

Зверніть увагу, що тут _time переміщено у виклик stats() – робити статистку по останній хвилині для кожного path.

І результат такий:

Крім того, Data source поки не дає можливості переписати Options > Legend.

Висновки

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

До LogsQL треба звикнути та навчитись з ним працювати, але можливостей дає більше.

По ресурсам CPU/Memory – тут взагалі жодних питань.

Grafana data source працює, чекаємо на його реліз.

Ну і чекаємо, коли завезуть підтримку AWS S3 та аналог Loki RecordingRules, бо на сьогодні VictoriaLogs можна використовувати виключно як систему для роботи з логами – але не для графіків чи алертів.

Біда, що всякі ChatGPT толком не можуть допомогти з запитами LogsQL, бо для Loki я ними користувався доволі часто, але згодом і вони цьому навчаться. Проте Perplexity відповідає майже без помилок.

Отже, з плюсів:

  • працює дійсно швидше, і дійсно НАБАГАТО менше споживає ресурсів
  • LogsQL приємний, багато можливостей
  • документація у VictoriaMetrics завжди досить детальна, з прикладами, добре структурована
  • підтримка у VictoriaMetrics теж чудова – і в GitHub Issues, і в Slack, і в Telegram – завжди можна поставити питання, і досить швидко отримати відповідь
  • на відміну від Grafana Loki – VictoriaLogs має власний Web UI, і як на мене – то це жирний плюс

З відносних мінусів:

  • і VictoriaLogs і Grafana data source все ще в Beta – тому можливі і якісь неочікувані проблеми, і не всі можливості поки що реалізовані
    • але знаючи команду VictoriaMetrics – вони досить швидко все роблять
  • відсутність RecordingRules та підтримки AWS S3 – це наразі те, що блокує особисто мене від того, аби повністю видалити Grafana Loki
    • але всі основні плюшки мають завезти до кінця 2024
  • ChatGPT/Gemini/Claude прям зовсім погано знають LogsQL, тому на їх допомогу очікувати не треба
    • але є допомога в Slack, і в Telegram самої VictoriaMetrics – і від комьюніті, і від команди розробників, ну і Perplexity непогано справляється

Loading

AWS: Kubernetes та External Secrets Operator для AWS Secrets Manager
0 (0)

21 Серпня 2024

Маємо на проекті новий EKS кластер 1.30, на якому хочемо повністю відмовитись від старого IRSA з OIDC і почати користуватись EKS Pod Identities – див. AWS: EKS Pod Identities – заміна IRSA? Спрощуємо менеджмент IAM доступів.

І все наче працює чудово, але коли почав деплоїти наш Backend API – поди не стартують, і висять в статусі ContainerCreating.

Проблема: Kubernetes Secrets Store CSI Driver та “An IAM role must be associated with service account”

Сікрети для бекенду зберігаються в AWS Secrets Manager, звідки за допомогою Kubernetes Secrets Store CSI Driver синхронізуються в Kubernetes Secrets, з яких поди Бекенду створюють свої змінні оточення – див. AWS: Kubernetes – інтеграція AWS Secrets Manager та Parameter Store.

Перевіряємо статус поду – і бачимо чудове повідомлення “An IAM role must be associated with service account“:

Warning  FailedMount  43s (x2 over 2m45s)  kubelet  MountVolume.SetUp failed for volume "backend-api-secret-class-volume" : rpc error: code = Unknown desc = failed to mount secrets store objects for pod dev-backend-api-ns/backend-api-deployment-65c559d47-bb4dz, err: rpc error: code = Unknown desc = us-east-1: An IAM role must be associated with service account backend-api-sa (namespace: dev-backend-api-ns)

Google приводить нас до GitHub Issue Pod Identity Association not recognised by secrets store CSI driver, яка відкрита ще в грудні 2023.

В ній жеж є і пул-реквест Add support for Pod Identity Association, який наче має пофіксити цю проблему – але і він досі в Open, хоча декілька днів тому додали комент, що “We are conducting initial investigation on this feature request and will share updates soon“.

І драйвер зараз останньої версії – 0.3.9:

$ helm list -n kube-system | grep secret
secrets-store-csi-driver                kube-system     1               2024-07-18 12:46:20.945937022 +0300 EEST        deployed        secrets-store-csi-driver-1.4.4                  1.4.4      
secrets-store-csi-driver-provider-aws   kube-system     1               2024-07-18 12:46:23.287242734 +0300 EEST        deployed        secrets-store-csi-driver-provider-aws-0.3.9                

То що робити?

Варіант перший – це знов додавати OIDC і стару схему. Але цього прям зовсім не хочеться, бо на новому Kubernetes-кластері хотілося б вже і повністю нову аутентифікацію, а не ліпити костилі, які потім треба буде випилювати.

Варіант другий – спробувати перейти з  Kubernetes Secrets Store CSI Driver на External Secrets Operator, який вміє працювати з купою різних провайдерів – AWS Secrets Manager, Hashicorp Vault, Google Secrets Manager тощо.

Крім того, мені не дуже подобається те, що для створення Kubernetes Secret за допомогою Kubernetes Secrets Store CSI Driver, в його SecretProviderClass треба окремо описувати objects, а потім їх фактично дублювати в secretObjects.

External Secrets Operator: знайомство

Отже, External Secrets Operator (ESO) вміє отримувати сікрети із зовнішніх ресурсів і створювати звичайні Kubernetes Secrets.

Для доступу в AWS він використовує стандартну схему з ServiceAccount, а значить ми можемо створити EKS Pod Identity Association на AWS IAM Role, яка буде давати доступ до AWS Secrets Manager.

External Secrets Operator використовує два основні ресурси:

  • SecretStore: описує як саме отримати доступ до секретів – який провайдер (AWS, Google, Vault, etc) та аутентифікація, і створюється на рівні окремого Kubernetes Namespace для розподілення доступів
    • також є ClusterSecretStore, який можна створити глобально і доступний з будь-якого Namespace
  • ExternalSecret: описує які дані отримати від провайдера, і при потребі – які зміни зробити

External Secrets Operator має цілу купу зовнішніх провайдерів – див. Provider.

В AWS вміє працювати як з самим SecretsManager, та і ParameterStore.

Крім того, External Secrets Operator може навіть вносити зміни в AWS Secrets Manager – але ми будемо його використовувати тільки для створення Kubernetes Secrets, бо самі секрети в AWS Secrets Manager створюються з Terraform кожного проекту.

Отже, наша задача:

  • встановити External Secrets Operator
  • налаштувати йому доступ до AWS з AWS IAM Role та Kubernetes ServiceAccount використовуючи EKS Pod Identities
  • і створити Kubernetes Secret, який ми зможемо підключити в Kubernetes Pod, аби задати потрібні environment variables для роботи нашого сервісу

Запуск External Secrets Operator з Helm

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

$ helm repo add external-secrets https://charts.external-secrets.io
"external-secrets" has been added to your repositories

Сам Helm-чарт і доступні values – external-secrets.

Встановлюємо чарт з оператором в ops-external-secrets-ns Namespace:

$ helm install -n ops-external-secrets-ns --create-namespace external-secrets external-secrets/external-secrets

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

$ kk -n ops-external-secrets-ns get pod
NAME                                                READY   STATUS    RESTARTS   AGE
external-secrets-5859d8dc69-vxhjb                   1/1     Running   0          33s
external-secrets-cert-controller-5bbb8c4bb8-nmjn9   1/1     Running   0          33s
external-secrets-webhook-564cd5b69-r5mmb            1/1     Running   0          33s

Та ServiceAccounts:

$ kk -n ops-external-secrets-ns get sa 
NAME                               SECRETS   AGE
default                            0         10m
external-secrets                   0         9m59s
external-secrets-cert-controller   0         9m59s
external-secrets-webhook           0         9m59s

Нас тут цікавить ServiceAccount external-secrets – оператор буде використовувати його для доступу до Secrets Manager та Parameter Store, і його ми будемо підключати до EKS з Pod Indentity.

Аутентифікація з AWS IAM

Що нам потрібно:

  • IAM Policy, яка надає доступ до Secrets Manager та Parameter Store
  • IAM Role з Trust Policy для EKS Pod Indentity
    • і до цієї ролі підключимо IAM Policy

Тоді под з External Secrets Operator через Kubernetes ServiceAccount буде виконувати Assume цієї ролі, і отримувати доступ до секретів.

Поки зробимо найпростішою схемою, а далі подивимось, як додатково можна розділяти доступи через окремі IAM Roles для кожного SecretStore в різних неймспейсах.

Створення IAM Policy

Переходимо в IAM, створюємо нову IAM Policy, дозволяємо тільки read-операції:

{
    "Version": "2012-10-17",
    "Statement": [
        {
            "Sid": "AllowAccessToSecretsManager",
            "Effect": "Allow",
            "Action": [
                "secretsmanager:ListSecrets",
                "secretsmanager:GetSecretValue",
                "secretsmanager:DescribeSecret",
                "secretsmanager:ListSecretVersionIds"
            ],
            "Resource": [
                "arn:aws:secretsmanager:<AWS_REGION>:<AWS_ACCOUNT_ID>:secret:*"
            ]
        },
        {
            "Sid": "AllowAccessToParameterStore",
            "Effect": "Allow",
            "Action": [
                "ssm:GetParameters",
                "ssm:GetParameter",
                "ssm:GetParametersByPath"
            ],
            "Resource": [
                "arn:aws:ssm:<AWS_REGION>:<AWS_ACCOUNT_ID>:parameter/*"
            ]
        }
    ]
}

Зберігаємо з ім’ям external-secrets-operator-test-policy:

AWS: Kubernetes та External Secrets Operator для AWS Secrets Manager

Створення IAM Role для EKS Pod Identity

Переходимо в IAM Role, створюємо нову роль, в Use case вибираємо EKS – Pod Identity:

AWS: Kubernetes та External Secrets Operator для AWS Secrets Manager

Підключаємо створену вище IAM Policy external-secrets-operator-test-policy:

AWS: Kubernetes та External Secrets Operator для AWS Secrets Manager

Зберігаємо нову роль з ім’ям external-secrets-operator-test-role, в Trust policy маємо "Service": "pods.eks.amazonaws.com":

AWS: Kubernetes та External Secrets Operator для AWS Secrets Manager

Створення EKS Pod Identity Association

Тепер підключаємо цю роль до нашого EKS-кластеру atlas-eks-ops-1-30-cluster в неймспейс ops-external-secrets-ns до Kubernetes ServiceAccount з ім’ям external-secrets:

$ aws --profile work eks create-pod-identity-association --cluster-name atlas-eks-ops-1-30-cluster \
> --role-arn arn:aws:iam::492***148:role/external-secrets-operator-test-role \
> --namespace ops-external-secrets-ns \
> --service-account external-secrets

Перевіряємо в EKS > Access:

AWS: Kubernetes та External Secrets Operator для AWS Secrets Manager

Окей – тут все готово. Тепер ESO має отримати доступ до секретів та параметрів.

Далі нам потрібен SecretStore, який буде описувати як отримати доступ до AWS Secrets Manager або Parameter Store, та ExternalSecret, який власне буде відповідати за Kubernetes Secrets.

Створення Kubernetes Secrets з AWS Secrets Manager

Документація по всім значенням для ресурсів Operator – в API specification.

Створення SecretStore

Тестити будемо в окремому Namespace ops-test-ns.

Пишемо маніфест для SecretStore:

apiVersion: external-secrets.io/v1beta1
kind: SecretStore
metadata:
  name: test-secret-store
  namespace: ops-test-ns
spec:
  provider:
    aws:
      service: SecretsManager
      region: us-east-1

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

$ kk apply -f test-secretstore.yml 
secretstore.external-secrets.io/test-secret-store created

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

$ kk get secretstore
NAME                AGE   STATUS   CAPABILITIES   READY
test-secret-store   29s   Valid    ReadWrite      True

Створення ExternalSecret

Тепер нам потрібен ExternalSecret, який буде використовувати створений вище SecretStore.

Створимо тестовий секрет в AWS Secrets Manager:

AWS: Kubernetes та External Secrets Operator для AWS Secrets Manager

Зберігаємо його з ім’ям test-aws-secret:

AWS: Kubernetes та External Secrets Operator для AWS Secrets Manager

І маємо секрет зі значенням {"secret_key_1":"secret_value_1","secret_key_2":"secret_value_2"}:

AWS: Kubernetes та External Secrets Operator для AWS Secrets Manager

Спочатку отримаємо всю строку, а потім подивимось, як її можна додавати в Kubernetes Secret.

Описуємо маніфест для ExternalSecret:

apiVersion: external-secrets.io/v1beta1
kind: ExternalSecret
metadata:
  name: test-external-secret
  namespace: ops-test-ns
spec:
  refreshInterval: 1h
  secretStoreRef:
    name: test-secret-store
    kind: SecretStore
  target:
    name: test-kubernetes-secret
    creationPolicy: Owner
    deletionPolicy: Delete
    template:
      metadata:
        labels:
          app: test
  data:
    - secretKey: key_in_the_kubernetes_secret
      remoteRef:
        # AWS Secrets Manager secret's name
        key: test-aws-secret

Тут в spec:

  • refreshInterval: як часто перевіряти зміни в AWS Secrets Manager
  • secretStoreRef: який SecretStore використовувати для доступу в AWS Secrets Manager (можна вказати окремо в data.sourceRef.storeRef)
  • target:
    • name: ім’я Kubernetes Secret, який буде створено
    • creationPolicy: “власник” Kubernetes Secret:
      • Owner: видаляє Kubernetes Secret, якщо видаляється відповідний ExternalSecret
      • Merge: не створює Kubernetes Secret, а міняє записи в існуючому
      • Orphan: залишає Kubernetes Secret, якщо видаляється відповідний ExternalSecret
    • deletionPolicy:
      • Retain: залишає Kubernetes Secret, якщо у відповідному AWS Secrets Manager Secret видалені всі поля
      • Delete: видаляє Kubernetes Secret, якщо у відповідному AWS Secrets Manager Secret видалені всі поля
      • Merge: видаляє всі записи в Kubernetes Secret, якщо у відповідному AWS Secrets Manager Secret видалені всі поля, але залишає сам Kubernetes Secret
    • template: описує структуру Kubernetes Secret, який буде створено – type, labels, annnotations, etc
  • data: описує зв’язок між секретом в AWS Secrets Manager та Kubernetes Secret:
    • secretKey: ім’я key в Kubernetes Secret
    • remoteRef:
      • key: ім’я секрету в AWS Secrets Manager

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

$ kk apply -f test-externalsecret.yml 
externalsecret.external-secrets.io/test-external-secret created

Перевіряємо його статус:

$ kk get externalsecret
NAME                   STORE               REFRESH INTERVAL   STATUS         READY
test-external-secret   test-secret-store   1h                 SecretSynced   True

STATUS == SecretSynced – тобто, External Secrets Operator зміг отримати значення з AWS Secrets Manager та створити Kubernetes Secret.

При проблемах можна глянути логи ESO:

$ ktail -n ops-external-secrets-ns -l app.kubernetes.io/instance=external-secrets

Перевіряємо сам Kubernetes Secret:

$ kk get secret test-kubernetes-secret -o yaml
apiVersion: v1
data:
  key_in_the_kubernetes_secret: eyJzZWNyZXRfa2V5XzEiOiJzZWNyZXRfdmFsdWVfMSIsInNlY3JldF9rZXlfMiI6InNlY3JldF92YWx1ZV8yIn0=
...

І строка “eyJzZWNyZXRfa2V5XzEiOiJzZWNyZXRfdmFsdWVfMSIsInNlY3JldF9rZXlfMiI6InNlY3JldF92YWx1ZV8yIn0=” дає нам весь секрет з AWS Secrets Manager:

$ echo eyJzZWNyZXRfa2V5XzEiOiJzZWNyZXRfdmFsdWVfMSIsInNlY3JldF9rZXlfMiI6InNlY3JldF92YWx1ZV8yIn0= | base64 -d
{"secret_key_1":"secret_value_1","secret_key_2":"secret_value_2"}

Але в такому вигляді воно нам не дуже корисно, тому можемо зробити інакше – додати в data параметр property, в якому вказати конкретний ключ з секрету:

data:
  - secretKey: secret_key_1_value
    remoteRef:
      # AWS Secrets Manager secret's name
      key: test-aws-secret
      property: secret_key_1

Тоді наш Kubernetes Secret вже буде виглядати так:

$ kk get secret test-kubernetes-secret -o yaml
apiVersion: v1
data:
  secret_key_1_value: c2VjcmV0X3ZhbHVlXzE=
...

Де “c2VjcmV0X3ZhbHVlXzE=” – це значення “secret_value_1“.

Або замість того, щоб описувати кожен ключ з AWS Secrets Manager – в нашому ExternalSercret замість spec.data можемо використати spec.dataFrom:

...
spec:
  refreshInterval: 1h
  secretStoreRef:
    name: test-secret-store
    kind: SecretStore
  target:
    name: test-kubernetes-secret
    creationPolicy: Owner
  ...     
  dataFrom:
  - extract:
      key: test-aws-secret
...

І тоді наш Kubernetes Secret буде виглядати так:

$ kk get secret test-kubernetes-secret -o yaml
apiVersion: v1
data:
  secret_key_1: c2VjcmV0X3ZhbHVlXzE=
  secret_key_2: c2VjcmV0X3ZhbHVlXzI=
...

Де “c2VjcmV0X3ZhbHVlXzE=” – це значення “secret_value_1“, а “c2VjcmV0X3ZhbHVlXzI=” – “secret_value_2“.

Або можемо переіменувати ключі, наприклад:

...
  dataFrom:
  - extract:
      key: test-aws-secret
    rewrite:
    - regexp:
        source: "secret_key_([0-9])"
        target: "SECRET_KEY_${1}"

І результат:

$ kk get secret test-kubernetes-secret -o yaml
apiVersion: v1
data:
  SECRET_KEY_1: c2VjcmV0X3ZhbHVlXzE=
  SECRET_KEY_2: c2VjcmV0X3ZhbHVlXzI=
...

Advanced IAM Permissions per SecretStore

Тепер давайте глянемо, як ми можемо використовувати IAM Role з окремою IAM Policy на рівні SecretStore.

Тобто замість того, аби мати одну IAM Role з IAM Policy, яка надає доступ до всіх секретів в AWS Secrets Manager, і яку використовує наш External Secrets Operator – ми можемо створити окрему IAM Role, її підключити до конкретного SecretStore в конкретному Kubernetes Namespace, і тоді цей SecretStore буде мати доступ тільки до тих секретів, які описані у відповідній IAM Policy.

Схематично це можна відобразити так:

AWS: Kubernetes та External Secrets Operator для AWS Secrets ManagerОтже, що зробимо:

  • в ролі external-secrets-operator-test-role, яка через EKS Pod Identity підключена до ServiceAccount external-secrets відключимо IAM Policy, яка дає доступ до всіх AWS Secrets
  • створимо нову IAM Policy external-secrets-operator-test-application-policy, яка буде надавати дозвіл тільки до конкретного секрету
  • створимо нову IAM Role external-secrets-operator-test-application-role:
    • їй в Trust Policy дозволимо виконувати Assume від імені ролі external-secrets-operator-test-role
    • і до цієї ролі підключимо IAM Policy external-secrets-operator-test-application-policy
  • а до SecretStore додамо параметр з role: external-secrets-operator-test-application-role

Тоді External Secrets буде працювати так:

  • Kubernets Pod з External Secrets через EKS Pod Identity виконує AssumeRole external-secrets-operator-test-role
  • при створенні ExternalSecret він використає SecretStore, в якому задана external-secrets-operator-test-application-role
    • External Secrets з external-secrets-operator-test-role виконає другий AssumeRole – “візьме” роль external-secrets-operator-test-application-role з її IAM Policy external-secrets-operator-test-application-policy
    • і вже з цієї роллю отримає доступ до секрету в AWS Secrets Manager

Поїхали.

В IAM Role external-secrets-operator-test-role видаляємо підключену IAM Policy, яка давала повний доступ до AWS Secrets Manager:

AWS: Kubernetes та External Secrets Operator для AWS Secrets Manager

Створимо нову IAM Policy external-secrets-operator-test-application-policy з доступом до одного конкретного секрету test-aws-secret:

{
    "Version": "2012-10-17",
    "Statement": [
        {
            "Sid": "AllowAccessToSecretsManager",
            "Effect": "Allow",
            "Action": [
                "secretsmanager:ListSecrets",
                "secretsmanager:GetSecretValue",
                "secretsmanager:DescribeSecret",
                "secretsmanager:ListSecretVersionIds"
            ],
            "Resource": [
                "arn:aws:secretsmanager:us-east-1:492***148:secret:test-aws-secret*"
            ]
        }
    ]
}

Зберігаємо:

AWS: Kubernetes та External Secrets Operator для AWS Secrets Manager

Створюємо нову IAM Role з Custom trust policy, де дозволяємо виконувати Assume від ролі external-secrets-operator-test-role:

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Sid": "Statement1",
      "Effect": "Allow",
      "Action": [
          "sts:AssumeRole",
           "sts:TagSession"
         ],
 			"Principal": {
 			    "AWS": "arn:aws:iam::492***148:role/external-secrets-operator-test-role"
 			}
    }
  ]
}

До цієї ролі підключаємо нову політику:

AWS: Kubernetes та External Secrets Operator для AWS Secrets Manager

І зберігаємо нову роль як external-secrets-operator-test-application-role:

Тепер повертаємось до нашого SecretStore, і в spec.provider.aws додаємо параметр role:

apiVersion: external-secrets.io/v1beta1
kind: SecretStore
metadata:
  name: test-secret-store
  namespace: ops-test-ns
spec:
  provider:
    aws:
      service: SecretsManager
      region: us-east-1
      role: arn:aws:iam::492***148:role/external-secrets-operator-test-application-role

Оновлюємо SecretStore :

$ kk apply -f test-secretstore.yml 
secretstore.external-secrets.io/test-secret-store configured

Для перевірки видалимо старий ExternalSecret:

$ kk delete externalsecret test-external-secret
externalsecret.external-secrets.io "test-external-secret" deleted

Створимо ще раз:

$ kk apply -f test-externalsecret.yml 
externalsecret.external-secrets.io/test-external-secret created

І дивимось статус:

$ kk get externalsecret
NAME                   STORE               REFRESH INTERVAL   STATUS         READY
test-external-secret   test-secret-store   1h                 SecretSynced   True

А тепер давайте спробуємо використати інший секрет з AWS Secrets Manager – “test/rds/kraken“, до якого ми не давали дозволу в IAM Policy external-secrets-operator-test-application-policy:

apiVersion: external-secrets.io/v1beta1
kind: ExternalSecret
metadata:
  name: test-external-secret
  namespace: ops-test-ns
spec:
  refreshInterval: 1h
  secretStoreRef:
    name: test-secret-store
    kind: SecretStore
  target:
    name: test-kubernetes-secret
  ...
  dataFrom:
  - extract:
      key: test/rds/kraken

Деплоїмо, перевіряємо:

$ kk get externalsecret
NAME                   STORE               REFRESH INTERVAL   STATUS              READY
test-external-secret   test-secret-store   1h                 SecretSyncedError   False

І тепер STATUS == SecretSyncedError.

В логах це добре видно – “external-secrets-operator-test-application-role is not authorized to perform: secretsmanager:GetSecretValue on resource: test/rds/kraken“:

external-secrets-5859d8dc69-2fgc8:external-secrets {... "msg":"could not get secret data from provider","ExternalSecret":{"name":"test-external-secret","namespace":"ops-test-ns"},"error":"AccessDeniedException: User: arn:aws:sts::492***148:assumed-role/external-secrets-operator-test-application-role/1724248118674412261 is not authorized to perform: secretsmanager:GetSecretValue on resource: test/rds/kraken because no identity-based policy allows the secretsmanager:GetSecretValue action\n\tstatus code: 400 ...}

Висновки

Поки що мені External Secrets Operator прям дуже сподобався.

По-перше – дійсно набагато менше коду маніфестів для створення ресурсів.

По-друге – він нормально працює з EKS Pod Identity.

Третє – це дуже гнучка система розподілення доступів до секретів.

Четверте – що за допомогою одного оператора можна створювати Kubernetes Secrets з різних провайдерів.

Та і взагалі простіша система, бо не потрібно мати DaemonSet з подами на кожній WorkerNode, як це реалізовано в secrets-store-csi-driver-provider-aws.

Виглядає дуже прикольно, тому будемо мігрувати на нього.

Loading