VictoriaLogs: створення Recording Rules з VMAlert
0 (0)

8 Січня 2025

Продовжуємо міграцію з Grafana Loki на VictoriaLogs, і наступна задача – це перенести Recording Rules з Loki до VictoriaLogs, і оновити алерти.

Recording Rules та інтеграцію з VMAlert до VictoriaLogs завезли відносно недавно, і цю схему ще не тестував.

Тому спершу все зробимо руками, подивимось як це працює, які є нюанси, а потім будемо оновлювати Helm chart, яким деплоїться мій Monitoring Stack, і додавати туди нові Recording Rules.

Тож, що сьогодні:

  • встановимо VMAlert з Helm чарту в Kubernetes
  • перепишемо запит Loki LogQL на VictoriaLogs LogsQL
  • створимо VMAlert Recording Rule для генерації метрик з логів
  • протестуємо, як робити алерти з логів та Recording Rules
  • і подивимось, як цю схему можна інтегрувати в існуючий стек VictoriaMetrics

Попередні пости по VictoriaLogs:

Також див:

VictoriaLogs, Recording Rules та VMAlert

Отже, в чому полягає ідея:

  • VMAlert може робити запити до VictoriaLogs
  • в цих запитах він виконує якісь expr – як в звичайних алертах
  • по результатам цих запитів VMAlert або генерує метрику – якщо це Recording Rule – і записує її в VictoriaMetrics чи Prometheus, або генерує алерт – якщо це Alert

Тобто тут та ж сама схема, як і в Loki, і метрики з Recording Rule ми можемо використовувати не тільки для алертів, а і в Grafana dashboards.

Як завжди – у VictoriaMetrics є чудова документація:

Запуск VMAlert в Kubernetes з Helm чарту

В мене вже є повністю задеплоєний стек VictoriaMetrics і решта всього моніторингу власним чартом, але зараз VMAlert запустимо окремо від нього, бо є момент з тим, як VMAlert робить запити до VictoriaMetrics та VictoriaLogs – далі з цим розберемось.

Сам чарт тут – victoria-metrics-alert.

Для деплою нам знадобляться такі параметри:

  • datasource.url: адреса VictoriaLogs – до кого виконувати запити
  • notifier.url: адреса Alertmanager – куди слати алерти
  • remoteWrite.url: адреса VictoriaMetrics/Prometheus – куди записуємо метрики і стан алертів
  • remoteRead.url: адреса VictoriaMetrics/Prometheus – звідки читаємо стан алертів при рестарті VMAlert

Генеруємо values.yaml:

$ helm show values vm/victoria-metrics-alert > vmalert-test-values.yaml

Знаходимо потрібні Kubernetes Services:

$ kk -n ops-monitoring-ns get svc | grep 'alertmanager\|logs\|vmsingle'
atlas-victoriametrics-victoria-logs-single-server      ClusterIP   None             <none>        9428/TCP                     116d
vmalertmanager-vm-k8s-stack                            ClusterIP   None             <none>        9093/TCP,9094/TCP,9094/UDP   138d
vmsingle-vm-k8s-stack                                  ClusterIP   172.20.89.111    <none>        8429/TCP                     138d

Редагуємо vmalert-test-values.yaml:

...
  # VictoriaLogs Svc
  datasource:
    url: "http://atlas-victoriametrics-victoria-logs-single-server:9428"
...
  # Alertmanager Svc
  notifier:
    alertmanager:
      url: "http://vmalertmanager-vm-k8s-stack:9093"
...
  # VictoriaMetrics/Prometheus Svc
  remote:
    write:
      url: "http://vmsingle-vm-k8s-stack:8429"
...
    read:
      url: "http://vmsingle-vm-k8s-stack:8429"
...

Деплоїмо:

$ helm -n ops-monitoring-ns upgrade --install vmalert-test vm/victoria-metrics-alert -f vmalert-test-values.yaml

Перевіряємо Kubernetes Pod з VMalert:

$ kk -n ops-monitoring-ns get pod | grep vmalert-
vmalert-test-victoria-metrics-alert-server-6f485dc8b-tgcfd        1/1     Running     0             36s
vmalert-vm-k8s-stack-7d5bd6f955-dgx2r                             2/2     Running     0             47h

Тут vmalert-vm-k8s-stack-7d5bd6f955-dgx2r – це мій “дефолтний” VMAlert, а vmalert-test-victoria-metrics-alert-server – наш новий тестовий VMAlert.

Grafana Loki LogQL query => VictoriaLogs LogsQL query

В Grafana Loki в мене є такий Recording Rule:

kind: ConfigMap
apiVersion: v1
metadata:
  name: loki-alert-rules
data:
  rules.yaml: |-
    groups:
...
      - name: EKS-Pods-Metrics

        rules:

        - record: eks:pod:backend:api:path_duration:avg
          expr: |
            topk (10,
                avg_over_time (
                    {app="backend-api"} | json | regexp "https?://(?P<domain>([^/]+))" | line_format "{{.path}}: {{.duration}}"  | unwrap duration [5m]
                ) by (domain, path, node_name)
            )
...

Тут вичитуються логи з Kubernetes Pods нашого Backend API, з кожного запису створюється нове поле domain, і використовуються існуючі в логах поля path та duration.

А потім для кожного domain, path, node_name обчислюється average duration на виконання запиту.

Аби зробити аналогічний запит з VictoriaLogs LogsQL, нам потрібно:

  • вибрати логи з app:="backend-api"
  • створити поле domain
  • отримати значення path та duration
  • обчислити mean (average) за 5 хвилин по полю duration
  • згрупувати результат по полям domain, path, node_name

Знайдемо логи з VMLogs:

Далі:

  • додамо unpack_json, бо логи пишуться в JSON – парсимо його, і створюємо нові поля
  • додамо фільтр по полю http.url, бо частина записів в логах або не мають URL взагалі, або там адреса Kubernetes Pods у вигляді http://10.0.32.14:8080/ping – всякі Liveness && Readiness Probes, які нам не цікаві
  • використовуємо extract_regexp, аби з поля _msg створити нове поле domain
  • полів у нас тут забагато, всі вони нам не потрібні – використаємо fields pipe, і залишимо тільки ті, які будемо використовувати
  • можемо додати фільтр path:~".+", аби скіпнути всі записи з пустим path
app:="backend-api" | unpack_json | http.url:~"example.co" | extract_regexp "https?://(?P<domain>([^/]+))" | fields _time, path, duration, node_name, domain | path:~".+"

Замість фільтра http.url:~"example.co" можемо використати Sequence filter у формі http.url:seq("example.co") – але різниці у швидкості виконання запита не побачив:

Насправді для перформансу фільтр http.url:~"example.co" краще перенести на початок запиту, відразу за stream selector app:="backend-api", і спростити просто до Word filter "example.co" – але вже поробив скріни, тому ОК, тут нехай буде так, потім зробимо, як треба.

Тепер маємо потрібні записи, маємо потрібні поля – йдемо далі.

Далі нам потрібен stats pipe зі stats pipe function avg() за 5 хвилин зі значення в полі duration.

Додаємо в запит | stats by (_time:5m, path, node_name, domain) avg(duration) avg_duration.

Тут вже краще використати Time series візуалізацію в Grafana dashboard:

І давайте порівняємо результат з Loki.

Візьмемо якийсь домен, ноду, та URI, наприклад в Loki результат буде таким:

avg_over_time (
    {app="backend-api"} | json | regexp "https?://(?P<domain>([^/]+))" | line_format "{{.path}}: {{.duration}}" 
    | domain="api.challenge.example.co"
    | path="/coach/clients/{client_id}/accountability/groups"
    | node_name="ip-10-0-34-247.ec2.internal"
    | unwrap duration [5m]
) by (domain, path, node_name)

І в VictoriaLogs:

Значення “393” в обох випадках.

Гуд!

Тепер можемо власне переходити до Recording Rules.

Створення VictoriaLogs Recording Rules та Alerts

Для додавання Recording Rules в values чарту VMAlert є блок config.alerts.groups, в якому ми можемо з типом record описати або власне Recording Rule, або з типом alert – описати алерт.

Створення Recording Rule

Спочатку спробуємо Recording Rule.

Додаємо record: vmlogs:eks:pod:backend:api:path_duration:avg в наш файл vmalert-test-values.yaml:

...
  # -- VMAlert alert rules configuration.
  # Use existing configmap if specified
  configMap: ""
  # -- VMAlert configuration
  config:
    alerts:
      groups:
        - name: VmLogsEksPodsMetrics
          type: vlogs
          interval: 15s
          rules:
            - record: vmlogs:eks:pods:backend:api:path_duration:avg
              expr: |
                app:="backend-api" | unpack_json 
                | http.url:~"example.co" 
                | extract_regexp "https?://(?P<domain>([^/]+))" 
                | fields _time, path, duration, node_name, domain | path:~".+"
                | stats by (_time:5m, path, node_name, domain) avg(duration) avg_duration
...

Деплоїмо, глянемо логи тестового VMAlert:

$ ktail -n ops-monitoring-ns -l app.kubernetes.io/instance=vmalert-test
...
vmalert-test-victoria-metrics-alert-server-6469894c78-cmktk:vmalert {"ts":"2024-12-30T14:21:43.815Z","level":"info","caller":"VictoriaMetrics/app/vmalert/rule/group.go:486","msg":"group \"VmLogsEksPodsMetrics\" started; interval=15s; eval_offset=<nil>; concurrency=1"}
...

group \"VmLogsEksPodsMetrics\" started; – ОК.

Перевіряємо метрику vmlogs:eks:pods:backend:api:path_duration:avg в VMSingle:

Yay!

It works!

Створення Alert

Алерти можемо додати двома шляхами:

  • можемо описати новий алерт прямо в values чарту нового VMAlert, який буде виконувати запити напряму до VictoriaLogs
  • або, оскільки у нас є Recording Rule, який створює метрику – то ми можемо створити звичайний VMRule, який буде опрацьований оператором, і переданий до “дефолтного” VMAlert

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

Спочатку додамо алерт до файлу vmalert-test-values.yaml, поруч з нашим Recording Rule, в імені алерту вкажемо “Raw“:

...
  config:
    alerts:
      groups:
        - name: VmLogsEksPodsMetrics
          type: vlogs
          interval: 5s
          rules:

            - record: vmlogs:eks:pods:backend:api:path_duration:avg
              expr: |
                app:="backend-api" | unpack_json 
                | http.url:~"example.co" 
                | extract_regexp "https?://(?P<domain>([^/]+))" 
                | fields _time, path, duration, node_name, domain | path:~".+"
                | stats by (_time:5m, path, node_name, domain) avg(duration) avg_duration

            - alert: Test API Path duration Raw
              expr: |
                app:="backend-api" | unpack_json 
                | http.url:~"example.co" 
                | extract_regexp "https?://(?P<domain>([^/]+))" 
                | fields _time, path, duration, node_name, domain | path:~".+"
                | stats by (_time:5m, path, node_name, domain) avg(duration) as avg_duration
              for: 1s
              labels:
                severity: warning
                component: backend
                environment: dev
              annotations:
                summary: 'Test API Path duration Raw'
                description: |-
                  Request duration is too slow
                  *Domain Name*: `{{ $labels.domain }}`
                  *URI*: `{{ $labels.path }}`
                  *Duration*: `{{ $value | humanize }}`
                grafana_alb_overview_url: 'https://monitoring.ops.example.co/d/aws-alb-oveview/aws-alb-oveview?from=now-1h&to=now&var-domain={{ $labels.domain }}'
                tags: backend
...

Деплоїмо Helm з цим новим алертом:

$ helm -n ops-monitoring-ns upgrade --install vmalert-test vm/victoria-metrics-alert -f vmalert-test-values.yaml

Тепер створимо файл з VMRule з аналогічним алертом, але з метрики, яка створюється нашим Recording Rule – в ім’я алерту додаємо “VMSingle“:

apiVersion: operator.victoriametrics.com/v1beta1
kind: VMRule
metadata:
  name: alerts-vmlogs-test
spec:

  groups:

    - name: VMAlertVMlogsTest
      rules:
        - alert: Test API Path duration VMSingle
          expr: vmlogs:eks:pods:backend:api:path_duration:avg > 0
          for: 1s
          labels:
            severity: warning
            component: backend
            environment: dev
          annotations:
            summary: 'Test API Path duration VMSigle'
            description: |-
              Request duration is too slow
              *Domain Name*: `{{ $labels.domain }}`
              *URI*: `{{ $labels.path }}`
              *Duration*: `{{ $value | humanize }}`
            grafana_alb_overview_url: 'https://monitoring.ops.example.co/d/aws-alb-oveview/aws-alb-oveview?from=now-1h&to=now&var-domain={{ $labels.domain }}'
            tags: backend

Деплоїмо його:

$ kk -n ops-monitoring-ns apply -f test-alert.yaml
vmrule.operator.victoriametrics.com/alerts-vmlogs-test created

І чекаємо повідомлення від Alertmanager в Slack:

Гуд!

Працює.

Тепер можемо переносити цей конфіг до загального Helm-чарту нашого моніторингу.

VictoriaLogs, VMAlert, та чарт victoria-metrics-k8s-stack

Отже, в моєму проекті є наш власний чарт, в якому через Helm dependencies встановлюються такі чарти:

apiVersion: v2
name: atlas-victoriametrics
description: A Helm chart for Atlas Victoria Metrics Kubernetes monitoring stack
type: application
version: 0.1.1
appVersion: "1.17.0"
dependencies:
- name: victoria-metrics-k8s-stack
  version: ~0.31.0
  repository: https://victoriametrics.github.io/helm-charts
- name: victoria-metrics-auth
  version: ~0.8.0
  repository: https://victoriametrics.github.io/helm-charts
  condition: victoria-metrics-auth.enabled
- name: victoria-logs-single
  version: ~0.8.0
  repository: https://victoriametrics.github.io/helm-charts
...

А далі в values.yaml для кожного сабчарту задаються параметри.

VMAlert: datasource.url, VictoriaMetrics та VictoriaLogs

Що нам треба – це додати інтеграцію VMAlert з VictoriaLogs сюди, але є нюанс: VMAlert може мати тільки один параметр datasource.url, в якому зараз заданий Kubernetes Service з VMSingle – звідки VMAlert бере метрики для обчислення умов існуючих алертів:

$ kk -n ops-monitoring-ns describe pod vmalert-vm-k8s-stack-7d5bd6f955-m6mz4
...
Containers:
  vmalert:
    ...
    Args:
      -datasource.url=http://vmsingle-vm-k8s-stack.ops-monitoring-ns.svc.cluster.local.:8429
...

Але ж нам треба задати адресу VictoriaLogs, і при цьому залишити можливість запитів до VMSingle.

В документації VictoriaLogs How to use one vmalert for VictoriaLogs and VictoriaMetrics rules in the same time? описуються два варіанти рішення:

  • або просто мати два окремих інстанси VMAlert – один для метрик з VictoriaLogs, другий – для роботи з VictoriaLogs
  • або використати VMAuth, і в залежності від URI запиту від VMAlert роутити запити на потрібний бекенд – або VictoriaMetrics/VMSingle, або VictoriaLogs

Опція 1: два інстанси VMAlert

Перший варіант – запускати два VMAlert, і кожному передати власний datasource.url.

Але є питання – як в різні VMAlert передавати Recording Rules та власне Алерти?

Бо в мене Алерти описуються через ресурси VMRules, які з VictoriaMetrics Operator записуються в ConfigMap, який потім підключається до мого “дефолтного” VMAlert:

$ kk -n ops-monitoring-ns describe pod vmalert-vm-k8s-stack-7d5bd6f955-m6mz4
...
Volumes:
  ...
  vm-vm-k8s-stack-rulefiles-0:
    Type:      ConfigMap (a volume populated by a ConfigMap)
    Name:      vm-vm-k8s-stack-rulefiles-0
...

І цей ConfigMap містить в собі всі алерти:

$ kk get cm vm-vm-k8s-stack-rulefiles-0 -o yaml | head -n 30
apiVersion: v1
data:
  ops-monitoring-ns-alerts-alertmanager.yaml: |
    groups:
    - name: VM.Alertmanager.rules
      rules:
      - alert: Alertmanager Failed To Send Alerts
        annotations:
          description: |-
            Alertmanager failed to send {{ $value | humanizePercentage }} of notifications
            *Kubernetes cluster*: `{{ $labels.cluster }}`
            *Pod*: `{{ $labels.pod }}`
            *Integration*:  `{{ $labels.integration }}`
          summary: Alertmanager Failed To Send Alerts
          tags: devops
        expr: |-
          sum(
            rate(alertmanager_notifications_failed_total [5m])
            /
            rate(alertmanager_notifications_total [5m])
          ) by (cluster, integration, pod)
          > 0.01
        for: 1m
        labels:
          component: devops
          environment: ops
          severity: warning
  ops-monitoring-ns-alerts-aws-alb.yaml: |
    groups:
    - name: AWS.ALB.Logs.rules

Якщо робити схему з двома інстансами VMAlert з різними datasource.url – то для інстансу, який буде робити запити до VictoriaLogs нам потрібно створювати власний ConfigMap, і маунтити його з вальюсів цього інстансу VMAlert, без VMRules і участі VM Operator.

Хоча технічно, мабуть, можливо мати VMRules з Recording Rules та Alerts і два інстанси VMAlert, де в кожен інстанс будуть мапитись один і той самий ConfigMap і з RecordingRules, і з Alerts – але тоді один VMAlert буде постійно писати про помилки запитів до VictroriaMetrcis, а другий – про помилки запитів до VictoriaLogs.

Тому тут бачу тільки варіант з окремим ConfgiMap для RecordingRules, і окремо мати VMRules для алертів, як воно є зараз.

Мені така схема якось не дуже подобається, бо я хотів би і RecordingRules, і Алерти описувати через VMRules.

ОК, тоді розглянемо інший варіант – з VMAuth.

Опція 2: VMAuth і src_paths

Другий варіант – редіректити запити від єдиного інстансу VMAlert до VictoriaLogs та VictoriaMetrics/VMSingle через VMAuth.

В мене VMAuth вже є, писав про нього в пості VictoriaMetrics: VMAuth – проксі, аутентифікація та авторизація, де налаштована аутентифікація і вже є роути – я ним користуюсь для доступу до деяких внутрішніх ресурсів, коли мені ліньки робити kubectl port-forward.

Що нам треба – це додати ще пару src_paths:

  • /api/v1/query.* – для запитів до VictoriaMetrics/VMSingle
  • /select/logsql/.* – для запитів до VictoriaLogs

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

apiVersion: v1
kind: Secret
metadata:
  name: vmauth-config-secret
stringData:
  auth.yml: |-
    users:
    - username: vmadmin
      password: {{ .Values.vmauth_password }}
      url_map:
      - src_paths:
        - /alertmanager.*
        url_prefix: http://vmalertmanager-vm-k8s-stack.ops-monitoring-ns.svc:9093
      - src_paths:
        - /vmui.*
        url_prefix: http://vmsingle-vm-k8s-stack.ops-monitoring-ns.svc:8429
      - src_paths:
        - /prometheus.*
        url_prefix: http://vmsingle-vm-k8s-stack.ops-monitoring-ns.svc:8429
      - src_paths:
        - /api/v1/query.*
        url_prefix: http://vmsingle-vm-k8s-stack:8429
      - src_paths:
        - /select/logsql/.*
        url_prefix: http://atlas-victoriametrics-victoria-logs-single-server:9428
      default_url:
        - http://vmalertmanager-vm-k8s-stack.ops-monitoring-ns.svc:9093

Цей Secret передається в values для VMAuth:

...
victoria-metrics-auth:
  ingress:
    enabled: true
  ...
  secretName: vmauth-config-secret
...

Якщо у вас VMAuth не використовується, або працює без паролю – то простіше, бо для VMAlert просто можна задати datasource.url.

Якщо ж потрібна аутентифікація – то додамо ще один Kubernetes Secret з логіном та паролем:

apiVersion: v1
kind: Secret
metadata:
  name: vmauth-password
stringData:
  username: vmadmin
  password: {{ .Values.vmauth_password }}

Далі в вальюсах для VMAlert додаємо datasource.url та datasource.basicAuth:

...
  vmalert:
    annotations: {}
    enabled: true
    spec:
      datasource:
        basicAuth:
          username:
            name: vmauth-password
            key: username
          password:
            name: vmauth-password
            key: password
        url: http://atlas-victoriametrics-victoria-metrics-auth:8427    
...

Тут:

  • поле spec для VMAlert описується в VMAlertSpec і має поле datasource
    • поле datasource описується в VMAlertDatasourceSpec і має поля basicAuth та url
      • поле basicAuth описується в basicauth і має два поля – password та username
        • поля password та username описуються в SecretKeySelector, і мають два поля – name та key
          • поле name: ім’я Kubernetes Secret
          • поле key: ключ в цьому сікреті

Деплоїмо, і тепер наш VMAlert відправляє запити для алертів на VMAuth, а VMAuth редіректить їх до url_prefix: http://vmsingle-vm-k8s-stack:8429.

Додавання VMRule з RecordingRule

Тепер додамо новий VMRule, в якому опишемо RecordingRule, в якому будемо генерити метрику vmlogs:eks:pods:backend:api:path_duration:avg:

apiVersion: operator.victoriametrics.com/v1beta1
kind: VMRule
metadata:
  name: vmlogs-alert-rules
spec:

  groups:

    - name: VM-Logs-Backend-Pods-Logs
      # an expressions for the VictoriaLogs datasource
      type: vlogs
      rules:
        - record: vmlogs:eks:pods:backend:api:path_duration:avg
          expr: |
            app:="backend-api" "example.co" | unpack_json 
            | extract_regexp "https?://(?P<domain>([^/]+))" 
            | fields _time, path, duration, node_name, domain | path:~".+"
            | stats by (_time:5m, path, node_name, domain) avg(duration) avg_duration

Деплоїмо, перевіряємо новий VMRule:

$ kk get vmrule | grep vmlogs
vmlogs-alert-rules                                      4s              

Логи VMAlert – нова група створена:

$ ktail -l app.kubernetes.io/name=vmalert
...
vmalert-vm-k8s-stack-6c5cb6d76d-dxpbf:vmalert 2025-01-08T13:30:43.609Z  info    VictoriaMetrics/app/vmalert/rule/group.go:486   group "VM-Logs-Backend-Pods-Logs" will start in 1.540718685s; interval=15s; eval_offset=<nil>; concurrency=1
vmalert-vm-k8s-stack-6c5cb6d76d-dxpbf:vmalert 2025-01-08T13:30:45.151Z  info    VictoriaMetrics/app/vmalert/rule/group.go:486   group "VM-Logs-Backend-Pods-Logs" started; interval=15s; eval_offset=<nil>; concurrency=1
...

І перевіряємо нову метрику в VMSingle:

Готово.

Тепер можна мігрувати решту Recording Rules з Loki до VictoriaLogs.

Loading

Vector.dev: знайомство, логи з AWS S3 та інтеграція з VictoriaLogs
0 (0)

17 Грудня 2024

Отже, знов повертаємось до теми AWS VPC Flow Logs, VictoriaLogs, та Grafana dashboard.

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

Але там є маленький недолік – всі дані будуються з raw logs, які пишуться з VPC Flow Logs в AWS S3, з S3 їх збирає Promtail в AWS Lambda, і потім пише до VictoriaLogs.

Проблема: перформанс з raw logs

В цій Grafana dashboard з VictoriaLogs виконуються запити типу:

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

Де з extract отримуємо значення для нових полів прямо із логу.

І все це більш-менш працює, але максимальний період, за який вдається побудувати графіки – 24 години (з Loki було взагалі 30 хвилин).

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

В принципі, це можна було б зробити прямо з поточним сетапом – через Promtail. Щось схоже я робив в Grafana Loki: alerts from Ruler and labels from logs, але – ну не хочеться мені мати справу з Lambda Promtail від Grafana, бо мені навіть не вдалося оновити версію Promtail в моєму Docker image – а я не пам’ятаю, як робив перший. Тому в мене Promtail в Lambda досі той, який я створив ще у 2023 році – див. Loki: збір логів з CloudWatch Logs з використанням Lambda Promtail.

Тому замість Promtail вирішив спробувати Vector.dev. Він трохи складний в налаштуванні, але має просто безліч можливостей.

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

Тож сьогодні зробимо простенький Proof of Concept з Flow Logs, Vector.dev та VictoriaLogs:

  • встановимо Helm-чарт з Vector
  • створимо новий AWS S3, налаштуємо VPC Flow Logs з custom format для запису в цей бакет
  • подивимось, як ми можемо збирати логи з S3 до Vector.dev і додавати нові поля
  • і порівняємо швидкість роботи з raw logs vs логи з Vector з полями

Vector.dev

Отже, що таке Vector.dev?

Vector is a high-performance observability data pipeline that puts organizations in control of their observability data. Collecttransform, and route all your logs, metrics, and traces to any vendors

Тобто основна ідея – збирати будь-які дані моніторингу, будь то метрики або логи, виконувати над ними якісь дії, і потім кудись писати.

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

Components

Див. Concepts.

Нас зараз цікавлять три компоненти:

  • Sources: звідки збираємо дані
  • Transforms: що ми з даними робимо
  • Sinks: куди ми оброблені дані передаємо далі

В нашому випадку Sources буде AWS S3, в Transforms – будемо парсити логи VPC FLow logs і створювати нові fields, а в Sinks – використаємо Elasticsearch Sink для VictoriaLogs, див. документацію по Vector setup в VictoriaLogs docs.

Взагалі, Vector має окремий Loki Sink, але з ним більше проблем, ніж користі, а з Elasticsearch (або HTTP) все запрацювало без проблем.

Запуск в Kubernetes з Helm

Документація по запуску з Helm – в Install Vector on Kubernetes та в самому чарті – README.md.

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

$ helm repo add vector https://helm.vector.dev
"vector" has been added to your repositories
$ helm repo update

Встановлюємо Vector – поки з дефолтними параметрами, потім створимо власний values.yaml:

$ helm install vector vector/vector
NAME: vector
LAST DEPLOYED: Mon Dec  2 15:13:30 2024
...

Переходимо до VPC Flow Logs.

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

Далі, нам потрібна S3 корзина, в яку ми будемо писати VPC Flow Logs, і SQS, в яку будуть відправлятись повідомлення, коли в S3 створюються нові об’єкти, тобто логи.

Потім Vector буде читати повідомлення з цієї SQS, і забирати логи з S3.

Створення AWS SQS

Документація по SQS для S3 – Walkthrough: Configuring a bucket for notifications (SNS topic or SQS queue).

Створюємо нову чергу:

Тип – Standart:

Задаємо Access policy:

{
  "Version": "2012-10-17",
  "Id": "example-ID",
  "Statement": [
    {
      "Sid": "vpc-ops-flow-vmlogs-s3-allow",
      "Effect": "Allow",
      "Principal": {
        "Service": "s3.amazonaws.com"
      },
      "Action": "SQS:SendMessage",
      "c": "arn:aws:sqs:us-east-1:492***148:s3-vector-vmlogs-queue",
      "Condition": {
        "StringEquals": {
          "aws:SourceAccount": "492***148"
        },
        "ArnLike": {
          "aws:SourceArn": "arn:aws:s3:*:*:s3-vector-vmlogs-flow-logs-bucket"
        }
      }
    }
  ]
}

В Resource вказуємо ім’я нашої queue, а в Condition – дозволяємо доступ з ID нашого акаунту та S3-бакету з ім’ям s3-vector-vmlogs-flow-logs-bucket:

Тут все – параметри Dead-letter queue залишаємо дефолтні, клікаємо Create, і переходимо до S3.

Створення AWS S3

Створюємо новий S3 бакет з ім’ям s3-vector-vmlogs-flow-logs-bucket – як ми задали в SQS Access Policy.

ACL нам зараз не потрібна, але Block Public Access лишаємо в дефолтному Block All:

 

Клікаємо Create, переходимо в Properties > Event notifications:

Задаємо Event name, в Event types вибираємо s3:ObjectCreated:*:

В Destination задаємо нашу SQS:

Клікаємо Save changes, і переходимо вже до VPC Flow Logs.

Створення VPC Flow Logs до S3

Створюємо новий Flow Log.

Якщо у вас VPC створюється з Terraform – то можна використати ресурс aws_flow_log:

resource "aws_flow_log" "vpc_flow_vector" {
  vpc_id               = module.vpc.vpc_id
  log_destination      = "arn:aws:s3:::s3-vector-vmlogs-flow-logs-bucket"
  log_destination_type = "s3"
  traffic_type         = "ALL"
  log_format = "$${region} $${vpc-id} $${az-id} $${subnet-id} $${instance-id} $${interface-id} $${flow-direction} $${srcaddr} $${dstaddr} $${srcport} $${dstport} $${pkt-srcaddr} $${pkt-dstaddr} $${pkt-src-aws-service} $${pkt-dst-aws-service} $${traffic-path} $${packets} $${bytes} $${action}"
  tags = {
    "Name" = "flow-logs-s3-to-vector"
  }
}

Або робимо руками – переходимо в VPC, вкладка Flow logs, клікаємо Create flow log – тут я вже маю два Flow Logs для Promtail Lambda:

В Destination задаємо Send to an Amazon S3 bucket, і вказуємо ARN нашого бакета:

Я завжди використовую Custom format з додатковими полями:

Зберігаємо, і перевіряємо статус:

Все зелененьке, працює.

Можна зачекати 10 хвилин (дефолтний період доставки логів), і перевірити дані в самій S3:

І вкладку Monitoring в SQS:

Налаштування Vector.dev

Ну а тепер саме цікаве.

Отже, що нам треба:

  • додати Source S3 з параметром SQS – звідки будемо збирати логи
  • додати трансформацію – створення нових fields
  • і додати Sink для VictoriaLogs – куди будемо писати

Тобто створюється такий собі pipeline – Source збирає дані, Transform їх трансформує, а Sink – передає оброблені дані далі, в нашому випадку до VictoriaLogs.

Документація по AWS S3 source – тут>>>.

Документація по Transformations – тут>>>.

Документація по всім Sinks – тут>>>, і по Loki – тут>>>, але ми будемо використовувати інший, Elasticsearch.

Документація по Elasticsearch Sink в Vector.dev – тут>>>, і документація по Elasticsearch data ingest в VictoriaLogs – тут>>>.

Також може бути цікавим – як з Vector збирати логи зі звичайних файлів – тут>>>.

І ще цікавий use case – збирати логи Kubernetes, і пушити їх в AWS S3 – див. How to Collect, Transform, and Ship Logs from AWS S3 to Codegiant Observability Using Vector.

З документацією розібрались – поїхали конфігуряти.

Vector.dev: Sources – S3

Першим налаштуємо збір логів з AWS S3 бакету. Для цього нам потрібні такі параметри:

  • type: aws_s3
  • auth: як будемо виконувати аутентифікацію
    • поки зробимо банальним Access/Secret ключами, коли будемо це запускати в Production – то додамо EKS Pod Identity з IAM Role, яка буде дозволяти доступ Kubernetes Pod з Vector до S3 та SQS
  • sqs.queue_url: звідки Vector буде отримувати інформацію, що в S3 з’явились нові логи

Задавати параметри будемо через Helm chart values і параметр customConfig, до якого є важливий коментар:

# customConfig — Override Vector’s default configs, if used **all** options need to be specified.

Тобто, нам потрібно буде задати всі параметри.

Тому зараз конфіг буде таким:

image:
  repository: timberio/vector
  pullPolicy: IfNotPresent

replicas: 1

service:
  enabled: false

customConfig:

  sources:
    s3-vector-vmlogs-flow-logs-bucket: # source name to be used later in Transforms
      type: aws_s3
      region: us-east-1
      compression: gzip
      auth:
        region: us-east-1
        access_key_id: AKI***B7A
        secret_access_key: pAu***2gW
      sqs:
        queue_url: https://sqs.us-east-1.amazonaws.com/492***148/s3-vector-vmlogs-queue

Vector.dev: Transforms – remap та VRL

Transforms є багато, але нам зараз цікавий remap, в якому з Vector Remap Language (VRL) ми можемо виконувати прям безліч всяких операцій.

VRL – це domain-specific language (DSL) для самого Vector.dev, в якому є різні функції для роботи з даними.

Є навіть VRL Playground, де можна спробувати що і як працює.

З того, що може бути цікавим нам – це Parse functions, а саме – функція parse_aws_vpc_flow_log. А для роботи з AWS Load Balancer logs – є функція parse_aws_alb_log.

Сама parse_aws_vpc_flow_log описується тут – parse_aws_vpc_flow_log.rs.

А приклади є тут – VRL example reference.

Що ми нею можемо зробити – передати їй на “вхід” дані з наших логів, і задати custom format.

Самий простий конфіг, з яким власне все працює так, як мені треба, виглядає так:

...
  transforms:

    s3-vector-vmlogs-flow-logs-transform:
      type: remap
      inputs:
        - s3-vector-vmlogs-flow-logs-bucket # a name from the 'sources', can have several Inputs
      source: |
        . = parse_aws_vpc_flow_log!(
          .message,
          format: "region vpc_id az_id subnet_id instance_id interface_id flow_direction srcaddr dstaddr srcport dstport pkt_srcaddr pkt_dstaddr pkt_src_aws_service pkt_dst_aws_service traffic_path packets bytes action"
        )

Якщо хочеться виконати якісь операції над полями – то можна оформити таким чином:

...
      source: |
        .parsed = parse_aws_vpc_flow_log!(
          .message,
          format: "region vpc_id az_id subnet_id instance_id interface_id flow_direction srcaddr dstaddr srcport dstport pkt_srcaddr pkt_dstaddr pkt_src_aws_service pkt_dst_aws_service traffic_path packets bytes action"
        )

        .region = .parsed.region
        .vpc_id = .parsed.vpc.id
        .az_id = .parsed.az_id
        .subnet_id = .parsed.subnet_id
        .instance_id = .parsed.instance_id
        .interface_id = .parsed.interface_id
        .account_id = .parsed.account_id
        .srcaddr = .parsed.srcaddr
        .dstaddr = .parsed.dstaddr
        .srcport = .parsed.srcport
        .dstport = .parsed.dstport
        .protocol = .parsed.protocol
        .packets = to_int(.parsed.packets)
        .bytes = to_int(.parsed.bytes)

        del(.parsed)
...

Тут ми створюємо власні поля region, vpc_id etc, приводимо поля packets та bytes до типу integer, і в кінці видаляємо весь message з .parsed викликом Path function del().

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

Vector.dev: Sinks – Elasticsearch та VictoriaLogs

І останнім нам потрібно задати Sink.

Я пробував це робити з Loki Sink, але з ним так і не вийшло правильно оформити нові поля, тому по рекомендації розробників VictoriaLogs просто взяв Elasticsearch Sink.

Описуємо наш конфіг:

...
  sinks:

    s3-flow-logs-to-victorialogs:
      inputs:
        - s3-vector-vmlogs-flow-logs-transform # a Transform name to get processed data from
      type: elasticsearch
      endpoints:
        - http://atlas-victoriametrics-victoria-logs-single-server:9428/insert/elasticsearch/ # VictoriaLogs Kubernetes Service URL and Elasticsearch endpoint
      api_version: v8
      compression: gzip
      healthcheck:
        enabled: false
      query: # HTTP query params
        extra_fields: source=vector # add a custom label
        # _msg_field: message # ommited here, as we have everything in the fields from the Transform, but may be used for other data
        _time_field: timestamp # set the '_time' field for the VictoriaLogs
        _stream_fields: source,vpc_id,az_id # create Stream fields for the VictoriaLogs to save data in a dedicated Stream; specify fields without spaces

Власне, я тут наче все додав в коменти, але пройдемось ще:

  • inputs: задаємо ім’я Transform, з якого беремо дані
  • endpoints: передаємо адресу VictoriaLogs в нашому Kubernetes кластері
  • healthcheck: відключаємо, бо VictoriaLogs поки не підтримує /ping ендпоінт
  • query: передаємо додаткові параметри, див. VictoriaLogs HTTP
    • в _stream_fields описуємо по яким полям VictoriaLogs буде створювати log stream – див. Stream fields

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

image:
  repository: timberio/vector
  pullPolicy: IfNotPresent

replicas: 1

service:
  enabled: false

customConfig:

  sources:
    s3-vector-vmlogs-flow-logs-bucket: # source name to be used later in Transforms
      type: aws_s3
      region: us-east-1
      compression: gzip
      auth:
        region: us-east-1
        access_key_id: AKI***B7A
        secret_access_key: pAu***2gW
      sqs:
        queue_url: https://sqs.us-east-1.amazonaws.com/492***148/s3-vector-vmlogs-queue

  transforms:

    s3-vector-vmlogs-flow-logs-transform: # a name from the 'sources', can have several Inputs
      type: remap
      inputs:
        - s3-vector-vmlogs-flow-logs-bucket
      source: |
        . = parse_aws_vpc_flow_log!(
          .message,
          format: "region vpc_id az_id subnet_id instance_id interface_id flow_direction srcaddr dstaddr srcport dstport pkt_srcaddr pkt_dstaddr pkt_src_aws_service pkt_dst_aws_service traffic_path packets bytes action"
        )

  sinks:

    s3-flow-logs-to-victorialogs:
      inputs:
        - s3-vector-vmlogs-flow-logs-transform # a Transform name to get processed data from
      type: elasticsearch
      endpoints:
        - http://atlas-victoriametrics-victoria-logs-single-server:9428/insert/elasticsearch/ # VictoriaLogs Kubernetes Service URL and Elasticsearch endpoint
      api_version: v8
      compression: gzip
      healthcheck:
        enabled: false
      query: # HTTP query params
        extra_fields: source=vector # add a custom label
        # _msg_field: message # ommited here, as we have everything in the fields from the Transform, but may be used for other data
        _time_field: timestamp # set the '_time' field for the VictoriaLogs
        _stream_fields: source,vpc_id,az_id # create Stream fields for the VictoriaLogs to save data in a dedicated Stream; specify fields without spaces

Деплоїмо наші зміни:

$ helm upgrade --install vector vector/vector -f vector-values.yaml

В логах чомусь помилка обробки поля srcport з Flow Logs:

ERROR transform{component_kind="transform" component_id=s3-vector-vmlogs-flow-logs-transform component_type=remap}: vector::internal_events::remap: Mapping failed with event. error="function call error for \"parse_aws_vpc_flow_log\" at (4:254): failed to parse value as i64 (key: `srcport`): `srcport`" error_type="conversion_failed" stage="processing" internal_log_rate_limit=true

Чому – не знаю, бо поле таке саме і в Flow Logs, і в нашому custom format. Але воно наче ні на що не впливає, пізніше зроблю GitHub Issue, спитаю.

Чекаємо, коли з S3 прийдуть дані, і перевіряємо в нашій VictoriaLogs, використовуючи _stream: {source="vector", vpc_id="vpc-0fbaffe234c0d81ea", az_id="use1-az2"} – поля, які ми задавали в _stream_fields:

Вау!

“It works!” (c)

Grafana та VictoriaLogs

Давайте глянемо, як це все працює в Grafana.

Спершу – просто перевіримо дані там:

В моїй Grafana dashboard є така панелька:

З таким запитом:

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

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

{source="vector", vpc_id="vpc-0fbaffe234c0d81ea", az_id="use1-az2"} interface_id:="eni-0352f8c82da6aa229" action:="ACCEPT" pkt_dstaddr:ipv4_range("10.0.32.0/20")
  | stats by (pkt_srcaddr, srcport, pkt_dstaddr, dstport) sum(bytes) bytes_total 
  | sort by (bytes_total) desc

Performance: “raw logs” vs “fielded logs”

І порівняємо швидкість такого запиту із запитом з сирих логів.

Старий запит, візьмемо 3 години:

Новий запит за ті ж 3 години:

Різниця у 2 рази.

При цьому ресурси самого Vector:

$ kk top pod vector-0
NAME       CPU(cores)   MEMORY(bytes)   
vector-0   3m           104Mi           

І VictoriaLogs:

$ kk top pod atlas-victoriametrics-victoria-logs-single-server-0
atlas-victoriametrics-victoria-logs-single-server-0   12m   840Mi

Можна пробувати цю схему запускати в Production.

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

Loading

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

11 Грудня 2024

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

$ docker exec -ti nexus bash                          
bash-4.4$

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

root@addeba5d307c:/# pip uninstall setuptools

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

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

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

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

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

Запуск Nexus в Kubernetes

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

  config:  
    enabled: true
    anonymous:
      enabled: true

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

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

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

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

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

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

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

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

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

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

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

Деплоїмо:

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

real    0m3.958s

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

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

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

root@pod:/# pip uninstall setuptools

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

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

real    0m2.364s

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Трафік:

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

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

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

Loading

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

4 Грудня 2024

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

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

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

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

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

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

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

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

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

Terraform

S3 та Promtail Lambda

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

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

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

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

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

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

Створення S3 buckets

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

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

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

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

resource "aws_s3_bucket" "s3_logs" {
  for_each = local.logs_bucket_names

  bucket = each.value

  # to drop a bucket, set to `true` first
  # run `terraform apply`
  # then remove the block
  # and run `terraform apply` again
  force_destroy = true
}

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

  bucket = each.value.id

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

    expiration {
      days = 30
    }
  }
}

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

  bucket = each.value.id

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

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

  bucket = each.value.id

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

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

  bucket = each.value.id

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

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

  bucket = each.value.id

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

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

Файл lambda.tf:

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

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

  # 'ops-1-30-loki-lambda-sg'
  name        = "${var.aws_env}-${var.eks_version}-lambda-${var.logger_type}-sg"
  description = "Security Group for Lambda Egress"

  vpc_id = var.vpc_id

  egress_cidr_blocks      = var.vpc_private_subnets_cidrs
  egress_ipv6_cidr_blocks = []
  egress_prefix_list_ids  = [data.aws_prefix_list.s3.id]

  ingress_cidr_blocks      = var.vpc_private_subnets_cidrs
  ingress_ipv6_cidr_blocks = []

  egress_rules  = ["https-443-tcp"]
  ingress_rules = ["https-443-tcp"]
}

# S3 buckets names:

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Collect ALB Logs to Loki module

S3:

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

Lambda:

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

*/

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Модуль VPC та Flow Logs

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

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

  ...

  enable_flow_log = var.vpc_params.enable_flow_log

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

  # disalbe to use S3
  create_flow_log_cloudwatch_log_group = false
  create_flow_log_cloudwatch_iam_role  = false

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

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

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

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

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

resource "aws_flow_log" "vpc_flow_vmlogs" {
  vpc_id               = module.vpc.vpc_id
  log_destination      = "arn:aws:s3:::ops-1-30-devops-vpc-ops-flow-vmlogs-logs"
  log_destination_type = "s3"
  traffic_type         = "ALL"
  log_format = "$${region} $${vpc-id} $${az-id} $${subnet-id} $${instance-id} $${interface-id} $${flow-direction} $${srcaddr} $${dstaddr} $${srcport} $${dstport} $${pkt-srcaddr} $${pkt-dstaddr} $${pkt-src-aws-service} $${pkt-dst-aws-service} $${traffic-path} $${packets} $${bytes} $${action}"
  tags = {
    "Name" = "flow-logs-s3-to-vmlogs"
  }
}

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

Створення Grafana dashboard

NAT Gateway Total processed

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

Запит в Loki

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

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

Тут:

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

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

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

Запит з VictoriaLogs

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

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

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

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

Тут ми:

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

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

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

Зробимо такий запит в Logs Insights:

parse @message "* * * * * * * * * * * * * * * * * * *" 
| as region, vpc_id, az_id, subnet_id, instance_id, interface_id, 
| flow_direction, srcaddr, dstaddr, srcport, dstport, 
| pkt_srcaddr, pkt_dstaddr, pkt_src_aws_service, pkt_dst_aws_service, 
| traffic_path, packets, bytes, action 
| filter ((interface_id="eni-0352f8c82da6aa229") AND ((isIpv4InSubnet(pkt_srcaddr,"10.0.32.0/20") OR isIpv4InSubnet(pkt_dstaddr,"10.0.32.0/20") )) | stats sum(bytes) as bytes_total

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

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

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

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

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

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

А в VMLogs – в 13:05:29:

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

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

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

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

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

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

NAT Gateway Total OUT та IN processed

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

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

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

Запити Loki

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

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

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

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

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

Запити VictoriaLogs

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

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

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

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

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

NAT Gateway Total processed bytes/sec

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

Запит Loki

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

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

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

Запит VictoriaLogs

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

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

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

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

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

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

Тут:

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

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

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

Kubernetes Pods IN From IP

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

Запит Loki

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

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

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

Запит VictoriaLogs

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

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

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

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

І що ми маємо:

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

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

Давайте перевіримо IP 20.150.90.164 в CloudWatch Logs Insights з таким запитом:

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

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

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

Kubernetes Pods IN From IP bytes/sec

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

Запит Loki

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

Запит VictoriaLogs

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

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

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

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

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

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

Kubernetes Pods IN by IP and Port

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

Запит Loki

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

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

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

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

Запит VictoriaLogs

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

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

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

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

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

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

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

І ресурси:

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

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

І ресурси:

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

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

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

Що далі?

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

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

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

Loading

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

29 Листопада 2024

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Інтернет

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

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

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

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

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

Освітлення

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

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

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

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


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

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

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

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

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

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

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

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

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

Датчики диму

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

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

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

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

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

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

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

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

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

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

Loading

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

19 Листопада 2024

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

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

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

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

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

Тест Load Balancer Controller IngressGroup

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

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

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

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

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

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

Деплоїмо:

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Наприклад:

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

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

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

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

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

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

Моніторинг

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

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

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

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

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

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

Loading

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

16 Листопада 2024

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

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

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

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

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

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

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

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

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

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

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

Маємо:

Terraform та VPC Flow Logs до S3

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

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

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

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

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

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

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

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

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

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

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

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

...
  enable_flow_log = var.vpc_params.enable_flow_log

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

  # disalbe to use S3
  create_flow_log_cloudwatch_log_group = false
  create_flow_log_cloudwatch_iam_role  = false

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

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

  # set custom log format for more detailed information
  flow_log_log_format = "$${region} $${vpc-id} $${az-id} $${subnet-id} $${instance-id} $${interface-id} $${flow-direction} $${srcaddr} $${dstaddr} $${srcport} $${dstport} $${pkt-srcaddr} $${pkt-dstaddr} $${pkt-src-aws-service} $${pkt-dst-aws-service} $${traffic-path} $${packets} $${bytes} $${action}"

...

Нас тут зараз цікавлять такі параметри:

  • flow_log_destination_type: замість дефолтного cloud-watch-logs задаємо s3
  • create_flow_log_cloudwatch_log_group та create_flow_log_cloudwatch_iam_role: відключаємо створення ресурсів для CloudWatch Logs
  • flow_log_destination_arn: задаємо ARN корзини, в яку будуть писатись логи
  • flow_log_log_format: створюємо власний формат, аби мати більше інформації, в тому числі з IP подів в Kubernetes, див. VPC Flow Log – Custom format

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

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

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

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

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

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

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

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

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

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

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

Тут:

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

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

NAT Gateway та traffic_path

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

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

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

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

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

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

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

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

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

Створення Grafana dashboard

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

Планування

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

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

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

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

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

NAT Gateway total traffic processed

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

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

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

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

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

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

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

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

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

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

NAT Gateway Egress та Ingress traffic processed – Stat

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Grafana Data links

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Додаємо Transformations:

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

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

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

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

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

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

Loading

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

24 Вересня 2024

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Запуск Actions Runner Controller з Helm

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Тест з GitHub Actions Workflow

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

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

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

name: "Test GitHub Runners"

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

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

jobs:

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

      - name: Test Runner
        run: echo $HOSTNAME

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

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

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

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

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

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

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

Створення Karpenter NodePool

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

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

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

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

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

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

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

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

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

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

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

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

runnerScaleSetName: "test-runners"

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

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

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

Деплоїмо:

$ make deploy-helm-runners-test

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

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

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

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

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

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

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

Білд Backend API

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

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

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

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

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

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

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

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

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

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

І білд пішов:

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Пушимо:

$ docker push 492***148.dkr.ecr.us-east-1.amazonaws.com/github-runners/kraken

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

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

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

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

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

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

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

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

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

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

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

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

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

containerMode:
  type: "dind"

template:
  spec:
...

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

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

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

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

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

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

Docker in Docker та Docker volumes

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

runnerScaleSetName: "kraken-eks-runners"

containerMode:
  type: "dind"

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Loading

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

22 Вересня 2024

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

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

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

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

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

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

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

VictoriaMetrics Cloud Authentification

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

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

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

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

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

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

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

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

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

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

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

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

В Destination – HTTP Endpoint:

Задаємо Firehose stream name:

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

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

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

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

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

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

Cloudwatch Metrics to AWS Data Firehose

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

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

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

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

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

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

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

Перевірка Firehose Stream

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

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

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

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

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

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

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

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

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

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

Готово.

Loading

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

17 Вересня 2024

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

Наприклад в моєму випадку ми не хочемо, аби EC2 вбивались в робочі часи по США, бо там у нас клієнти, а тому зараз маємо consolidationPolicy=whenEmpty, аби запобігти “зайвому” видаленню серверів та Pods на них.

Натомість з Disruption budgets ми можемо налаштувати політики таким чином, що в один період часу будуть дозволені операції з WhenEmpty, а в інший – WhenEmptyOrUnderutilized.

Див. також Kubernetes: забезпечення High Availability для Pods – бо при використанні Karpenter навіть при налаштованих Disruption budgets необхідно мати відповідно налаштовані поди з Topology Spread та PodDisruptionBudget.

Типи Karpenter Disruption

Документація – Automated Graceful Methods.

Спочатку глянемо, в яких випадках Disruption взагалі відбувається:

  • Drift: виникає, коли є різниця між створеними конфігураціями NodePools або EC2NodeClass та існуючими WorkerNodes – тоді Karpenter почне перестворювати EC2 аби привести їх у відповідність до заданих параметрів
  • Interruption: якщо Karpenter отримує AWS Event, що інстанс буде виключено, наприклад – якщо це Spot
  • Consolidation: якщо маємо налаштування Consolidation на WhenEmptyOrUnderutilized або WhenEmpty, і Karpenter переносить наші Pods на інші WorkerNodes
    • у нас Karpenter 1.0, тому полісі WhenEmptyOrUnderutilized, для 0.37 це WhenUnderutilized

Karpenter Disruption Budgets

За допомогою Disruption budgets ми можемо дуже гнучко налаштувати в який час і які операції Karpenter може проводити, і задати ліміт на те, скільки WorkerNodes одночасно будуть видалятись.

Документація – NodePool Disruption Budgets.

Формат конфігурації доволі простий:

budgets:
- nodes: "20%"
  reasons: 
  - "Empty"
  schedule: "@daily"
  duration: 10m

Тут ми задаємо:

  • дозволити видалення WorkerNodes для 20% від загальної кількості
  • для операції, коли Disruption викликаний умовою WhenEmpty
  • виконуємо це кожен день
  • на протязі 10 хвилин

Параметри тут можуть мати значення:

  • nodes: в процентах або просто кількості нод
  • reasons: Drifted, Underutilized або Empty
  • schedule: розклад, за яким правило застосовується, в UTC (інші таймзони поки не підтримуються), див. Kubernetes Schedule syntax
  • duration: і скільки часу правило діє, наприклад – 1h15m

При цьому не обов’язково задавати всі параметри.

Наприклад, ми можемо описати два таких бюджети:

- nodes: "25%"
- nodes: "10"

Тоді у нас постійно будуть працювати обидва правила, і перше обмежує кількість нод в 25% від загальної кількості, а друге – не більше як 10 інстансів – якщо у нас більш ніж 40 серверів.

Також, Budgets можна комбінувати, і якщо їх задано кілька – то ліміти будуть братись по найбільш суворому.

В першому прикладі ми застосовуємо правило на 20% нод і умові WhenEmpty, а решту часу будуть працювати дефолтні правила disruption – тобто, 10% від загальної кількості серверів із заданою consolidationPolicy.

Тому можемо записати правило так:

budgets:
- nodes: "20%"
  reasons: 
  - "Empty"
  schedule: "@daily"
  duration: 10m
- nodes: 0

Тут останнє правило працює постійно, і буде таким собі запобіжником: ми забороняємо все, але дозоляємо виконувати disruption за політикою WhenEmpty на протязі 10 хвилин раз на добу починаючи з 00:00 UTC.

Приклад Disruption Budgets

Повертаючись до моєї задачі:

  • маємо Backend API в Kubernetes на окремому NodePool, а наші клієнти в основному з США, тому ми хочемо мінімізувати down-скейлінг WorkerNodes в робочий час по США
  • для цього ми хочемо заблокувати всі операції по WhenUnderutilized в період робочого часу по Central Time USA
    • в schedule Karpenter використовує зону UTC, тому початок робочого дня по Central Time USA 9:00 – це 15:00 UTC
  • операції з WhenEmpty дозволимо в будь-який час, але тільки по 1 WorkerNode одночасно
  • Drift – аналогічно, бо коли я деплою зміни – то хочу побачити результат відразу

Фактично, нам потрібно задати два бюджети:

  • по Underutilized – забороняємо все з понеділка по п’ятницю на протязі 9 годин починаючи з 15:00 по UTC
  • по Empty та Drifted – дозволяємо в будь-який час, але тільки по 1 ноді, а не дефолтні 10%

Тоді наш NodePool буде виглядати так:

apiVersion: karpenter.sh/v1
kind: NodePool
metadata:
  name: backend1a
spec:
  template:
    metadata:
      labels:
        created-by: karpenter
        component: devops
    spec:
      taints:
        - key: BackendOnly
          operator: Exists
          effect: NoSchedule
      nodeClassRef:
        group: karpenter.k8s.aws
        kind: EC2NodeClass      
        name: defaultv1a
      requirements:
        - key: karpenter.k8s.aws/instance-family
          operator: In
          values: ["c5"]
        - key: karpenter.k8s.aws/instance-size
          operator: In
          values: ["large", "xlarge"]
        - key: topology.kubernetes.io/zone
          operator: In
          values: ["us-east-1a"]
        - key: karpenter.sh/capacity-type
          operator: In 
          values: ["spot", "on-demand"]
  # total cluster limits 
  limits:
    cpu: 1000
    memory: 1000Gi
  disruption:
    consolidationPolicy: WhenEmptyOrUnderutilized
    consolidateAfter: 600s
    budgets:
      - nodes: "0"                   # block all
        reasons:
        - "Underutilized"            # if reason == underutilized
        schedule: "0 15 * * mon-fri" # starting at 15:00 UTC during weekdays
        duration: 9h                 # during 9 hours
      - nodes: "1"                   # allow by 1 WorkerNode at a time
        reasons:
        - "Empty"
        - "Drifted"

Деплоїмо, перевіряємо NodePool:

$ kk describe nodepool backend1a   
Name:         backend1a
...
API Version:  karpenter.sh/v1
Kind:         NodePool
...
Spec:
  Disruption:
    Budgets:
      Duration:  9h
      Nodes:     0
      Reasons:
        Underutilized
      Schedule:  0 15 * * mon-fri
      Nodes:     1
      Reasons:
        Empty
        Drifted
    Consolidate After:     600s
    Consolidation Policy:  WhenEmptyOrUnderutilized
...

І в логах бачимо, що спрацював Disruption по WhenUnderutilized:

karpenter-55b845dd4c-tlrdr:controller {"level":"INFO","time":"2024-09-16T10:48:26.777Z","logger":"controller","message":"disrupting nodeclaim(s) via delete, terminating 1 nodes (2 pods) ip-10-0-42-250.ec2.internal/t3.small/spot","commit":"62a726c","controller":"disruption","namespace":"","name":"","reconcileID":"db2233c3-c64b-41f2-a656-d6a5addeda8a","command-id":"1cd3a8d8-57e9-4107-a701-bd167ed23686","reason":"underutilized"}
karpenter-55b845dd4c-tlrdr:controller {"level":"INFO","time":"2024-09-16T10:48:27.016Z","logger":"controller","message":"tainted node","commit":"62a726c","controller":"node.termination","controllerGroup":"","controllerKind":"Node","Node":{"name":"ip-10-0-42-250.ec2.internal"},"namespace":"","name":"ip-10-0-42-250.ec2.internal","reconcileID":"f0815e43-94fb-4546-9663-377441677028","taint.Key":"karpenter.sh/disrupted","taint.Value":"","taint.Effect":"NoSchedule"}
karpenter-55b845dd4c-tlrdr:controller {"level":"INFO","time":"2024-09-16T10:50:35.212Z","logger":"controller","message":"deleted node","commit":"62a726c","controller":"node.termination","controllerGroup":"","controllerKind":"Node","Node":{"name":"ip-10-0-42-250.ec2.internal"},"namespace":"","name":"ip-10-0-42-250.ec2.internal","reconcileID":"208e5ff7-8371-442a-9c02-919e3525001b"}

Готово.

Loading