OpenTelemetry вместо APM: один SDK, любой бэкенд

Published: 2026-06-11

APM-инструменты — Elastic APM, Datadog, Dynatrace, New Relic — продают одно и то же: трейсы, метрики и логи из вашего приложения в их UI. Цена — vendor lock-in: проприетарные агенты, проприетарные протоколы и ценообразование, которое растёт вместе с трафиком. OpenTelemetry разрывает эту схему. Один SDK, одна конфигурация экспортёра — и сигналы идут в любой бэкенд или сразу в несколько. Смена вендора перестаёт быть двухнедельным рефакторингом и становится правкой конфига в OTel Collector.


Почему вендорский APM создаёт lock-in

Datadog-агент инструментирует Go-сервис через dd-trace-go. New Relic — через newrelic-go-agent. Elastic APM — через go-elastic-apm. Это разные пакеты с разными API. При смене вендора переписывается инструментирование.

Глубже — lock-in на уровне протокола. Datadog-агенты говорят на Datadog API. New Relic — на New Relic API. Даже если UI хуже, затраты на повторное инструментирование 50 сервисов удерживают команды на исходном вендоре годами.

OpenTelemetry — это стандарт CNCF для observability-сигналов. SDK не привязан к вендору, протокол (OTLP) — тоже. Код, написанный под OTel, работает с любым бэкендом, принимающим OTLP: Jaeger, Grafana Tempo, Zipkin, Honeycomb, Datadog (он тоже умеет OTLP), Elastic и десятки других.


Что даёт OpenTelemetry

Три типа сигналов, один SDK:

  • Трейсы — спаны, представляющие работу сервиса, связанные в дерево трейса через несколько сервисов
  • Метрики — счётчики, гистограммы, gauge; те же данные, что скрейпит Prometheus, но с распределённым контекстом
  • Логи — структурированные записи, коррелированные с трейсами через trace_id и span_id

OTel Collector — это слой маршрутизации. Приложения отправляют все сигналы в него по OTLP. Collector разворачивает их в нужные бэкенды. Можно параллельно слать трейсы в Tempo и Jaeger во время миграции. Можно фильтровать, трансформировать и собирать сигналы в батчи до отправки.


Архитектура: замена Elastic APM на стек Grafana

До:

App (elastic-apm-agent) → APM Server → Elasticsearch → Kibana APM

После:

App (OTel SDK) → otel-collector → Grafana Tempo     (трейсы)
                               → Prometheus          (метрики)
                               → Loki                (логи)

Grafana становится единым UI для всех трёх типов сигналов с корреляцией из коробки: кликаешь спан трейса — видишь логи, которые писались в этот момент, переходишь на гистограмму задержек для того же сервиса. APM Server и его индексный оверхед в Elasticsearch исчезают. Tempo хранит трейсы эффективно в object storage. Loki хранит логи дёшево.


OTel Collector: fan-out пайплайн

Один Collector в namespace observability обслуживает весь кластер:

yamlapiVersion: helm.toolkit.fluxcd.io/v2
kind: HelmRelease
metadata:
  name: otel-collector
  namespace: observability
spec:
  chart:
    spec:
      chart: opentelemetry-collector
      version: "0.x"
      sourceRef:
        kind: HelmRepository
        name: open-telemetry
        namespace: flux-system
  values:
    mode: deployment
    replicaCount: 2
    config:
      receivers:
        otlp:
          protocols:
            grpc:
              endpoint: 0.0.0.0:4317
            http:
              endpoint: 0.0.0.0:4318

      processors:
        memory_limiter:
          limit_mib: 512
        batch:
          timeout: 5s
          send_batch_size: 1000
        resource:
          attributes:
            - key: k8s.cluster.name
              value: "prod"
              action: upsert

      exporters:
        otlp/tempo:
          endpoint: "tempo.observability.svc.cluster.local:4317"
          tls:
            insecure: true
        prometheusremotewrite:
          endpoint: "http://vmsingle.observability.svc.cluster.local:8428/api/v1/write"
        loki:
          endpoint: "http://loki.observability.svc.cluster.local:3100/loki/api/v1/push"
          default_labels_enabled:
            exporter: false
            job: true
          labels:
            resource:
              service.name: "service_name"
              k8s.namespace.name: "namespace"

      service:
        pipelines:
          traces:
            receivers: [otlp]
            processors: [memory_limiter, batch, resource]
            exporters: [otlp/tempo]
          metrics:
            receivers: [otlp]
            processors: [memory_limiter, batch]
            exporters: [prometheusremotewrite]
          logs:
            receivers: [otlp]
            processors: [memory_limiter, batch, resource]
            exporters: [loki]

Процессор resource проставляет k8s.cluster.name на каждый сигнал. Полезно, когда несколько кластеров шлют данные в один бэкенд — в Grafana можно фильтровать по кластеру без правок в приложениях.

Процессор batch обязателен. Без него каждый спан — отдельный gRPC-вызов. При 1000 rps сервис генерирует тысячи спанов в секунду; батчинг снижает CPU Collector на порядок. Порядок важен: memory_limiter должен стоять перед batch — если наоборот, batch успевает накопить данные в памяти до того, как memory_limiter их отклонит.


Grafana Tempo для трейсов

Tempo заменяет Kibana APM для просмотра трейсов. Для on-prem подходит local storage или MinIO:

yamlapiVersion: helm.toolkit.fluxcd.io/v2
kind: HelmRelease
metadata:
  name: tempo
  namespace: observability
spec:
  chart:
    spec:
      chart: tempo
      version: "1.x"
      sourceRef:
        kind: HelmRepository
        name: grafana
        namespace: flux-system
  values:
    tempo:
      storage:
        trace:
          backend: local
          local:
            path: /var/tempo/traces
      persistence:
        enabled: true
        size: 20Gi
      receivers:
        otlp:
          protocols:
            grpc:
              endpoint: 0.0.0.0:4317

Для продакшна с S3 или MinIO:

yamltempo:
  storage:
    trace:
      backend: s3
      s3:
        bucket: tempo-traces
        endpoint: minio.observability.svc.cluster.local:9000
        access_key: "${MINIO_ACCESS_KEY}"
        secret_key: "${MINIO_SECRET_KEY}"
        insecure: true

Tempo 2.x добавляет TraceQL — язык запросов для поиска по трейсам, который Grafana 10+ использует нативно. Фиксация версии чарта tempo >= 1.7.x даёт Tempo 2.x.


Корреляция трейсов и логов в Grafana

Конфигурация datasource связывает спаны с запросами к Loki:

yamlapiVersion: 1
datasources:
  - name: Tempo
    type: tempo
    url: http://tempo.observability.svc.cluster.local:3100
    jsonData:
      tracesToLogsV2:
        datasourceUid: loki
        spanStartTimeShift: "-1m"
        spanEndTimeShift: "1m"
        filterByTraceID: true
        customQuery: true
        query: '{service_name="${__span.tags.service.name}"} | trace_id = "${__trace.traceId}"'
      serviceMap:
        datasourceUid: prometheus

Кликаешь спан в Grafana — автоматически выполняется запрос к Loki по trace_id. Именно за это APM-вендоры брали доплату — здесь это просто конфиг.

Для работы корреляции логи приложения должны нести trace_id. В .NET:

csharpbuilder.Logging.AddOpenTelemetry(logging =>
{
    logging.IncludeScopes = true;
    logging.AddOtlpExporter(opts =>
        opts.Endpoint = new Uri("http://otel-collector.observability.svc.cluster.local:4317"));
});

В Go через slog bridge:

goimport (
    "go.opentelemetry.io/contrib/bridges/otelslog"
    "go.opentelemetry.io/otel/sdk/log"
)

loggerProvider := log.NewLoggerProvider(
    log.WithProcessor(log.NewBatchProcessor(otlpExporter)),
)
slog.SetDefault(otelslog.NewLogger("my-service", otelslog.WithLoggerProvider(loggerProvider)))

Миграция с Elastic APM

Миграция инкрементальная. OTel Collector может одновременно слать трейсы в Elastic APM Server и в Grafana Tempo:

yamlexporters:
  otlp/tempo:
    endpoint: "tempo.observability.svc.cluster.local:4317"
    tls:
      insecure: true
  otlp/elastic:
    endpoint: "apm-server.observability.svc.cluster.local:8200"
    tls:
      insecure: true

service:
  pipelines:
    traces:
      receivers: [otlp]
      processors: [memory_limiter, batch]
      exporters: [otlp/tempo, otlp/elastic]  # dual-write во время миграции

Запускаем оба параллельно. Проверяем, что Tempo показывает те же трейсы, что Kibana APM. Затем убираем otlp/elastic из пайплайна и выключаем APM Server.

С сервисами, которые ещё используют агенты Elastic APM (не OTel SDK), сложнее. Варианты:

  1. Переключить агент на OTLP — Java/Node-агенты Elastic APM версии 1.40+ поддерживают OTLP output (ELASTIC_APM_OPENTELEMETRY_BRIDGE_ENABLED=true)
  2. Временно оставить APM Server — агенты Elastic APM → APM Server → OTel Collector (у APM Server есть OTLP exporter)
  3. Заменить агент — переписать инструментирование на OTel SDK; с auto-instrumentation это обычно день на сервис

Вариант 1 — наименьшие усилия при поддерживаемой версии агента.


Что может пойти не так

Трейсы есть в Collector, но не в Tempo

Проверяем экспортёр:

bashkubectl port-forward -n observability svc/otel-collector 8888:8888
curl http://localhost:8888/metrics | grep otelcol_exporter
# otelcol_exporter_sent_spans должно быть > 0
# otelcol_exporter_send_failed_spans должно быть 0

Если sent_spans = 0, в конфиге пайплайна ошибка. Включаем debug-логирование:

yamlenv:
  - name: OTEL_LOG_LEVEL
    value: debug

Корреляция логов не работает в Grafana

Проверяем, что trace_id есть в логах, которые доходят до Loki:

bashkubectl port-forward -n observability svc/loki 3100:3100
curl -G "http://localhost:3100/loki/api/v1/query" \
  --data-urlencode 'query={service_name="my-service"} | json | trace_id != ""' \
  | jq '.data.result[0].values[0]'

Если trace_id нет — приложение экспортирует трейсы через OTel, но не логи. Оба пайплайна должны использовать один SDK.

Collector падает с OOMKilled

bashkubectl top pod -n observability -l app.kubernetes.io/name=opentelemetry-collector

memory_limiter работает только если стоит перед batch в цепочке. Если порядок обратный — batch накапливает спаны в памяти до того, как memory_limiter успевает их отклонить. Увеличиваем limit_mib или снижаем send_batch_size.

Service map не строится

Service map в Grafana использует атрибуты спанов для рисования рёбер между сервисами. Атрибут service.name должен быть одинаково проставлен на всех сервисах:

yamlenv:
  - name: OTEL_SERVICE_NAME
    value: "my-service"  # должен быть уникальным и стабильным для каждого сервиса

Если два сервиса делят одно имя — их спаны сольются на service map.


Итого

  • OTel заменяет вендорские APM-агенты: один SDK, один протокол (OTLP), любой бэкенд
  • OTel Collector — слой маршрутизации: смена бэкендов без правок в коде приложений
  • Стек Grafana (Tempo + Prometheus + Loki) заменяет Elastic APM + Kibana без лицензионных затрат
  • Корреляция трейс → лог — это конфиг datasource в Grafana, не вендорская фича
  • Dual-write в двух экспортёрах одновременно позволяет валидировать Tempo до переключения
  • memory_limiter перед batch в цепочке процессоров — порядок важен