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), сложнее. Варианты:
- Переключить агент на OTLP — Java/Node-агенты Elastic APM версии 1.40+ поддерживают OTLP output (
ELASTIC_APM_OPENTELEMETRY_BRIDGE_ENABLED=true) - Временно оставить APM Server — агенты Elastic APM → APM Server → OTel Collector (у APM Server есть OTLP exporter)
- Заменить агент — переписать инструментирование на 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в цепочке процессоров — порядок важен