OpenTelemetry и Jaeger: ищем узкое место там, где метрики молчат
Внедряем распределённый трейсинг с OpenTelemetry 1.12 в микросервисном приложении клиента. Как один trace нашёл то, что Prometheus не показывал месяцами.
OpenTelemetry 1.8 - stable Tracing API для Go, Java, Python и .NET; готовая интеграция с Jaeger и Zipkin без вендорной привязки
Несколько месяцев у клиента висела жалоба на периодические «тормоза» в веб-приложении. Не всегда, не у всех, не воспроизводимо по требованию. Метрики в Prometheus показывали норму: CPU ровный, память в порядке, latency на уровне p99 - в рамках. Запросы в PostgreSQL - быстрые. Очередь в RabbitMQ - пустая. Всё хорошо, только у пользователей что-то лагает.
Это классический сценарий, при котором метрики бесполезны: они агрегируют, усредняют и прячут именно то, что нужно найти. Мы решили поставить распределённый трейсинг - и OpenTelemetry 1.8 вышел как раз вовремя.
Почему OpenTelemetry, а не что-то проще
Приложение - микросервисная архитектура: Go-сервис на входе, два Java-бэкенда, Python-воркер для фоновых задач. Предыдущие попытки инструментировать это добро заканчивались на вопросе «какой SDK брать». Jaeger-клиент для Go, OpenTracing для Java, что-то своё для Python - это три разных API, три разных способа передавать контекст через HTTP-заголовки, и гарантированный ад при первом же рефакторинге.
OpenTelemetry решает это на уровне спецификации: один стандарт propagation, vendor-agnostic экспортёры, и в версии 1.x Tracing API уже стабилен для всех четырёх языков, которые нам нужны. Можно инструментировать один раз и не думать, куда потом отправлять данные - хоть в Jaeger, хоть в Zipkin, хоть в облачный collector.
Как разворачивали
Схема стандартная: OpenTelemetry Collector в роли агрегатора, Jaeger как backend для хранения и UI. Jaeger поднял в Docker Compose рядом с остальной наблюдаемостью клиента - там уже жили Prometheus и Grafana.
[Go service] --OTLP--> [OTel Collector] ---> [Jaeger]
[Java backend x2] /
[Python worker] /
Collector нужен не строго обязательно - можно слать прямо в Jaeger через Jaeger exporter - но с Collector появляется возможность фильтровать, семплировать и перенаправлять без изменений кода. Выбрали head-based sampling с частотой 10% для обычного трафика и 100% для запросов с ошибками. Для поиска периодических тормозов это не идеально - нужная транзакция может попасть в 90% выброшенных - но на первое время сойдёт, потом перешли на tail-based sampling через Collector.
Инструментирование Go - через go.opentelemetry.io/otel и авто-инструментирование HTTP-middleware. Java - через Java Agent, который подключается без изменений кода вообще: -javaagent:opentelemetry-javaagent.jar в JVM-аргументы и несколько переменных окружения. Python - через opentelemetry-distro, там тоже есть команда opentelemetry-instrument для авто-инструментирования Flask и Celery.
На весь стек ушло около двух дней - большая часть времени на выяснение, какой именно header использует каждый сервис для передачи trace context, и почему Python-воркер упорно не подхватывал его из очереди RabbitMQ. Оказалось - нужна явная инъекция контекста в properties сообщения при публикации, авто-инструментирование Celery это не делает автоматически. Добавили вручную десяток строк.
Что нашли
Через три дня после включения трейсинга на продакшне картинка начала складываться.
Нашли классику, которую метрики не видят: один из Java-бэкендов при определённом сценарии делал N+1 запросов к базе. Не всегда - только когда в ответе приходил список пользователей с вложенными настройками. Если пользователей было пятеро - запросов 6. Если двадцать - 21 запрос. При стандартной нагрузке это размазывалось по времени и среднее latency не выбивалось из нормы. Но когда несколько таких запросов прилетало одновременно - всё вставало в очередь к пулу соединений PostgreSQL.
Как это выглядело в Jaeger. Один trace с двумя десятками span-ов, где первый span - входящий HTTP-запрос на Go-сервисе, а дальше видно, как один вызов Java-бэкенда порождает цепочку из 21 последовательного SQL-запроса каждый в 3-8ms. Итого 60-170ms только на базу там, где должен быть один запрос на 5-10ms. В метриках это теряется - средний latency SQL всё равно в норме, просто их много.
Без трейсинга мы бы смотрели на этот Prometheus ещё месяц.
Что поправили
Fix банальный: загрузка вложенных настроек через JOIN вместо отдельных запросов в цикле. Три часа работы Java-разработчика, одна строчка в HQL. После деплоя та же транзакция в трейсах стала выглядеть опрятно - один SQL-запрос вместо двадцати одного, latency упал в несколько раз на этом сценарии.
Где трейсинг не помог
Честно: не всё нашли трейсингом. Ещё одна периодическая проблема - всплески latency раз в несколько часов без видимой причины - трейсы показывают замедление, но не объясняют источник. Подозрение на GC-паузы в JVM, но это уже нужен другой инструментарий - JVM metrics и, возможно, async-profiler. Трейсинг показывает что медленно, но не всегда почему.
Общее ощущение
OpenTelemetry 1.8 - это версия, где перестаёшь чувствовать себя первопроходцем. Stable API, нормальная документация, авто-инструментирование для основных фреймворков работает. Jaeger как backend для небольшого стека - разумный выбор: быстро поднимается, UI понятен, запросы к трейсам не требуют изучения PromQL.
Инструмент оправдал ожидания - не как «серебряная пуля» для всех проблем, а как дополнение к метрикам там, где метрики слепы. Узкое место, которое мы не могли найти несколько месяцев, нашлось за три дня после включения трейсинга.
Мы ведём эту инфраструктуру в рамках managed-контракта - добавили OpenTelemetry Collector и Jaeger в стандартный стек наблюдаемости для всех микросервисных проектов.