September 2

OpenTelemetry: OTel Collectors в Kubernetes и интеграция со стеком VictoriaMetrics

Это перевод оригинальной статьи OpenTelemetry: OTel Collectors in Kubernetes and VictoriaMetrics Stack integration.

Перевод сделан специально для телеграм-канала Мониторим ИТ. Подписывайтесь! Там еще больше полезных постов о мониторинге.

Сегодня поговорим о том, как запустить OpenTelemetry в Kubernetes и интегрировать его со стеком VictoriaMetrics — VictoriaMetrics для метрик, VictoriaLogs для логов и VictoriaTraces для трейсов.

Содержание

  • OpenTelemetry, наблюдаемость и контекст
  • Плюсы и минусы OpenTelemetry
  • VictoriaMetrics и мой текущий стек мониторинга
  • OpenTelemetry — общая архитектура и компоненты
  • Структура конфигурации OpenTelemetry Collector
  • Пайплайны OpenTelemetry
  • OpenTelemetry: запуск в Kubernetes
  • Запуск OpenTelemetry Gateway
  • Проверка метрик
  • Проблема кардинальности
  • Проверка логов
  • Добавление transform для логов
  • Запуск Kubernetes Agent
  • Grafana и запросы Prometheus vs OpenTelemetry

OpenTelemetry, наблюдаемость и контекст

Основная идея наблюдаемости заключается в контексте, потому что контекст, как ни странно, касается не только ИИ/LLM, но и мониторинга и наблюдаемости.

Вкратце, наблюдаемость строится на «трех столпах наблюдаемости» — метриках, логах и трейсах.

Но одних только метрик, логов и трейсов недостаточно — потому что все три наших столпа должны иметь общие атрибуты, общие данные, которые позволили бы обеспечить «сквозную наблюдаемость» — то есть, возможность в рамках одного контекста проверять метрики EC2, метрики AWS Application Load Balancer, конкретные поды Kubernetes самого бэкенд-API и, в конечном итоге, — конкретные вызовы функций, бизнес-логику, которая выполняется внутри этого пода в ответ на запрос, пришедший от AWS ALB от конкретного пользователя — то есть, построить конвейер наблюдаемости.

А для того, чтобы все данные имели общий контекст, им необходимы общие характеристики, атрибуты, по которым мы можем группировать все получаемые данные — другими словами, метки.

Но при использовании «стека Prometheus по умолчанию» у нас есть куча разных экспортёров для метрик, отдельные экспортёры для логов, а вдобавок трейсы в формате OTel — и каждый из них записывает labels по-своему. Поэтому, чтобы как-то унифицировать всё это в дашбордах Grafana или алертах, приходится разбираться со всевозможными проблемами label_replace.

Реальный пример из одного из моих алертов:

- record: aws:node:cpu_utilization:percent
  expr: |
    100 * (1 - avg by(instance, cluster) (
      label_replace(
        rate(node_cpu_seconds_total{mode="idle"}[5m]),
        "instance",
        "ip-${1}-${2}-${3}-${4}.ec2.internal",
        "instance",
        "(.*)\\.(.*)\\.(.*)\\.(.*):9100"
      )
    ))

Здесь из метрики node_cpu_seconds_total мы берём значение label instance, например 10.0.50.18, и строим новое значение вида ip-10-0-50-18.ec2.internal, которое затем используется в дашбордах Grafana для фильтров — потому что какая-то другая метрика возвращает имя хоста в таком формате, в то время как метрика node_exporter по умолчанию не имеет label вроде node_name="ip-10-0-50-18.ec2.internal".

Таким образом, мы можем пойти другим путем — заменить способ, которым мы вообще собираем эти метрики: вместо 10 разных экспортёров для метрик — node_exporter для EC2, YACE exporter для AWS CloudWatch, отдельный k8s-event-logger exporter для отправки Kubernetes Events как логов, отдельный AWS ALB Logs collector, читающий из S3 — мы можем иметь единую систему, которая делает всё это самостоятельно и, что самое главное, сама добавляет общие labels ко всем signals — метрикам, логам, трейсам.

Плюсы и минусы OpenTelemetry

Конечно, не всё так радужно: OpenTelemetry Collector немного сложнее в настройке, потребляет больше ресурсов и требует дополнительного мониторинга.

Это вполне ожидаемо, потому что если система предоставляет больше возможностей «из коробки», то её конфигурация будет несколько сложнее, чем у какого-либо отдельного Prometheus Node Exporter.

То же самое касается ресурсов — когда экспортёр одновременно занимается сбором метрик и логов — он будет потреблять больше ресурсов, чем отдельный экспортёр, «сфокусированный» только на одной задаче: тот факт, что OTel имеет защиту OOMKiller «из коробки», о многом говорит.

Тем не менее, если сложить потребление CPU/RAM всех экспортёров в формате Prometheus и сравнить его с одним подом Kubernetes для OpenTelemetry Collector — всё ещё вопрос, какой из них окажется легче.

Кроме того, формат OTel для метрик больше по размеру, чем метрики Prometheus — потому что сам формат содержит больше данных.

И последний нюанс, который сейчас приходит в голову, заключается в том, что 95% всех алертов и дашбордов Grafana написаны специально для метрик в формате Prometheus и из экспортёров Prometheus, таких как node_exporter и cAdvisor.

Поэтому, если вы разворачиваете OTel в качестве основной системы сбора данных, имейте в виду, что вам также потребуется обновить все связанные с ней ресурсы.

При этом в моём конкретном случае мы всё ещё небольшая стартап-компания, а основные дашборды Grafana я в любом случае делаю вручную, поэтому с LLM задача обновления всего этого выполняется относительно быстро.

Поэтому я попробую, пока запущу его параллельно с существующим стеком экспортёров и логов, похожим на Prometheus, и посмотрю, что из этого получится.

VictoriaTraces и трейсы тоже уже работают, но об этом поговорим отдельно.

VictoriaMetrics и мой текущий набор инструментов мониторинга

В нашем проекте всё работает на AWS Elastic Kubernetes Service — бэкенд API и другие сервисы проекта, сам стек мониторинга VictoriaMetrics, а также различные сервисы AWS — RDS, CloudFront, DynamoDB и т. д.

Что остаётся без изменений — наши «хранилища»: VictoriaMetrics для метрик, VictoriaLogs для логов, VictoriaTraces для трейсов.

Изменения коснутся способа сбора данных: вместо набора экспортёров Prometheus и VMAgent, который их собирает, у нас будет отдельный сервис OTel Gateway, принимающий данные от OTel Collector. А OTel Collector заменит весь зоопарк Prometheus Exporters и Log collectors.

Помимо этой инфраструктуры, существует множество интеграций с поставщиками ИИ — Anthropic, OpenAI, — но их мониторинг — это совершенно отдельная тема, о которой я (надеюсь) напишу позже.

OpenTelemetry — общая архитектура и компоненты

Для сбора данных — метрик, логов и трейсов — у OpenTelemetry есть собственный OpenTelemetry Collector, который может выполнять различные функции.

На самом деле это один и тот же бинарный файл, поведение которого зависит от того, что мы передаём ему в конфигурации:

  • Роль Kubernetes Collector: собирает Kubernetes events, метрики с Kubernetes WorkerNodes, Kubernetes Pods, контейнеров, а также логи.
  • Роль AWS Collector: собирает метрики из CloudWatch и/или логи из AWS ALB через S3 и/или VPC Flow Log.
  • Роль OpenTelemetry Gateway: агенты (OTel Collectors) отправляют свои данные в Gateway, а Gateway перенаправляет их в конкретные бэкенды — VictoriaMetrics, VictoriaLogs, VictoriaTraces.

Схематически это может выглядеть примерно так:

Прежде чем мы продолжим, стоит отметить один момент: я называю OTel Collectors как «collector», так и «agent», но название не меняет сути — это всего лишь роль, которую выполняет сервис.

Структура конфигурации OpenTelemetry Collector

В интернете есть множество примеров файлов, например, в официальном репозитории k8s/otel-config.yaml, или небольшая подборка в Cloud-Architect-Emma/opentelemetry-collector-examples.

Но чтобы использовать их или написать свои — стоит бегло посмотреть на общий синтаксис и компоненты, описанные в конфигурации.

Документация:

Для каждого компонента мы будем задавать собственные параметры, но структура везде одинакова:

  • receivers: описывают, откуда получать данные — API Kubernetes, API AWS, логи.
  • Для Kubernetes Collector здесь у нас будут hostmetrics (метрики вроде тех, что выдаёт node_exporter), kubeletstats (метрики контейнеров), filelog (логи подов).
  • На Gateway в receivers будет otlp — для получения данных от Collectors, а также k8s_cluster и k8sobjects. Он будет собирать данные из Kubernetes API и непосредственно от kubelet
  • Processors: преобразования данных — добавляют метаданные (атрибуты, labels), фильтруют или удаляют ненужное, группируют, выполняют преобразования — переименование полей, нормализацию.
  • exporters: куда мы отправляем данные.
  • На Gateway exporters будут otlphttp/vmetrics, otlphttp/vlogs, otlphttp/vtraces.
  • На Agent exporters будут otlp_grpc (с адресом Gateway)
  • extensions: дополнительные возможности (аутентификация, health check, расширения кодирования и т. д.)/
  • Connectors: соединяют разные пайплайны между собой.
  • Service: связывает и активирует описанные конфигурации — recievers, processors и т. д.

Пайплайны OpenTelemetry

Все полученные сигналы проходят через pipeline: то есть receiver получает сигнал, processor обрабатывает его, exporter отправляет его куда-либо.

Для каждого типа сигналов — метрик, логов и трейсов — у нас будут собственные пайплайны, потому что данные связаны между собой, но обрабатываются по-разному.

Каждый pipeline может иметь свой идентификатор — просто имя, чтобы конфигурацию было проще читать, например:

connectors:
  spanmetrics:
    # config...

service:
  pipelines:
    traces:
      receivers: [otlp]
      exporters: [otlphttp/vtraces, spanmetrics]  # spanmetrics here is an exporter
    
    metrics/from_traces:
      receivers: [spanmetrics]                    # the same spanmetrics here is an receiver
      exporters: [otlphttp/vmetrics]

Теперь мы можем начать писать собственные конфигурации и запускать коллекторы.

OpenTelemetry: запуск в Kubernetes

Есть несколько способов запустить стек: «bare»-контейнеры, Helm chart или OpenTelemetry Operator (см. Install the Collector).

Для VictoriaMetrics я использую Helm chart victoria-metrics-k8s-stack, который устанавливает VictoriaMetrics Operator, VMAgent, VMAlert, Alertmanager, Grafana, а все настройки выполняются через ресурсы VictoriaMetrics CRD.

Об этой настройке я писал в VictoriaMetrics: building a Kubernetes monitoring stack with a custom Helm chart, а о Kubernetes Operators и CRDs — в Kubernetes: what is a Kubernetes Operator and CustomResourceDefinition.

Для OpenTelemetry я пока просто остановлюсь на Helm chart — так будет проще разобраться с основными компонентами, не тратя время на документацию оператора и его CRD.

И когда всё это будет отправлено в production — мы сможем перейти на OpenTelemetry Operator.

Мы настроим его как три отдельных компонента:

  • OTel Gateway: получает данные от Kubernetes API, Kubernetes и AWS Collectors, обрабатывает их, отправляет их в backend-системы — VictoriaMetrics, VictoriaLogs, VictoriaTraces
  • Kubernetes Agent: запускается на каждом Kubernetes WorkerNode, собирает данные из kubelet и логи подов
  • AWS Agent: собирает данные из AWS — метрики, логи.

Начнём с OTel Gateway, потому что все остальные компоненты будут отправлять данные через него: именно он выполняет всю обработку и отправляет данные в стек VictoriaMetrics.

Добавим Helm repo:

$ helm repo add open-telemetry https://open-telemetry.github.io/opentelemetry-helm-charts
$ helm repo update

Проверим, что чарты доступны:

$ helm search repo open-telemetry/opentelemetry-collector
NAME                                    CHART VERSION   APP VERSION     DESCRIPTION                                      
open-telemetry/opentelemetry-collector  0.155.0         0.151.0         OpenTelemetry Collector Helm chart for Kubernetes

Все компоненты — OTel Gateway, Kubernetes Agent, AWS Agent — будут установлены из него, но каждый со своими значениями.

Запуск OpenTelemetry Gateway

Подготовим файл otel-gateway-values.yaml — в нем будут указаны values для нашего OTel Gateway:

# OTel Collector - Gateway role (Deployment)
#
# Responsibilities at this phase:
#   - Accept OTLP from future Agents (DaemonSet)
#   - Collect cluster-level metrics via k8s_cluster receiver
#   - Collect K8s events as logs via k8sobjects receiver
#   - Enrich all signals with K8s metadata (k8sattributes processor)
#   - Export metrics to VictoriaMetrics, logs to VictoriaLogs
#
# Traces pipeline is intentionally not enabled yet - that's Phase 2

# docs: https://opentelemetry.io/docs/collector/architecture/

mode: deployment

replicaCount: 2

# contrib image has all the receivers/processors/exporters we need
image:
  repository: otel/opentelemetry-collector-contrib

resources:
  limits:
    cpu: 1000m
    memory: 2Gi
  requests:
    cpu: 200m
    memory: 512Mi

# RBAC for k8sattributes (pod metadata lookup) and k8s_cluster (cluster state).
# Full list of required permissions:
# https://github.com/open-telemetry/opentelemetry-collector-contrib/tree/main/receiver/k8sclusterreceiver
clusterRole:
  create: true
  rules:
    - apiGroups: [""]
      resources:
        - pods
        - namespaces
        - nodes
        - nodes/stats
        - nodes/proxy
        - events
        - services
        - resourcequotas
        - replicationcontrollers
        - replicationcontrollers/status
      verbs: ["get", "list", "watch"]
    - apiGroups: ["apps"]
      resources: ["replicasets", "deployments", "statefulsets", "daemonsets"]
      verbs: ["get", "list", "watch"]
    - apiGroups: ["extensions"]
      resources: ["replicasets"]
      verbs: ["get", "list", "watch"]
    - apiGroups: ["batch"]
      resources: ["jobs", "cronjobs"]
      verbs: ["get", "list", "watch"]
    - apiGroups: ["autoscaling"]
      resources: ["horizontalpodautoscalers"]
      verbs: ["get", "list", "watch"]
    - apiGroups: ["events.k8s.io"]
      resources: ["events"]
      verbs: ["get", "list", "watch"]

# Self-monitoring port
ports:
  metrics:
    enabled: true
    containerPort: 8888
    servicePort: 8888
    protocol: TCP

service:
  type: ClusterIP

config:
  receivers:
    # PUSH receiver
    # Accepts data from Agents and from apps
    # OTel TracerProvider() for the Backend API will send traces to this receiver
    otlp:
      protocols:
        grpc:
          endpoint: 0.0.0.0:4317
          # Agent batches of logs may exceed default 4 MiB gRPC limit
          max_recv_msg_size_mib: 16
        http:
          endpoint: 0.0.0.0:4318

    # PULL receiver
    # Will go to the Kubernetes API to get the cluster-level state
    # Runs only on Gateway (one place per cluster)
    # uses GET /api/v1/nodes, GET /apis/apps/v1/deployments etc.
    # converts responses to metircs like k8s.deployment.available, k8s.node.condition_ready, k8s.hpa.current_replicas
    # returns them to a corresponding pipeline
    k8s_cluster:
      collection_interval: 30s
      node_conditions_to_report: [Ready, MemoryPressure, DiskPressure, PIDPressure]
      allocatable_types_to_report: [cpu, memory, ephemeral-storage]

    # PULL receiver
    # Will go to the Kubernetes API, but uses `watch` mode
    # uses the 'events.k8s.io/v1/events' endpoint to receive event stream in real time
    # converts Kubernetes Events to Log records
    # returns them to the logs pipeline
    k8sobjects:
      objects:
        - name: events
          mode: watch
          group: events.k8s.io

  processors:
    # Memory protection against traffic spikes to avoid OOM kills
    memory_limiter:
      check_interval: 1s
      limit_percentage: 80
      spike_limit_percentage: 25

    # Enrich every signal with K8s pod metadata - this is what unifies labels
    # across metrics, logs and traces
    # docs: https://opentelemetry.io/docs/platforms/kubernetes/collector/components/#kubernetes-attributes-processor
    k8sattributes:
      auth_type: serviceAccount
      passthrough: false
      extract:
        # data taken from the Kubernetes API - fields from the Pod object to be added as attributes
        # i.e. a Kubernetes Namespace 'dev-backend-api-ns' for a Pod will be set as k8s.namespace.name="dev-backend-api-ns"
        # https://github.com/open-telemetry/opentelemetry-collector-contrib/tree/main/processor/k8sattributesprocessor#configuration
        metadata:
          - k8s.namespace.name
          - k8s.pod.name
          - k8s.pod.uid
          - k8s.pod.start_time
          - k8s.deployment.name
          - k8s.statefulset.name
          - k8s.daemonset.name
          - k8s.cronjob.name
          - k8s.job.name
          - k8s.node.name
        # add custom labels from the Pod object
        # i.e. a Pod with label 'app.kubernetes.io/component=backend' will be set as app.label.component="backend"
        labels:
          - tag_name: app.label.component
            key: app.kubernetes.io/component
            from: pod
          - tag_name: app.label.name
            key: app.kubernetes.io/name
            from: pod
      # pod_association processor is used to associate signals (metrics, logs, traces) with the correct Pod
      # e.g. when the Gateway receive a metric from a Pod, it need to know how to find that Pod in the Kubernetes API
      # for example, our Kubernetes Agent will send a metric from 'kubeletstats' for a container
      # but this metrics will not have a corresponding 'k8s.deployment.name'
      # so here, k8sattributes proecessor will ask the Kubernetes API to get additional metadata and set it as attributes
      pod_association:
        - sources:
            - from: resource_attribute
              name: k8s.pod.ip
        - sources:
            - from: resource_attribute
              name: k8s.pod.uid
        - sources:
            - from: connection

    # similar to the k8sattributes.extract.labels above, but for the resource attributes to all signals
    # sets hard-coded values
    resource:
      attributes:
        # action may be set as:
        # - insert: add only if not exists
        # - update: update if exists
        # - upsert: insert if not exists, update if exists
        # - delete: delete if exists
        - key: k8s.cluster.name
          value: eks-ops-1-33
          action: upsert
        - key: cloud.provider
          value: aws
          action: upsert

    # Batch records for efficient export
    # collects data to its buffer and sends it to the exporter in batches
    # docs: https://github.com/open-telemetry/opentelemetry-collector-contrib/tree/main/processor/batchprocessor
    batch:
      send_batch_size: 8192
      timeout: 10s

  # Where to send the data to - in our case, to VictoriaMetrics and VictoriaLogs
  # docs: https://docs.victoriametrics.com/opentelemetry/
  exporters:
    # VictoriaMetrics - OTLP endpoint
    # docs: https://docs.victoriametrics.com/victoriametrics/data-ingestion/opentelemetry-collector/
    # the '/v1/metrics' part will be added by the exporter itself
    otlphttp/vmetrics:
      endpoint: http://vmsingle-vm-k8s-stack.ops-monitoring-ns.svc.cluster.local:8428/opentelemetry
      tls:
        insecure: true

    # VictoriaLogs - OTLP endpoint
    # docs: https://docs.victoriametrics.com/victorialogs/data-ingestion/opentelemetry/
    # the '/v1/logs' part will be added by the exporter itself
    otlphttp/vlogs:
      endpoint: http://atlas-victoriametrics-victoria-logs-single-server.ops-monitoring-ns.svc.cluster.local:9428/insert/opentelemetry
      tls:
        insecure: true

    # Debug exporter - for troubleshooting, can be added to any pipeline temporarily
    debug:
      verbosity: basic

  # Combine everything into a single service definition
  service:
    # Pipelines operate on three telemetry data types: traces, metrics, and logs.
    # Each pipeline has its own set of receivers, processors and exporters.
    # docs: https://opentelemetry.io/docs/collector/architecture/#pipelines
    pipelines:
      metrics:
        # Reference receivers by their names from the config.receivers section above
        receivers: [otlp, k8s_cluster]
        # Reference processors by their names from the config.processors section above
        # IMPORTANT NOTE: order matters - processors run in the order listed here
        processors: [memory_limiter, k8sattributes, resource, batch]
        # Reference exporters by their names from the config.exporters section above
        exporters: [otlphttp/vmetrics]

      logs:
        receivers: [otlp, k8sobjects]
        processors: [memory_limiter, k8sattributes, resource, batch]
        exporters: [otlphttp/vlogs, debug]

    telemetry:
      metrics:
        readers:
          - pull:
              exporter:
                prometheus:
                  host: 0.0.0.0
                  port: 8888

На самом деле, я всё объяснил в комментариях — но вкратце, вот что у нас есть:

  • mode="deployment": создаём Gateway как Kubernetes Deployment с двумя подами
  • Для Kubernetes Agent мы сделаем DaemonSet, потому что ему нужно запускаться на каждом WorkerNode
  • receivers: описывает входные данные — может быть PULL (они сами обращаются к внешним API) или PUSH (agents/collectors отправляют данные в них)
  • otlp: endpoints для агентов Kubernetes и AWS
  • k8s_cluster: обращается к Kubernetes API, получает информацию о Nodes, Pods, Events.
  • k8sobjects.objects="events": непрерывно получает события Kubernetes из API Kubernetes и записывает их в логи.
  • processors:
  • k8sattributes: добавляет атрибуты к каждой метрике или логу (namespace, имя deployment и т. д.)
  • resource.attributes: добавляет «глобальные» атрибуты к каждому полученному сигналу (см. OpenTelemetry Resource Attributes Explained Practically).
  • exporters: куда записываются данные — бэкенды, в нашем случае мы отправляем их в VictoriaMetrics, VictoriaLogs и VictoriaTraces.
  • service: связывает всё описанное выше.
  • pipelines:
  • metrics: в каком порядке и что делать с метриками.
  • logs: то же самое — но для логов.
  • Позже здесь будет pipeline для трейсов.
  • telemetry: включает сам мониторинг — чтобы мы могли смотреть собственные метрики OTel.

Разворачиваем:

$ helm -n ops-monitoring-ns upgrade --install otel-gateway open-telemetry/opentelemetry-collector -f otel-gateway-values.yaml

Проверяем поды:

$ kubectl -n ops-monitoring-ns get pod -l app.kubernetes.io/instance=otel-gateway
NAME                                                    READY   STATUS    RESTARTS   AGE
otel-gateway-opentelemetry-collector-57b74ffd98-4pqhw   1/1     Running   0          68s
otel-gateway-opentelemetry-collector-57b74ffd98-td6hr   1/1     Running   0          68s

Сервис Kubernetes — тот, который будут использовать агенты:

$ kubectl -n ops-monitoring-ns get svc -l app.kubernetes.io/instance=otel-gateway
NAME                                   TYPE        CLUSTER-IP       EXTERNAL-IP   PORT(S)                                                            AGE
otel-gateway-opentelemetry-collector   ClusterIP   172.20.204.222   <none>        6831/UDP,14250/TCP,14268/TCP,8888/TCP,4317/TCP,4318/TCP,9411/TCP   90s

Проверка метрик

И уже через минуту мы можем проверить метрики с помощью {k8s.cluster.name="eks-ops-1-33"}:

Мы видим метрику k8s.container.cpu_limit — она поступает от k8s_cluster receiver, который обратился к /api/v1/pods в Kubernetes API и прочитал spec.containers[].resources.limits.cpu.

Проблема кардинальности

И вот важный момент — в labels мы видим множество разных идентификаторов, например:

k8s.container.cpu_limit {..., container.id="a6a73186104e064e406330620b09bc367418ad4ce3564a1ef21d48de3597dad7", ..., k8s.pod.name="otel-gateway-opentelemetry-collector-57b74ffd98-td6hr",k8s.pod.start_time="2026-05-15T10:36:54Z",k8s.pod.uid="55b9990a-49e7-4913-be53-40d0d640cf72", ...}

Каждый раз, когда создается новый под Kubernetes, для его k8s.pod.uid генерируется новое значение.

Я подробно разбирал, почему и как это влияет на хранилище и нагрузку VictoriaMetrics в статье «VictoriaMetrics: Churn Rate, High cardinality, metrics and IndexDB», но вкратце — каждое уникальное значение каждого label увеличивает как использование диска, так и размер IndexDB VictoriaMetrics и, соответственно, влияет на потребление CPU/RAM и скорость поиска.

Чтобы предотвратить это — мы можем добавить ещё один processor, который будет удалять такие labels.

Порядок объявления в config.processors не имеет значения — он важен в pipeline, но логично разместить его рядом с блоком resource:

...
  processors:
    ...
    resource:
      attributes:
        - key: k8s.cluster.name
          value: eks-ops-1-33
          action: upsert
        - key: cloud.provider
          value: aws
          action: upsert

    # Drop high-cardinality resource attributes from metrics only
    # These change on every pod recreation and cause series explosion in VictoriaMetrics.
    # Logs and traces keep them - useful for debugging specific pod instances.
    resource/drop_volatile_labels:
      attributes:
        - key: k8s.pod.uid
          action: delete
        - key: container.id
          action: delete
        - key: k8s.pod.start_time
          action: delete
...

Другой вариант — удалить их через search.maxStalenessInterval=4h непосредственно в VictoriaMetrics, см. List of command-line flags.

Следует помнить, что у нас есть два разных типа атрибутов и, соответственно, это будут разные processors:

  • Атрибуты уровня записи: атрибуты конкретной записи (например, использование CPU контейнером).
  • Атрибуты уровня ресурса: атрибуты источника — добавляются ко всем сигналам, отправляемым на бэкенды.

Чтобы проверить, какие именно атрибуты нужно изменить, смотрите документацию конкретного processor, например для процессора k8sattributes:

Процессор автоматически обнаруживает ресурсы Kubernetes (поды), извлекает из них метаданные и добавляет извлеченные метаданные к соответствующим трейсам, метрикам и логам в качестве атрибутов ресурсов.

Или в спецификации OTel, например для Pod, в документации есть URI /resource/k8s/#pod.

Добавим новый processor в metrics pipeline — после resource, но перед batch:

...
  service:
    pipelines:
      metrics:
        receivers: [otlp, k8s_cluster]
        processors: [memory_limiter, k8sattributes, resource, resource/drop_volatile_labels, batch]
...

Почему именно эта позиция в pipeline — потому что всё в pipeline выполняется в порядке, в котором объявлено, и обработка resource/drop_volatile_labels должна идти:

  • после k8sattributes — потому что именно он добавляет k8s.pod.uid, нам нужно удалить его после того, как он появился.
  • после resource — чтобы processor resource успел установить свои собственные labels.
  • перед batch — чтобы batch группировал уже очищенные данные

Обновите развертывание, проверьте:

И метки с.id исчезли.

Теперь у нас есть работающий OTel Gateway, где мы:

  • готовы принимать данные от будущих Agents и наших сервисов, таких как Backend API (порты 4317/4318)
  • собираем метрики на уровне кластера (k8s_cluster)
  • собираем события Kubernetes в виде логов (k8sobjects)
  • обогащаем их метаданными k8s (k8sattributes)
  • добавляем собственные labels ко всем данным (k8s.cluster.name, cloud.provider)
  • контролируем кардинальность (resource/drop_volatile_labels)
  • имеем защиту от OOM Killer (memory_limiter)
  • Настроили пакетный экспорт в VictoriaMetrics и VictoriaLogs.

Что осталось — AWS Collector для метрик из AWS CloudWatch и логов AWS ALB, а также настройка приёма и отправки трейсов.

Проверка логов

Проверяем логи — запрос {k8s.cluster.name="eks-ops-1-33"}.

На данный момент у нас есть только логи из Kubernetes Events — логи подов мы добавим позже через filelog в Kubernetes Agent:

Здесь есть две небольшие проблемы:

  • поле _msg не сформировано
  • в object.metadata.managedFields находится мусор

Добавление transform для логов

Мы можем переопределить, что именно записывается в лог и как, через processors.transform:

...
config:
  ...
  processors:

    ...
    # Normalize k8sobjects events: set readable body, drop noisy fields
    transform/k8s_events:
      #error_mode: ignore
      error_mode: propagate
      log_statements:
        - context: log
          statements:
            # k8sobjects stores the Event as a map in body.
            # VictoriaLogs flattens it into object.* fields automatically.
            # Build readable "REASON: note" message from body fields.
            - >-
              set(body, Concat([body["object"]["reason"], ": ", body["object"]["note"]], ""))
              where attributes["event.domain"] == "k8s" and attributes["k8s.resource.name"] == "events"
...

Здесь мы сами формируем поле body, которое VictoriaLogs будет использовать для своего поля _msg.

Чтобы посмотреть, как именно изначально формируется объект события, включим debug exporter с уровнем детализации detailed:

...
debug:
      verbosity: detailed
...

Затем добавим его в logs pipeline:

...
logs:
        receivers: [otlp, k8sobjects]
        processors: [memory_limiter, k8sattributes, resource, batch]
        exporters: [otlphttp/vlogs, debug]
...

А затем просто посмотрим логи подов Gateway.

Добавим transform/k8s_events в logs pipeline перед batch:

...
  service:
    pipelines:
      metrics:
        ...

      logs:
        receivers: [otlp, k8sobjects]
        processors: [memory_limiter, k8sattributes, resource, transform/k8s_events, batch]
        exporters: [otlphttp/vlogs, debug]
...

И теперь у нас есть хорошо читаемое поле _msg:

Запуск агента Kubernetes

Следующий шаг — добавить exporter, который будет собирать данные на уровне подов — метрики и логи.

Создаём файл otel-k8s-agent-values.yaml:

# OTel Collector - Agent role (DaemonSet)
#
# Runs on every node, collects local data only:
#   - System metrics from host /proc, /sys (hostmetrics receiver)
#   - Pod/container metrics from local kubelet (kubeletstats receiver)
#   - Container logs from /var/log/pods (filelog receiver)
#
# Forwards everything to Gateway via OTLP gRPC.
# Gateway adds k8s metadata and exports to Victoria-* backends.

mode: daemonset

# contrib image has hostmetrics, kubeletstats, filelog receivers
image:
  repository: otel/opentelemetry-collector-contrib

# Mount host filesystem paths needed by hostmetrics and filelog
extraVolumes:
  - name: varlogpods
    hostPath:
      path: /var/log/pods
  - name: varlibdockercontainers
    hostPath:
      path: /var/lib/docker/containers
  - name: hostfs
    hostPath:
      path: /

extraVolumeMounts:
  - name: varlogpods
    mountPath: /var/log/pods
    readOnly: true
  - name: varlibdockercontainers
    mountPath: /var/lib/docker/containers
    readOnly: true
  - name: hostfs
    mountPath: /hostfs
    readOnly: true
    mountPropagation: HostToContainer

# Root is required to read /proc, /sys from the host
securityContext:
  runAsUser: 0
  runAsGroup: 0

resources:
  limits:
    cpu: 500m
    memory: 1Gi
  requests:
    cpu: 100m
    memory: 256Mi

# Agent must run on every node, including tainted ones
tolerations:
  - effect: NoSchedule
    operator: Exists
  - key: CriticalAddonsOnly
    operator: Exists
    effect: NoSchedule
  - key: CriticalAddonsOnly
    operator: Exists
    effect: NoExecute
  - key: BackendOnly
    operator: Exists
  - key: BackendDevOnly
    operator: Exists
  - key: BackendProdOnly
    operator: Exists
  - key: GitHubOnly
    operator: Exists
  - key: GitHubControllerOnly
    operator: Exists
  - key: GitHubRunnersOnly
    operator: Exists

# Inject node identity and host paths into the collector container
extraEnvs:
  - name: K8S_NODE_NAME
    valueFrom:
      fieldRef:
        fieldPath: spec.nodeName
  - name: K8S_POD_IP
    valueFrom:
      fieldRef:
        fieldPath: status.podIP
  # hostmetrics uses these env vars to read host /proc, /sys instead of container's
  - name: HOST_PROC
    value: /hostfs/proc
  - name: HOST_SYS
    value: /hostfs/sys
  - name: HOST_ETC
    value: /hostfs/etc
  - name: HOST_VAR
    value: /hostfs/var
  - name: HOST_RUN
    value: /hostfs/run
  - name: HOST_DEV
    value: /hostfs/dev

# Need read access to kubelet stats endpoint
clusterRole:
  create: true
  rules:
    - apiGroups: [""]
      resources: ["nodes/stats", "nodes/proxy", "nodes/metrics"]
      verbs: ["get"]
    - apiGroups: [""]
      resources: ["pods", "namespaces", "nodes"]
      verbs: ["get", "list", "watch"]

# Self-monitoring port
ports:
  metrics:
    enabled: true
    containerPort: 8888
    servicePort: 8888
    protocol: TCP

config:
  receivers:
    # PULL receiver
    # Reads node-level system metrics from host /proc and /sys
    # Replaces node_exporter functionality
    # Produces: system.cpu.*, system.memory.*, system.disk.*, system.network.*,
    #           system.filesystem.*, system.load.*, system.paging.*, system.processes.*
    hostmetrics:
      collection_interval: 30s
      root_path: /hostfs
      scrapers:
        cpu:
          metrics:
            system.cpu.utilization:
              enabled: true
        memory:
          metrics:
            system.memory.utilization:
              enabled: true
        disk:
        filesystem:
          exclude_mount_points:
            mount_points: ["/var/lib/kubelet/*", "/var/lib/docker/*", "/proc/*", "/sys/*"]
            match_type: regexp
          exclude_fs_types:
            fs_types: [tmpfs, devtmpfs, overlay, squashfs]
            match_type: strict
        network:
        load:
        paging:
        processes:

    # PULL receiver
    # Queries local kubelet (port 10250) for per-pod and per-container metrics
    # Replaces cadvisor functionality (which is built into kubelet)
    # Produces: k8s.node.*, k8s.pod.*, container.* (cpu/memory/network/filesystem)
    kubeletstats:
      collection_interval: 30s
      auth_type: serviceAccount
      endpoint: "https://${env:K8S_NODE_NAME}:10250"
      insecure_skip_verify: true
      metric_groups:
        - node
        - pod
        - container
        - volume

    # PULL receiver
    # Reads container logs from disk - standard CRI/containerd path
    # Replaces promtail / fluent-bit functionality
    # Container operator parses CRI log format and extracts k8s.* attributes from file path
    filelog:
      include:
        - /var/log/pods/*/*/*.log
      exclude:
        # Don't collect our own logs to avoid feedback loops
        - /var/log/pods/ops-monitoring-ns_otel-*/*/*.log
      start_at: end
      include_file_path: true
      include_file_name: false
      operators:
        - type: container
          id: container-parser

  processors:
    # Memory protection against traffic spikes
    memory_limiter:
      check_interval: 1s
      limit_percentage: 80
      spike_limit_percentage: 25

    # Tag everything with the node we're running on
    # Cluster-level attributes (k8s.cluster.name etc.) are added by Gateway
    resource:
      attributes:
        - key: k8s.node.name
          value: ${env:K8S_NODE_NAME}
          action: upsert

    # Batch records before sending to Gateway
    batch:
      send_batch_size: 8192
      timeout: 10s

  exporters:
    # Forward everything to Gateway via OTLP gRPC
    # Gateway will add k8s metadata and route to the right Victoria backend
    otlp:
      endpoint: otel-gateway-opentelemetry-collector.ops-monitoring-ns.svc.cluster.local:4317
      tls:
        insecure: true
      sending_queue:
        enabled: true
        num_consumers: 4
        queue_size: 1000
      retry_on_failure:
        enabled: true
        initial_interval: 5s
        max_interval: 30s

  service:
    pipelines:
      metrics:
        receivers: [hostmetrics, kubeletstats]
        processors: [memory_limiter, resource, batch]
        exporters: [otlp]

      logs:
        receivers: [filelog]
        processors: [memory_limiter, resource, batch]
        exporters: [otlp]

    telemetry:
      metrics:
        readers:
          - pull:
              exporter:
                prometheus:
                  host: 0.0.0.0
                  port: 8888

Здесь у нас структура, похожая на Gateway — также receivers, processors, exporters и pipelines.

Разница заключается в том, как мы разворачиваем поды, какие receivers описываем и куда выполняем export:

  • mode="daemonset": Collector должен запускаться на каждом WorkerNode в кластере
  • receivers:
  • hostmetrics: на уровне ноды — CPU, RAM, диски, сеть (эквивалент Prometheus Node Exporter)
  • kubeletstats: метрики контейнеров (эквивалент cAdvisor_exporter)
  • filelog: сбор логов контейнеров (эквивалент Promtail/Filebeat/и т. д.)
  • exporters: данные, собранные агентом, перенаправляются в OTel Gateway — он обработает их и отправит в VictoriaMetrics/Logs/Traces

Разворачиваем:

$ helm -n ops-monitoring-ns upgrade --install otel-k8s-agent open-telemetry/opentelemetry-collector -f otel-k8s-agent-values.yaml

Проверяем поды:

$ kubectl -n ops-monitoring-ns get pods -l app.kubernetes.io/instance=otel-k8s-agent
NAME                                                 READY   STATUS    RESTARTS   AGE
otel-k8s-agent-opentelemetry-collector-agent-2ft7s   1/1     Running   0          35s
otel-k8s-agent-opentelemetry-collector-agent-79gs2   1/1     Running   0          35s
otel-k8s-agent-opentelemetry-collector-agent-bdhsd   0/1     Pending   0          35s
...

Через минуту проверяем метрики в VictoriaMetrics — {__name__=~"k8s\\.pod\\.cpu\\..*", k8s.cluster.name="eks-ops-1-33"}:

И логи, например из {k8s.namespace.name="dev-backend-api-ns"}:

Что здесь не очень хорошо, так это то, что log streams создаются с таким огромным набором меток:

_stream {cloud.provider="aws",k8s.cluster.name="eks-ops-1-33",k8s.container.name="backend-celery-workers-container",k8s.container.restart_count="1",k8s.deployment.name="backend-celery-workers-deployment",k8s.namespace.name="dev-backend-api-ns",k8s.node.name="ip-10-0-37-96.ec2.internal",k8s.pod.name="backend-celery-workers-deployment-669c8bb67-vspzn",k8s.pod.start_time="2026-05-15T11:10:26Z",k8s.pod.uid="6c6c12e6-cade-41e4-aa80-20cb4e08a54a"}

Это также можно решить с помощью processor, который мы сделали для metrics, или создав новый, например:

resource/drop_log_labels:
      attributes:
        - key: k8s.pod.uid
          action: delete
        - key: k8s.container.restart_count
          action: delete

А затем подключить его к logs pipeline:

...
      logs:
        receivers: [otlp, k8sobjects]
        processors: [memory_limiter, k8sattributes, resource, resource/drop_log_labels, transform/k8s_events, batch]
        exporters: [otlphttp/vlogs]
...

Но некоторые метки могут быть полезны — например, k8s.container.restart_count.

Поэтому другой вариант — передать collector.streamFields или collector.ignoreFields непосредственно в VictoriaLogs или сделать это прямо в OTel Gateway через заголовок VL-Stream-Fields:

...

    otlphttp/vlogs:
      endpoint: http://atlas-victoriametrics-victoria-logs-single-server.ops-monitoring-ns.svc.cluster.local:9428/insert/opentelemetry
      tls:
        insecure: true
      headers:
        VL-Stream-Fields: "k8s.cluster.name,k8s.namespace.name,k8s.deployment.name,k8s.container.name,k8s.pod.name"

...

Grafana и Prometheus против запросов OpenTelemetry

И немного о том, что меняется в Grafana и алертах.

Например, вот запрос в формате Prometheus:

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

В формате OpenTelemetry это будет выглядеть так:

sum({__name__="container.memory.working_set", k8s.namespace.name="$namespace", k8s.pod.name="$pod"}) by (k8s.pod.name)

Результат на графиках — старый сверху (Prometheus), новый снизу (OpenTelemetry):

Для VictoriaMetrics можно установить opentelemetry.usePrometheusNaming (см. List of command-line flags) и Label sanitization — тогда метрики будут создаваться в формате Prometheus с "_" вместо ".".

Но для VictoriaLogs и VictoriaTraces я не вижу такой опции — спрошу разработчиков, есть ли какие-нибудь разумные способы решить это.

Подписывайтесь на телеграм-канал Мониторим ИТ, там еще больше полезной информации о мониторинге!