VictoriaMetrics: створення Kubernetes monitoring stack з власним Helm-чартом
0 (0)

20 Липня 2023

Зараз маємо VictoriaMetrics + Grafana на звичайному EC2-інстансі, запущені з Docker Compose – то був Proof of Concept, прийшов час запускати “по-дорослому” – в Kubernetes, і всі конфіги вже винести в GitHub.

У VictoriaMetrics є чарти під кожен компонент, див. Victoria Metrics Helm Charts, і є чарти для запуску VictoriaMetrics Operator та victoria-metrics-k8s-stack – аналог Kuber Prometheus Stack, який я використовував раніше.

Використовувати будемо саме victoria-metrics-k8s-stack, який “під капотом” запустить VictoriaMetrics Operator, Grafana та kube-state-metrics, див. dependencies.

Матеріал вийшов досить великий, але наче описав всі цікаві моменти по розгортанню повноцінного стеку моніторингу з VictoriaMetrics Kubernetes Monitoring Stack.

UPD: робив документацію по нашому моніторингу, вийшла ось така схема того, про що буде в цьому пості:

Планування

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

  • перевірити деплой чарту victoria-metrics-k8s-stack
  • подивитись і подумати, як запускати існуючі експортери – частина мають чарти, але ми маємо і самописні (див. – Prometheus: GitHub Exporter – пишемо власний експортер для GitHub API), тож їх треба буде пушити в Elastic Container Service і думати, як звідти пулити і запускати в Kubernetes
  • секрети для моніторингу – паролі Grafana і все таке інше
  • IRSA для експортерів – створити IAM Policy та ролі для ServiceAccounts
  • перенесення алертів
  • конфіг для VMAgent – збір метрик с експортерів
  • запустити Grafana Loki

Стосовно логів – так, нещодавно вийшла VictoriaLogs, але вона ще в preview, не має підтримки збегірання в AWS S3, не має інтеграції з Grafana, та й взагалі поки не хочеться витрачати зайвий час, а Loki я вже більш-менш знаю. Можливо, запущу VictoriaLogs окремо, “погратись-подивитись”, а коли її вже інтегрують з Grafana – то заміню Loki на VictoriaLogs, бо зараз ми вже маємо дашборди з графіками з логів Loki.

Ще окремо треба буде глянути як там з persistance у VictoriaMetrics в Kubernetes – розмір, типи дисків і так далі. Можливо, подумати про їхні бекапи (VMBackup?).

На існуючому моніторингу маємо досить багато всього:

[simterm]

root@ip-172-31-89-117:/home/admin/docker-images/prometheus-grafana# tree .
.
├── alertmanager
│   ├── config.yml
│   └── notifications.tmpl
├── docker-compose.yml
├── grafana
│   ├── config.monitoring
│   └── provisioning
│       ├── dashboards
│       │   └── dashboard.yml
│       └── datasources
│           └── datasource.yml
├── prometheus
│   ├── alert.rules
│   ├── alert.templates
│   ├── blackbox-targets
│   │   └── targets.yaml
│   ├── blackbox.yml
│   ├── cloudwatch-config.yaml
│   ├── loki-alerts.yaml
│   ├── loki-conf.yaml
│   ├── prometheus.yml
│   ├── promtail.yaml
│   └── yace-config.yaml
└── prometheus.yml

[/simterm]

Яке взагалі деплоїти? Через AWS CDK та його cluster.add_helm_chart() – чи робити окремий степ в GitHub Actions з Helm?

Нам в будь-якому разі буде потрібен CDK – створити сертифікати з ACM, Lambda для логів в Loki, S3-корзини, IAM-ролі для експортерів тощо.

Але чомусь зовсім не хочеться тягнути в CDK деплой чартів, бо краще всеж відокремити деплой інфрастуктурних об’єктів від деплою самого стеку моніторинга.

Добре – зробимо окремо: CDK буде створювати ресурси в AWS, Helm буде деплоїти чарти. Чи чарт? Може – просто зробити власний чарт, а йому вже сабчартами підключити і VictoriaMetrics Stack, и експортери? Виглядає наче непоганою ідею. “Мінуси будуть?” (с)

Також нам треба буде створити Kubernetes Secrets та ConfigMaps з конфігами для VMAgent, Loki (див. Loki: збір логів з CloudWatch Logs з використанням Lambda Promtail), Alertmanager тощо. Робити їх з Kustomize? Чи просто YAML-маніфестами в директорії templates нашого чарту?

Подивимось. Поки думаю, що таки Kustomize.

Тепер по порядку – що треба буде зробити:

  1. запустити експортери
  2. підключити конфіг до VMAgent, щоб почати збирати метрики з експортерів
  3. перевірити, як налаштовуються ServiceMonitors (VMServiceScrape у VictoriaMetrics)
  4. Grafana:
    1. датасорси
    2. дашборди
  5. додати Loki
  6. алерти

Поїхали. Почнемо з перевірки самого чарту victoria-metrics-k8s-stack.

Встановлення VictoriaMetrics Stack Helm Chart

Додаємо репозиторії с залежностями:

[simterm]

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

[/simterm]

І саму VictoriaMetrics:

[simterm]

$ helm repo add vm https://victoriametrics.github.io/helm-charts/
"vm" has been added to your repositories
$ helm repo update
Hang tight while we grab the latest from your chart repositories...
...Successfully got an update from the "vm" chart repository
...Successfully got an update from the "grafana" chart repository
...Successfully got an update from the "prometheus-community" chart repository
Update Complete. ⎈Happy Helming!⎈

[/simterm]

Перевіряємо версії чарту victoria-metrics-k8s-stack:

[simterm]

$ helm search repo vm/victoria-metrics-k8s-stack -l
NAME                            CHART VERSION   APP VERSION     DESCRIPTION                                       
vm/victoria-metrics-k8s-stack   0.17.0          v1.91.3         Kubernetes monitoring on VictoriaMetrics stack....
vm/victoria-metrics-k8s-stack   0.16.4          v1.91.3         Kubernetes monitoring on VictoriaMetrics stack....
vm/victoria-metrics-k8s-stack   0.16.3          v1.91.2         Kubernetes monitoring on VictoriaMetrics stack....
...

[/simterm]

Всі values можна взяти так:

[simterm]

$ helm show values vm/victoria-metrics-k8s-stack > default-values.yaml

[/simterm]

Або просто в репозиторії – values.yaml.

Мінімальний values для VictoriaMetrics chart

У VictoriaMetrics взагалі гарна документація, тож по ходу діла часто будемо користоватись API Docs.

Використаємо VMSingle замість VMCluster – проект у нас маленький, з VictoriaMetrics я тільки знайомлюсь, не хочеться ускладнювати систему.

Створюємо мінімальний конфіг:

# to confugire later
victoria-metrics-operator:
  serviceAccount:
    create: false

# to confugire later
alertmanager:
  enabled: true

# to confugire later
vmalert:
  annotations: {}
  enabled: true

# to confugire later
vmagent:
  enabled: true

grafana:
  enabled: true
  ingress:
    enabled: true
    annotations:
      kubernetes.io/ingress.class: alb
      alb.ingress.kubernetes.io/target-type: ip
      alb.ingress.kubernetes.io/scheme: internet-facing
    hosts:
      - monitoring.dev.example.co

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

[simterm]

$ helm upgrade --install victoria-metrics-k8s-stack -n dev-monitoring-ns --create-namespace vm/victoria-metrics-k8s-stack -f atlas-monitoring-dev-values.yaml

[/simterm]

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

[simterm]

$ kk -n dev-monitoring-ns get pod
NAME                                                              READY   STATUS              RESTARTS   AGE
victoria-metrics-k8s-stack-grafana-76867f56c4-6zth2               0/3     Init:0/1            0          5s
victoria-metrics-k8s-stack-kube-state-metrics-79468c76cb-75kgp    0/1     Running             0          5s
victoria-metrics-k8s-stack-prometheus-node-exporter-89ltc         1/1     Running             0          5s
victoria-metrics-k8s-stack-victoria-metrics-operator-695bdxmcwn   0/1     ContainerCreating   0          5s
vmsingle-victoria-metrics-k8s-stack-f7794d779-79d94               0/1     Pending             0          0s

[/simterm]

Та Ingress:

[simterm]

$ kk -n dev-monitoring-ns get ing
NAME                                 CLASS    HOSTS                      ADDRESS                                                                   PORTS   AGE
victoria-metrics-k8s-stack-grafana   <none>   monitoring.dev.example.co   k8s-devmonit-victoria-***-***.us-east-1.elb.amazonaws.com   80      6m10s

[/simterm]

Чекаємо оновлення DNS, або просто відкриваємо доступ до Grafana Service – знаходимо його:

[simterm]

$ kk -n dev-monitoring-ns get svc
NAME                                                   TYPE        CLUSTER-IP       EXTERNAL-IP   PORT(S)                      AGE
victoria-metrics-k8s-stack-grafana                     ClusterIP   172.20.162.193   <none>        80/TCP                       12m
...

[/simterm]

І виконуємо port-forward:

[simterm]

$ kk -n dev-monitoring-ns port-forward svc/victoria-metrics-k8s-stack-grafana 8080:80
Forwarding from 127.0.0.1:8080 -> 3000
Forwarding from [::1]:8080 -> 3000

[/simterm]

В браузері переходимо до http://localhost:8080/

Username по дефолту admin, отримуємо його згенерований пароль:

[simterm]

$ kubectl -n dev-monitoring-ns get secret victoria-metrics-k8s-stack-grafana -o jsonpath="{.data.admin-password}" | base64 --decode ; echo
1Ev***Ko2

[/simterm]

Маємо вже готові дашборди (параметр defaultDashboardsEnabled у values):

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

Створення власного чарту для моніторинг-стеку

Отже, зробимо такий собі “umbrella chart“, який буде запускати і сам стек VictoriaMetrics, і експортери, і створювати всі необхідні Secrets/ConfgiMaps тощо.

Як воно буде працювати?

  1. створимо чарт
  2. йому в dependencies вписуємо VictoriaMetrics
  3. через тіж dependencies додамо запуск екпортерів
  4. в каталозі templates опишемо наші кастомні ресурси (ConfigMaps, VMRules, etc)

Згадаємо, як воно взагалі робиться – Helm Create, Helm: dependencies aka subcharts – обзор и пример, How to make a Helm chart in 10 minutes, One Chart to rule them all – How to implement Helm Subcharts.

Замість helm create – зробимо все руками, бо він нагенерує забагато зайвих файлів.

В своєму репозиторії моніторингу створюємо каталоги:

[simterm]

$ mkdir -p victoriametrics/{templates,charts,values}

[/simterm]

Перевіряємо структуру:

[simterm]

$ tree victoriametrics
victoriametrics
├── charts
├── templates
└── values

[/simterm]

Переходимо в каталог victoriametrics, створюємо файл Chart.yaml:

apiVersion: v2
name: atlas-victoriametrics
description: A Helm chart for Atlas Victoria Metrics kubernetes monitoring stack
type: application
version: 0.1.0
appVersion: "1.16.0"

Додавання subcharts

Далі додаємо dependencies, починаємо з victoria-metrics-k8s-stack.

Версії вже знаходили, згадаємо яка там була остання:

[simterm]

$ helm search repo vm/victoria-metrics-k8s-stack -l
NAME                            CHART VERSION   APP VERSION     DESCRIPTION                                       
vm/victoria-metrics-k8s-stack   0.17.0          v1.91.3         Kubernetes monitoring on VictoriaMetrics stack....
vm/victoria-metrics-k8s-stack   0.16.4          v1.91.3         Kubernetes monitoring on VictoriaMetrics stack....
...

[/simterm]

Додаємо з ~ в номері версії, щоб включити патчі до версії 0.17 (див. Dependencies):

apiVersion: v2
name: atlas-victoriametrics
description: A Helm chart for Atlas Victoria Metrics kubernetes monitoring stack
type: application
version: 0.1.0
appVersion: "1.16.0"
dependencies:
- name: victoria-metrics-k8s-stack
  version: ~0.17.0
  repository: https://victoriametrics.github.io/helm-charts/

Values для сабчартів

Далі, створюємо каталоги для values:

[simterm]

$ mkdir -p values/{dev,prod}

[/simterm]

Копіюємо наш мінімальний конфіг у values/dev/:

[simterm]

$ cp ../atlas-monitoring-dev-values.yaml values/dev/

[/simterm]

Потім всі загальні параметри винесемо в якійсь common-values.yaml, а значення, которі будуть різні для Dev/Prod – по окремим файлам.

Оновлюємо наш values – додаємо блок victoria-metrics-k8s-stack, бо він у нас тепер буде сабчартом:

victoria-metrics-k8s-stack:
  # no need yet
  victoria-metrics-operator:
    serviceAccount:
      create: true

  # to confugire later
  alertmanager:
    enabled: true

  # to confugire later
  vmalert:
    annotations: {}
    enabled: true

  # to confugire later
  vmagent:
    enabled: true

  grafana:
    enabled: true
    ingress:
      enabled: true
      annotations:
        kubernetes.io/ingress.class: alb
        alb.ingress.kubernetes.io/target-type: ip
        alb.ingress.kubernetes.io/scheme: internet-facing
      hosts:
        - monitoring.dev.example.co

Загружаємо чарти з dependencies:

[simterm]

$ helm dependency update
Hang tight while we grab the latest from your chart repositories...
...Successfully got an update from the "vm" chart repository
...Successfully got an update from the "grafana" chart repository
...Successfully got an update from the "prometheus-community" chart repository
Update Complete. ⎈Happy Helming!⎈
Saving 1 charts
Downloading victoria-metrics-k8s-stack from repo https://victoriametrics.github.io/helm-charts/
Deleting outdated charts

[/simterm]

Перевіряємо каталог charts:

[simterm]

$ ls -1 charts/
victoria-metrics-k8s-stack-0.17.0.tgz

[/simterm]

І робимо helm template нового Helm-чарту з нашим VictoriaMetrics Stack, щоб перевірити, що сам чарт, його dependencies і values працюють:

[simterm]

$ helm template . -f values/dev/atlas-monitoring-dev-values.yaml 
---
# Source: victoriametrics/charts/victoria-metrics-k8s-stack/charts/grafana/templates/serviceaccount.yaml
apiVersion: v1
kind: ServiceAccount
metadata:
  labels:
    helm.sh/chart: grafana-6.44.11
    app.kubernetes.io/name: grafana
    app.kubernetes.io/instance: release-name
...

[/simterm]

Наче ОК – спробуємо задеплоїти – видаляємо старий реліз:

[simterm]

$ helm -n dev-monitoring-ns uninstall victoria-metrics-k8s-stack
release "victoria-metrics-k8s-stack" uninstalled

[/simterm]

Service Invalid value: must be no more than 63 characters

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

[simterm]

$ helm -n dev-monitoring-ns upgrade --install atlas-victoriametrics . -f values/dev/atlas-monitoring-dev-values.yaml 
Release "atlas-victoriametrics" does not exist. Installing it now.
Error: 10 errors occurred:
        * Service "atlas-victoriametrics-victoria-metrics-k8s-stack-kube-controlle" is invalid: metadata.labels: Invalid value: "atlas-victoriametrics-victoria-metrics-k8s-stack-kube-controller-manager": must be no more than 63 characters

[/simterm]

Перевіряємо довжину імені:

[simterm]

$ echo atlas-victoriametrics-victoria-metrics-k8s-stack-kube-controller-manager | wc -c
73

[/simterm]

Щоб вирішити це – додаємо до values параметр fullnameOverride зі скороченим іменем:

victoria-metrics-k8s-stack:
  fullnameOverride: "vm-k8s-stack"
  ...

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

[simterm]

$ helm -n dev-monitoring-ns upgrade --install atlas-victoriametrics . -f values/dev/atlas-monitoring-dev-values.yaml 
Release "atlas-victoriametrics" has been upgraded. Happy Helming!
...

[/simterm]

Перевіряємо ресурси:

[simterm]

$ kk -n dev-monitoring-ns get all
NAME                                                      TYPE        CLUSTER-IP       EXTERNAL-IP   PORT(S)            AGE
service/atlas-victoriametrics-grafana                     ClusterIP   172.20.93.0      <none>        80/TCP             0s
service/atlas-victoriametrics-kube-state-metrics          ClusterIP   172.20.113.37    <none>        8080/TCP           0s
...

[/simterm]

Наче все є – давайте додамо експортери.

Prometheus CloudWatch Exporter subchart

Для аутентифікації експортів в AWS використаємо IRSA, але тут описувати не буду, бо вже є у AWS: CDK та Python, IAM OIDC Provider, та Kubernetes Controllers, та й навряд чи ви це будете робити з AWS CDK та Python.

Тож будемо вважати, що IAM Role для експортера вже є – нам треба тільки встановити prometheus-cloudwatch-exporter чарт і вказати ARN IAM-ролі.

Знову перевіряємо доступні версії:

[simterm]

$ helm repo add prometheus-community https://prometheus-community.github.io/helm-charts
$ helm search repo prometheus-community/prometheus-cloudwatch-exporter
NAME                                                    CHART VERSION   APP VERSION     DESCRIPTION                                    
prometheus-community/prometheus-cloudwatch-expo...      0.25.1          0.15.4          A Helm chart for prometheus cloudwatch-exporter

[/simterm]

Додаємо його до нашого Chart.yaml у dependencies:

...
dependencies:
- name: victoria-metrics-k8s-stack
  version: ~0.17.0
  repository: https://victoriametrics.github.io/helm-charts/
- name: prometheus-cloudwatch-exporter
  version: ~0.25.1
  repository: https://prometheus-community.github.io/helm-charts

До файлу values/dev/atlas-monitoring-dev-values.yaml додаємо параметр prometheus-cloudwatch-exporter.serviceAccount.annotations з ARN нашої IAM-ролі і блок config з метриками, які будемо збирати:

prometheus-cloudwatch-exporter:
  serviceAccount: 
    annotations:
      eks.amazonaws.com/role-arn: arn:aws:iam::492***148:role/atlas-monitoring-dev-CloudwatchExporterRole0613A27-EU5LW9XRWVRL
  config: |-
    region: us-east-1
    metrics:
          
    - aws_namespace: AWS/Events
      aws_metric_name: FailedInvocations
      aws_dimensions: [RuleName]
      aws_statistics: [Sum, SampleCount]
          
    - aws_namespace: AWS/Events
      aws_metric_name: Invocations
      aws_dimensions: [EventBusName, RuleName]
      aws_statistics: [Sum, SampleCount]

Хоча якщо конфіг великий – то мабуть краще його робити через створення власного ConfigMap для експортеру.

Оновлюємо залежності:

[simterm]

$ helm dependency update

[/simterm]

Деплоїмо:

[simterm]

$ helm -n dev-monitoring-ns upgrade --install atlas-victoriametrics . -f values/dev/atlas-monitoring-dev-values.yaml

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

[simterm]

$ kk -n dev-monitoring-ns get pod | grep cloud
atlas-victoriametrics-prometheus-cloudwatch-exporter-564ccfjm9j   1/1     Running   0          53s

[/simterm]

Та ServiceAccount:

[simterm]

$ kk -n dev-monitoring-ns get pod atlas-victoriametrics-prometheus-cloudwatch-exporter-64b6f6b9rv -o yaml
...
    - name: AWS_ROLE_ARN
      value: arn:aws:iam::492***148:role/atlas-monitoring-dev-CloudwatchExporterRole0613A27-EU5LW9XRWVRL
    - name: AWS_WEB_IDENTITY_TOKEN_FILE
      value: /var/run/secrets/eks.amazonaws.com/serviceaccount/token
...

[/simterm]

Робимо port-forward:

[simterm]

$ kk -n dev-monitoring-ns port-forward svc/atlas-victoriametrics-prometheus-cloudwatch-exporter 9106

[/simterm]

І з cURL глянемо, чи пішли метрики:

[simterm]

$ curl -s localhost:9106/metrics | grep aws_
# HELP aws_events_invocations_sum CloudWatch metric AWS/Events Invocations Dimensions: [EventBusName, RuleName] Statistic: Sum Unit: Count
# TYPE aws_events_invocations_sum gauge
aws_events_invocations_sum{job="aws_events",instance="",event_bus_name="***-staging",rule_name="***_WsConnectionEstablished-staging",} 2.0 1689598980000
aws_events_invocations_sum{job="aws_events",instance="",event_bus_name="***-prod",rule_name="***_ReminderTimeReached-prod",} 2.0 1689598740000
aws_events_invocations_sum{job="aws_events",instance="",event_bus_name="***-prod",rule_name="***_PushNotificationEvent-prod",} 2.0 1689598740000

[/simterm]

Є, чудово.

Тепер нам треба налаштувати VMAgent, щоб він почав збирати ці метрики з цього експортеру.

Збір метрик з експортерів: VMAgent && scrape_configs

Звична з Kube Prometheus Stack схема – це просто включити servicemonitor.enabled=true в вальюсах чарту експотера, і Prometheus Operator створить ServiceMonitor та почне збирати метрики.

Проте з VictoriaMetrics це не спрацює, бо ServiceMonitor CRD є частиною kube-prometheus-stack, і ресурс ServiceMonitor просто не буде створено.

Натомість у VictoriaMetrics є власний аналог – VMServiceScrape, який можна створити з маніфесту, і йому вказати з якого ендпоінту збирати метрики. До того ж, VictoriaMetrics вміє створювати VMServiceScrape з існуючих ServiceMonitor, але це потребує установки самого ServiceMonitor CRD.

Також ми можемо передати список таргетів через inlineScrapeConfig або additionalScrapeConfigs, див. VMAgentSpec.

Скоріш за все, у нас поки що буде inlineScrapeConfig, бо конфіг не надто великий.

Ще цікаво взагалі глянути VMAgent values.yaml – наприклад, там є дефолтні scrape_configs.

Ще один нюанс, которий треба мати на увазі – VMAgent не перевіряє конфіги таргетів, тобто якщо є помилка в YAML – то VMAgent просто ігнорує зміни, і не перезавантажує файл, при цьому в лог нічого не пише.

VMServiceScrape

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

Перевіряємо лейбли у CloudWatch Exporter Service:

[simterm]

$ kk -n dev-monitoring-ns describe svc atlas-victoriametrics-prometheus-cloudwatch-exporter 
Name:              atlas-victoriametrics-prometheus-cloudwatch-exporter
Namespace:         dev-monitoring-ns
Labels:            app=prometheus-cloudwatch-exporter
                   app.kubernetes.io/managed-by=Helm
                   chart=prometheus-cloudwatch-exporter-0.25.1
                   heritage=Helm
                   release=atlas-victoriametrics
...

[/simterm]

Описуємо VMServiceScrape з matchLabels, де вказуємо лейбли сервісу експортера:

apiVersion: operator.victoriametrics.com/v1beta1
kind: VMServiceScrape
metadata:
  name: prometheus-cloudwatch-exporter-vm-scrape
  namespace: dev-monitoring-ns
spec:
  selector:
    matchLabels:
      app: prometheus-cloudwatch-exporter
  endpoints:
  - port: http

Деплоїмо:

[simterm]

$ kubectl apply -f vmsvcscrape.yaml 
vmservicescrape.operator.victoriametrics.com/prometheus-cloudwatch-exporter-vm-scrape created

[/simterm]

Перевіряємо всі vmservicescrape – тут вже є пачка дефолтних, які створив сам VictoriaMetrics Operator:

[simterm]

$ kk -n dev-monitoring-ns get vmservicescrape
NAME                                       AGE
prometheus-cloudwatch-exporter-vm-scrape   6m45s
vm-k8s-stack-apiserver                     4d22h
vm-k8s-stack-coredns                       4d22h
vm-k8s-stack-grafana                       4d22h
vm-k8s-stack-kube-controller-manager       4d22h
...

[/simterm]

Конфіг VMAgent створюється в поді у файл /etc/vmagent/config_out/vmagent.env.yaml.

Глянемо, чи додався там наш CloudWatch Exporter:

[simterm]

$ kk -n dev-monitoring-ns exec -ti vmagent-vm-k8s-stack-98d7678d4-cn8qd -c vmagent -- cat /etc/vmagent/config_out/vmagent.env.yaml
global:
  scrape_interval: 25s
  external_labels:
    cluster: eks-dev-1-26-cluster
    prometheus: dev-monitoring-ns/vm-k8s-stack
scrape_configs:
- job_name: serviceScrape/dev-monitoring-ns/prometheus-cloudwatch-exporter-vm-scrape/0
  honor_labels: false
  kubernetes_sd_configs:
  - role: endpoints
    namespaces:
      names:
      - dev-monitoring-ns
...

[/simterm]

І тепер маємо побачити метрики в самій VictoriaMetrics.

Відкриваємо порт:

[simterm]

$ kk -n dev-monitoring-ns port-forward svc/vmsingle-vm-k8s-stack 8429

[/simterm]

В браузері заходимо на http://localhost:8429/vmui/ і для перевірки робимо запит на будь-яку метрику від CloudWatch Exporter:

Добре – побачили, як вручну створити VMServiceScrape. Але що там з автоматизацією цього процесу? Бо якось не дуже хочеться через Kustomize свторювати VMServiceScrape для кожного сервісу.

VMServiceScrape з ServiceMonitor та VictoriaMetrics Prometheus Converter

Тож як вже писалось, для того, щоб в кластері створився об’єкт ServiceMonitor нам потрібен Custom Resource Definition з ServiceMonitor.

Можемо встановити його прямо з маніфесту в репозиторії kube-prometheus-stack:

[simterm]

$ kubectl apply -fhttps://raw.githubusercontent.com/prometheus-community/helm-charts/main/charts/kube-prometheus-stack/charts/crds/crds/crd-servicemonitors.yaml
customresourcedefinition.apiextensions.k8s.io/servicemonitors.monitoring.coreos.com created

[/simterm]

Оновлюмо наш values – додаємо serviceMonitorenabled=true:

...
prometheus-cloudwatch-exporter:
  serviceAccount: 
    annotations:
      eks.amazonaws.com/role-arn: arn:aws:iam::492***148:role/atlas-monitoring-dev-CloudwatchExporterRole0613A27-EU5LW9XRWVRL
      eks.amazonaws.com/sts-regional-endpoints: "true"
  serviceMonitor:
    enabled: true
...

І у вальюсах victoria-metrics-k8s-stack додаємо параметр operator.disable_prometheus_converter=false:

victoria-metrics-k8s-stack:
  fullnameOverride: "vm-k8s-stack"
  # no need yet
  victoria-metrics-operator:
    serviceAccount:
      create: true
    operator:
      disable_prometheus_converter: false
...

Деплоїмо та перевіряємо, чи створено servicemonitor:

[simterm]

$ kk -n dev-monitoring-ns get servicemonitors
NAME                                                   AGE
atlas-victoriametrics-prometheus-cloudwatch-exporter   2m22s

[/simterm]

І автоматом мав з’явитись vmservicescrape:

[simterm]

$ kk -n dev-monitoring-ns get vmservicescrape
NAME                                                   AGE
atlas-victoriametrics-prometheus-cloudwatch-exporter   2m11s
...

[/simterm]

Глянемо targets:

Все є.

Єдиний тут нюанс в тому, что при видалені ServiceMonitor – відповідний vmservicescrape залишиться. Плюс сама необхідність в становленні стороннього CRD, який з часом треба буде якось оновлювати, бажано автоматично.

inlineScrapeConfig

Найбільш мабуть простий варіант – це описати конфіг через inlineScrapeConfig прямо в values нашого чарту:

...
  vmagent:
    enabled: true
    spec: 
      externalLabels:
        cluster: "eks-dev-1-26-cluster"
      inlineScrapeConfig: |
        - job_name: cloudwatch-exporter-inline-job
          metrics_path: /metrics
          static_configs:
            - targets: ["atlas-victoriametrics-prometheus-cloudwatch-exporter:9106"]
...

Деплоймо і перевіряємо сам vmagent:

[simterm]

$ kk -n dev-monitoring-ns get vmagent -o yaml
apiVersion: v1
items:
- apiVersion: operator.victoriametrics.com/v1beta1
  kind: VMAgent
  ...
    inlineScrapeConfig: |
      - job_name: cloudwatch-exporter-inline-job
        metrics_path: /metrics
        static_configs:
          - targets: ["atlas-victoriametrics-prometheus-cloudwatch-exporter:9106"]
...

[/simterm]

Ще раз глянемо таргети:

additionalScrapeConfigs

Більш безпечний варіант, якщо в параметрах є якісь токени/ключі доступу, але потребує створення окремого об’єкту Kubernetes Secret.

В принципі не проблема, бо ConfigMaps/Secrets все одно доведеться створювати, і якщо захочется винести конфіг таргетів окремим файлом – то, скоріш за все, перероблю на additionalScrapeConfigs.

Зараз створимо вручну, просто глянути як воно буде працювати – беремо приклад прямо з документації:

apiVersion: v1
kind: Secret
metadata:
  name: additional-scrape-configs
stringData:
  prometheus-additional.yaml: |
    - job_name: cloudwatch-exporter-secret-job
      metrics_path: /metrics
      static_configs:
      - targets: ["atlas-victoriametrics-prometheus-cloudwatch-exporter:9106"]

Не забуваємо його задеплоїти 🙂

[simterm]

$ kubectl -n dev-monitoring-ns apply -f vmagent-targets-secret.yaml 
secret/additional-scrape-configs created

[/simterm]

Оновлюємо вальюси VMAgent – додаємо блок additionalScrapeConfigs:

...
  vmagent:
    enabled: true
    spec: 
      externalLabels:
        cluster: "eks-dev-1-26-cluster"
      additionalScrapeConfigs:
        name: additional-scrape-configs
        key: prometheus-additional.yaml        
      inlineScrapeConfig: |
        - job_name: cloudwatch-exporter-inline-job
          metrics_path: /metrics
          static_configs:
            - targets: ["atlas-victoriametrics-prometheus-cloudwatch-exporter:9106"]
...

Оновлюємо деплой, та перевіряємо таргети:

Тепер, як маємо метрики – можемо переходити до Grafana.

Grafana provisioning

Що нам треба для Grafana? Плагіни, датасорси та дашборди.

Спершу додамо Data Sources, див. документацію.

Підключення Data Sources && Plugins

Якщо з дашбордами все більш-менш просто, то з Data Sources є питання: як в них передавати якісь секрети? Наприклад – для датасорсу Sentry треба задати токен, який в вальюсах чарту світити зовсім не хочеться, бо дані в GitHub ми не шифруємо, хоч репозиторії і приватні (див. git-crypt – на минулому проекті був, в приципі робоче рішення).

Давайте спершу поглянемо як воно працює взагалі, потім подумаємо, як нам передати токен.

Будемо додавати Senrty Data Source, див. grafana-sentry-datasource. Токен вже маємо – створено в sentry.io > User settings > User Auth Tokens.

В вальюси Grafana додаємо plugins, де вказуємо ім’я плагіну grafana-sentry-datasource (значення поля type в документації вище) і описуємо блок additionalDataSources з полем secureJsonData, в якому вказуємо сам токен:

...
  grafana:
    enabled: true
    ingress:
      enabled: true
      annotations:
        kubernetes.io/ingress.class: alb
        alb.ingress.kubernetes.io/target-type: ip
        alb.ingress.kubernetes.io/scheme: internet-facing
      hosts:
        - monitoring.dev.example.co
    plugins:
      - grafana-sentry-datasource
    additionalDataSources:
      - name: Sentry
        type: grafana-sentry-datasource
        access: proxy
        orgId: 1
        version: 1
        editable: true
        jsonData:
          url: https://sentry.io
          orgSlug: ***
        secureJsonData:
          authToken: 974***56b
...

Деплоїмо, перевіряємо плагін:

Та Data Source:

Окей, це працює.

Токен для Data Source через envFromSecret

Тепер спробуємо через змінну, значення для якої отримаємо з Kubernetes Secret за допомогою envFromSecret.

Створимо Secret:

---
apiVersion: v1
kind: Secret
metadata:
  name: grafana-datasource-sentry-token
stringData:
  SENTRY_TOKEN: 974***56b

Оновлюємо вальюси Grafana – додаємо envFromSecret, за допомогою якого отримаємо $SENTRY_TOKEN, та використовуємо його у additionalDataSources:

...
  grafana:
    ...
    envFromSecret: grafana-datasource-sentry-token
    additionalDataSources:
      - name: Sentry
        type: grafana-sentry-datasource
        access: proxy
        orgId: 1
        version: 1
        editable: true
        jsonData:
          url: https://sentry.io
          orgSlug: ***
        secureJsonData:
          authToken: ${SENTRY_TOKEN}
...

Деплоїмо, і перевіряємо змінну в поді Grafana:

[simterm]

$ kk -n dev-monitoring-ns exec -ti atlas-victoriametrics-grafana-64d9db677-g7l25 -c grafana -- printenv | grep SENTRY_TOKEN
SENTRY_TOKEN=974***56b

[/simterm]

Перевіряємо конфіг датасорсів:

[simterm]

$ kk -n dev-monitoring-ns exec -ti atlas-victoriametrics-grafana-64d9db677-bpkw8 -c grafana -- cat /etc/grafana/provisioning/datasources/datasource.yaml
...
apiVersion: 1
datasources:
- name: VictoriaMetrics
  type: prometheus
  url: http://vmsingle-vm-k8s-stack.dev-monitoring-ns.svc:8429/
  access: proxy
  isDefault: true
  jsonData: 
    {}
- access: proxy
  editable: true
  jsonData:
    orgSlug: ***
    url: https://sentry.io
  name: Sentry
  orgId: 1
  secureJsonData:
    authToken: ${SENTRY_TOKEN}
  type: grafana-sentry-datasource
  version: 1

[/simterm]

І ще раз тестуємо датасорс:

Тож при такому підході ми можемо використати AWS Secrets and Configuration Provider (ASCP) (див. AWS: Kubernetes – інтеграція AWS Secrets Manager та Parameter Store):

  • створити секрет в GitHub Actions Secrets
  • при деплої AWS CDK взяти значення у змінну через os.env("SECRET_NAME_VAR") і створити секрет в AWS Secrets Manager
  • в templates нашого чарту створити SecretProviderClass з полем secretObjects.secretName для створення Kubernetes Secret

При запуску поду з Grafana вона цей секрет підключить до поду:

[simterm]

$ kk -n dev-monitoring-ns get pod atlas-victoriametrics-grafana-64d9db677-dlqfr -o yaml
...
    envFrom:
    - secretRef:
        name: grafana-datasource-sentry-token
...

[/simterm]

І передасть значення до самої Grafana.

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

Але є ще один варіант – з sidecar.datasources.

Kubernetes Secret для всього Data Source і sidecar.datasources

Є другий варіант – створювати дата-сорси через sidecar container: можемо створити Kubernetes Secret з потрібною labels, і в цьому сікреті додати датасорс. Див. Sidecar for datasources

В принципі – цілком робоча схема: створити маніфест секрету в каталозі templates, і при виклику helm install в GitHub Actions передати значення з --set, а значення взяти з GitHub Actions Secrets. І виглядає простішою. Спробуємо.

У файлу templates/grafana-datasources-secret.yaml описуємо Kubernetes Secret:

apiVersion: v1
kind: Secret
metadata:
  name: grafana-datasources
  labels:
    grafana_datasource: 'true'
stringData:
  sentry.yaml: |-
    apiVersion: 1
    datasources:
      - name: Sentry
        type: grafana-sentry-datasource
        access: proxy
        orgId: 1
        version: 1
        editable: true
        jsonData:
          url: https://sentry.io
          orgSlug: ***
        secureJsonData:
          authToken: {{ .Values.grafana.sentry_token }}

Деплоїмо з --set:

[simterm]

$ helm -n dev-monitoring-ns upgrade --install atlas-victoriametrics . -f values/dev/atlas-monitoring-dev-values.yaml --set grafana.sentry_token="974***56b"

[/simterm]

Перевіряємо конфіги датасорсів в поді Grafana:

[simterm]

$ kk -n dev-monitoring-ns exec -ti atlas-victoriametrics-grafana-5967b494f6-5zmjb -c grafana -- ls -l /etc/grafana/provisioning/datasources
total 8
-rw-r--r--    1 grafana  472            187 Jul 19 13:36 datasource.yaml
-rw-r--r--    1 grafana  472            320 Jul 19 13:36 sentry.yaml

[/simterm]

Зміст файлу sentry.yaml:

[simterm]

$ kk -n dev-monitoring-ns exec -ti atlas-victoriametrics-grafana-5967b494f6-5zmjb -c grafana -- cat /etc/grafana/provisioning/datasources/sentry.yaml
apiVersion: 1
datasources:
  - name: Sentry
    type: grafana-sentry-datasource
    access: proxy
    orgId: 1
    version: 1
    editable: true
    jsonData:
      url: https://sentry.io
      orgSlug: ***
    secureJsonData:
      authToken: 974***56b

[/simterm]

І ще раз датасорс в самій Grafana:

It’s a magic!

Підключення Dashboards

Отже, ми вже маємо Графану, і там є дашборди, які нам треба перенести в новий стек моніторингу, і деплоїти з GitHub-репозиторію.

Документація по імпорту дашборд – Import dashboards.

Для створення дашборд через Helm-чарт маємо аналогічний до grafana-sc-datasources сайдкар-контейнер grafana-sc-dashboard, який буде перевіряти всі ConfigMaps з лейблою, і підключати їх до поду. Див. Sidecar for dashboards.

Майте на увазі рекомендацію:

A recommendation is to use one configmap per dashboard, as a reduction of multiple dashboards inside one configmap is currently not properly mirrored in grafana.

Тобто – один ConfigMap на кожну дашборду.

Тож що нам треба зробити – це описати ConfigMap для кожної дашборди, і Grafana сама додасть їх до /tmp/dashboards.

Експорт існуючої dashboard та Data Source UID not found

Щоб уникнути помилки з UID (“Failed to retrieve datasource Datasource f0f2c234-f0e6-4b6c-8ed1-01813daa84c9 was not found”) – йдемо до дашборди в існуючому інстансі Grafana, і додаємо нову змінну з типом Data Source:

Повторюємо для Loki, Sentry:

Та оновлюємо панелі – задаємо датасорс зі змінної:

Повторюємо теж саме для всіх запитів у Annotations та Variables:

Створюємо каталог для файлів, які потім будемо імпортити в Kubernetes:

[simterm]

$ mkdir -p grafana/dashboards/

[/simterm]

Та робимо Export дашборди в JSON і зберігаємо як grafana/dashboards/overview.json:

Dashboard ConfigMap

В каталозі templates створюємо маніфест для ConfigMap:

apiVersion: v1
kind: ConfigMap
metadata:
  name: overview-dashboard
  labels:
    grafana_dashboard: "1"  
data:
  overview.json: |
{{ .Files.Get "grafana/dashboards/overview.json" | indent 4 }}

Тепер всі файли нашого проекту виглядають так:

Деплоїмо чарт та перевіряємо ConfigMap:

[simterm]

$ kk -n dev-monitoring-ns get cm overview-dashboard -o yaml | head 
apiVersion: v1
data:
  overview.json: |
    {
      "annotations": {
        "list": [
          {
            "builtIn": 1,
            "datasource": {
              "type": "grafana",

[/simterm]

І перевіряємо в поді – чи додався файл до /tmp/dashboards:

[simterm]

$ kubectl -n dev-monitoring-ns exec -ti atlas-victoriametrics-grafana-5967b494f6-gs4jm -c grafana -- ls -l /tmp/dashboards
total 1032
...
-rw-r--r--    1 grafana  472          74821 Jul 19 10:31 overview.json
...

[/simterm]

Та в самій Grafana:

І маємо наши графіки – поки не всі, бо запущено тільки один експортер:

Поїхали далі.

Що нам залишилось?

  • GitHub exporter – зробити чарт, додати до загального чарту (чи просто створити маніфест з Deployment? там один под, нічного більше йому не треба)
  • Loki
  • алерти

GitHub exporter мабуть таки просто з Deployment зроблю – маніфест у templates основного чарту, та й все – окремий чарт там не потрібен. Тут описувати не буду, бо це просто.

Тож давайте тоді зараз загадаємо, як там запускається Loki, бо коли робив це восени – то було трохи геморно. Сподіваюсь, нічого особливо не змінилося, і зможу просто взяти конфіг з Grafana Loki: архітектура та запуск в Kubernetes з AWS S3 storage та boltdb-shipper.

Запуск Grafana Loki з AWS S3

Що нам тут треба?

  • створити S3
  • IAM Policy && IAM Role для доступу до бакету
  • ConfigMap з конфігом Loki
  • додати чарт Loki сабчартом нашого основного чарту

AWS CDK для S3 та IAM Role

S3 та IAM описуємо з AWS CDK:

...
        ##################################
        ### Grafana Loki AWS resources ###
        ##################################

        ### AWS S3 to store logs data and indexes
        loki_bucket_name = f"{environment}-grafana-loki"

        bucket = s3.Bucket(
            self, 'GrafanaLokiBucket',
            bucket_name=loki_bucket_name,
            block_public_access=s3.BlockPublicAccess.BLOCK_ALL
        )

        # Create an IAM Role to be assumed by Loki
        grafana_loki_role = iam.Role(
            self,
            'GrafanaLokiRole',
            # for Role's Trust relationships
            assumed_by=iam.FederatedPrincipal(
                federated=oidc_provider_arn,
                conditions={
                    'StringEquals': {
                        f'{oidc_provider_url.replace("https://", "")}:sub': f'system:serviceaccount:{monitoring_namespace}:loki'
                    }
                },
                assume_role_action='sts:AssumeRoleWithWebIdentity'
            )
        )

        # Attach an IAM Policies to that Role
        grafana_loki_policy = iam.PolicyStatement(
            actions=[
                "s3:ListBucket",
                "s3:PutObject",
                "s3:GetObject",
                "s3:DeleteObject"
            ],
            resources=[
                f"arn:aws:s3:::{loki_bucket_name}",
                f"arn:aws:s3:::{loki_bucket_name}/*"                
            ]
        )

        grafana_loki_role.add_to_policy(grafana_loki_policy) 
...
        CfnOutput(
            self,
            'GrafanaLokiRoleArn',
            value=grafana_loki_role.role_arn
        )
...

Деплоїмо:

[simterm]

$ cdk deploy atlas-monitoring-dev
...
Outputs:
atlas-monitoring-dev.CloudwatchExporterRoleArn = arn:aws:iam::492***148:role/atlas-monitoring-dev-CloudwatchExporterRole0613A27-EU5LW9XRWVRL
atlas-monitoring-dev.GrafanaLokiRoleArn = arn:aws:iam::492***148:role/atlas-monitoring-dev-GrafanaLokiRole27EECE19-1HLODQFKFLDNK
...

[/simterm]

Можемо додавати чарт.

Деплой Loki Helm chart

Додаємо репозиторій, шукаємо останню версію чарту:

[simterm]

$ helm repo add grafana https://grafana.github.io/helm-charts
$ helm search repo grafana/loki
NAME                            CHART VERSION   APP VERSION     DESCRIPTION                                       
grafana/loki                    5.8.9           2.8.2           Helm chart for Grafana Loki in simple, scalable...
grafana/loki-canary             0.12.0          2.8.2           Helm chart for Grafana Loki Canary                
grafana/loki-distributed        0.69.16         2.8.2           Helm chart for Grafana Loki in microservices mode 
grafana/loki-simple-scalable    1.8.11          2.6.1           Helm chart for Grafana Loki in simple, scalable...
grafana/loki-stack              2.9.10          v2.6.1          Loki: like Prometheus, but for logs.              

[/simterm]

Тут знов купа чартів, коротко:

  • loki-canary: система для аудіту роботи самої Loki
  • loki-distributed: Loki у microservices mode
  • simple-scalable: deprecated, тепер це просто loki
  • loki-stack: тут відразу все – Grafana, Promtail, etc

Ми візьмемо grafana/loki 5.8.9. Додаємо в залежності нашого чарту у Chart.yaml:

apiVersion: v2
name: atlas-victoriametrics
description: A Helm chart for Atlas Victoria Metrics kubernetes monitoring stack
type: application
version: 0.1.0
appVersion: "1.16.0"
dependencies:
- name: victoria-metrics-k8s-stack
  version: ~0.17.0
  repository: https://victoriametrics.github.io/helm-charts/
- name: prometheus-cloudwatch-exporter
  version: ~0.25.1
  repository: https://prometheus-community.github.io/helm-charts
- name: loki
  version: ~5.8.9
  repository: https://grafana.github.io/helm-charts

Всі дефолтні values – тут>>>, я брав зі свого старого конфігу – все завелося:

...
loki:
  loki:

    auth_enabled: false
    commonConfig:
      path_prefix: /var/loki
      replication_factor: 1

    storage:
      bucketNames:
        chunks: dev-grafana-loki
      type: s3

    schema_config:
      configs:
      - from: "2023-07-20"
        index:
          period: 24h
          prefix: loki_index_
        store: boltdb-shipper
        object_store: s3
        schema: v12
    
    storage_config:
      aws:
        s3: s3://us-east-1/dev-grafana-loki
        insecure: false
        s3forcepathstyle: true
      boltdb_shipper:
        active_index_directory: /var/loki/index
        shared_store: s3
    rulerConfig:
      storage:
        type: local
        local:
          directory: /var/loki/rules

  serviceAccount:
    create: true
    annotations:
      eks.amazonaws.com/role-arn: "arn:aws:iam::492***148:role/atlas-monitoring-dev-GrafanaLokiRole27EECE19-1HLODQFKFLDNK"

  write:
    replicas: 1
      
  read:
    replicas: 1

  backend:
    replicas: 1

  test:
    enabled: false

  monitoring:
    dashboards:
      enabled: false
    rules:
      enabled: false
    alerts:
      enabled: false
    serviceMonitor:
      enabled: false
    selfMonitoring:
      enabled: false
      grafanaAgent:
        installOperator: false
    lokiCanary:
      enabled: false
...

Ще треба буде прикрутити алерти Loki, але це вже окремо (див. Grafana Loki: алерти з Ruler та labels з логів)

Деплой Promtail Helm chart

Запустимо в кластері Promtail, щоб по-перше перевірити роботу Loki, по-друге – всеж мати логи з кластеру.

Знаходимо версії чарта:

[simterm]

$ helm search repo grafana/promtail -l | head
NAME                    CHART VERSION   APP VERSION     DESCRIPTION                                       
grafana/promtail        6.11.7          2.8.2           Promtail is an agent which ships the contents o...
grafana/promtail        6.11.6          2.8.2           Promtail is an agent which ships the contents o...
grafana/promtail        6.11.5          2.8.2           Promtail is an agent which ships the contents o...
...

[/simterm]

Додаємо сабчарт в dependencies в нашому Chart.yaml:

apiVersion: v2
name: atlas-victoriametrics
description: A Helm chart for Atlas Victoria Metrics kubernetes monitoring stack
type: application
version: 0.1.0
appVersion: "1.16.0"
dependencies:
- name: victoria-metrics-k8s-stack
  version: ~0.17.0
  repository: https://victoriametrics.github.io/helm-charts/
- name: prometheus-cloudwatch-exporter
  version: ~0.25.1
  repository: https://prometheus-community.github.io/helm-charts
- name: loki
  version: ~5.8.9
  repository: https://grafana.github.io/helm-charts
- name: promtail
  version: ~6.11.7
  repository: https://grafana.github.io/helm-charts

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

[simterm]

$ kk -n dev-monitoring-ns get svc | grep loki-gateway
loki-gateway                                           ClusterIP   172.20.102.186   <none>        80/TCP                       160m

[/simterm]

Додаємо вальюси для Promtail:

...
promtail:
  loki:
    serviceName: "loki-gateway"

Деплоїмо, перевіряємо поди:

[simterm]

$ kk -n dev-monitoring-ns get pod | grep 'loki\|promtail'
atlas-victoriametrics-promtail-cxwpz                              0/1     Running       0          17m
atlas-victoriametrics-promtail-hv94f                              1/1     Running       0          17m
loki-backend-0                                                    0/1     Running       0          9m55s
loki-gateway-749dcc85b6-5d26n                                     1/1     Running       0          3h4m
loki-read-6cf6bc7654-df82j                                        1/1     Running       0          57s
loki-write-0                                                      0/1     Running       0          52s

[/simterm]

Додаємо Grafana Data Source через additionalDataSources (див. Provision the data source):

...
  grafana:
    enabled: true
    ...
    additionalDataSources:
      - name: Loki
        type: loki
        access: proxy
        url: http://loki-gateway:80
        jsonData:
          maxLines: 1000
...

Деплоїмо, перевіряємо датасорси:

Та логи:

Тепер давайте глянемо, як там з алертами.

Налаштування алертів з VMAlert

Сам VMAlert && Alertmanager вже маємо з чарту, див. values – vmalert та alertmanager:

[simterm]

$ kk -n dev-monitoring-ns get pod | grep alert
vmalert-vm-k8s-stack-dff5bf755-57rxd                              2/2     Running   0          6d19h
vmalertmanager-vm-k8s-stack-0                                     2/2     Running   0          6d19h

[/simterm]

Спочатку глянемо як конфігуриться Alertmanager, бо алерти будуть відправлятись через нього.

Конфігурація Alertmanager

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

Глянемо, де його конфіг:

[simterm]

$  kk -n dev-monitoring-ns describe pod vmalertmanager-vm-k8s-stack-0
...
    Args:
     ....
      --config.file=/etc/alertmanager/config/alertmanager.yaml
...
    Mounts:
      ...
      /etc/alertmanager/config from config-volume (rw)
...
Volumes:
  config-volume:
    Type:        Secret (a volume populated by a Secret)
    SecretName:  vmalertmanager-vm-k8s-stack-config
...

[/simterm]

Тобто файл /etc/alertmanager/config/alertmanager.yaml береться з Kubernetes Secret vmalertmanager-vm-k8s-stack-config:

[simterm]

$ kk -n dev-monitoring-ns get secret vmalertmanager-vm-k8s-stack-config -o yaml | yq '.data'
{
  "alertmanager.yaml": "Z2x***GwK"
}

[/simterm]

Перевіряємо зміст з base64 -d або на сайті www.base64decode.org.

Тепер давайте додамо власний конфіг.

Тут знову ж таки треба буде подумати про секрет, бо в slack_api_url маємо токен. Мабуть, зробимо як з Sentry-токеном – просто будемо передавати через --set.

Оновлюємо values/dev/atlas-monitoring-dev-values.yaml:

...
  alertmanager:
    enabled: true

    config:
      global:
        resolve_timeout: 5m
        slack_api_url: ""

      route:
        repeat_interval: 12h
        group_by: ["alertname"]
        receiver: 'slack-default'

        routes: []

      receivers:
        - name: "slack-default"
          slack_configs:
            - channel: "#alerts-devops"
              send_resolved: true
              title: '{{ template "slack.monzo.title" . }}'
              icon_emoji: '{{ template "slack.monzo.icon_emoji" . }}'
              color: '{{ template "slack.monzo.color" . }}'
              text: '{{ template "slack.monzo.text" . }}'
              actions:
                # self
                - type: button
                  text: ':grafana: overview'
                  url: '{{ (index .Alerts 0).Annotations.grafana_url }}'
                - type: button
                  text: ':grafana: Loki Logs'
                  url: '{{ (index .Alerts 0).Annotations.logs_url }}'
                - type: button
                  text: ':mag: Alert query'
                  url: '{{ (index .Alerts 0).GeneratorURL }}' 
                - type: button
                  text: ':aws: AWS dashboard'
                  url: '{{ (index .Alerts 0).Annotations.aws_dashboard_url }}'
                - type: button
                  text: ':aws-cloudwatch: AWS CloudWatch Metrics'
                  url: '{{ (index .Alerts 0).Annotations.aws_cloudwatch_url }}'
                - type: button
                  text: ':aws-cloudwatch: AWS CloudWatch Logs'
                  url: '{{ (index .Alerts 0).Annotations.aws_logs_url }}'
...

Хоча взагалі ми маємо власний непоганий темплейт для Slack, але поки глянемо, як виглядаю цей monzo.

Деплоїмо з --set victoria-metrics-k8s-stack.alertmanager.config.global.slack_api_url=$slack_url:

[simterm]

$ slack_url="https://hooks.slack.com/services/T02***37X"
$ helm -n dev-monitoring-ns upgrade --install atlas-victoriametrics . -f values/dev/atlas-monitoring-dev-values.yaml --set grafana.sentry_token=$sentry_token --set victoria-metrics-k8s-stack.alertmanager.config.global.slack_api_url=$slack_url --debug

[/simterm]

Та перевіримо.

Знаходимо Alertmanager Service:

[simterm]

$ kk -n dev-monitoring-ns get svc | grep alert
vmalert-vm-k8s-stack                                   ClusterIP   172.20.251.179   <none>        8080/TCP                     6d20h
vmalertmanager-vm-k8s-stack                            ClusterIP   None             <none>        9093/TCP,9094/TCP,9094/UDP   6d20h

[/simterm]

Робимо port-forward:

[simterm]

$ kk -n dev-monitoring-ns port-forward svc/vmalertmanager-vm-k8s-stack 9093

[/simterm]

І з cRUL відправляємо алерт:

[simterm]

$ curl -H 'Content-Type: application/json' -d '[{"labels":{"alertname":"testalert"}}]' http://127.0.0.1:9093/api/v1/alerts
{"status":"success"}

[/simterm]

Дивимось в Слаку:

Wait, what?)))

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

Custom Slack messages template

Зараз кастомні темплейти підключені через ConfigMap vmalertmanager-vm-k8s-stack-0:

[simterm]

...
Volumes:
  ...
  templates-vm-k8s-stack-alertmanager-monzo-tpl:
    Type:      ConfigMap (a volume populated by a ConfigMap)
    Name:      vm-k8s-stack-alertmanager-monzo-tpl
    Optional:  false
...

[/simterm]

А включаються параметром monzoTemplate.enabled=true.

Також там же маємо templateFiles, де можемо описати власні шаблони:

...
  alertmanager:
    enabled: true

    monzoTemplate:
      enabled: false

    templateFiles: 

      slack.tmpl: |-
        {{/* Title of the Slack alert */}}
        {{ define "slack.title" -}}
          {{ if eq .Status "firing" }} :scream: {{- else -}} :relaxed: {{- end -}}
          [{{ .Status | toUpper -}} {{- if eq .Status "firing" -}}:{{ .Alerts.Firing | len }} {{- end }}] {{ (index .Alerts 0).Annotations.summary }}
        {{ end }}
                  
        {{ define "slack.text" -}}
                    
            {{ range .Alerts }}
                {{- if .Annotations.description -}}
                {{- "\n\n" -}}
                *Description*: {{ .Annotations.description }}
                {{- end }}
            {{- end }}

        {{- end }}
...

Деплоїмо і перевіряємо ConfigMap, який описано у custom-templates.yaml:

[simterm]

$ kk -n dev-monitoring-ns get cm | grep extra
vm-k8s-stack-alertmanager-extra-tpl                    1      2m4s

[/simterm]

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

[simterm]

$ kk -n dev-monitoring-ns exec -ti vmalertmanager-vm-k8s-stack-0 -- ls -l /etc/vm/templates/
Defaulted container "alertmanager" out of: alertmanager, config-reloader
total 0
drwxrwxrwx    3 root     root            78 Jul 20 10:06 vm-k8s-stack-alertmanager-extra-tpl

[/simterm]

І чекаємо на якийсь алерт:

Тепер все красиво. Поїхали до створення власних алертів.

Алерти для VMAlert з VMRules

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

Отже – як до VMAlert додати наші алерти?

VMAlert використовуює VMRules, які вибирає за ruleSelector:

[simterm]

$ kk -n dev-monitoring-ns get vmrule
NAME                                                AGE
vm-k8s-stack-alertmanager.rules                     6d19h
vm-k8s-stack-etcd                                   6d19h
vm-k8s-stack-general.rules                          6d19h
vm-k8s-stack-k8s.rules                              6d19h
...

[/simterm]

Тобто – можемо описати в маніфестах потрібні рули, задеплоїти – і VMAlert їх підхопить.

Глянемо сам VMAlert – він у нас зараз один, і в принципі нам цього поки вистачить:

[simterm]

$ kk -n dev-monitoring-ns get vmalert vm-k8s-stack -o yaml
apiVersion: operator.victoriametrics.com/v1beta1
kind: VMAlert
...
spec:
  datasource:
    url: http://vmsingle-vm-k8s-stack.dev-monitoring-ns.svc:8429/
  evaluationInterval: 15s
  extraArgs:
    remoteWrite.disablePathAppend: "true"
  image:
    tag: v1.91.3
  notifiers:
  - url: http://vmalertmanager-vm-k8s-stack.dev-monitoring-ns.svc:9093
  remoteRead:
    url: http://vmsingle-vm-k8s-stack.dev-monitoring-ns.svc:8429/
  remoteWrite:
    url: http://vmsingle-vm-k8s-stack.dev-monitoring-ns.svc:8429/api/v1/write
  resources: {}
  selectAllByDefault: true

[/simterm]

Спробуємо додати тестовий алерт – створюємо файл victoriametrics/templates/vmalert-vmrules-test.yaml з kind: VMRule:

apiVersion: operator.victoriametrics.com/v1beta1
kind: VMRule
metadata:
  name: vmrule-test
  # no need now, as we have one VMAlert with selectAllByDefault
  #labels:
  #    project: devops
spec:
  groups:
    - name: testing-rule
      rules:
        - alert: TestAlert
          expr: up == 1
          for: 1s
          labels:
            severity: test
            job:  '{{ "{{" }} $labels.job }}'
            summary: Testing VMRule
          annotations:
            value: 'Value: {{ "{{" }} $value }}'
            description: 'Monitoring job {{ "{{" }} $labels.job }} failed'

Тут додаємо костиль у вигляді "{{" }}, бо {{ }} використовується і самим Helm, і алертами.

Деплоїмо, перевіряємо VMRule vmrule-test:

[simterm]

$ kk -n dev-monitoring-ns get vmrule vmrule-test -o yaml
apiVersion: operator.victoriametrics.com/v1beta1
kind: VMRule
...
spec:
  groups:
  - name: testing-rule
    rules:
    - alert: TestAlert
      annotations:
        description: Monitoring job {{ $labels.job }} failed
        value: 'Value: {{ $value }}'
        summary: Testing VMRule
      expr: up == 1
      for: 1s
      labels:
        job: '{{ $labels.job }}'
        severity: test

[/simterm]

Чекаємо на алерт у Слаку:

Все є.

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

Потім ще глянемо як там зі storage і бекапами.

Loading

AWS: Kubernetes – інтеграція AWS Secrets Manager та Parameter Store
0 (0)

17 Липня 2023

Зберігання даних доступу у Kubernetes Secrets має важливий недолік, бо вони доступні тільки всередені самого Kubernetes кластеру.

Щоб зробити їх доступними зовнішнім сервісам – можемо використати Hashicorp Vault і інтегрувати його з Kubernetes за допомогою таких рішень, як vault-k8s, або скористуватись сервісами від AWS – Secrets Manager або Parameter Store.

Інтеграція AWS Secrets Manager та Parameter Store в Kubernetes дасть нам можливість створювати новий тип ресурсів – SecretProviderClass, який ми зможемо підключати до Kubernetes Pods у вигляді файлів або змінних оточення.

Для цього нам знадобляться AWS Secrets and Configuration Provider (ASCP) та Kubernetes Secrets Store CSI Driver.

AWS Secrets and Configuration Provider vs Hashicorp Vault

Я давно не користувався Vault, але щодо питання “Що використовувати” – то тут вибір між “сетапити, конфігурити та менеджити Hashicorp Vault самому” (встановлення Helm-чарту та конфігурація доступів)  або “використати готове рішення від AWS” (по суті, потрібно тільки налаштувати IAM-ролі).

Також враховуйте, що використання AWS сервісів (suprize!) платне, тож якщо ви плануєте мати тисячі секретів – то мабуть краще таки з Vault.

Крім того, Vault сам по собі дає набагато більше можливостей, наприклад – генерація тимчасових токенів для сервісів, плюс наскільки пам’ятаю – Kubernetes Pods можуть отримувати параметри з Vault без необхідності в створенні Kubernetes Secrets, тоді як при використанні AWS Secrets and Configuration Provider (ASCP) та Kubernetes Secrets Store CSI Driver для підключення змінних будуть створюватиcm звичайні Kubernetes Secrets.

Втім, на нашому проекті вже використовуються Secrets Manager та Parameter Store, сенсу в Vault поки не бачу, тож інтегруємо наші секрети до кластеру в AWS Elastic Kubernetes Service.

AWS Secrets Manager vs Parameter Store

Детальніше про різницю між ними можна почитати тут – AWS — Difference between Secrets Manager and Parameter Store (Systems Manager), тут кратенько.

Загальні риси:

  • обидва використовують AWS KMS для шифрування даних
  • обидва являють собою Key/Value Store
  • обидва підтримують versioning

Різниця:

  • вартість:
    • Secrets Manager: бере $0.40 за кожен секрет та $0.05 за кожні 10,000 API запросів
    • Parameter Store: за Standard не бере грошей за зберігання, при higher throughput – коштує $0.05 за кожні 10,000 API запросів, при Advanced parameters – $0.05 за зберігання та $0.05 за кожні 10,000 API запросів
  • ротація секретів:
    • Secrets Manager: має вбудований механізм ротації та інтегрує його з сервісами (RDS, DocumentDB, etc)
    • Parameter Store: маєте імплементувати ротацію самостійно
  • Cross-account Access:
    • Secrets Manager: підтримує
    • Parameter Store: не підтримує
  • Cross-Regions Replication:
    • Secrets Manager: підтримує
    • Parameter Store: не підтримує
  • розмір даних:
    • Secrets Manager: до 10KB на кожен секрет
    • Parameter Store: 4KB на кожен запис (8KB при Advanced Parameters)
  • ліміти кількості:
    • Secrets Manager: 500,000 на регіон та акаунт
    • Parameter Store: 10,000 на регіон та акаунт

Встановлення Secrets Store CSI Driver

Отже, для інтеграції нам потрібні два сервіси – Secrets Store CSI Driver та AWS Secrets and Configuration Provider.

Першим додаємо Secrets Store CSI Driver.

За його допомогою зможемо підключати секрети/параметри з AWS файлами або змінними до Kubernetes Pods.

Додаємо Helm-чарт і встановлюємо з опцією syncSecret.enabled=true для створення RBAC-ролей для роботи з Kubernetes Secrets та їх синхронізації з секретами AWS під час ротації даних (див. Sync as Kubernetes Secret):

[simterm]

$ helm repo add secrets-store-csi-driver https://kubernetes-sigs.github.io/secrets-store-csi-driver/charts
$ helm -n kube-system install csi-secrets-store secrets-store-csi-driver/secrets-store-csi-driver --set syncSecret.enabled=true

[/simterm]

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

[simterm]

$ kubectl -n kube-system get pod | grep secret
csi-secrets-store-secrets-store-csi-driver-kzmcx   3/3     Running   0          31s
csi-secrets-store-secrets-store-csi-driver-t7bqc   3/3     Running   0          31s

[/simterm]

Встановлення AWS Secrets and Configuration Provider

Додаємо репозиторій та встановлюємо чарт:

[simterm]

$ helm repo add aws-secrets-manager https://aws.github.io/secrets-store-csi-driver-provider-aws
$ helm install -n kube-system secrets-provider-aws aws-secrets-manager/secrets-store-csi-driver-provider-aws

[/simterm]

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

[simterm]

$ kubectl -n kube-system get pod | grep secret
csi-secrets-store-secrets-store-csi-driver-kzmcx                  3/3     Running   0          9m
csi-secrets-store-secrets-store-csi-driver-t7bqc                  3/3     Running   0          9m
secrets-provider-aws-secrets-store-csi-driver-provider-awskq5g8   1/1     Running   0          23s
secrets-provider-aws-secrets-store-csi-driver-provider-awsksq9d   1/1     Running   0          23s

[/simterm]

Та глянемо CSIDriver:

[simterm]

$ kubectl get csidriver
NAME                       ATTACHREQUIRED   PODINFOONMOUNT   STORAGECAPACITY   TOKENREQUESTS   REQUIRESREPUBLISH   MODES        AGE
ebs.csi.aws.com            true             false            false             <unset>         false               Persistent   46h
efs.csi.aws.com            false            false            false             <unset>         false               Persistent   4d
secrets-store.csi.k8s.io   false            true             false             <unset>         false               Ephemeral    10m

[/simterm]

Далі – налаштуємо IAM для IRSA.

IAM Policy та IAM Role для ServiceAccount

Щоб Kubernetes Pod зміг отримати доступ до AWS SecretManager та Parameter Store використаємо IRSA – створимо ServiceAacount, який буде використовувати IAM Role з IAM Policy, яка буде мати дозволи на виклик Secrets Manager та Parameter Store (див. AWS: EKS, OpenID Connect та ServiceAccounts).

Описуємо політику:

{
    "Version": "2012-10-17",
    "Statement": [
        {
            "Action": [
                "secretsmanager:DescribeSecret",
                "secretsmanager:GetSecretValue",
                "ssm:DescribeParameters",
                "ssm:GetParameter",
                "ssm:GetParameters",
                "ssm:GetParametersByPath"
            ],
            "Effect": "Allow",
            "Resource": "*"
        }
    ]
}

Створюємо її в IAM:

[simterm]

$ aws iam create-policy --policy-name ascp-iam-policy --policy-document file://ascp-policy.json
{
    "Policy": {
        "PolicyName": "ascp-policy",
        "PolicyId": "ANPAXFIUAIGSBPFEDKZZT",
        "Arn": "arn:aws:iam::492***148:policy/ascp-iam-policy",
        ...

[/simterm]

Описуємо Trust policy для ролі:

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Effect": "Allow",
      "Action": "sts:AssumeRoleWithWebIdentity",
      "Principal": {
        "Federated": "arn:aws:iam::492***148:oidc-provider/oidc.eks.us-east-1.amazonaws.com/id/2DC***124"
      },
      "Condition": {
        "StringEquals": {
          "oidc.eks.us-east-1.amazonaws.com/id/2DC***124:aud": "sts.amazonaws.com",
          "oidc.eks.us-east-1.amazonaws.com/id/2DC***124:sub": "system:serviceaccount:default:ascp-test-serviceaccount"
        }
      }
    }
  ]
}

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

[simterm]

aws iam create-role --role-name ascp-iam-role --assume-role-policy-document file://ascp-trust.json
{
    "Role": {
        "Path": "/",
        "RoleName": "ascp-iam-role",
        "RoleId": "AROAXFIUAIGSLDCB3L4AR",
        "Arn": "arn:aws:iam::492***148:role/ascp-iam-role",
      ...

[/simterm]

До ролі підключаємо політику ascp-iam-policy:

[simterm]

$ aws iam attach-role-policy --role-name ascp-iam-role --policy-arn=arn:aws:iam::492***148:policy/ascp-iam-policy

[/simterm]

Тепер можемо створити SecretProviderClass та Pod, який буде його використовувати.

Створення SecretProviderClass

Додамо SecretProviderClass, який буде отримувати строку з Secrets Manager та строку Parameter Store, які потім підключимо до Kubernetes Pod.

Створюємо секрет в Secrets Manager:

[simterm]

$ aws secretsmanager create-secret --name ascp-secret-test-string --secret-string "secretLine"
{
    "ARN": "arn:aws:secretsmanager:us-east-1:492***148:secret:ascp-secret-test-string-DNweNg",
    "Name": "ascp-secret-test-string",
    "VersionId": "9d4f490d-edcc-4ee0-b43d-5b4e25fa271b"
}

[/simterm]

Додаємо запис до Parameter Store:

[simterm]

$ aws ssm put-parameter --name ascp-ssm-test-param --value 'paramLine' --type "String"
{
    "Version": 1,
    "Tier": "Standard"
}

[/simterm]

Далі описуємо сам SecretProviderClass з двома objects – в parameters.objects.objectName – ім’я об’єкту в Secrets Manager або Parameter Store, а в objectType вказуємо звідки беремо цей об’єкт:

--- 
apiVersion: secrets-store.csi.x-k8s.io/v1
kind: SecretProviderClass
metadata: 
  name: aspc-test-secret-class
spec:         
  provider: aws
  parameters:
    objects: |
        - objectName: "ascp-test-string"
          objectType: "secretsmanager"
        - objectName: "ascp-ssm-test-param"
          objectType: "ssmparameter"

Переходимо до поду.

Підключення SecretProviderClass в Pod файлом

Додаємо ServiceAccount з IAM-ролью, яку створили раніше, та Pod з цим ServiceAccount:

---
apiVersion: v1
kind: ServiceAccount
metadata:
  name: ascp-test-serviceaccount
  namespace:
  annotations:
    eks.amazonaws.com/role-arn: arn:aws:iam::492***148:role/ascp-iam-role
---
apiVersion: v1
kind: Pod
metadata:
  name: ascp-test-pod
spec:
  containers:
    - name: my-aws-cli
      image: amazon/aws-cli:latest
      command: ['sleep', '36000']
      volumeMounts:
      - name: ascp-test-secret-volume
        mountPath: /mnt/ascp-secret
        readOnly: true
  restartPolicy: Never
  serviceAccountName: ascp-test-serviceaccount
  volumes:
  - name: ascp-test-secret-volume
    csi:
      driver: secrets-store.csi.k8s.io
      readOnly: true
      volumeAttributes:
        secretProviderClass: aspc-test-secret-class

Деплоїмо:

[simterm]

$ kubectl apply -f ascp-test.yaml
serviceaccount/ascp-test-serviceaccount created
secretproviderclass.secrets-store.csi.x-k8s.io/aspc-test-secret-class created
pod/ascp-test-pod created

[/simterm]

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

[simterm]

$ kk describe pod ascp-test-pod
...
    Mounts:
      /mnt/ascp-secret from ascp-test-secret-volume (ro)
...
Volumes:
  ...
  ascp-test-secret-volume:
    Type:              CSI (a Container Storage Interface (CSI) volume source)
    Driver:            secrets-store.csi.k8s.io
    FSType:            
    ReadOnly:          true
    VolumeAttributes:      secretProviderClass=aspc-test-secret-class
...

[/simterm]

Та зміст каталогу /mnt/ascp-secret:

[simterm]

$ kk exec -ti ascp-test-pod -- ls -l /mnt/ascp-secret
total 8
-rw-r--r-- 1 root root 10 Jul 17 09:32 ascp-secret-test-string
-rw-r--r-- 1 root root  9 Jul 17 09:32 ascp-ssm-test-param

[/simterm]

І зміст файлів:

[simterm]

$ kk exec -ti ascp-test-pod -- cat /mnt/ascp-secret/ascp-secret-test-string
secretLine

$ kk exec -ti ascp-test-pod -- cat /mnt/ascp-secret/ascp-ssm-test-param
paramLine

[/simterm]

Підключення SecretProviderClass в Pod змінною оточення

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

Для цього до SecretProviderClass додаємо secretObjects – тоді Kubernetes Secrets Store CSI Driver створить звичайний Kubernetes Secret, котрий зможемо підключити в под :

---
apiVersion: secrets-store.csi.x-k8s.io/v1
kind: SecretProviderClass
metadata:
  name: aspc-test-secret-class
spec:
  provider: aws
  parameters:
    objects: |
        - objectName: "ascp-secret-test-string"
          objectType: "secretsmanager"
        - objectName: "ascp-ssm-test-param"
          objectType: "ssmparameter"
  secretObjects:
    - secretName: aspc-test-kube-secret
      type: Opaque
      data:
        - objectName: ascp-secret-test-string
          key: kube-secret-key

Тут:

  • secretObjects.secretName: ім’я Kubernetes Secret, який буде створено
  • secretObjects.secretName.data.objectName: має збігатись з parameters.objects.objectName
  • secretObjects.secretName.data.key: ключ для Kubernetes Secret – data.kube-secret-key

Деплоїмо, перевіряємо Kubernetes Secret:

[simterm]

$ kk get secret aspc-test-kube-secret -o yaml
apiVersion: v1
data:
  kube-secret-key: c2VjcmV0TGluZQ==
  ...

[/simterm]

І значення kube-secret-key:

[simterm]

$ echo c2VjcmV0TGluZQ== | base64 -d
secretLine

[/simterm]

Тепер підключимо до поду – додаємо spec.containers.env з valueFrom.secretKeyRef:

---
apiVersion: v1
kind: Pod
metadata:
  name: ascp-test-pod
spec:
  containers:
    - name: my-aws-cli
      image: amazon/aws-cli:latest
      command: ['sleep', '36000']
      env:
      - name: SECRET
        valueFrom:
          secretKeyRef:
            name: aspc-test-kube-secret
            key: kube-secret-key
      volumeMounts:
      - name: ascp-test-secret-volume
        mountPath: /mnt/ascp-secret
        readOnly: true
  restartPolicy: Never
  serviceAccountName: ascp-test-serviceaccount
  volumes:
  - name: ascp-test-secret-volume
    csi:
      driver: secrets-store.csi.k8s.io
      readOnly: true
      volumeAttributes:
        secretProviderClass: aspc-test-secret-class

Деплоїмо, перевіряємо:

[simterm]

$ kk exec -ti ascp-test-pod -- printenv | grep SECRET
SECRET=secretLine

[/simterm]

Або:

[simterm]

$ kk exec -ti ascp-test-pod -- bash
bash-4.2# echo $SECRET
secretLine

[/simterm]

При цьому маємо підключати і сам volumes та volumeMounts, як робили це для підключення секретів файлом.

Створення SecretProviderClass з JSON

Якщо дані в Secrets Manager та Parameter Store зберігаються в JSON – то для SecretProviderClass маємо використовувати jmesPath.

Створимо ще один секрет в Secrets Manager:

[simterm]

$ aws secretsmanager create-secret --name ascp-secret-test-json --secret-string '{"username":"admin", "password":"foobar"}'
{
    "ARN": "arn:aws:secretsmanager:us-east-1:492***148:secret:ascp-secret-test-json-iOtcBf",
    "Name": "ascp-secret-test-json",
    "VersionId": "32666608-6416-46cf-8b93-cf090eef1bc5"
}

[/simterm]

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

Оновлюємо наш SecretProviderClass:

---
apiVersion: secrets-store.csi.x-k8s.io/v1
kind: SecretProviderClass
metadata:
  name: aspc-test-secret-class
spec:
  provider: aws
  parameters:
    objects: |
        - objectName: "ascp-secret-test-string"
          objectType: "secretsmanager"
        - objectName: "ascp-ssm-test-param"
          objectType: "ssmparameter"
        - objectName: "ascp-secret-test-json"
          objectType: "secretsmanager"
          jmesPath:
              - path: "username"
                objectAlias: "ascp-test-username"
              - path: "password"
                objectAlias: "ascp-test-password"
  secretObjects:
    - secretName: aspc-test-kube-secret
      type: Opaque
      data:
        - objectName: ascp-secret-test-string
          key: kube-secret-key
    - secretName: aspc-test-kube-secret-json
      type: Opaque
      data:
        - objectName: ascp-test-username
          key: kube-secret-user
        - objectName: ascp-test-password
          key: kube-secret-pass

Тут:

  • в parameters.objects.objectName: "ascp-secret-test-json" викликаємо jmesPath, який парсить наш секрет і отримує значення довх полів – username та password, для яких створює два objectAliasascp-test-username та ascp-test-password
  • в secretObjects.secretName: aspc-test-kube-secret-json додаємо data з двома objectName, в яких використуємо objectAlias з parameters

Обновлюємо наш Kubernetes Pod – додаємо два secretKeyRef з ключами kube-secret-user та kube-secret-user з секрету aspc-test-kube-secret-json:

---
apiVersion: v1
kind: Pod
metadata:
  name: ascp-test-pod
spec:
  containers:
    - name: my-aws-cli
      image: amazon/aws-cli:latest
      command: ['sleep', '36000']
      env:
      - name: SECRET
        valueFrom:
          secretKeyRef:
            name: aspc-test-kube-secret
            key: kube-secret-key
      - name: USER
        valueFrom:
          secretKeyRef:
            name: aspc-test-kube-secret-json
            key: kube-secret-user
      - name: PASS
        valueFrom:
          secretKeyRef:
            name: aspc-test-kube-secret-json
            key: kube-secret-pass
      volumeMounts:
      - name: ascp-test-secret-volume
        mountPath: /mnt/ascp-secret
        readOnly: true
  restartPolicy: Never
  serviceAccountName: ascp-test-serviceaccount
  volumes:
  - name: ascp-test-secret-volume
    csi:
      driver: secrets-store.csi.k8s.io
      readOnly: true
      volumeAttributes:
        secretProviderClass: aspc-test-secret-class

Деплоїмо, та перевіряємо Kubernetes Secrets:

[simterm]

$ kk get secret
NAME                         TYPE     DATA   AGE
aspc-test-kube-secret        Opaque   1      2s
aspc-test-kube-secret-json   Opaque   2      2s

[/simterm]

І значення з aspc-test-kube-secret-json:

[simterm]

$ kk get secret aspc-test-kube-secret-json -o yaml 
apiVersion: v1 
data: 
  kube-secret-pass: Zm9vYmFy 
  kube-secret-user: YWRtaW4= 
  ...

[/simterm]

Та змінні оточення в поді:

[simterm]

$ kk exec -ti ascp-test-pod -- printenv | grep 'SECRET\|USER\|PASS'
SECRET=secretLine
USER=admin
PASS=foobar

[/simterm]

Все є.

Готово.

Loading

AWS: CDK підключення EBS CSI driver Add-On до EKS
0 (0)

13 Липня 2023

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

Отже, маємо кластер, маємо пару контролерів. Наче все готово – почав встановлювати чарт VictoriaMetrics, і все завелося окрім поду з VMSingle, який завис в статусі Pending.

“VolumeBinding”: binding volumes: timed out waiting for the condition

Перевіряємо Events цього поду:

[simterm]

...
Events:
  Type     Reason            Age   From               Message
  ----     ------            ----  ----               -------
  Warning  FailedScheduling  10m   default-scheduler  running PreBind plugin "VolumeBinding": binding volumes: timed out waiting for the condition

[/simterm]

Швидкий гуглінг привів до питання на StackOverflow, де я і згадав про EKS Add-ons, в саме – про EBS CSI дайвер, який має створювати EBS при появі PersistentVolumeClaim.

Тож сьогодні глянемо, як з AWS CDK додати аддони до кластеру.

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

IAM Role для EBS CSI driver

OIDC Provider вже маємо, див. AWS: EKS, OpenID Connect та ServiceAccounts.

Для драйверу теж використовуємо IRSA – описуємо ServiceAccount, політику беремо вже готову – AWS Managed Policy, підключаємо через виклик iam.ManagedPolicy.from_aws_managed_policy_name():

...
        # Create an IAM Role to be assumed by ExternalDNS
        ebs_csi_addon_role = iam.Role(
            self,
            'EbsCsiAddonRole',
            # for Role's Trust relationships
            assumed_by=iam.FederatedPrincipal(
                federated=oidc_provider_arn,
                conditions={
                    'StringEquals': {
                        f'{oidc_provider_url.replace("https://", "")}:sub': 'system:serviceaccount:kube-system:ebs-csi-controller-sa'
                    }
                },
                assume_role_action='sts:AssumeRoleWithWebIdentity'
            )
        )
        ebs_csi_addon_role.add_managed_policy(iam.ManagedPolicy.from_aws_managed_policy_name("service-role/AmazonEBSCSIDriverPolicy"))
...

У from_aws_managed_policy_name вказуємо ім’я як “service-role/ManagedPolicyName“.

CfnAddon для EBS CSI driver

Знаходимо актувальну версію дайверу, вказавши версію кластеру – у нас 1.26, бо CDK досі не підтримує 1.27:

[simterm]

$ aws eks describe-addon-versions --addon-name aws-ebs-csi-driver --kubernetes-version 1.26 --query "addons[].addonVersions[].[addonVersion, compatibilities[].defaultVersion]" --output text
v1.20.0-eksbuild.1
True
...

[/simterm]

І описуємо підключення самого аддону з CfnAddon – вказуємо ім’я кластеру, версію та ServiceAccount IAM Role ARN:

...
        # Add EBS CSI add-on
        ebs_csi_addon = eks.CfnAddon(
            self,
            "EbsCsiAddonSa",
            addon_name="aws-ebs-csi-driver",
            cluster_name=cluster_name,
            resolve_conflicts="OVERWRITE",
            addon_version="v1.20.0-eksbuild.1",
            service_account_role_arn=ebs_csi_addon_role.role_arn
        )
...

Деплоїмо, перевіряємо:

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

[simterm]

$ kk -n kube-system get pod | grep csi
ebs-csi-controller-896d87c6b-7rv9z              6/6     Running   0          9m59s
ebs-csi-controller-896d87c6b-v7xg7              6/6     Running   0          9m59s
ebs-csi-node-2zwnr                              3/3     Running   0          9m59s
ebs-csi-node-pt5zs                              3/3     Running   0          9m59s

[/simterm]

І тепер маємо PVC для VictoriaMetrcis в статусі Bound:

[simterm]

$ kk -n dev-monitoring-ns get pvc
NAME                                  STATUS   VOLUME                                     CAPACITY   ACCESS MODES   STORAGECLASS   AGE
vmsingle-victoria-metrics-k8s-stack   Bound    pvc-151a631b-f6de-4567-8baa-97adb4e04a87   20Gi       RWO            gp2            91m

[/simterm]

І сам под з VMSingle запустився:

[simterm]

$ kk -n dev-monitoring-ns get po | grep vmsingle
vmsingle-victoria-metrics-k8s-stack-f7794d779-n6sc7               1/1     Running   0          28m

[/simterm]

Готово.

Loading

AWS: CDK та Python, IAM OIDC Provider, та Kubernetes Controllers
0 (0)

12 Липня 2023

Отже, маємо AWS EKS кластер, створений з AWS CDK та Python – AWS: CDK – створення EKS з Python та загальні враження від CDK та маємо уявлення, як працює IRSA – AWS: EKS, OpenID Connect та ServiceAccounts.

Наступним кроком після розгортання самого кластеру треба налаштувати OIDC Identity Provider в AWS IAM, та додати два контролери – ExternalDNS для роботи з Route53, то AWS ALB Controller для створення лоад-балансерів при створенні Ingress.

Для аутентифікації в AWS обидва контролери будуть використовувати модель IRSA – IAM Roles for ServiceAccounts, тобто в Kubernetes Pod з контролером підключаємо ServiceAccount, який дозволить використання IAM-ролі, до якої будуть підключені IAM Policy з необіхідними дозволами.

Пізніше окремо розглянемо питання контролеру для скейлінгу WorkerNodes: раніше я використовував Cluster AutoScaler, але цього разу хочу спробувати Karpenter, тож винесу це окремим постом.

Рішення, описані в цьому пості виглядають місцями дуже не гуд, і, може, є варіанти, як це зробити красивіше, але в мене вийшло так. “At least, it works” ¯\_(ツ)_/¯

“Так історично склалося” (с), що продовжуємо їсти кактус використовувати AWS CDK з Python. Ним будемо створювати і IAM-ресурси, і деплоїти Helm-чарти контролерів прямо з CloudFormation-стеку кластеру.

Я намагався винести деплой контролерів окремим стеком, але витратив годину-півтори намагаючись знайти, як у CDK передати значення з одного стеку в інший через CloudFormation Exports та Outputs, тож вреті-решт забив і зробив все в одному класі стеку.

EKS cluster, VPC та IAM

Створення кластеру описано в одному з попередніх постів – AWS: CDK – створення EKS з Python та загальні враження від CDK.

Що ми маємо зараз?

Сам клас для створення стеку:

...
class AtlasEksStack(Stack):

    def __init__(self, scope: Construct, construct_id: str, stage: str, region: str, **kwargs) -> None:
        super().__init__(scope, construct_id, **kwargs)

        # egt AWS_ACCOUNT
        aws_account = kwargs['env'].account

        # get AZs from the $region
        availability_zones = ['us-east-1a', 'us-east-1b']

        ...

aws_account передаємо з app.py при створенні об’єкту класу AtlasEksStack():

...
AWS_ACCOUNT = os.environ["AWS_ACCOUNT"] 
...
eks_stack = AtlasEksStack(app, f'eks-{EKS_STAGE}-1-26',
        env=cdk.Environment(account=AWS_ACCOUNT, region=AWS_REGION),
        stage=EKS_STAGE,
        region=AWS_REGION
    )
...

І далі будемо використовувати для нашалтувань AWS IAM.

Також маємо окрему VPC:

...
        vpc = ec2.Vpc(self, 'Vpc',
            ip_addresses=ec2.IpAddresses.cidr("10.0.0.0/16"),
            vpc_name=f'eks-{stage}-1-26-vpc',
            enable_dns_hostnames=True,
            enable_dns_support=True,
            availability_zones=availability_zones,
            ...
        )
...

Та сам кластер ЕКС:

...
        print(cluster_name)
        cluster = eks.Cluster(
            self, 'EKS-Cluster',
            cluster_name=cluster_name,
            version=eks.KubernetesVersion.V1_26,
            vpc=vpc,
            ...
        )
...

Далі треба додати створення OIDC в IAM, та деплой Helm-чартів з контролерами.

Налаштування OIDC Provider в AWS IAM

Використовуємо boto3 (це одна з речей, яка в AWS CDK не дуже подобається – що багато чого доводиться робити не методами/конструктами самого CDK, а “костилями” у вигляді boto3 чи інших модулів/бібліотек).

Нам треба отримати OIDC Issuer URL, та отримати його thumbprint – тоді зможемо використати create_open_id_connect_provider.

OIDC Provider URL отримаємо за допомогою boto3.client('eks'):

...
import boto3

...
        ############
        ### OIDC ###
        ############

        eks_client = boto3.client('eks')
        # Retrieve the cluster's OIDC provider details
        response = eks_client.describe_cluster(name=cluster_name)

        # https://oidc.eks.us-east-1.amazonaws.com/id/2DC***124
        oidc_provider_url = response['cluster']['identity']['oidc']['issuer']
...

Далі, за допомогою бібліотек ssl та hashlib отримуємо thumbprint сертифікату ендпоінту oidc.eks.us-east-1.amazonaws.com:

...
import ssl
import hashlib
...

        # AWS EKS OIDC root URL
        eks_oidc_url = "oidc.eks.us-east-1.amazonaws.com"

        # Retrieve the SSL certificate from the URL
        cert = ssl.get_server_certificate((eks_oidc_url, 443))
        der_cert = ssl.PEM_cert_to_DER_cert(cert)

        # Calculate the thumbprint for the create_open_id_connect_provider()
        oidc_provider_thumbprint = hashlib.sha1(der_cert).hexdigest()
...

І тепер з boto3.client('iam') та create_open_id_connect_provider() створюємо IAM OIDC Identity Provider:

...
from botocore.exceptions import ClientError
...
        # Create IAM Identity Privder
        iam_client = boto3.client('iam')
        # to catch the "(EntityAlreadyExists) when calling the CreateOpenIDConnectProvider operation"
        try:
            response = iam_client.create_open_id_connect_provider(
                    Url=oidc_provider_url,
                    ThumbprintList=[oidc_provider_thumbprint],
                    ClientIDList=["sts.amazonaws.com"]
                )
        except ClientError as e:
            print(f"\n{e}")
...

Тут все загортаємо у костиль у вигляді try/except, бо при подальших апдейтах стеку boto3.client('iam') натикається на те, що Provider вже є, і падає з помилкою EntityAlreadyExists.

Встановлення ExternalDNS

Першим додамо ExternalDNS – в нього досить проста IAM Policy, тож на ньому протестимо як взагалі CDK працює з Helm-чартами.

IRSA для ExternalDNS

Тут першим кроком нам треба створити IAM Role, яку зможе assume наш ServiceAccount для ExternalDNS, і яка дозволить ExternalDNS виконувати дії з доменною зоною у Route53, бо зараз ExternalDNS має ServiceAccount, але видає помилку:

msg=”records retrieval failed: failed to list hosted zones: WebIdentityErr: failed to retrieve credentials\ncaused by: AccessDenied: Not authorized to perform sts:AssumeRoleWithWebIdentity\n\tstatus code: 403

Trust relationships

У Trust relationships цієї ролі маємо вказати Principal у вигляді ARN створенного OIDC Provider, в Action – sts:AssumeRoleWithWebIdentity, а в Condition – якщо запит приходить від ServiceAccount, який буде створений ExternalDNS Helm-чартом.

Створим пару змінних:

...
        # arn:aws:iam::492***148:oidc-provider/oidc.eks.us-east-1.amazonaws.com/id/2DC***124
        oidc_provider_arn = f'arn:aws:iam::{aws_account}:oidc-provider/{oidc_provider_url.replace("https://", "")}'

        # deploy ExternalDNS to a namespace
        controllers_namespace = 'kube-system'
...

oidc_provider_arn формуємо зі змінної oidc_provider_url, яку отримали раніше у response = eks_client.describe_cluster(name=cluster_name).

Описуємо створення ролі за допомогою iam.Role():

...
        # Create an IAM Role to be assumed by ExternalDNS
        external_dns_role = iam.Role(
            self,
            'EksExternalDnsRole',
            # for Role's Trust relationships
            assumed_by=iam.FederatedPrincipal(
                federated=oidc_provider_arn,
                conditions={
                    'StringEquals': {
                        f'{oidc_provider_url.replace("https://", "")}:sub': f'system:serviceaccount:{controllers_namespace}:external-dns'
                    }
                },
                assume_role_action='sts:AssumeRoleWithWebIdentity'
            )
        )
...

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

Наступний крок – IAM Policy.

IAM Policy для ExternalDSN

Якщо задеплоїти стек зараз, то ExternalDSN почне сваритись на права доступу:

msg=”records retrieval failed: failed to list hosted zones: AccessDenied: User: arn:aws:sts::492***148:assumed-role/eks-dev-1-26-EksExternalDnsRoleB9A571AF-7WM5HPF5CUYM/1689063807720305270 is not authorized to perform: route53:ListHostedZones because no identity-based policy allows the route53:ListHostedZones action\n\tstatus code: 403

Тож описуємо два iam.PolicyStatement() – один для роботи з доменною зоною, другий – для доступу до route53:ListHostedZones.

Робимо їх окремими, бо для route53:ChangeResourceRecordSets у resources хочеться мати обмеження тільки однією конкретною зоною, але для дозволу на route53:ListHostedZones resources має бути у вигляді "*":

...
        # A Zone ID to create records in by ExternalDNS
        zone_id = "Z04***FJG"
        # to be used in domainFilters
        zone_name = example.co

        # Attach an IAM Policies to that Role so ExternalDNS can perform Route53 actions
        external_dns_policy = iam.PolicyStatement(
            actions=[
                'route53:ChangeResourceRecordSets',
                'route53:ListResourceRecordSets'
            ],
            resources=[
                f'arn:aws:route53:::hostedzone/{zone_id}',
            ]
        )

        list_hosted_zones_policy = iam.PolicyStatement(
            actions=[
                'route53:ListHostedZones'
            ],
            resources=['*']
        )

        external_dns_role.add_to_policy(external_dns_policy)
        external_dns_role.add_to_policy(list_hosted_zones_policy)
...

Тепер можемо додати сам ExternalDNS Helm-чарт.

AWS CDK та ExternalDNS Helm-чарт

Тут використовуємо aws-cdk.aws-eks.add_helm_chart().

У values вказуємо на створення serviceAccount, і в його annotations передаємо 'eks.amazonaws.com/role-arn': external_dns_role.role_arn:

...
        # Install ExternalDNS Helm chart
        external_dns_chart = cluster.add_helm_chart('ExternalDNS',
            chart='external-dns',
            repository='https://charts.bitnami.com/bitnami',
            namespace=controllrs_namespace,
            release='external-dns',
            values={
                'provider': 'aws',
                'aws': {
                    'region': region
                },
                'serviceAccount': {
                    'create': True,
                    'annotations': {
                        'eks.amazonaws.com/role-arn': external_dns_role.role_arn
                    }
                },
                'domainFilters': [
                    f"{zone_name}"
                ],
                'policy': 'upsert-only'
            }
        )
...

Деплоїмо, і глянемо под ExternalDNS – бачимо і наш domain-filter, і вже знайомі нам змінні оточення для роботи IRSA:

[simterm]

$ kubectl -n kube-system describe pod external-dns-85587d4b76-hdjj6
...
    Args:
      --metrics-address=:7979
      --log-level=info
      --log-format=text
      --domain-filter=test.example.co
      --policy=upsert-only
      --provider=aws
...
    Environment:
      AWS_DEFAULT_REGION:           us-east-1
      AWS_STS_REGIONAL_ENDPOINTS:   regional
      AWS_ROLE_ARN:                 arn:aws:iam::492***148:role/eks-dev-1-26-EksExternalDnsRoleB9A571AF-7WM5HPF5CUYM
      AWS_WEB_IDENTITY_TOKEN_FILE:  /var/run/secrets/eks.amazonaws.com/serviceaccount/token
...

[/simterm]

Перевіримо логи:

[simterm]

...
time="2023-07-11T10:28:28Z" level=info msg="Applying provider record filter for domains: [example.co. .example.co.]"
time="2023-07-11T10:28:28Z" level=info msg="All records are already up to date"
...

[/simterm]

І протестуємо його роботу.

Перевірка роботи ExternalDNS

Для перевірки – створимо простий Service з типом Loadbalancer, в annotations додаємо external-dns.alpha.kubernetes.io/hostname:

---
apiVersion: v1
kind: Service
metadata:
  name: nginx-service
  annotations:
    external-dns.alpha.kubernetes.io/hostname: "nginx.test.example.co"
spec:
  type: LoadBalancer
  selector:
    app: nginx
  ports:
    - name: nginx-http-svc-port
      protocol: TCP
      port: 80
      targetPort: nginx-http
---
apiVersion: v1
kind: Pod
metadata:
  name: nginx-pod
  labels:
    app: nginx
spec:
  containers:
  - name: nginx
    image: nginxdemos/hello
    ports:
      - containerPort: 80
        name: nginx-http

Дивимось логи:

[simterm]

...
time="2023-07-11T10:30:29Z" level=info msg="Applying provider record filter for domains: [example.co. .example.co.]"
time="2023-07-11T10:30:29Z" level=info msg="Desired change: CREATE cname-nginx.test.example.co TXT [Id: /hostedzone/Z04***FJG]"
time="2023-07-11T10:30:29Z" level=info msg="Desired change: CREATE nginx.test.example.co A [Id: /hostedzone/Z04***FJG]"
time="2023-07-11T10:30:29Z" level=info msg="Desired change: CREATE nginx.test.example.co TXT [Id: /hostedzone/Z04***FJG]"
time="2023-07-11T10:30:29Z" level=info msg="3 record(s) in zone example.co. [Id: /hostedzone/Z04***FJG] were successfully updated"
...

[/simterm]

Перевіряємо роботу домену:

[simterm]

$ curl -I nginx.test.example.co
HTTP/1.1 200 OK

[/simterm]

“It works!” (c)

Весь код для OIDC та ExternalDNS

Весь код разом зараз виглядає так:

...
        ############
        ### OIDC ###
        ############

        eks_client = boto3.client('eks')
        # Retrieve the cluster's OIDC provider details
        response = eks_client.describe_cluster(name=cluster_name)

        # https://oidc.eks.us-east-1.amazonaws.com/id/2DC***124
        oidc_provider_url = response['cluster']['identity']['oidc']['issuer']

        # AWS EKS OIDC root URL
        eks_oidc_url = "oidc.eks.us-east-1.amazonaws.com"

        # Retrieve the SSL certificate from the URL
        cert = ssl.get_server_certificate((eks_oidc_url, 443))
        der_cert = ssl.PEM_cert_to_DER_cert(cert)

        # Calculate the thumbprint for the create_open_id_connect_provider()
        oidc_provider_thumbprint = hashlib.sha1(der_cert).hexdigest()

        # Create IAM Identity Privder
        iam_client = boto3.client('iam')
        # to catch the "(EntityAlreadyExists) when calling the CreateOpenIDConnectProvider operation"
        try:
            response = iam_client.create_open_id_connect_provider(
                    Url=oidc_provider_url,
                    ThumbprintList=[oidc_provider_thumbprint],
                    ClientIDList=["sts.amazonaws.com"]
                )
        except ClientError as e:
            print(f"\n{e}")
    
        ###################
        ### Controllers ###
        ###################

        ### ExternalDNS ###

        # arn:aws:iam::492***148:oidc-provider/oidc.eks.us-east-1.amazonaws.com/id/2DC***124
        oidc_provider_arn = f'arn:aws:iam::{aws_account}:oidc-provider/{oidc_provider_url.replace("https://", "")}'

        # deploy ExternalDNS to a namespace
        controllers_namespace = 'kube-system'

        # Create an IAM Role to be assumed by ExternalDNS
        external_dns_role = iam.Role(
            self,
            'EksExternalDnsRole',
            # for Role's Trust relationships
            assumed_by=iam.FederatedPrincipal(
                federated=oidc_provider_arn,
                conditions={
                    'StringEquals': {
                        f'{oidc_provider_url.replace("https://", "")}:sub': f'system:serviceaccount:{controllers_namespace}:external-dns'
                    }
                },
                assume_role_action='sts:AssumeRoleWithWebIdentity'
            )
        )

        # A Zone ID to create records in by ExternalDNS
        zone_id = "Z04***FJG"
        # to be used in domainFilters
        zone_name = "example.co"

        # Attach an IAM Policies to that Role so ExternalDNS can perform Route53 actions
        external_dns_policy = iam.PolicyStatement(
            actions=[
                'route53:ChangeResourceRecordSets',
                'route53:ListResourceRecordSets'
            ],
            resources=[
                f'arn:aws:route53:::hostedzone/{zone_id}',
            ]
        )

        list_hosted_zones_policy = iam.PolicyStatement(
            actions=[
                'route53:ListHostedZones'
            ],
            resources=['*']
        )

        external_dns_role.add_to_policy(external_dns_policy)
        external_dns_role.add_to_policy(list_hosted_zones_policy)

        # Install ExternalDNS Helm chart
        external_dns_chart = cluster.add_helm_chart('ExternalDNS',
            chart='external-dns',
            repository='https://charts.bitnami.com/bitnami',
            namespace=controllers_namespace,
            release='external-dns',
            values={
                'provider': 'aws',
                'aws': {
                    'region': region
                },
                'serviceAccount': {
                    'create': True,
                    'annotations': {
                        'eks.amazonaws.com/role-arn': external_dns_role.role_arn
                    }
                },
                'domainFilters': [
                    zone_name
                ],
                'policy': 'upsert-only'
            }
        )
...

Переходимо до ALB Controller.

Встановлення AWS ALB Controller

Тут, в принципі, все теж саме, єдине, з чим довелось повозитись – це IAM Policy, бо якщо для ExternalDNS маємо тільки два дозволи, і можемо описати їх прямо при створенні цієї Policy, то для ALB Controller політику треба взяти з GitHub, бо вона досить велика.

IAM Policy з GitHub URL

Тут використовуємо requests (знов костилі):

...
import requests
...
        alb_controller_version = "v2.5.3"
        url = f"https://raw.githubusercontent.com/kubernetes-sigs/aws-load-balancer-controller/{alb_controller_version}/docs/install/iam_policy.json"

        response = requests.get(url)
        response.raise_for_status()  # Check for any download errors
        
        # format as JSON
        policy_document = response.json()
        document = iam.PolicyDocument.from_json(policy_document)
...

Отримуємо файл політики, формуємо його в JSON, і потм з JSON формуємо вже сам policy document.

IAM Role для ALB Controller

Далі створюємо IAM Role з аналогічними до ExternalDNS Trust relationships, тільки міняємо conditions – вказуємо ServiceAccount, який буде створено для AWS ALB Contoller:

...
        alb_controller_role = iam.Role(
            self,
            'AwsAlbControllerRole',
            # for Role's Trust relationships
            assumed_by=iam.FederatedPrincipal(
                federated=oidc_provider_arn,
                conditions={
                    'StringEquals': {
                        f'{oidc_provider_url.replace("https://", "")}:sub': f'system:serviceaccount:{controllers_namespace}:aws-load-balancer-controller'
                    }
                },
                assume_role_action='sts:AssumeRoleWithWebIdentity'
            )
        )        
        alb_controller_role.attach_inline_policy(iam.Policy(self, "AwsAlbControllerPolicy", document=document))
...

AWS CDK та AWS ALB Controller Helm-чарт

І тепер встановлюємо сам чарт з потрібними values – вказуємо на необхідність створення ServiceAccount, йому в annotations передаємо ARM ролі, яку створили перед цим, та задаємо clusterName:

...
        # Install AWS ALB Controller Helm chart
        alb_controller_chart = cluster.add_helm_chart('AwsAlbController',
            chart='aws-load-balancer-controller',
            repository='https://aws.github.io/eks-charts',
            namespace=controllers_namespace,
            release='aws-load-balancer-controller',
            values={
                'image': {
                    'tag': alb_controller_version
                },
                'serviceAccount': {
                    'name': 'aws-load-balancer-controller',
                    'create': True,
                    'annotations': {
                        'eks.amazonaws.com/role-arn': alb_controller_role.role_arn
                    },
                    'automountServiceAccountToken': True                  
                },
                'clusterName': cluster_name,
                'replicaCount': 1
            }
        )
...

Перевірка роботи AWS ALB Controller

Створимо простий Pod, Service та до них – Ingress:

---
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
  name: nginx-ingress
  annotations:
    kubernetes.io/ingress.class: alb
spec:
  rules:
  - http:
      paths:
      - path: /
        pathType: Prefix
        backend:
          service:
            name: nginx-http-svc-port
            port:
              number: 80
---
apiVersion: v1
kind: Service
metadata:
  name: nginx-service
  annotations:
    external-dns.alpha.kubernetes.io/hostname: "nginx.test.example.co"
spec:
  selector:
    app: nginx
  ports:
    - name: nginx-http-svc-port
      protocol: TCP
      port: 80
      targetPort: nginx-http
---
apiVersion: v1
kind: Pod
metadata:
  name: nginx-pod
  labels:
    app: nginx
spec:
  containers:
  - name: nginx
    image: nginxdemos/hello
    ports:
      - containerPort: 80
        name: nginx-http

Деплоїмо, і перевіряємо Ingress:

[simterm]

$ kubectl get ingress
NAME            CLASS    HOSTS   ADDRESS                                                                                                                        PORTS   AGE
nginx-ingress   <none>   *       internal-k8s-default-nginxing-***-***.us-east-1.elb.amazonaws.com   80          34m

[/simterm]

Єдине тут, що спрацювало не з першого разу – це підключення aws-iam-token: саме тому я в values чарту явно передав 'automountServiceAccountToken': True, хоча в нього і так дефолтне значення true.

Але після декулькох редеплоїв з cdk deploy – токен таки створився і підключився до поду:

...
    - name: AWS_ROLE_ARN
      value: arn:aws:iam::492***148:role/eks-dev-1-26-AwsAlbControllerRole4AC4054B-1QYCGEG2RZUD7
    - name: AWS_WEB_IDENTITY_TOKEN_FILE
      value: /var/run/secrets/eks.amazonaws.com/serviceaccount/token
...

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

Як завжди з CDK – це біль та страждання через відсутність нормальної документації та прикладів, але за допомогою ChatGPT та матюків – воно таки запрацювало.

Ще, мабуть, було б добре створення ресурсів винести хоча б у окремі функції, а не робити все з AtlasEksStack.__init__(), але то може пізніше.

Далі за планом – запуск VictoriaMetrics в Kubernetes, а потім вже потицяємо Karpenter.

Loading

AWS: EKS, OpenID Connect та ServiceAccounts
0 (0)

7 Липня 2023

Зараз сетаплю новий ЕКС кластер, і серед інших компонентів запускаю в ньому ExternalDNS, який використовує Kubernetes ServiceAccount для аутентифікації в AWS, щоб мати змогу вносити зміни до доменної зони в Route53.

Однак забув налаштувати Identity Provider в AWS IAM, і ExternalDNS видав помилку:

level=error msg=”records retrieval failed: failed to list hosted zones: WebIdentityErr: failed to retrieve credentials\ncaused by: InvalidIdentityToken: No OpenIDConnect provider found in your account for https://oidc.eks.us-east-1.amazonaws.com/id/FDF***F2F\n\tstatus code: 400

Тож почав згадувати за OIDC, потім взагалі про аутентифікацію в Kubernetes, и вирішив ще раз копнути в те, як воно все працює, бо в останніх версіях EKS/Kubernetes були досить цікаві зміни.

Що таке OpenID Connect та Identity Provider

OpenID Connect (OIDC) це протокол, який дозволяє сервісам виконувати аутентифікацію іншого сервісу або користувача на основі Identity Tokens, які являють собою JSON Web Tokens (JWT).

Сам JWT підписується Identity Provider (IDP), і містить в собі інформацю про юзера або сервіс.

В нашому випадку, AWS Elastic Kubernetes Service – це Identity Provider, а AWS – це Service Provider. Тобто, EKS аутентифікує юзерів, і каже Амазону, що цьому юзеру можна довіряти виконувати якісь дії в AWS.

Тож головне, що треба усвідомлювати, коли ви налаштовуєте Identity Providers в AWS IAM, це те, що ви не налаштовуєте якийсь окремий AWS Service під назвою “Identity Providers“, а налаштовуєте AWS IAM, якому кажете – “Хей, довіряй чуваку з оцим URL”, тобто налаштовуєте Trust relations між вашим Identity Provider (EKS, GitHub, GitLab, Google тощо) та Service Provider (AWS).

Якщо провести аналогію, то це якби ви в аеропорту на паспортному контролі десь в Амстердамі прийшли зі своїм українським паспортом, і вам там повірили, що ви – то саме ви, бо прикордонна служба Нідерландів (Service Provider) довіряє уряду України (Identity Provider), який вам видав цей паспорт (JWT).

AWS EKS та IAM Role

Окей, тож як Kubernetes Pod у EKS отримує доступ до AIM-ролі?

Ми повернемось детальніше до цієї теми в кінці, у AWS IAM Roles for Kubernetes ServiceAccounts, але зараз глянемо загальну картину процесу:

  • ми створюємо ServiceAccount для Kubernetes Pod, в annotations цього ServiceAccount вказуємо ARN IAM-ролі, яку цей Pod має використовувати для аутентифікації в AWS (авторизація, тобто перевірка які саме дії ви можете в AWS виконувати, буде виконуватись на рівні самого AWS в IAM за допомогою IAM Policy, яка підключена до вашої IAM Role)
  • EKS генерує JWT-токен, в якому вказано, що “подавач” цього токену дійсно є валідним EKS-юзером, і EKS це підтвержує своїм сертифікатом
  • процес із поду за допомогою цього токену проходить аутентифікацію в AWS IAM і виконує AssumeRole
  • і вже від імені цїєї ролі виконує необхідні дії з AWS API

Тобто, в процесі приймаються участь Kubernetes ServiceAccount, AWS AIM, та JWT-токени.

Розберемося з цим усім по черзі, і почнемо з ServiceAccounts та JWT в EKS, бо з часів написання Kubernetes: ServiceAccounts, JWT-токены, аутентификация и RBAC-авторизация процес трохи змінився.

EKS ServiceAccounts та Projected Volumes

Якщо раніше при створенні ServiceAccount створювався статичний Kubernetes Secret, який в собі мав три поля – namespace, ca.crt та власне token, то тепер це все генерується динамічно для кожного поду та ServiceAccount.

Давайте переглянемо, що ми зараз маємо в поді з ExternalDNS:

[simterm]

$ kk -n kube-system get pod external-dns-85587d4b76-2flhg -o yaml
...
    env:
    - name: AWS_DEFAULT_REGION
      value: us-east-1
    - name: AWS_STS_REGIONAL_ENDPOINTS
      value: regional
    - name: AWS_ROLE_ARN
      value: arn:aws:iam::492***148:role/eks-dev-1-26-EksExternalDnsRoleB9A571AF-1CFSB6BBQDGSZ
    - name: AWS_WEB_IDENTITY_TOKEN_FILE
      value: /var/run/secrets/eks.amazonaws.com/serviceaccount/token
...
    volumeMounts:
    - mountPath: /var/run/secrets/kubernetes.io/serviceaccount
      name: kube-api-access-qdgjr
      readOnly: true
    - mountPath: /var/run/secrets/eks.amazonaws.com/serviceaccount
      name: aws-iam-token
      readOnly: true
...
  serviceAccount: external-dns
  serviceAccountName: external-dns
...
  volumes:
  - name: aws-iam-token
    projected:
      defaultMode: 420
      sources:
      - serviceAccountToken:
          audience: sts.amazonaws.com
          expirationSeconds: 86400
          path: token
  - name: kube-api-access-qdgjr
    projected:
      defaultMode: 420
      sources:
      - serviceAccountToken:
          expirationSeconds: 3607
          path: token
      - configMap:
          items:
          - key: ca.crt
            path: ca.crt
          name: kube-root-ca.crt
      - downwardAPI:
          items:
          - fieldRef:
              apiVersion: v1
              fieldPath: metadata.namespace
            path: namespace
...

[/simterm]

Отже, в volumeMounts ми бачимо два volumeskube-api-access-qdgjr та aws-iam-token. До aws-iam-token повернемось пізніше, а поки давайте розглянемо volumes.projected для kube-api-access-qdgjr.

ServiceAccount Tokens

Починаючи з версії 1.22, Kubernetes має два типи токені – Long Live та Time Bound.

Long Live вже вважається deprecated, і не має використовуватись, хоча його можливо зробити зо допомогою Secret – це той самий тип токенів, які використовувались для ServiceAccounts раніше:

apiVersion: v1
kind: ServiceAccount
metadata:
  name: test-sa
---
apiVersion: v1
kind: Secret
metadata:
  name: test-secret
  annotations:
    kubernetes.io/service-account.name: test-sa
type: kubernetes.io/service-account-token

Time Bound токени генеруються Kubernetes TokenRequest API, мають обмежений час життя, валідні тільки для конкретного Pod та ServiceAccount, і підключаються до поду за допомогою Projected Volumes та serviceAccountToken.

Kubernetes API JWT authentification

Глянемо в самому поді зміст каталогу /var/run/secrets/kubernetes.io/serviceaccount:

[simterm]

$ kk exec -ti pod/test-pod -- ls -l /var/run/secrets/kubernetes.io/serviceaccount
total 0
lrwxrwxrwx    1 root     root            13 Jul  5 09:37 ca.crt -> ..data/ca.crt
lrwxrwxrwx    1 root     root            16 Jul  5 09:37 namespace -> ..data/namespace
lrwxrwxrwx    1 root     root            12 Jul  5 09:37 token -> ..data/token

[/simterm]

Тут маємо три файли, які створені з Projected Volumes, в яких ми бачили три source, кожний з власним path:

  • serviceAccountToken: містить токен, отриманий від kube-apiserver за допомогою TokenRequest API, і використовується подом для аутентифікації на Kubernetes API. Має обмежений час життя, і валідний тільки для цього конкретного поду та його ServiceAccount
    • підключається у path: token
  • configMap: бере зміст kube-root-ca.crt ConfigMap, використовується подом, щоб впевнитись, що він підключається саме до потрібного Kubernetes API
    • підключається у path: ca.crt
  • downwardAPI: отримує від API інформацію про metadata.namespace
    • підключається у path: namespace

Давайте глянемо, що в самому токені – він теж змінився.

Отримуємо сам токен:

[simterm]

$ token=`kubectl -n kube-system exec external-dns-85587d4b76-2flhg -- cat /var/run/secrets/kubernetes.io/serviceaccount/token`

[/simterm]

І дивимось зміст за допомогою jwt-cli або на сайті https://jwt.io:

[simterm]

$ jwt decode $token

Token header
------------
{
  "alg": "RS256",
  "kid": "64aacc8aa986bf6161312dfdfeba00e63ed64f9d"
}

Token claims
------------
{
  "aud": [
    "https://kubernetes.default.svc"
  ],
  "exp": 1720254790,
  "iat": 1688718790,
  "iss": "https://oidc.eks.us-east-1.amazonaws.com/id/FDF***F2F",
  "kubernetes.io": {
    "namespace": "kube-system",
    "pod": {
      "name": "external-dns-85587d4b76-2flhg",
      "uid": "d59b56f1-fa01-4a0f-8897-1933926e4d42"
    },
    "serviceaccount": {
      "name": "external-dns",
      "uid": "38c8f023-60bf-416e-b6c2-d37939ac3c06"
    },
    "warnafter": 1688722397
  },
  "nbf": 1688718790,
  "sub": "system:serviceaccount:kube-system:external-dns"
}

[/simterm]

Тут:

  • aud (audience): для кого цей токен призначений – отримувач має ідентифікувати себе з цим ім’ям, інакше токен має бути відхилений
  • exp (expiration time): “термін придатності” цього токену – після його закінчення, токен має бути відхилений
  • iat (issued at): час створення токену, від якого буде рахуватись exp
  • iss (issuer): OIDC Issuer URL нашого кластеру – той самий Identity Provider URL, який потім будемо використовувати при налаштувані AWS IAM
  • kubernetes.io: тут бачимо UID самого пода та ServiceAccount – саме тому якщо под або його ServiceAccount буде перестворено, то цей токен стане невалідним, бо зміняться UID
  • sub (subject): “ім’я користувача” цього токену – буде перевірятись у AWS IAM для авторизації дій з AWS API

Використовуючи це токен – ми з поду можемо аутентифікуватись на API нашого Kubernetes-кластеру.

Описуємо под з cURL:

---
apiVersion: v1
kind: Pod
metadata:
  name: test-pod
spec:
  containers:
    - name: curl
      image: curlimages/curl
      command: ['sleep', '36000']
  restartPolicy: Never

Створюємо його:

[simterm]

$ kubectl apply -f test-pod.yaml 
pod/test-pod created

[/simterm]

Підключаємось, та створюємо змінні:

[simterm]

$ kubectl exec -ti test-pod -- sh
~ $ SERVICEACCOUNT=/var/run/secrets/kubernetes.io/serviceaccount
~ $ TOKEN=$(cat ${SERVICEACCOUNT}/token)
~ $ CACERT=${SERVICEACCOUNT}/ca.crt
~ $ curl --cacert ${CACERT} --header "Authorization: Bearer ${TOKEN}" -X GET https://kubernetes.default.svc/api
{
  "kind": "APIVersions",
  "versions": [
    "v1"
  ],
  "serverAddressByClientCIDRs": [
    {
      "clientCIDR": "0.0.0.0/0",
      "serverAddress": "ip-172-16-110-147.ec2.internal:443"
    }
  ]
}

[/simterm]

Тоді як без токену – підемо за російським кораблем отримаємо відповідь 403:

[simterm]

~ $ curl --cacert ${CACERT} -X GET https://kubernetes.default.svc/api
{
  "kind": "Status",
  "apiVersion": "v1",
  "metadata": {},
  "status": "Failure",
  "message": "forbidden: User \"system:anonymous\" cannot get path \"/api\"",
  "reason": "Forbidden",
  "details": {},
  "code": 403
}

[/simterm]

Добре – з аутентифікацією в Kubernetes API наче розібралися, давайте глянемо на AWS.

AWS IAM Roles для Kubernetes ServiceAccounts

Для роботи з AWS API, Kubernetes Pod використовує модель IRSA – IAM Role for Service Accounts.

Хоча ви все ще можете використовувати підхід з ACCESS/SECRET через змінні оточення, або підключати необхідну роль до EC2 WorkerNode як EC2 IAM Instance Role, робота через IRSA дозволяє вам видавати права на роботу з AWS для конкретного поду, а не всіх подів на цьому ЕС2-інстансі.

У випадку ж з ACCESS/SECRET для поду – ключі у вас статичні, і по-перше – можуть бути скомпрометовані (вкрадені), по-друге – вам необхідно їх десь тримати та передавати у Deployment/StatefulSet, etc під час створення вашого workload, тоді як IRSA використовує динамічні дані доступу (credentials), яки створються під час запиту поду до IAM-ролі, і вам не потрібно їх ані зберігати, ані хвилюватись через їх витік.

Assume Role з AWS CLI

Отже, Kubernetes Pod буде виконувати AssumeRole для отримання ролі, тож давайте згадаємо, як AssumeRole працює з AWS CLI – тоді будемо краще уявляти собі, як це працює в EKS з його подами.

Описуємо IAM Policy, яка дозволяє доступ до S3-бакетів:

{
    "Version": "2012-10-17",
    "Statement": [
        {
            "Effect": "Allow",
            "Action": [
                "s3:ListAllMyBuckets",
                "s3:GetBucketLocation"
            ],
            "Resource": "*"
        }
    ]
}

Створюємо її:

[simterm]

$ aws iam create-policy --policy-name irsa-test --policy-document file://irsa-policy.json
{
    "Policy": {
        "PolicyName": "irsa-test",
        ...
        "Arn": "arn:aws:iam::492***148:policy/irsa-test",
        ...
    }
}

[/simterm]

Описуємо Trusted Policy для майбутньої IAM Role – хто зможе виконувати запит sts:AssumeRole цієї ролі до AWS API:

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Sid": "",
      "Effect": "Allow",
      "Principal": {
        "AWS": "arn:aws:iam::492***148:root"
      },
      "Action": "sts:AssumeRole"
    }
  ]
}

Тут в Principal "arn:aws:iam::492***148:root" вказуємо, що будь-який валідний юзер цього AWS-аккаунту може виконати "Action": "sts:AssumeRole".

Cтворюємо саму роль, якій підключаємо цю полісі:

[simterm]

$ aws iam create-role --role-name irsa-test-role --assume-role-policy-document file://irsa-trust.json
{
    "Role": {
        "Path": "/",
        "RoleName": "irsa-test-role",
        "RoleId": "AROAXFIUAIGSBE2Q2WORF",
        "Arn": "arn:aws:iam::492***148:role/irsa-test-role",
        "CreateDate": "2023-07-05T11:05:37Z",
        "AssumeRolePolicyDocument": {
            "Version": "2012-10-17",
            "Statement": [
                {
                    "Sid": "",
                    "Effect": "Allow",
                    "Principal": {
                        "AWS": "arn:aws:iam::492***148:root"
                    },
                    "Action": "sts:AssumeRole"
                }
            ]
        }
    }
}

[/simterm]

І додаємо до ролі політику, яка дозволяє виконувати запити до S3:

[simterm]

$ aws iam attach-role-policy --role-name irsa-test-role --policy-arn arn:aws:iam::492****148:policy/irsa-test

[/simterm]

Тепер з AWS CLI перевіряємо чи зможемо ми виконати assume цієї ролі:

[simterm]

$ aws sts assume-role --role-arn arn:aws:iam::492***148:role/irsa-test-role --role-session-name TestIrsa
{
    "Credentials": {
        "AccessKeyId": "ASI***GU3",
        "SecretAccessKey": "g5N***xhR",
        "SessionToken": "Fwo***g==",
        "Expiration": "2023-07-05T12:25:54Z"
    },
    "AssumedRoleUser": {
        "AssumedRoleId": "AROAXFIUAIGSBE2Q2WORF:TestIrsa",
        "Arn": "arn:aws:sts::492***148:assumed-role/irsa-test-role/TestIrsa"
    }
}

[/simterm]

Працює.

Що тут відбувається?

  • AWS CLI виконує запит до AWS STS
  • STS перевіряє, чи може користувач (який у ~/.aws/credentials має ACCESS/SECRET ключі юзеру в AWS) виконувати API-запит sts:AssumeRole (а так як ми в Trust Policy  цієї ролі вказали Principal "arn:aws:iam::492***148:root" – то може)
  • якщо перевірку пройдено, то STS створює тимчасові дані доступу для цієї ролі – AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY та AWS_SESSION_TOKEN і повертає їх до AWS CLI

Далі, використовуючи ці дані, ми можемо виконувати дії від імені ціьєї IAM-ролі:

[simterm]

$ export AWS_ACCESS_KEY_ID=ASI***YHO
$ export AWS_SECRET_ACCESS_KEY=WPN***ZiN
$ export AWS_SESSION_TOKEN=Fwo***Vo=

[/simterm]

Перевіримо юзера тепер:

[simterm]

$ aws sts get-caller-identity
{
    "UserId": "AROAXFIUAIGSBE2Q2WORF:TestIrsa",
    "Account": "492***148",
    "Arn": "arn:aws:sts::492***148:assumed-role/irsa-test-role/TestIrsa"
}

[/simterm]

І глянемо чи маємо ми доступ до бакетів:

[simterm]

$ aws s3 ls
2023-02-01 13:29:34 amplify-staging-112927-deployment
2023-02-02 17:40:56 amplify-dev-174045-deployment
...

[/simterm]

Окей, з цим розібралися.

Тепер глянемо, як це відбувається в EKS.

AssumeRole as a ServiceAccount

Спочатку налаштуємо Identity Provider в IAM, створимо ServiceAccount та IAM Role, яку будемо використовувати, перевіримо, і потім глянемо як саме воно працює.

Отримуємо OpenID Connect provider URL:

[simterm]

$ aws eks describe-cluster --name eks-dev-1-26-cluster --query "cluster.identity.oidc.issuer" --output text
https://oidc.eks.us-east-1.amazonaws.com/id/FDF***F2F

[/simterm]

Переходимо до AWS Console > IAM > Identity Providers, додаємо нового провайдера з типом OpenID Connect.

Вказуємо Provider URL, клікаємо Get thumbprint:

За цим відбитком IAM в майбутньому буде перевіряти, чи дійсно до нього прийшов той самий Issuer, якого ми вказуємо в Provider URL.

В полі Audience задаємо sts.amazonaws.com – “до кого” цей IDP зможе звертатись.

Клікаємо Add provider, переходимо до нього, та копіюємо його ARN:

Створюємо файл “політики довіри” – описуємо, хто зможе виконувати Assume ролі, яку будемо створювати для нашого тестового поду:

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Effect": "Allow",
      "Principal": {
        "Federated": "arn:aws:iam::492***148:oidc-provider/oidc.eks.us-east-1.amazonaws.com/id/FDF***F2F"
      },
      "Action": "sts:AssumeRoleWithWebIdentity",
      "Condition": {
        "StringEquals": {
          "oidc.eks.us-east-1.amazonaws.com/id/FDF***F2F:aud": "sts.amazonaws.com",
          "oidc.eks.us-east-1.amazonaws.com/id/FDF***F2F:sub": "system:serviceaccount:default:irsa-test-service-account"
        }
      }
    }
  ]
}

Тут:

  • Federated: ARN Identity Provider-у, якого ми створили
  • Action: яку саме дію він зможе виконувати
  • Condition: і при яких умовах – якщо sub, тобто “юзер” буде irsa-test-service-account ServiceAccount, і він буде звертатись до sts.amazonaws.com

Створюємо IAM Role, нотуємо її ARN:

[simterm]

$ aws iam create-role --role-name irsa-test --assume-role-policy-document file://irsa-trust.json
...
        "Arn": "arn:aws:iam::492***148:role/irsa-test",
...

[/simterm]

Підключимо ту саму S3 Policy, яку робили ще на початку:

[simterm]

$ aws iam attach-role-policy --role-name irsa-test --policy-arn arn:aws:iam::492***148:policy/irsa-test

[/simterm]

Описуємо ServiceAccount, в annotations якого вказуємо IAM Role ARN – тієї ролі, яку тільки що створили:

apiVersion: v1
kind: ServiceAccount
metadata:
  name: irsa-test-service-account
  namespace: default
  annotations:
    eks.amazonaws.com/role-arn: arn:aws:iam::492***148:role/irsa-test

І додаємо тестовий под з AWS CLI, якому вказуємо цей serviceAccountName:

---
apiVersion: v1
kind: Pod
metadata:
  name: irsa-test-pod
spec:
  containers:
    - name: aws-cli
      image: amazon/aws-cli:latest
      command: ['sleep', '36000']
  restartPolicy: Never
  serviceAccountName: irsa-test-service-account

Деплоїмо:

[simterm]

$ kubectl apply -f irsa-sa.yaml 
serviceaccount/irsa-test-service-account created
pod/irsa-test-pod created

[/simterm]

Підключаємось в под, і пробуємо переглянути S3 корзини в аккаунті:

[simterm]

sh-4.2# aws s3 ls
2023-02-01 11:29:34 amplify-staging-112927-deployment
2023-02-02 15:40:56 amplify-dev-174045-deployment
...

[/simterm]

І переконаємось, що ми це зробили дійсно використовуючи роль irsa-test:

[simterm]

sh-4.2# aws sts get-caller-identity
{
    "UserId": "AROAXFIUAIGSM3R35H4WY:botocore-session-1688726924",
    "Account": "492***148",
    "Arn": "arn:aws:sts::492***148:assumed-role/irsa-test/botocore-session-1688726924"
}

[/simterm]

А тепер розберемося, як воно працює.

IRSA та Amazon EKS Pod Identity webhook

Глянемо на наш под, як ми це робили на самому початку з подом ExternalDNS:

[simterm]

$ kubectl get pod/irsa-test-pod -o yaml
...
    - name: AWS_ROLE_ARN
      value: arn:aws:iam::492***148:role/irsa-test
    - name: AWS_WEB_IDENTITY_TOKEN_FILE
      value: /var/run/secrets/eks.amazonaws.com/serviceaccount/token
...
    volumeMounts:
    - mountPath: /var/run/secrets/kubernetes.io/serviceaccount
      name: kube-api-access-frc4n
      readOnly: true
    - mountPath: /var/run/secrets/eks.amazonaws.com/serviceaccount
      name: aws-iam-token
      readOnly: true
...
  volumes:
  - name: aws-iam-token
    projected:
      defaultMode: 420
      sources:
      - serviceAccountToken:
          audience: sts.amazonaws.com
          expirationSeconds: 86400
          path: token
  - name: kube-api-access-frc4n
    projected:
      defaultMode: 420
      sources:
      - serviceAccountToken:
          expirationSeconds: 3607
          path: token
      - configMap:
          items:
          - key: ca.crt
            path: ca.crt
          name: kube-root-ca.crt
      - downwardAPI:
          items:
          - fieldRef:
              apiVersion: v1
              fieldPath: metadata.namespace
            path: namespace

[/simterm]

Ми вже розбирали що знаходиться в /var/run/secrets/kubernetes.io/serviceaccount/token, який створюється з Projected Volume kube-api-access-frc4n – теперь глянемо на /var/run/secrets/eks.amazonaws.com/serviceaccount/token.

Для нього використовується той самий тип serviceAccountToken, якому передається audience: sts.amazonaws.com. В результаті маємо JWT-токен для аутентифікації в AWS:

[simterm]

$ token=`kubectl exec -ti pod/irsa-test-pod -- cat /var/run/secrets/eks.amazonaws.com/serviceaccount/token`
$ jwt decode $token
...
Token claims
------------
{
  "aud": [
    "sts.amazonaws.com"
  ],
  "exp": 1688813222,
  "iat": 1688726822,
  "iss": "https://oidc.eks.us-east-1.amazonaws.com/id/FDF***F2F",
  "kubernetes.io": {
    "namespace": "default",
    "pod": {
      "name": "irsa-test-pod",
      "uid": "cc040630-1e85-4339-9699-7106c2b37a9b"
    },
    "serviceaccount": {
      "name": "irsa-test-service-account",
      "uid": "65b197d7-1609-433c-825e-b423f622978b"
    }
  },
  "nbf": 1688726822,
  "sub": "system:serviceaccount:default:irsa-test-service-account"
}

[/simterm]

Бачимо всі тіж поля:

  • aud: має співпадати з audience нашого Identity Provider в AWS AIM (інакше отримаємо помилку “An error occurred (InvalidIdentityToken) when calling the AssumeRoleWithWebIdentity operation: Incorrect token audience” – я с першого разу помилився, коли додавав IDP – в Audience вказав sts.amazon.com замість sts.amazonaws.com)
  • iss: IAM буде перевіряти, від кого прийшов токен, і чи може він довіряти цьому джерелу
  • sub: буде використовуватись у IAM Role Trusted Policy – згадайте Condition.StringEquals у файлі irsa-trust.json

Тобто, з цим токеном ми можемо звернутись до AWS STS, і отримати temporary crdentials, за якими зможемо виконати запит на sts:AssumeRoleWithWebIdentity.

Перевіримо?

Використаємо AWS CLI та assume-role-with-web-identity:

[simterm]

sh-4.2# token=`cat /var/run/secrets/eks.amazonaws.com/serviceaccount/token`
sh-4.2# aws sts assume-role-with-web-identity --role-session-name "test-irsa" --role-arn arn:aws:iam::492***148:role/irsa-test --web-identity-token $token
{
    "Credentials": {
        "AccessKeyId": "ASI***PUU",
        "SecretAccessKey": "Y/Z***KQW",
        "SessionToken": "IQo***A==",
        "Expiration": "2023-07-07T12:54:30+00:00"
    },
    "SubjectFromWebIdentityToken": "system:serviceaccount:default:irsa-test-service-account",
    "AssumedRoleUser": {
        "AssumedRoleId": "AROAXFIUAIGSM3R35H4WY:test-irsa",
        "Arn": "arn:aws:sts::492***148:assumed-role/irsa-test/test-irsa"
    },
    "Provider": "arn:aws:iam::492***148:oidc-provider/oidc.eks.us-east-1.amazonaws.com/id/FDF***F2F",
    "Audience": "sts.amazonaws.com"
}

[/simterm]

Wow! It’s a magic!

То щож відбувається, коли ми створюємо ServiceAccount с IAM Role ARN в аннотаціях?

Чудово описано ось тут – Introducing fine-grained IAM roles for service accounts:

Отже:

  • при створенні пода з ServiceAccount, якому вказано IAM Role, Amazon EKS Pod Identity webhook створює змінні оточення AWS_ROLE_ARN та AWS_WEB_IDENTITY_TOKEN_FILE, і додає aws-iam-token projected volume, в якому генерує JWT
  • при роботі процесу всередині поду – цей процес (AWS CLI, CDK, SDK, whatever) використовує змінні оточення:
    • AWS_ROLE_ARN – щоб знати, Assume якої ролі робити
    • та AWS_WEB_IDENTITY_TOKEN_FILE – що знати, звідки йому взяти токен для аутентифікації в AWS

Тобто, коли ми викликали aws s3 ls і не передавали йому ніяких параметрів – він просто взяв їх з оточення:

[simterm]

sh-4.2# env | grep AWS_
AWS_ROLE_ARN=arn:aws:iam::492***148:role/irsa-test
AWS_WEB_IDENTITY_TOKEN_FILE=/var/run/secrets/eks.amazonaws.com/serviceaccount/token
AWS_DEFAULT_REGION=us-east-1
AWS_REGION=us-east-1
AWS_STS_REGIONAL_ENDPOINTS=regional

[/simterm]

That’s all, folks!

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

Loading

AWS: CDK – створення EKS з Python та загальні враження від CDK
0 (0)

23 Червня 2023

Terraform то чудово, але поки що вирішили перші кластера AWS EKS створювати за допомогою AWS CDK, бо по-преше – він вже є на проекті, по-друге – самому цікаво спробувати новий інструмент.

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

Про перше знайомство з СDK писав тут – AWS: CDK – знайомство та приклади на Python.

Забігаючи наперед – особисто я, дуже м’яко кажучи, не в захваті від CDK:

  • ніякого тобі KISSKeep It Simple Stupid, ніякого тобі “явне краще неявного”
  • місцями незрозуміла документація з приклами на TypeScript навіть в репозиторії PyPi
  • купа окремих бібліотек та модулів, іноді геморой з їхніми імпортами
  • загальна перенавантаженість коду CDK/Python – Terraform з його HCL або Pulumi з Python виглядає набагато простішим для розуміння загальної картини інфрастуктури, котора цим кодом описана
  • перенавантаженість самого CloudFormation стеку, створенного за допомогою CDK – купа IAM-ролей, якісь Lambda-функції і таке інше – коли воно зламається, то доведеться дуже довго шукати де і що саме “пішло не так”
  • питати Google на тему “AWS CDK Python create something” – майже марна справа, бо результатів або не буде взагалі, але будуть на TypeScript

Хоча пост планувався в стилі “як зробити”, але в результаті вийшло “Як вистрілити собі в ногу, запроваджуючи на проекті AWS CDK”.

AWS CDK vs Terraform

Знову-таки, хоча сам пост не про це, але кілька слів після роботи з CDK та його порівняння з Terraform.

Time To Create: AWS CDK vs Terraform

Перше, що хочеться прям на початку показати – це швидкість роботи AWS CDK vs Terraform:

Тест, звісно, достаточно штучний, але дуже гарно показав різницю в роботі.

Я спецільно не створював NAT Gateways, бо їхнє створення займає більше хвилини просто на запуск самих NAT-інстансів, тоді як на створення VPC/Subnets/etc час не витрачається, тож бачимо саме швидкість роботи CDK/CloudFormation versus Terraform.

Пізніше ще заміряв створення VPC+EKS з CDK та Terraform:

CDK

  • create: 18m54.643s
  • destroy: 26m4.509s

Terraform:

  • create: 12m56.801s
  • destroy: 5m32.329s

AWS CDK workflow

Та й в цілому процес роботи CDK виглядає занадто ускладненим:

  • пишемо код на Python
  • який траснлюється до бекенду CDK на NodeJS
  • генерує CloudFormation Template та ChangeSets
  • CDK для своєї роботи створює пачку Lambda-функцій
  • і тільки потім створюються ресурси

Плюс в самому CloudFormation стеку для EKS створюється ціла купа AIM ролей та Lambda-функцій з неясним та неявним призначенням.

AWS CDK та нові “фічі” AWS

Ще з насправді досить очікуваного – CDK не має всіх нових “плюшок” AWS. Я с цим зіткнувся ще кілька років тому, коли потрібно було у CloudFormation створити cross-region VPC Peering, а CloudFormation це не підтримував, хоча у Terraform це вже було реалізовано.

Аналогічно виявилось і зараз: остання версія CDK (2.84.0) не має підтримки EKS 1.27, реліз якої відбувся майже місць тому, 24-го травня – Amazon EKS now supports Kubernetes version 1.27.  А ось Terraform її вже підтримує – AWS EKS Terraform module.

Але таке. Окей, най буде, як то кажуть.

Давайте пробувати.

Початок роботи: спитаємо ChatGPT

Щоб мати якусь точку для старту – спитав ChatGPT. В цілому, ідею від подав, хоча з застарілими імпортами та деякими атрибутами, які довелось переписувати:

Поїхали.

Python virtualevn

Створюємо Python virtualevn:

[simterm]

$ python -m venv .venv
$ ls -l .venv/
total 16
drwxr-xr-x 2 setevoy setevoy 4096 Jun 20 11:18 bin
drwxr-xr-x 3 setevoy setevoy 4096 Jun 20 11:18 include
drwxr-xr-x 3 setevoy setevoy 4096 Jun 20 11:18 lib
lrwxrwxrwx 1 setevoy setevoy    3 Jun 20 11:18 lib64 -> lib
-rw-r--r-- 1 setevoy setevoy  176 Jun 20 11:18 pyvenv.cfg

[/simterm]

Активуємо його:

[simterm]

$ . .venv/bin/activate
(.venv)

[/simterm]

Тепер можемо створювати новий application.

AWS CDK Init

Створюємо шаблон нашого стеку з Python:

[simterm]

$ cdk init app --language python
...
✅ All done!

[/simterm]

Отримуємо таку структуру файлів та каталогів:

[simterm]

$ tree .
.
├── README.md
├── app.py
├── atlas_eks
│   ├── __init__.py
│   └── atlas_eks_stack.py
├── cdk.json
├── requirements-dev.txt
├── requirements.txt
├── source.bat
└── tests
    ├── __init__.py
    └── unit
        ├── __init__.py
        └── test_atlas_eks_stack.py

4 directories, 11 files

[/simterm]

Встановлюємо залежності:

[simterm]

$ pip install -r requirements.txt
Collecting aws-cdk-lib==2.83.1
  Using cached aws_cdk_lib-2.83.1-py3-none-any.whl (41.5 MB)
...

[/simterm]

Перевіряємо, що все працює:

[simterm]

$ cdk list
AtlasEksStack

[/simterm]

Зміст app.py зараз маємо такий:

Та у atlas_eks/atlas_eks_stack.py маємо шаблон для створення стеку:

from aws_cdk import (
    # Duration,
    Stack,
    # aws_sqs as sqs,
)
from constructs import Construct

class AtlasEksStack(Stack):

    def __init__(self, scope: Construct, construct_id: str, **kwargs) -> None:
        super().__init__(scope, construct_id, **kwargs)

        # The code that defines your stack goes here

        # example resource
        # queue = sqs.Queue(
        #     self, "AtlasEksQueue",
        #     visibility_timeout=Duration.seconds(300),
        # )

Додамо змінні оточення до app.py – аккаунт та регіон, та оновлюємо виклик AtlasEksStack():

...
AWS_ACCOUNT = os.environ["AWS_ACCOUNT"]
AWS_REGION = os.environ["AWS_REGION"]

app = cdk.App()
AtlasEksStack(app, "AtlasEksStack",
        env=cdk.Environment(account=AWS_ACCOUNT, region=AWS_REGION),
    )
...

Задаємо змінні в консолі:

[simterm]

$ export AWS_ACCOUNT=492***148 
$ export AWS_REGION=us-east-1

[/simterm]

Перевіряємо ще раз з cdk list.

Створення EKS кластеру

Повертаємось до ChatGPT, що він там далі рекомендує:

Нам тут цікаві тільки імпорти (з якими він не вгадав), та сам ресурс cluster = eks.Cluster(), якому він пропонує версію 1.21, бо сам ChatGPT, як ми знаємо, має базу до 2021 року.

CDK: AttributeError: type object ‘KubernetesVersion’ has no attribute ‘V1_27’

Щодо AWS CDK та версії EKS, писав про це на початку – виглядала помилка так:

AttributeError: type object ‘KubernetesVersion’ has no attribute ‘V1_27’

Окей – давайте поки 1.26, там подивимось, як з цим жити.

Обновлюємо файл atlas_eks_stack.py, використовуємо eks.KubernetesVersion.V1_26:

from aws_cdk import (
    aws_eks as eks,
    Stack
)
from constructs import Construct

class AtlasEksStack(Stack):

    def __init__(self, scope: Construct, construct_id: str, **kwargs) -> None:
        super().__init__(scope, construct_id, **kwargs)

        cluster = eks.Cluster(
            self, 'EKS-Cluster',
            cluster_name='my-eks-cluster',
            version=eks.KubernetesVersion.V1_26,
        )

Перевіряємо з cdk synth:

[simterm]

$ cdk synth
[Warning at /AtlasEksStack/EKS-Cluster] You created a cluster with Kubernetes Version 1.26 without specifying the kubectlLayer property. This may cause failures as the kubectl version provided with aws-cdk-lib is 1.20, which is only guaranteed to be compatible with Kubernetes versions 1.19-1.21. Please provide a kubectlLayer from @aws-cdk/lambda-layer-kubectl-v26.
Resources:
  EKSClusterDefaultVpc01B29049:
    Type: AWS::EC2::VPC
    Properties:
      CidrBlock: 10.0.0.0/16
      EnableDnsHostnames: true
      EnableDnsSupport: true
      InstanceTenancy: default
      Tags:
        - Key: Name
          Value: AtlasEksStack/EKS-Cluster/DefaultVpc
    Metadata:
      aws:cdk:path: AtlasEksStack/EKS-Cluster/DefaultVpc/Resource
...

[/simterm]

CDK сам тсворить VPC та subnets і все інше для мережі, та IAM ролі. Це, в принципі, зручно, хоча там є свої питання.

Ми далі будемо створювати власну VPC.

Warning: You created a cluster with Kubernetes Version 1.26 without specifying the kubectlLayer property

На початку cdk synth каже щось про kubectlLayer:

[Warning at /AtlasEksStack/EKS-Cluster] You created a cluster with Kubernetes Version 1.26 without specifying the kubectlLayer property. This may cause failures as the kubectl version provided with aws-cdk-lib is 1.20, which is only guaranteed to be compatible with Kubernetes versions 1.19-1.21. Please provide a kubectlLayer from @aws-cdk/lambda-layer-kubectl-v26.

З імені можно припустити, що CDK створить Lambda-функцію, в якій буде викликати kubectl для виконання якихось задач в самоу Kubernetes.

В документації KubectlLayer сказано, що “An AWS Lambda layer that includes kubectl and helm.

Дуже дякую – все відразу стало зрозуміло. Де воно використовується, для чого?

Ну, ок… Давайте спробуємо позбутися цього варнінгу.

Знову спитаємо ChatGP:

Пробуємо встановити aws-lambda-layer-kubectl-v26:

[simterm]

$ pip install aws-cdk.aws-lambda-layer-kubectl-v26
ERROR: Could not find a version that satisfies the requirement aws-cdk.aws-lambda-layer-kubectl-v26 (from versions: none)
ERROR: No matching distribution found for aws-cdk.aws-lambda-layer-kubectl-v26

[/simterm]

Da f*****ck!

Ну, добре… Пам’ятаємо, що ChatGP “старенький” – може, ліба якось інакше називається?

PyPI no longer supports ‘pip search’

Пробуємо pip search – спочатку перевіримо, що search в PiP взагалі є, бо давно ним не користувався:

[simterm]

$ pip search --help

Usage:   
  pip search [options] <query>

Description:
  Search for PyPI packages whose name or summary contains <query>.

Search Options:
  -i, --index <url>           Base URL of Python Package Index (default https://pypi.org/pypi)
...

[/simterm]

Окей – шукаємо:

[simterm]

$ pip search aws-lambda-layer-kubectl
ERROR: XMLRPC request failed [code: -32500]
RuntimeError: PyPI no longer supports 'pip search' (or XML-RPC search). Please use https://pypi.org/search (via a browser) instead. See https://warehouse.pypa.io/api-reference/xml-rpc.html#deprecated-methods for more information.

[/simterm]

WHAAAAT?!?

Тобто, просто з консолі з PiP знайти пакет неможливо? Це як так? Трохи “розрив шаблону”.

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

Змінні в CDK Stack

Що тепер хочеться, це додати змінну для Environment – Dev/Stage/Prod, і потім використовати її в іменах ресурсів та тегах.

Додамо до app.py змінну $EKS_STAGE, а до створення AtlasEksStack() – передаємо її другим агрументом, щоб використати як ім’я стеку, і додаємо параметр stage, що потім використовувати всередені класу:

...
EKS_STAGE = os.environ["EKS_ENV"]


app = cdk.App()
AtlasEksStack(app, "f'eks-{EKS_STAGE}-1-26'",
        env=cdk.Environment(account=AWS_ACCOUNT, region=AWS_REGION),
        stage=EKS_STAGE
    )
...

Далі у файлі atlas_eks_stack.py описуємо параметр stage: str, і використовуємо його при створенні eks.Cluster() у параметрі cluster_name:

...
    def __init__(self, scope: Construct, construct_id: str, stage: str, **kwargs)
    ...
        cluster = eks.Cluster(
            self, 'EKS-Cluster',
            cluster_name=f'eks-{stage}-1-26-cluster',
            version=eks.KubernetesVersion.V1_26,
        )

Задаємо змінну оточення в терміналі:

[simterm]

$ export EKS_ENV=dev

[/simterm]

З cdk list перевіримо, що ім’я стеку змінилось і має $EKS_ENV:

[simterm]

$ cdk list
eks-dev-1-26

[/simterm]

Та з sdk synth перевіримо, що ім’я кластеру теж змінилось:

[simterm]

$ cdk synth
...
    Type: Custom::AWSCDK-EKS-Cluster
    Properties:
      ServiceToken:
        Fn::GetAtt:
          - awscdkawseksClusterResourceProviderNestedStackawscdkawseksClusterResourceProviderNestedStackResource9827C454
          - Outputs.AtlasEksStackawscdkawseksClusterResourceProviderframeworkonEvent588F9666Arn
      Config:
        name: eks-dev-1-26-cluster
...

[/simterm]

Добре – кластер є, тепер створимо для нього VPC.

Створення VPC та Subnets

Кастомну VPC хочеться, бо по-дефолту CDK створить по Subnet-у у кожній AvailabilityZone, тобто три мережі, плюс до кожної буде свій NAT Gateway. Але по-перше – мені більше подобається самому контролювати розбивку мережі, по-друге – кожен NAT Gateway коштує грошей, а нам поки що fault-tolerance аж на три AvailabilityZone не потрібен, краще зекономити трохи грошей.

Документація по CDK VPC – aws_cdk.aws_ec2.Vpc.

Типи Subnet тут – SubnetType.

Тут як на мене ще один не найкращий нюанс CDK: так, це зручно, що він має багато викорорівневих ресурсів, коли тобі достатньо просто вказати subnet_type=ec2.SubnetType.PUBLIC, а CDK сам створить все необхідне, але особисто мені декларативний підхід Terraform та його HCL виглядає привабливішим, бо навіть якщо використовувати модуль VPC, а не описувати все вручну – набагато простіше зайти в код того модулю і подивитись, що він має “під капотом”, ніж копатись у бібліотеці CDK. Але це чисто особисте “Я так бачу“.

Крім того, в документації не сказано, що PRIVATE_WITH_NAT вже deprecated, побачив це тільки коли перевіряв створення ресурсів:

[simterm]

$ cdk synth
[WARNING] aws-cdk-lib.aws_ec2.VpcProps#cidr is deprecated.
  Use ipAddresses instead
  This API will be removed in the next major release.
[WARNING] aws-cdk-lib.aws_ec2.SubnetType#PRIVATE_WITH_NAT is deprecated.
  use `PRIVATE_WITH_EGRESS`
  This API will be removed in the next major release.
[WARNING] aws-cdk-lib.aws_ec2.SubnetType#PRIVATE_WITH_NAT is deprecated.
  use `PRIVATE_WITH_EGRESS`
  This API will be removed in the next major release.
...

[/simterm]

Окей.

Додаємо availability_zones, в яких хочемо створювати subnets, і описуємо subnet_configuration.

В subnet_configuration описуємо subnet group – одну Public, та одну Private – CDK створить subnet кожного типу в кожній Availability Zone.

На майбутнє – відразу створимо S3 Endpoint, бо в кластері планується Grafana Loki, яка буде ходити в S3 бакети.

До ресурсу eks.Cluster() додаємо параметр vpc.

Весь файл тепер виглядає так:

from aws_cdk import (
    aws_eks as eks,
    aws_ec2 as ec2,
    Stack
)
from constructs import Construct

class AtlasEksStack(Stack):

    def __init__(self, scope: Construct, construct_id: str, stage: str, **kwargs) -> None:
        super().__init__(scope, construct_id, **kwargs)

        availability_zones = ['us-east-1a', 'us-east-1b']

        # Create a new VPC
        vpc = ec2.Vpc(self, 'Vpc',
            ip_addresses=ec2.IpAddresses.cidr("10.0.0.0/16"),
            vpc_name=f'eks-{stage}-1-26-vpc',
            enable_dns_hostnames=True,
            enable_dns_support=True,
            availability_zones=availability_zones,
            subnet_configuration=[
                ec2.SubnetConfiguration(
                    name=f'eks-{stage}-1-26-subnet-public',
                    subnet_type=ec2.SubnetType.PUBLIC,
                    cidr_mask=24
                ),
                ec2.SubnetConfiguration(
                    name=f'eks-{stage}-1-26-subnet-private',
                    subnet_type=ec2.SubnetType.PRIVATE_WITH_EGRESS,
                    cidr_mask=24
                )
            ]
        )

        # Add an S3 VPC endpoint
        vpc.add_gateway_endpoint('S3Endpoint',
                                 service=ec2.GatewayVpcEndpointAwsService.S3)

        cluster = eks.Cluster(
            self, 'EKS-Cluster',
            cluster_name=f'eks-{stage}-1-26-cluster',
            version=eks.KubernetesVersion.V1_26,
            vpc=vpc
        )

Деплоємо, перевіряємо:

[simterm]

$ cdk deploy eks-dev-1-26
...
eks-dev-1-26: deploying... [1/1]
eks-dev-1-26: creating CloudFormation changeset...
...
✨  Total time: 1243.08s

[/simterm]

1243.08s секунд – 20 хвилин. Окей…

Додавання Stack Tags

Що ще хочеться – це додати власні теги до всіх ресурсів, які буде створювати CDK в цьому стеку.

В app.py використовуємо cdk.Tags, якому передаємо об’єкт AtlasEksStack():

...
app = cdk.App()

eks_stack = AtlasEksStack(app, f'eks-{EKS_STAGE}-1-26',
        env=cdk.Environment(account=AWS_ACCOUNT, region=AWS_REGION),
        stage=EKS_STAGE
    )
cdk.Tags.of(eks_stack).add("environment", EKS_STAGE)
cdk.Tags.of(eks_stack).add("component", "EKS")

app.synth()

Деплоїмо (Total time: 182.67s просто на додавання тегів на ресурси), та перевіряємо теги:

Все є.

Створення NodeGroup

Взагалі скоріш за все будемо використовувати Karpenter замість “класичного” Cluster Autoscaler, бо про Karpenter чув багато гарних відгуків і хочеться його спробувати у ділі, і тоді ноди треба буде переробити, але поки що створимо звичайну Managed NodeGroup за допомогою add_nodegroup_capacity().

До файлу atlas_eks_stack.py додаємо cluster.add_nodegroup_capacity() з Amazon Linux AMI :

...
        # Create the EC2 node group
        nodegroup = cluster.add_nodegroup_capacity(
            'Nodegroup',
            instance_types=[ec2.InstanceType('t3.medium')],
            desired_size=1,
            min_size=1,
            max_size=3,
            ami_type=eks.NodegroupAmiType.AL2_X86_64
        )

Необхідні IAM-ролі CDK має створити сам – подивимось.

У ресурсі eks.Cluster() вказуємо default_capacity=0, щоб СDK не створював власну дефолтну групу:

...
        cluster = eks.Cluster(
            self, 'EKS-Cluster',
            cluster_name=f'eks-{stage}-1-26-cluster',
            version=eks.KubernetesVersion.V1_26,
            vpc=vpc,
            default_capacity=0
        )
...

Error: b’configmap/aws-auth configured\nerror: error retrieving RESTMappings to prune: invalid resource batch/v1beta1, Kind=CronJob, Namespaced=true: no matches for kind “CronJob” in version “batch/v1beta1″\n’

Зараз стек вже задеплоєно, запускаємо cdk deploy, щоб оновити – і…

[simterm]

eks-dev-1-26: creating CloudFormation changeset...
1:26:35 PM | UPDATE_FAILED        | Custom::AWSCDK-EKS-KubernetesResource | EKSClusterAwsAuthmanifest5D430CCD
Received response status [FAILED] from custom resource. Message returned: Error: b'configmap/aws-auth configured\nerror: error retrieving RESTMappings to prune: invalid resource batch/v1beta1, Kind=CronJob, Namespaced=true: no matches for kind "CronJob" in version "bat
ch/v1beta1"\n'

[/simterm]

Шта? Якого біса?

aws-auth ConfigMap, Kind=CronJob? Звідки це?

Тобто, мабуть, CDK намагається оновити aws-auth ConfigMap, щоб додати NodeGroup AIM роль, але… Але – що?

Судячи з Гуглу, проблема як раз пов’язана з kubectlLayer, про яку писав вище – aws-eks: cdk should validate cluster version and kubectl layer version.

При чьому проявляється це тільки під час оновлення стеку. Якщо створювати його заново – то все працює. Але тут варто згадати про швидкість роботи CDK/CloudFormation, бо видалення і створення займає хвилин 30-40.

KubectlV26Layer

Ну, все ж довелося фіксити цю проблему.

Добре… Шукаємо просто в браузері – aws-cdk.lambda-layer-kubectl-v26. Є така ліба. Але навіть у PyPi репозиторії приклади на TypeScript – щиро дякую:

Це взагалі проблема при роботі з AWS CDK на Python – дуже багато прикладів все одно на TS.

Окей, ладно – лібу знайшли, вона називається aws-cdk.lambda-layer-kubectl-v26, встановлюємо:

[simterm]

$ pip install aws-cdk.lambda-layer-kubectl-v26

[/simterm]

Додаємо до requirements.txt:

[simterm]

$ pip freeze | grep -i lambda-layer-kubectl >> requirements.txt

[/simterm]

Додаємо у файл atlas_eks_stack.py:

...
from aws_cdk.lambda_layer_kubectl_v26 import KubectlV26Layer
...

        # to fix warning "You created a cluster with Kubernetes Version 1.26 without specifying the kubectlLayer property" 
        kubectl_layer = KubectlV26Layer(self, 'KubectlV26Layer')
        ...
        cluster = eks.Cluster(
            self, 'EKS-Cluster',
            cluster_name=f'eks-{stage}-1-26-cluster',
            version=eks.KubernetesVersion.V1_26,
            vpc=vpc,
            default_capacity=0,
            kubectl_layer=kubectl_layer
        )
...

Повторюємо деплой для апдейту вже існуючого стеку, і…

CloudFormation UPDATE_ROLLBACK_FAILED

І маємо іншу помилку, бо після “Error: b’configmap/aws-auth configured\nerror” стек лишився у статусі UPDATE_ROLLBACK_FAILED:

[simterm]

...
eks-dev-1-26: deploying... [1/1]
eks-dev-1-26: creating CloudFormation changeset...

 ❌  eks-dev-1-26 failed: Error [ValidationError]: Stack:arn:aws:cloudformation:us-east-1:492***148:stack/eks-dev-1-26/9c7daa50-10f4-11ee-b64a-0a9b7e76090b is in UPDATE_ROLLBACK_FAILED state and can not be updated.
...

[/simterm]

Тут варіант або просто видалити стек і створити заново (вбити ще хвилин 30-40 свого часу), або погуглилити та знайти How can I get my CloudFormation stack to update if it’s stuck in the UPDATE_ROLLBACK_FAILED state.

Спробуємо ContinueUpdateRollback:

Але нє – все-одно стек поломаний:

Тож видаляємо, і йдемо на Фейсбук дивитись котиків, поки воно буде перестворюватись.

Cannot replace cluster “since it has an explicit physical name

На цьому місці ще ловив “Cannot replace cluster “eks-dev-1-26-cluster” since it has an explicit physical name.“, виглядало це так:

[simterm]

...
2:30:45 PM | UPDATE_FAILED        | Custom::AWSCDK-EKS-Cluster            | EKSCluster676AE7D7
Received response status [FAILED] from custom resource. Message returned: Cannot replace cluster "eks-dev-1-26-cluster" since it has an explicit physical name. Either rename the cluster or remove the "name" configuration
...

[/simterm]

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

Добре, отже тепер вже маємо VPC, EKS Cluster та NodeGroup – час подумати про IAM.

IAM Role та aws-auth ConfigMap

Що треба зробити наступним – це створити IAM-роль, яку можна буде assume для отримання доступу до кластеру.

Поки що без всяких RBAC та юзер-груп – просто роль, щоб потім виконати aws eks update-kubeconfig.

Використовуємо aws_cdk.aws_iam.Role() і  aws_cdk.aws_eks.AwsAuth():

from aws_cdk import (
    ...
    aws_iam as iam,
    ...
)
...
        # Create an IAM Role to be assumed by admins
        masters_role = iam.Role(
            self,
            'EksMastersRole',
            assumed_by=iam.AccountRootPrincipal()
        )

        # Attach an IAM Policy to that Role so users can access the Cluster
        masters_role_policy = iam.PolicyStatement(
            actions=['eks:DescribeCluster'],
            resources=['*'],  # Adjust the resource ARN if needed
        )
        masters_role.add_to_policy(masters_role_policy)

        cluster.aws_auth.add_masters_role(masters_role)

        # Add the user to the cluster's admins
        admin_user = iam.User.from_user_arn(self, "AdminUser", user_arn="arn:aws:iam::492***148:user/arseny")
        cluster.aws_auth.add_user_mapping(admin_user, groups=["system:masters"])

masters_role – роль, яку можна буде assume будь-ким з AWS-аккаунту, а admin_user – мій IAM юзер для “прямого” доступу до кластеру.

CfnOutput

Outputs CloudFormation-стеку. Наскільки пам’ятаю, може використовуватись для cross-stack передачі values, але нам більше треба для отримання ARN-у masters_role:

from aws_cdk import (
    ...
    Stack, CfnOutput
)
...
        # Output the EKS cluster name
        CfnOutput(
            self,
            'ClusterNameOutput',
            value=cluster.cluster_name,
        )

        # Output the EKS master role ARN
        CfnOutput(
            self,
            'ClusterMasterRoleOutput',
            value=masters_role.role_arn
        )

Деплоїмо:

[simterm]

...
Outputs:
eks-dev-1-26.ClusterMasterRoleOutput = arn:aws:iam::492***148:role/eks-dev-1-26-EksMastersRoleD1AE213C-1ANPWK8HZM1W5
eks-dev-1-26.ClusterNameOutput = eks-dev-1-26-cluster

[/simterm]

Налаштування kubectl з AWS CLI

Ну й врешті-решт, після всіх страждань – спробуємо отримати доступ до кластеру.

Спочатку через master_role – оновлюємо ~/.aws/config:

[profile work]
region = us-east-1
output = json

[profile work-eks]
role_arn = arn:aws:iam::492***148:role/eks-dev-1-26-EksMastersRoleD1AE213C-1ANPWK8HZM1W5
source_profile = work

У [profile work-eks] виконуємо IAM Role Assume – використовуємо нашу master_role, використовуючи ACCESS/SECRET ключи профайлу [work].

Створюємо kube-config:

[simterm]

$ aws --profile work-eks eks update-kubeconfig --region us-east-1 --name eks-dev-1-26-cluster --alias eks-dev-1-26
Added new context eks-dev-1-26 to /home/setevoy/.kube/config

[/simterm]

І перевіряємо доступ:

[simterm]

$ kubectl get node
NAME                        STATUS   ROLES    AGE   VERSION
ip-10-0-2-60.ec2.internal   Ready    <none>   19h   v1.26.4-eks-0a21954

[/simterm]

Аналогічно, якщо використовувати персональний AIM-аккаунт, тобто user_arn="arn:aws:iam::492***148:user/arseny":

[simterm]

$ aws --profile work eks update-kubeconfig --region us-east-1 --name eks-dev-1-26-cluster --alias eks-dev-1-26-personal

[/simterm]

Вона працює” (с)

По результату можу сказати одне – особисто я не готовий брати відповідальність за роботу такого стеку у production.

Можливо, якщо з CDK попрацювати ще, і знати основні його підводні камені, та згадати всі “особливості” CloudFormation – то з ними можна жити. Але поки що – ні, взагалі не хочеться.

Loading

VictoriaMetrics: знайомство та використання замість Prometheus
0 (0)

8 Червня 2023

Давно і багато чув про VictoriaMetrics, і нарешті настав час, коли її можна спробувати.

Отже, в двох словах – VictoriaMetrics це “Prometheus на стероідах”, і повністю з ним сумісна – може використовувати його файли конфігурації, експортери, PromQL тощо.

Тож як для людини, яка завжди користувалась Prometheus, перше питання – в чьому різниця? Єдине, що пам’ятаю, це те, що VictoriaMetrics начебто вміє в anomaly detection, чого не вистачало в Prometheus – давно хотілось додати.

В Google порівнянь не так багато, але знайшлись такі:

З цікавого:

  • підримує Pull та Push  моделі (на відміну від Prometheus, якому для push потрібен Pushgateway)
  • можна налаштувати Prometheus з remote write у VictoriaMetrics, тобто з Prometheus писати дані у VictoriaMetrics
  • VictoriaMetrics має концепцію “неймспейсів” – можна мати ізольовані середовища для метрик, див. Multitenancy
  • має власний MetricsQL з ширшими ніж у PromQL можливостями
  • для знайомства є VictoriaMetrics playground
  • для AWS є Managed VictoriaMetrics

Отже, сьогодні глянемо на архітектуру і компоненти, запустимо VictoriaMetrics з Docker Compose, налаштуємо збір метрик с Prometheus exporters, глянемо як там з алертами, і підключимо Grafana.

Prometheus з Alertmanager, пачка експортерів та Grafana вже є, наразі запущені просто через Docker Compose на AWS EC2, туди ж додамо інстанс VictoriaMetrics. Тобто основна ідея – замінити Prometheus на VictoriaMetrics.

З того, що побачив поки запускав VictoriaMetrics – виглядає прям дуже цікаво. Більше можливостей по функціям, по шаблонам алертів, сам UI дає більше можливостей для роботи з метриками. Спробуємо його використати замість Prometehus в нашому проекті, подивимось, як воно буде. Правда, якихось прикладів в тому ж Гуглі небагато, проте ChatGPT може допомогти.

Архітектура VictoriaMetrics

VictoriaMetrics має cluster version та single-node version. Для невеликих проектів до мільйона метрик в секунду рекомендується використовувати single node, але у кластер-версії гарно описана загальна архітектура.

Основні сервіси та компоненти VictoriaMetrics:

  • vmstorage: відповідає за зберігання даних та відповіді на запит даних клієнтами (vmselect)
  • vmselect: відповідає за обробку вхідних запитів на вибірку даних та збор даних з нод vmstorage
  • vminsert: відповідає за прийом метрик та розподіл даних по нодам vmstorage у відповідності до імен та лейбл цих метрик
  • vmui: Web UI для доступу к даним і параметрам конфігурацї
  • vmalert: обробляє алерти з файлу конфігурації та відправляє їх до Alertmanager
  • vmagent: займається збором метрик з різних джерел, таких як експортери Prometheus, їхнє фільтрування та релейбл, і зберігання у сховищі даних (самій VictoriaMetrics або через remote_write протокол Prometheus)
  • vmanomaly:  VictoriaMetrics Anomaly Detection – сервіс, який постійно сканує дані у VictoriaMetrics і за допомогою механізмів machine learning виявляє несподівані зміни, які можна використовувати у алертах
  • vmauth: простий auth proxy, роутер та лоад-балансер для VictoriaMetrics.

Запуск VictoriaMetrics з Docker Compose

Отже, як ми можемо використати VictoriaMetrics у випадку, якщо вже є Prometheus та його експортери?

  1. можемо налаштувати Prometheus слати метрики у VictoriaMetrics, див. Prometheus setup (майте на увазі, що remote_write на Prometheus-інстансі збільшить споживання ресурсів ЦПУ та диску на 25%) – не бачу сенсу в нашому випадку, але можливо буде корисним у разі використання якогось KubePrometehusStack
  2. можемо налаштувати VictoriaMetrics на збір даних з експортерів Prometheus, див. How to scrape Prometheus exporters such as node-exporter, тобто як раз зробити те, що хочеться зараз – замінити Prometheus на VictoriaMetrics з мінімальними змінами у конфігурації Prometheus

Приклад Docker Compose файлу – docker-compose.yml.

Що ми в ньому маємо:

  • vmagent: збирає метрики з експортерів,див опції у Advanced usage
  • victoriametrics: зберігає дані, див опції у List of command-line flags
  • vmalert: працює з алертами, див опції у Flags
  • alertmanager: старий знайомий) приймає алерти від vmalert

Давайте почнемо з контейнерів vmagent та victoriametrics, алерти підключимо пізніше.

Тут приклад з усіма сервісами окрім експортерів Prometheus:

version: '3.8'

volumes:
  prometheus_data: {}
  grafana_data: {}
  vmagentdata: {}
  vmdata: {}

services:
  prometheus:
    image: prom/prometheus
    restart: always
    volumes:
      - ./prometheus:/etc/prometheus/
      - prometheus_data:/prometheus
    command:
      - '--config.file=/etc/prometheus/prometheus.yml'
      - '--storage.tsdb.path=/prometheus'
      - '--web.console.libraries=/usr/share/prometheus/console_libraries'
      - '--web.console.templates=/usr/share/prometheus/consoles'
      - '--web.external-url=http://100.***.****.197:9090/'
    ports:
      - 9090:9090
      - alertmanager:alertmanager

  alertmanager:
    image: prom/alertmanager
    restart: always
    ports:
      - 9093:9093
    volumes:
      - ./alertmanager/:/etc/alertmanager/
    command:
      - '--config.file=/etc/alertmanager/config.yml'
      - '--storage.path=/alertmanager'

  grafana:
    image: grafana/grafana
    user: '472'
    restart: always
    environment:
      GF_INSTALL_PLUGINS: 'grafana-clock-panel,grafana-simple-json-datasource'
    volumes:
      - grafana_data:/var/lib/grafana
      - ./grafana/provisioning/:/etc/grafana/provisioning/
    env_file:
      - ./grafana/config.monitoring
    ports:
      - 3000:3000
    depends_on:
      - prometheus

  loki:
    image: grafana/loki:latest
    ports:
      - "3100:3100"
    command: -config.file=/etc/loki/local-config.yaml

  victoriametrics:
    container_name: victoriametrics
    image: victoriametrics/victoria-metrics:v1.91.2
    ports:
      - 8428:8428
    volumes:
      - vmdata:/storage
    command:
      - "--storageDataPath=/storage"
      - "--httpListenAddr=:8428"
#      - "--vmalert.proxyURL=http://vmalert:8880"

  vmagent:
    container_name: vmagent
    image: victoriametrics/vmagent:v1.91.2
    depends_on:
      - "victoriametrics"
    ports:
      - 8429:8429
    volumes:
      - vmagentdata:/vmagentdata
      - ./prometheus:/etc/prometheus/
    command:
      - "--promscrape.config=/etc/prometheus/prometheus.yml"
      - "--remoteWrite.url=http://victoriametrics:8428/api/v1/write"
...

Для victoriametrics поки що закоментуємо --vmalert.proxyURL, додамо його згодом.

До vmagent підключаємо каталог ./prometheus – в ньому маємо файл prometheus.yaml з конфігурацією srape_jobs, та файли параметрів експортерів (наприклад – ./prometheus/blackbox.yml та /prometheus/blackbox-targets/targets.yaml для Blackbox Exporter).

У --remoteWrite.url вказуємо, куди будемо писати отримані метрики – до інстансу VictoriaMetrics.

Запускаємо:

[simterm]

# docker compose up

[/simterm]

Якщо перейти без URI, тобто просто на domain.com/ – то видасть всі доступні шляхи, дуже прям зручно:

field evaluation_interval not found in type promscrape.GlobalConfig

Але vmagent не запустився:

[simterm]

2023-06-05T09:38:31.376Z        fatal   VictoriaMetrics/lib/promscrape/scraper.go:117   cannot read "/etc/prometheus/prometheus.yml": cannot parse Prometheus config from "/etc/prometheus/prometheus.yml": cannot unmarshal data: yaml: unmarshal errors:
  line 4: field evaluation_interval not found in type promscrape.GlobalConfig
  line 13: field rule_files not found in type promscrape.Config
  line 19: field alerting not found in type promscrape.Config; pass -promscrape.config.strictParse=false command-line flag for ignoring unknown fields in yaml config

[/simterm]

Окей, відключимо strictParse – додаємо --promscrape.config.strictParse=false:

...
    command:
      - "--promscrape.config=/etc/prometheus/prometheus.yml"
      - "--remoteWrite.url=http://victoriametrics:8428/api/v1/write"
      - "--promscrape.config.strictParse=false"

Перезапускаємо сервіси, та заглянемо на порт 8429, vmagent – теж є лінки :

Перевіряємо таргети – вони є, тобто vmagent зчитав файл prometheus.yaml, але не всі працюють, наприклад – Sentry експортер є, YACE є, а от blackbox, node_exporter та cAdvisor не бачить:

А чому?

Ага… Не бачить тих, у кого sd_configs, тобто динамічний сервіс-діскавері:

...  - job_name: 'cadvisor'

    # Override the global default and scrape targets from this job every 5 seconds.
    scrape_interval: 5s

    dns_sd_configs:
    - names:
      - 'tasks.cadvisor'
      type: 'A'
      port: 8080
...

Хоча наче має вміти – Supported service discovery configs.

Глянемо логи vmagent.

error in A lookup for “tasks.cadvisor”: lookup tasks.cadvisor on 127.0.0.11:53: no such host

А логи кажуть, що контейнер з vmagent не може отримати A-запис з DNS:

[simterm]

...
vmagent                                 | 2023-06-05T10:04:10.818Z      error   VictoriaMetrics/lib/promscrape/discovery/dns/dns.go:163 error in A lookup for "tasks.cadvisor": lookup tasks.cadvisor on 127.0.0.11:53: no such host
vmagent                                 | 2023-06-05T10:04:10.821Z      error   VictoriaMetrics/lib/promscrape/discovery/dns/dns.go:163 error in A lookup for "tasks.node-exporter": lookup tasks.node-exporter on 127.0.0.11:53: no such host
...

[/simterm]

Читаємо документацію по dns_sd_configs, де говориться про “# names must contain a list of DNS names to query“, але в мене зараз job описана з names = tasks.container_name, див. Container discovery.

Спробуємо вказати просто ім’я, тобто cadvisor замість tasks.cadvisor:

...
  - job_name: 'cadvisor'
    dns_sd_configs: 
    - names:
#      - 'tasks.cadvisor'
      - 'cadvisor'
      type: 'A'
      port: 8080
...

А job_name: 'prometheus' просто вимикаємо – вона нам не потрібна.

І тепер всі таргети з’явились:

VictoriaMetrics та Grafana

Тепер давайте спробуємо використати VictoriaMetrics як data source у Grafana.

В принципі, тут все робиться однаково з Prometheus, використовуючи той же тип data source.

Додаємо новий data source з типом Prometheus, в URL вказуємо http://victoriametrics:8428:

Обновлюємо графік – вибираємо щойно доданий data source:

 

І тепер можемо використовувати функцію sort_by_label_numeric, якої не вистачало у пості Prometheus: GitHub Exporter – пишемо власний експортер для GitHub API.

З Prometheus ця панель виглядає так:

А з VictoriaMetrics та sort_by_label_numeric – так:

Добре, наче все працює.

Можемо пробувати роботу з алертами.

VictoriaMetrics та Alertmanager

Отже, зара маємо запущений Alertmanager та Prometheus.

У prometheus.yaml маємо вказаний файл з алертами:

...
rule_files:
  - 'alert.rules'
...

Що нам треба – це запустити vmalert, якому вкажемо “бекенд” у вигляді Alertmanager, якому він буде слати алерти, та сам файл з алертами у форматі Prometheus.

Як і сама VictoriaMetrics, vmalert має дещо ширші можливості, ніж Prometheus, наприклад – зберігає статус алертів, тож рестарт контейнеру не сбиває silenced алерти. Ще є зручна змінна $for для шаблонів, в якій передається значення for з алерту, і можемо мати щось таке:

...
    for: 5m
    annotations:
      description: |-
        {{ if $value }} *Current latency*: `{{ $value | humanize }}` milliseconds {{ end }} during `{{ $for }}` minutes
...

Також є підримка httpAuth, є можливість виконати запит алерту з query та багато іншого, див. Template functions.

Додаємо vmalert в docker-compose.yaml:

...
  vmalert: 
    container_name: vmalert
    image: victoriametrics/vmalert:v1.91.2
    depends_on:
      - "victoriametrics"
      - "alertmanager"
    ports:
      - 8880:8880
    volumes:
      - ./prometheus/alert.rules:/etc/alerts/alerts.yml
    command:
      - "--datasource.url=http://victoriametrics:8428/"
      - "--remoteRead.url=http://victoriametrics:8428/"
      - "--remoteWrite.url=http://victoriametrics:8428/"
      - "--notifier.url=http://alertmanager:9093/"
      - "--rule=/etc/alerts/*.yml"

Тут у datasource.url вказуємо, звідки брати метрики для перевірки у алертах, remoteRead.url та remoteWrite.url – де зберігати стан алертів.

У notifier.url – куди будемо слати алерти (а вже Alertmanager через свій конфіг відправить їх у Slack/Opsgenie/etc). І у rule вказуємо сам файл з алертами, який підключаємо у volumes.

Перезапускаємо контейнери з docker compose restart, и заходимо на порт 8880:

Окей, є алерт-рули.

Спробуємо тригернути тестовий алерт – і маємо новий алерт у vmalert Alerts:

Та повідомлення в Slack від Alertmanager:

Все працює.

Тепер можна відключати контейнер з Prometheus, тільки оновити depends_on у Grafana – замість prometheus вказати victoriametrics, і замінити data sources у дашбордах.

Bonus: Alertmanager Slack template

І приклад шаблону для нотіфікацій в Slack. Він ще буде перероблюватись, поки що вся система більше в стані proof of concept, але в цілому буде якось так.

Файл alertmanager/notifications.tmpl з шаблоном:

{{/* Title of the Slack alert */}}
{{ define "slack.title" -}}
  {{ if eq .Status "firing" }} :scream: {{- else -}} :relaxed: {{- end -}}
  [{{ .Status | toUpper -}} {{- if eq .Status "firing" -}}:{{ .Alerts.Firing | len }} {{- end }}] {{ (index .Alerts 0).Annotations.summary }}
{{ end }}    

{{ define "slack.text" -}}

    {{ range .Alerts }}
        {{- if .Annotations.description -}}
        *Description*: {{ .Annotations.description }}
        {{- end }}
    {{- end }}

{{- end }}

Його використання в alertmanager/config.yml:

receivers:

- name: 'slack-default'
 
  slack_configs:
    - title: '{{ template "slack.title" . }}'
      text: '{{ template "slack.text" . }}'
      send_resolved: true
      actions:
        - type: button
          text: 'Grafana :grafana:'
          url: '{{ (index .Alerts 0).Annotations.grafana_url }}'
        - type: button
          text: 'Prometheus query :mag:'
          url: '{{ (index .Alerts 0).GeneratorURL }}'
        - type: button
          text: 'AWS dashboard :aws:'
          url: '{{ (index .Alerts 0).Annotations.aws_dashboard_url }}'

Темплейт для алерту – підключається в контейнер vmalerts, див. Reusable templates:

{{ define "grafana.filter" -}}
  {{- $labels := .arg0 -}}
  {{- range $name, $label := . -}}
    {{- if (ne $name "arg0") -}}
      {{- ( or (index $labels $label) "All" ) | printf "&var-%s=%s" $label -}}
    {{- end -}}
  {{- end -}}
{{- end -}}

І сам алерт:

- record: aws:apigateway_integration_latency_average_sum
  expr: sum(aws_apigateway_integration_latency_average) by (dimension_ApiName, tag_environment)

- alert: APIGatewayLatencyBackendProdTEST2
  expr: aws:apigateway_integration_latency_average_sum{tag_environment="prod"} > 100
  for: 1s
  labels:
    severity: info
    component: backend
    environment: test
  annotations:
    summary: "API Gateway latency too high"
    description: |-
      The time between when API Gateway relays a request to the backend and when it receives a response from the backend
      *Environment*: `{{ $labels.tag_environment }}`
      *API Gateway name*: `{{ $labels.dimension_ApiName }}`
      {{ if $value }} *Current latency*: `{{ $value | humanize }}` milliseconds {{ end }}
    grafana_url: '{{ $externalURL }}/d/overview/overview?orgId=1{{ template "grafana.filter" (args .Labels "environment" "component") }}'

А $externalURL отримується vmalerts з параметру --external.url=http://100.***.***.197:3000".

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

Loading

Prometheus: GitHub Exporter – пишемо власний експортер для GitHub API
0 (0)

1 Червня 2023

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

Потрібно це тому, что ми намагаємось побудувати “true continuous deployment”, щоб код автоматично потрапляв у Production, і нам важливо бачити як саме проходить процес розробки.

Загалом для оцінки ефективності процессу розробки ми придумали 5 метрик:

  • Deployment Frequency: як часто виконуються деплої
  • Lead Time for Changes: скільки часу займає доставка фічі до Production, тобто час між її першим коммітом в репозиторій до моменту, коли вона потрапляє в Production
  • PR Lead Time: час, котрий фіча “вісить” у статусі Pull Request
  • Change Failure Rate: процент деплоїв, які викликали проблеми у Production
  • Time to Restore Service: час на відновлення системи у випадку її краху

Див. MKPISMeasuring the development process for Gitflow managed projects та The 2019 Accelerate State of DevOps: Elite performance, productivity, and scaling.

Почати вирішили з метрики для PR Lead Time – будемо міряти час від створення Pull Request до його мержу в master-гілку, і виводити його на Grafana-дашборді.

Що зробимо: напишемо власний GitHub Exporter, який буде ходити до GitHub API, збирати дані, та створювати Prometheus-метрику, яку потім використаємо у Grafana. Див. Prometheus: створення Custom Prometheus Exporter на Python.

Тобто у нас будуть:

  • Grafana/Prometheus стек
  • Python
  • бібліотека PyGithub для роботи з GitHub API
  • prometheus-client для створення власних метрик

GitHub API та PyGithub

Почнемо з GitHub API. Документація – Getting started with the REST API.

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

Нам знадобиться token – див. Authenticating to the REST API та Creating a personal access token.

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

[simterm]

$ curl -X GET -H "Authorization: token ghp_ys9***ilr" 'https://api.github.com/user'
{
  "login": "arseny***",
  "id": 132904972,
  "node_id": "U_kgDOB-v4DA",
  "avatar_url": "https://avatars.githubusercontent.com/u/132904972?v=4",
  "gravatar_id": "",
  "url": "https://api.github.com/users/arseny***",
...

[/simterm]

Окей, відповідь є, значить токен працює.

Бібліотека PyGithub

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

[simterm]

$ pip install PyGithub

[/simterm]

Тепер спробуємо сходити до GitHub API у коді на Python:

#!/usr/bin/env python

from github import Github

access_token = "ghp_ys9***ilr"

# connect to Gihub
github_instance = Github(access_token)
organization_name = 'OrgName'
# read org
organization = github_instance.get_organization(organization_name)
# get repos list     
repositories = organization.get_repos()

for repository in repositories:
    print(f"Repository: {repository.full_name.split('/')[1]}")

Тут створюємо github_instance, аутентифікуємось з нашим токеном, отримуємо інформацію про GitHub Organization, та всі репозиторії цієї організації.

Запускаємо:

[simterm]

$ ./test-api.py 
Repository: chatbot-automation
Repository: ***-sandbox
Repository: ***-ios
...

[/simterm]

Окей, працює.

Отримання інформації про Pull Request

Далі, спробуємо отримати інформацю про пул-реквест, а саме – час його створення та закриття.

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

...

# get infro about a repository
repository = github_instance.get_repo("OrgName/repo-name")
# get all PRs in a given repository
pull_requests = repository.get_pulls(state='closed')

# to get PRs closed during last N days
days_ago = datetime.now() - timedelta(days=7)

for pull_request in pull_requests:
    merged_at = pull_request.closed_at
    created_at = pull_request.merged_at

    if created_at >= days_ago and created_at and merged_at:
        print(f"Pull Request: {pull_request.number} Created at: {pull_request.created_at} Merged at: {pull_request.merged_at}")

Тут у циклі для кожного PR отримуємо його атрибути merged_at та created_at, див. List pull requests – у Response schema є список всіх атрибутів, які ми можемо побачити для кожного PR.

У days_ago = datetime.now() - timedelta(days=7) отримуємо день 7 днів тому, щоб вибрати пул-реквести, створені після цієї дати, а потім для перевірки виводимо на екран інформацію про дату створення PR та дату, коли його змержили в master.

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

[simterm]

$ ./test-api.py 
Pull Request: 1055 Created at: 2023-05-31 18:34:18 Merged at: 2023-06-01 08:14:49
Pull Request: 1049 Created at: 2023-05-31 10:22:16 Merged at: 2023-05-31 18:03:09
Pull Request: 1048 Created at: 2023-05-30 15:16:13 Merged at: 2023-05-31 14:17:57
...

[/simterm]

Гуд! Працює.

Тепер можемо починати думати про метрику для Prometheus.

Prometheus Client та метрики

Встановлюємо бібліотеку:

[simterm]

$ pip install prometheus_client

[/simterm]

Щоб мати більше уяви про те, що саме ми хочимо побудувати – можна почитати How to Read Lead Time Distribution, де є приклад такого графіку:

Тобто в нашому випадку будуть:

  • x-axis (горизонталь): час (години на закриття PR)
  • y-axis (вертикаль): кількість PR закриті за Х-годин

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

buckets = [1, 2, 5, 10, 20, 100, 1000]
gh_repo_lead_time = Histogram('gh_repo_lead_time', 'Time in hours between PR open and merge', buckets=buckets, labelnames=['gh_repo_name'])

Проте, з Histogram не вийшло, бо в бакет 1000 потрапляють всі значення меньше 1000, в бакет 100 – всі менше ста, і так далі, а нам потрібно в бакет 100 включати тільки дані про пул-реквести, які були закриті між 50 годин та 100 годин.

Але врешті-решт все вийшло з використанням типу Counter та лейбл repo_name та time_interval.

Див. A Deep Dive Into the Four Types of Prometheus Metrics.

Створення метрики

Спочатку створимо Python dictionary з “бакетами” – це години, на протязі яких були закриті пул-реквести:

time_intervals = [1, 2, 5, 10, 20, 50, 100, 1000]

Далі будемо отримувати кількість годин на закриття у кожному PR, перевіряти в який саме “бакет” цей PR попадає, і потім заносити дані у метрику – додавати лейблу time_interval зі значенням з бакету, в який це PR попав, та інкрементити значення каунтеру.

Створємо саму метрику pull_request_duration_count та функцію calculate_pull_request_duration(), в яку будемо передавати пул-реквест для перевірки:

...
# buckets for PRs closed during {interval}
time_intervals = [1, 2, 5, 10, 20, 50, 100, 1000]  # 1 hour, 2 hours, 5 hours
# prometheus metric to count PRs in each {interval}
pull_request_duration_count = Counter('pull_request_duration_count',
                                      'Count of Pull Requests within a time interval',
                                      labelnames=['repo_name', 'time_interval'])

def calculate_pull_request_duration(repository, pr):
    created_at = pr.created_at
    merged_at = pr.merged_at

    if created_at >= days_ago and created_at and merged_at:
        duration = (merged_at - created_at).total_seconds() / 3600

        # Increment the histogram for each time interval
        for interval in time_intervals:
            if duration <= interval:
                print(f"PR ID: {pr.number} Duration: {duration} Interval: {interval}")
                pull_request_duration_count.labels(time_interval=interval, repo_name=repository).inc()
                break
...

Тут у calculate_pull_request_duration():

  • отримуємо час створення та мержу пул-реквеста
  • перевіряємо, що PR молодший за $days_ago і має атрібути created_at та merged_at, тобто він вже змержений
  • рахуємо, скільки часу він провів до моменту його мержу в мастер-гілку, та переводимо в години – duration = (merged_at - created_at).total_seconds() / 3600
  • у циклі проходимось по “бакетах” з нашого time_intervals dictionary – шукаємо, в який з них попадає цей PR
  • і в кінці створюємо метрику pull_request_duration_count, в labels якої вносимо ім’я репозиторію та “бакет”, в який попав цей пул-реквест, і інкриментимо значення каунтера на +1:
    pull_request_duration_count.labels(time_interval=interval, repo_name=repository).inc()

Далі, описуємо функцію main() та ї виклик:

...

def main():
    # connect to Gihub
    github_instance = Github(github_token)
    organization_name = 'OrgName'
    # read org
    organization = github_instance.get_organization(organization_name)
    # get repos list 
    repositories = organization.get_repos()

    for repository in repositories:
        # to set in labels
        repository_name = repository.full_name.split('/')[1]
        pull_requests = repository.get_pulls(state='closed')

        if pull_requests.totalCount > 0:
            print(f"Checking repository: {repository_name}")
            for pr in pull_requests:
                calculate_pull_request_duration(repository_name, pr)
        else:
            print(f"Sckipping repository: {repository_name}")

    # Start Prometheus HTTP server
    start_http_server(8000)
    print("HTTP server started")
    while True:
        time.sleep(15)
        pass

if __name__ == '__main__':
    main()

Тут ми:

  • створємо об’єкт Github
  • отримуємо список репозиторіїв організацї
  • для кожного репозиторія викликаємо get_pulls(state='closed')
  • перевіряємо, що в репозиторії були пул-реквести, і по черзі відправляємо їх до функції calculate_pull_request_duration()
  • запускаємо HTTP-сервер на порту 8000, де будемо отримувати метрики

Повний код Prometheus-експортеру

Все разом тепер виходить так:

#!/usr/bin/env python

from datetime import datetime, timedelta
import time
from prometheus_client import start_http_server, Counter
from github import Github

# TODO: move to env vars
github_token = "ghp_ys9***ilr"

# to get PRs closed during last N days
days_ago = datetime.now() - timedelta(days=7)
# buckets for PRs closed during {interval}
time_intervals = [1, 2, 5, 10, 20, 50, 100, 1000]  # 1 hour, 2 hours, 5 hours
# prometheus metric to count PRs in each {interval}
pull_request_duration_count = Counter('pull_request_duration_count',
                                      'Count of Pull Requests within a time interval',
                                      labelnames=['repo_name', 'time_interval'])

def calculate_pull_request_duration(repository, pr):
    created_at = pr.created_at
    merged_at = pr.merged_at

    if created_at >= days_ago and created_at and merged_at:
        duration = (merged_at - created_at).total_seconds() / 3600

        # Increment the Counter for each time interval
        for interval in time_intervals:
            if duration <= interval:
                print(f"PR ID: {pr.number} Duration: {duration} Interval: {interval}")
                pull_request_duration_count.labels(time_interval=interval, repo_name=repository).inc()
                break

def main():
    # connect to Gihub
    github_instance = Github(github_token)
    organization_name = 'OrgNameg'
    # read org
    organization = github_instance.get_organization(organization_name)
    # get repos list 
    repositories = organization.get_repos()

    for repository in repositories:
        # to set in labels
        repository_name = repository.full_name.split('/')[1]
        pull_requests = repository.get_pulls(state='closed')

        if pull_requests.totalCount > 0:
            print(f"Checking repository: {repository_name}")
            for pr in pull_requests:
                calculate_pull_request_duration(repository_name, pr)
        else:
            print(f"Skipping repository: {repository_name}")

    # Start Prometheus HTTP server
    start_http_server(8000)
    print("HTTP server started")
    while True:
        time.sleep(15)
        pass

if __name__ == '__main__':
    main()

Запускаємо скрипт:

[simterm]

$ ./github-exporter.py
...
Skipping repository: ***-sandbox
Checking repository: ***-ios
PR ID: 1332 Duration: 5.4775 Interval: 10
PR ID: 1331 Duration: 0.32916666666666666 Interval: 1
PR ID: 1330 Duration: 20.796944444444446 Interval: 50
...

[/simterm]

Чекаємо, поки будуть перевірені всі репозиторії і запуститься http_server(), та перевіряємо метрики з curl:

[simterm]

$ curl localhost:8000
...
# HELP pull_request_duration_count_total Count of Pull Requests within a time interval
# TYPE pull_request_duration_count_total counter
pull_request_duration_count_total{repo_name="***-ios",time_interval="10"} 1.0
pull_request_duration_count_total{repo_name="***-ios",time_interval="1"} 1.0
pull_request_duration_count_total{repo_name="***-ios",time_interval="50"} 2.0
pull_request_duration_count_total{repo_name="***-ios",time_interval="100"} 1.0
pull_request_duration_count_total{repo_name="***-ios",time_interval="20"} 1.0
pull_request_duration_count_total{repo_name="***-ios",time_interval="1000"} 1.0
...

[/simterm]

Гуд! Працює.

GitHub API rate limits

Майте на увазі, що GitHub обмежує кількість запитів до API – 5,000 на годину зі звичайним юзерським токеном, та 15.000, якщо у вас Enterprise ліцензія. Див. Rate limits for requests from personal accounts.

Якщо його перевищити – отримаєте 403:

[simterm]

...
  File "/usr/local/lib/python3.11/site-packages/github/Requester.py", line 423, in __check
    raise self.__createException(status, responseHeaders, output)
github.GithubException.RateLimitExceededException: 403 {"message": "API rate limit exceeded for user ID 132904972.", "documentation_url": "https://docs.github.com/rest/overview/resources-in-the-rest-api#rate-limiting"}

[/simterm]

Prometheus Server та отримання метрик

Залишилось почати збирати метрики у Prometheus, та створити Grafana dashboard.

Запуск Prometheus Exporter

Створюємо Dockerfile:

FROM python:latest

COPY github-exporter.py ./
RUN pip install prometheus_client PyGithub

CMD [ "python", "./github-exporter.py"]

Збираємо образ:

[simterm]

$ docker build -t gh-exporter .

[/simterm]

У нас Prometheus/Grafana поки що в простому Docker Compose – додаємо запуск нашого нового експортеру:

...
  gh-exporter:
    scrape_timeout: 15s
    image: gh-exporter
    ports:
      - 8000:8000
...

(токен таки краще передавати через змінну оточення з docker-compose файлу, а не хардкодити в коді)

І у файлу конфігурації самого Prometheus – описуємо нову scrape_job:

scrape_configs:
...
  - job_name: gh_exporter
    scrape_interval: 5s
    static_configs:
      - targets: ['gh-exporter:8000']
...

Запускаємо, і за хвилину перевіряємо метрики в Prometheus:

Найс!

Grafana dashboard

Останнім робимо саму борду.

Додамо змінну, щоб мати змогу відобразити дані по конкретному репозіторію/ях:

Для візуалізації я використав тип Bar gauge і такий query:

sum(pull_request_duration_count_total{repo_name=~"$repository"}) by (time_interval)

У Overrides задаємо колір для кожної колонки.

Єдине, що тут не дуже – це сортування колонок: сам Prometheus це не вміє і не хоче (див. Added sort_by_label function for sorting by label values), а Grafana сортує по першим цифрам у отриманих  з label значеннях, тобто 1, 2, 5, не враховуючи кількість 0 після цифри.

Але то вже деталі – може, таки візьмемо Victoria Metrics з її sort_by_label, або в Grafana просто створимо кілька графіків, і в кожному будемо виводити дані по конкретному “бакету” та кількості пул-реквестів в ньому.

Loading

Loki: збір логів з CloudWatch Logs з використанням Lambda Promtail
0 (0)

20 Травня 2023

Збирати логи у Grafana Loki з Kubernetes дуже просто – запускаємо Promtail у DaemonSet, йому вказуємо читати всі дані з /var/logs – і готово (насправді взагалі нічого не вказуємо – з Helm-чарту все працює з коробки).

А от як бути з CloudWatch Logs? На новому проекті маємо купу AWS Lambda, API Gateways і т.д, і всі вони пишуть логи у CloudWatch.

Що стосується Lambda, то можна було б використати Lambda Telemetry API, і писати логи з функції відразу в Loki, див. Building an AWS Lambda Telemetry API extension for direct logging to Grafana Loki, і можливо пізніше ми цей підхід також використаємо, але зараз у нас вже є купа логів від інших сервісів у CloudWatch, і треба таки їх читати.

Ще є варіант встановити CloudWatch як data source у Grafana, і просто користуватись логами з інтерфейсу Grafana та мабуть навіть мати алерти Grafana з цих логів, але рано чи пізно все одно з’явиться Kubernetes або просто ЕС2 інстанси, і треба буде збирати з них логи, тож будемо відразу робити все з Loki, тим більш в неї чудовий LogQL і набагато більше гнучкості у створенні лейбл та алертів.

В такому випадку можемо використати Lambda Promtail від самої Grafana, а працювати воно буде наступним чином:

  • якась Lambda-функція (наприклад) пише лог у CloudWatch Log Group
  • у Log Group будемо мати Subscription filter, який буде слати логи на іншу Lambda-функцію – власне у Lambda Promtail
  • а Lambda Promtail буде пересилати їх до інстансу Loki

Отже, сьогодні створимо тестову Lambda-функцію, яка буде писати логи, і запустимо Lambda Promtail, яка буде слати логи в Grafana Loki, яка вже є.

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

Також треба мати на увазі, що до Loki потрібно буде відкривати доступ на порт 3100, тож Lambda Promtail краще мати в тій самій VPC, де запущена сама Grafana та/або мати якийсь NGINX з HTTP-аутентифікацією.

Тестова Lambda для створення логів

Створюємо функцію, нехай буде на Python:

У коді функції додамо кілька print(), щоб створити запис в лог:

import json
import os

def lambda_handler(event, context):
    return {
        'statusCode': 200,
        'body': json.dumps('Hello from Lambda!')
    }
    print('## ENVIRONMENT VARIABLES')
    print(os.environ['AWS_LAMBDA_LOG_GROUP_NAME'])
    print(os.environ['AWS_LAMBDA_LOG_STREAM_NAME'])
    print('## EVENT')
    print(event)

Тиснемо Test, щоб створити тестовий евент, дані в полі Event JSON нам не важливі, просто вказуємо ім’я евенту, та зберігаємо його:

Тиснемо Test ще раз – функція виконалась, Function Logs пішли:

Переходимо у Monitor > Logs, а звідти у CloudWatch Logs:

І перевіряємо, що Log events є:

Все – тепер можемо переходити до Lambda Promtail.

Запуск Lambda Promtail

Взагалі є готовий Terraform проект і навіть Cloudformation темплейт, тож можна скористатись ними. Єдине, що у Terrafrom треба пофіксити створення resource "aws_iam_role_policy_attachment" "lambda_sqs_execution" у файлі sqs.tf, бо там йде виклик ролі role = aws_iam_role.iam_for_lambda.name, а у main.tf вона називається resource "aws_iam_role" "this".

В усьому іншому Terraform працює – задаємо значення для змінних у variabels.tfwrite_address, log_group_names та lambda_promtail_image, і можна створювати ресурси.

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

Docker образ та Elastic Container Service

Спочатку підготуємо Docker-образ, бо запустити AWS Lambda з публічного ECR Grafana чомусь неможливо, хоча ніде в документації такого обмеження не знайшов.

Переходимо до ECR, створюємо репозиторій:

Завантажуємо публічний образ від Grafana:

[simterm]

$ docker pull public.ecr.aws/grafana/lambda-promtail:main

[/simterm]

Перетегаємо його на свій репозиторій:

[simterm]

$ docker tag public.ecr.aws/grafana/lambda-promtail:main 264***286.dkr.ecr.eu-central-1.amazonaws.com/lambda-promtail-writer:latest

[/simterm]

Логінимось до ECR – вказуємо --profile, якщо не дефолтний, та AWS Region:

[simterm]

$ aws --profile setevoy ecr get-login-password --region eu-central-1 | docker login --username AWS --password-stdin 264***286.dkr.ecr.eu-central-1.amazonaws.com
...
Login Succeeded

[/simterm]

Пушимо туди наш образ:

[simterm]

$ docker push 264***286.dkr.ecr.eu-central-1.amazonaws.com/lambda-promtail-writer:latest

[/simterm]

Переходимо до Lambda.

Створення Lambda Promtail function

Створюємо нову функцію, вибираємо Container image, та вказуємо URI імейджу, який запушили вище:

Переходимо до Configuration > Environment variables, і задаємо мінімально необхідні змінні:

  • EXTRA_LABELS: теги/лейбли, які будуть додані у Loki, тут вказуємо у форматі labelname,labelvalue
  • WRITE_ADDRESS: адреса Loki з https:// та  URI /loki/api/v1/push

Конфігурація CloudWatch Log Group Subscription filters

Повертаємось до CloudWatch Log Group, в якій були наші тестові логи, і у Subscription filters додаємо нову підписку для Lambda-функції (див. Using CloudWatch Logs subscription filters або How can I configure a CloudWatch subscription filter to invoke my Lambda function?):

Вибираємо функцію, в яку будемо стрімити логи, та при потребі у Configure log format and filters можемо вказати фільтр, через який буде вибиратись що саме пересилати у Lambda, щоб не слати зовсім всі строки.

Зараз нам це не потрібно, тож у Log format ставимо Other, а Subscription filter pattern лишаємо пустим.

У Subscription filter name вказуємо ім’я самого фільтру:

Зберігаємо – Start streaming, повертаємось до Lambda write-logs, і кілька раз тиснемо Test, щоб створити ще записів у CloudWatch Log Group, які мають стригерити функцію lambda-promtail-testing і передати їй дані, які вона відправить у Loki.

Перевіряємо у функції lambda-promtail-testing – у Monitoring мають бути виклики:

У випадку Errors – на вкладці Logs є посилання на CloudWatch Log цієї функції, де буде описана помилка.

Якщо ж все Success – то в Loki вже маємо побачити нову лейблу, і по ній можемо вибрати логи з функції write-logs:

Готово.

На сторінці документації Grafana ще пишуть, що “Or, have lambda-promtail write to Promtail and use pipeline stages.“, але я так і не знайшов можливості в Promtail писати дані по gRPC або HTTP, хоча така ідея була ще у 2020 році, але вона досі в Draft – Promtail Push API

Loading

Terraform: початок роботи та планування нового проекту – Dev/Prod та bootsrap
0 (0)

14 Травня 2023

Треба запланувати використання Terraform у новому проекті, а це включає в себе і планування структри файлів для проекті, і як створити бекенд (тобто bootstrap) і інші потрібні для початку роботи ресурси, і подумати на тему роботи з кількома оточеннями і AWS-аккаунтами.

Взагалі, цей пост спочатку писався чисто про створення AWS SES, але я почав додавати багато деталей по тому, як жеж саме створити та планувати новий проект з Terraform, тож вирішив винести окремим постом. Про SES пізніше теж допишу, бо там досить цікаво саме по самому SES та пошті в цілому.

Наприкінці цього поста, як завжди, буде багато цікавих посилань, але особливо хочу відмітити Terraform Best Practices від Anton Babenko.

Планування Terraform для нового проекту

Про що треба буде подумати?

  • структура файлів проектів
  • бекенд – AWS S3, як робити корзину для першого запуску?
  • гарно б DynamoDB для State Locking, але то іншим разом
  • dev/prod оточення та aws multi account – як робитимо?

Структура файлів Terraform

У проекті для AWS SES спочатку зробив все у одному файлі, але давайте зробимо “як треба”, див. наприклад How to Structure Your Terraform Projects (там ще багато чього).

Отже, як організуємо:

  • main.tf – виклики модулів
  • terraform.tf – параметри backend-у, провайдери, версії
  • providers.tf – тут сам провайдер AWS, аутентифікаця, регіон
  • variables.tf – тут декларуємо змінні
  • terraform.tfvars – значення для змінних

У проекті SES ще будуть окремі файли ses.tf та route53.tf для всього, що пов’язано з ними.

Multiple environments з Terraform

Окей, а що маємо по роботі з декількома оточеннями типу Dev/Prod, або взагалі різними AWS-аккаунтами?

Можемо зробити через Terraform Workspaces, але щось я не пам’ятаю, щоб багато чув про них в плані використання для Dev/Prod та ще й з CI/CD пайплайнами. Хоча – як варіант, і може має сенс якось спробувати. Див. How to manage multiple environments with Terraform using workspaces).

Взагалі-то не знайшов якось “золотої кулі”, і варіантів прям купа. Хтось використовує git-бранчі або Terragrunt (How to manage multiple environments with Terraform), хтось – різні директорії (How to Create Terraform Multiple Environments), хтось – рішення типу Spacelift.

Dev та Production по каталогах

Як на мене, для невеликого проекту найбільш привабливим виглядає варіант з використанням декільких калалогів для оточень і Terraform modules для ресурсів.

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

У каталозі проекту створимо структуру директорій:

[simterm]

$ mkdir -p environments/{dev,prod}
$ mkdir -p modules/vpc

[/simterm]

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

[simterm]

$ tree
.
|-- environments
|   |-- dev
|   `-- prod
`-- modules
    `-- vpc

[/simterm]

Тут у нас environments/dev/ та prod/ будуть незалежними проектами з власними параметрами та будуть використовувати загальні модулі з каталогу modules. Таким чином процес розробки чогось нового для інфрастуктури можна спочатку протестити у окремому файлі в каталозі environments/dev, потім перенести його до modules, додати до dev вже у вигляді модулю, і після повторного тестування там – додати до production.

Крім того, так як будемо мати власні файли параметрів для AWS, то зможемо використовувати окремі AWS-аккаунти.

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

[simterm]

$ aws s3 mb s3://tfvars-envs
make_bucket: tfvars-envs

[/simterm]

Створення shared-модулю

Переходимо до каталога modules/vpc/ і у файлі main.tf описуємо VPC (втім, якщо вже дотримуватися best practicies, то краще використовувати модуль VPC, також від Anton Babenko):

resource "aws_vpc" "env_vpc" {
  cidr_block      = var.vpc_cidr

  tags = {
    environment = var.environment
  }
}

В тому ж каталозі створюємо файл variables.tf зі змінними, але без значень – тільки декларуємо їх:

variable "vpc_cidr" {
  type = string   
}

variable "environment" {
  type = string
}

Створення Dev/Prod оточень

Переходимо до environments/dev і готуємо файли. Почнемо з параметрів – terraform.tf та provider.tf.

У terraform.tf описуємо потрібні провайдери, версії та бекенд.

У бекенді у key вказуємо шлях до стейт-файлу в директорії dev/ – її буде створено при деплої. А для Prod – вкажемо prod/ (хоча можна взагалі різні корзини):

terraform {
  required_providers {
    aws = { 
      source  = "hashicorp/aws"
      version = ">= 4.6.0"
    }
  }

  required_version = ">= 1.4"

  backend "s3" {
    bucket = "tfvars-envs"
    region = "eu-central-1"
    key    = "dev/terraform.tfstate"
  }   
}

У provider.tf параметри провайдера AWS – регіон та AWS profile з ~/.aws/config, який буде використовуватись:

provider "aws" {
  region    = var.region
  profile   = "default"
}

Хоча можна зараз було б об’єднати їх у terraform.tf, але на майбутнє – нехай буде так.

Створюємо main.tf, в якому використовуємо наш модуль з каталогу modules, якому передаємо змінні:

module "vpc" {
  source = "../../modules/vpc"

  vpc_cidr        = var.vpc_cidr
  environment     = var.environment
}

Додаємо файл variables.tf, в якому також тільки декларуємо змінні, тут нова змінна region для terraform.tf та providers.tf:

variable "vpc_cidr" {
  type = string 
}
    
variable "environment" {
  type = string
}

variable "region" {
  type = string
}

І нарешті самі значення змінних описуємо у файлі terraform.tfvars:

vpc_cidr      = "10.0.0.0/24"
environment   = "dev"
region        = "eu-central-1"

Аналогічно робимо для environments/prod/, тільки з каталогом prod/ у бекенді та іншими значеннями у terraform.tfvars:

vpc_cidr      = "10.0.1.0/24"
environment   = "prod"
region        = "eu-central-1"

Отримуємо таку структуру:

[simterm]

$ tree
.
|-- environments
|   |-- dev
|   |   |-- main.tf
|   |   |-- provider.tf
|   |   |-- terraform.tf
|   |   |-- terraform.tfvars
|   |   `-- variables.tf
|   `-- prod
|       |-- main.tf
|       |-- provider.tf
|       |-- terraform.tf
|       |-- terraform.tfvars
|       `-- variables.tf
`-- modules
    `-- vpc
        |-- main.tf
        `-- variables.tf

[/simterm]

Перевіримо наш Dev – виконуємо init:

[simterm]

$ terraform init

Initializing the backend...

Successfully configured the backend "s3"! Terraform will automatically
use this backend unless the backend configuration changes.
Initializing modules...

Initializing provider plugins...
- Reusing previous version of hashicorp/aws from the dependency lock file
- Installing hashicorp/aws v4.67.0...
- Installed hashicorp/aws v4.67.0 (signed by HashiCorp)

Terraform has been successfully initialized!

[/simterm]

І plan:

[simterm]

$ terraform plan
...
Terraform will perform the following actions:

  # module.vpc.aws_vpc.env_vpc will be created
  + resource "aws_vpc" "env_vpc" {
      + arn                                  = (known after apply)
      + cidr_block                           = "10.0.0.0/24"
      ...
      + tags                                 = {
          + "environment" = "dev"
        }
      + tags_all                             = {
          + "environment" = "dev"
        }
    }

Plan: 1 to add, 0 to change, 0 to destroy.

[/simterm]

Окей – можна створювати ресурси, але як щодо корзини tfvars-envs, яку ми вказали у backend? Якщо ми спробуємо виконати apply зараз, то деплой сфейлиться, бо бакету для бекенду нема.

Тобто – як взагалі підготувати AWS аккаунт до використання Terraform, виконати його bootstraping?

Terraform Backend Bootstrap

Тобто, маємо новий проект, новий аккаунт, і нам десь треба зберігати state-файли. Ми будемо використовувати AWS S3, а потім ще додамо DynamoDB для state-lock, але й корзина, і таблиця в DynamoDB мають бути створені до деплою нового проекту.

Поки бачу три основних варіанти:

  • “clickops”: все створюємо руками через AWS Console
  • скриптом або вручну створювати через AWS CLI
  • рішення по типу Terragrunt або Terraform Cloud, але це поки овер-інжинірінг для такого маленького проекту
  • мати окремий проект з Terraform, назвемо його bootstrap, в якому створюються ресурси і стейт-файл, який потім імпортуємо в новий бекенд

Ще з найдених варіантів – використовувати CloudFormation для цього – How to bootstrap an AWS account with Terraform state backend, але таке собі – не хочеться змішувати кілька orchestration management тулів.

Ще нагуглив рішення з Makefile – Terrastrap – Bootstrap a S3 and DynamoDB backend for Terraform, цікава реалізація.

До речі, якщо маєте GitLab – то в нього є свій бекенд для Terraform-state, див. GitLab-managed Terraform state, і в такому випадку нічого створювати не потрібно (хіба що DynamoDB та AIM, але питання зі стейтами вирішується).

В принципі якщо питання просто створити корзину, то можна й AWS CLI, але як бути, коли планується і S3, і DynamoDB, та ще й окремий IAM юзер для Terraform з власною IAM Policy? Все робити через AWS CLI? І це повторювати для всіх нових проектів вручну? Ну, таке собі.

Перше рішення, яке придумалось – це мати єдиний bootstrap-проект, в якому ми будемо створювати ресурси для всіх іншних проектів, тобто – всі корзини/Dynamo/IAM, просто через різні tfvars – можна було  б організувати щось накташлт рішення з Dev/Prod оточеннями, як робили вище. Тобто у репозиторії з bootstrap-проектом мати окремі директорії з власними файлами terraform.tf, provider.tf та terraform.tfvars під кожен новий проект.

В такому випадку можна руками з AWS CLI створити перший бакет для самого проекту bootstrap, і вже в цьому проекті описуємо створення DynamoDB, S3-бакетів, IAM-ресурсів для інших проектів.

Для проекту bootstrap для аутентифікації можна взяти якісь існуючі ACCESS/SECRET ключі, а інші проекти вже зсможуть використовувати IAM юзера або роль, яку ми створимо у бутстрапі.

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

Тобто:

  • модуль bootsrap – його зберігаємо в репозиторії для доступу з інших проектів
  • потім при створенні нового проекту – включаємо цей модуль в код, за його допомогою створюємо S3-бакет, AIM та DynamoDB
  • після створення – імпортуємо state-файл, який отримали після бутстрапу, в нову корзину
  • і вже тоді починаємо роботу з оточеннями

Спробуємо – як на мене, то цей варіант виглядає непогано.

Видаляємо корзину, яку створили на початку, вона має бути порожня, бо з terraform apply ми нічого не створювали:

[simterm]

$ aws s3 rb s3://tfvars-envs
remove_bucket: tfvars-envs

[/simterm]

Створення Bootstrap модулю

Створюємо репозиторій, і в ньому файл s3.tf з aws_s3_bucket – поки обійдемось без IAM/Dynamo, тут чисто для прикладу і перевірки плану:

resource "aws_s3_bucket" "project_tfstates_bucket" {
  bucket = var.tfstates_s3_bucket_name

  tags = {
    environment = "ops"
  }
}

resource "aws_s3_bucket_versioning" "project_tfstates_bucket_versioning" {
  bucket = aws_s3_bucket.project_tfstates_bucket.id
  versioning_configuration {
    status = "Enabled"
  }
}

Додаємо variables.tf з ім’ям корзини:

variable "tfstates_s3_bucket_name" {
  type = string
}

Тепер повертаємось до нашого проекту, і в корні створюємо файл main.tf, в якому використовєємо bootstrap модуль з Github:

module "bootstrap" {
  source = "[email protected]:setevoy2/terraform-bootsrap.git"

  tfstates_s3_bucket_name = var.tfstates_s3_bucket_name
}

У source, до речі, можемо задати бранч або версію, наприклад:

source = "[email protected]:setevoy2/terraform-bootsrap.git?ref=main

Далі, додаємо файл variables.tf:

variable "tfstates_s3_bucket_name" {
  type = string 
}

variable "region" {
  type = string
}

Файл provider.tf:

provider "aws" {
  region    = var.region
  profile   = "default"
}

І terraform.tf:

terraform {
  required_providers {
    aws = { 
      source  = "hashicorp/aws"
      version = ">= 4.6.0"
    }
  }

  required_version = ">= 1.4"

#  backend "s3" {
#    bucket = "tfvars-envs"
#    region = "eu-central-1"
#    key    = "bootstrap/terraform.tfstate"
#  }   
}

Тут блок backend поки закоментований – повернемось до нього, як створимо корзину, поки що state file буде згенеровано локально. У key вказуємо шлях bootstrap/terraform.tfstate – саме туди буде імпортовано наш стейт.

Додаємо файл terraform.tfvars:

tfstates_s3_bucket_name = "tfvars-envs"
region                  = "eu-central-1"

Тепер структура виходить така:

[simterm]

$ tree
.
|-- environments
|   |-- dev
|   |   |-- main.tf
|   |   |-- provider.tf
|   |   |-- terraform.tf
|   |   |-- terraform.tfvars
|   |   `-- variables.tf
|   `-- prod
|       |-- main.tf
|       |-- provider.tf
|       |-- terraform.tf
|       |-- terraform.tfvars
|       `-- variables.tf
|-- main.tf
|-- modules
|   `-- vpc
|       |-- main.tf
|       `-- variables.tf
|-- provider.tf
|-- terraform.tf
|-- terraform.tfvars
`-- variables.tf

[/simterm]

Тобто, в корні проекту у main.tf ми виконуємо тільки бутстрап для створення корзини, а потім вже з каталогів environments/{dev,prod} створюємо ресурси інфрастуктури.

Створення Bootstrap S3-корзини

У корні виконуємо terraform init:

[simterm]

$ terraform init

Initializing the backend...
Initializing modules...
Downloading git::ssh://[email protected]/setevoy2/terraform-bootsrap.git for bootstrap...
- bootstrap in .terraform/modules/bootstrap

Initializing provider plugins...
- Finding hashicorp/aws versions matching ">= 4.6.0"...
- Installing hashicorp/aws v4.67.0...
- Installed hashicorp/aws v4.67.0 (signed by HashiCorp)
...

[/simterm]

Перевіряємо з terraform plan, і як все гаразд – то запускаємо створення корзини:

[simterm]

$ terraform apply
...
  # module.bootstrap.aws_s3_bucket.project_tfstates_bucket will be created
  + resource "aws_s3_bucket" "project_tfstates_bucket" {
...
module.bootstrap.aws_s3_bucket_versioning.project_tfstates_bucket_versioning: Creation complete after 2s [id=tfvars-envs]

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

[/simterm]

Тепер наступний крок – імпортувати локальний state-файл:

[simterm]

$ head -5 terraform.tfstate
{
  "version": 4,
  "terraform_version": "1.4.6",
  "serial": 4,
  "lineage": "d34da6b7-08f4-6444-1941-2336f5988447",

[/simterm]

Розкоментуємо блок backend у terraform.tf рутового модулю:

terraform {
  required_providers {
    aws = { 
      source  = "hashicorp/aws"
      version = ">= 4.6.0"
    }
  }

  required_version = ">= 1.4"

  backend "s3" {
    bucket = "tfvars-envs"
    region = "eu-central-1"
    key    = "bootstrap/terraform.tfstate"
  }   
}

І визиваємо terraform init ще раз – тепер він бачить, що замість local backend має s3 backend, і запропонує мігрувати terraform.tfstate туди – відповідаємо yes:

[simterm]

$ terraform init

Initializing the backend...
Do you want to copy existing state to the new backend?
  Pre-existing state was found while migrating the previous "local" backend to the
  newly configured "s3" backend. No existing state was found in the newly
  configured "s3" backend. Do you want to copy this state to the new "s3"
  backend? Enter "yes" to copy and "no" to start with an empty state.

  Enter a value: yes


Successfully configured the backend "s3"! Terraform will automatically
use this backend unless the backend configuration changes.
Initializing modules...

Initializing provider plugins...
- Reusing previous version of hashicorp/aws from the dependency lock file
- Using previously-installed hashicorp/aws v4.67.0

Terraform has been successfully initialized!

[/simterm]

Тепер маємо налаштований бекенд, котрий можемо використовувати для проекту.

Повертаємось до environments/dev/, перевіряємо ще раз з plan, і нарешті створимо наше Dev-оточення:

[simterm]

$ terraform apply
...
module.vpc.aws_vpc.env_vpc: Creating...
module.vpc.aws_vpc.env_vpc: Creation complete after 2s [id=vpc-0e9bb9408db6a2968]

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

[/simterm]

Перевіряємо корзину:

[simterm]

$ aws s3 ls s3://tfvars-envs
                           PRE bootstrap/
                           PRE dev/

[/simterm]

І файл стейту:

[simterm]

$ aws s3 ls s3://tfvars-envs/dev/
2023-05-14 13:37:27       1859 terraform.tfstate

[/simterm]

Все є.

Отже, процесс створення нового проекту буде таким:

  1. у корні проекту створюємо main.tf, в якому описуємо використання модулю bootstrap з source = "[email protected]:setevoy2/terraform-bootsrap.git
  2. у файлі terraform.tf описуємо бекенд, але коментуємо його
  3. створюємо корзину з модулю bootsrap
  4. розкоментуємо бекенд, і через terrafrom init імпортуємо локальний state file

Після цього проект готовий до створення dev/prod оточень з бекендом для стейт-файлів у новій корзині.

І да, таки цікаво які тули/підходи використовуєте, тож велкам у коменти або чатик RTFM у Telegram.

Посилання по темі

Loading