В каждой компании есть необходимость выстроить систему observability. В hh.ru мы перестраивали архитектуру под большее количество данных несколько раз — сейчас имеем на входе 24к RPS, 1 миллион спанов в секунду, 5к инстансов сервисов. Если вы — инженер, который находится в процессе построения или перестройки собственной системы трейсинга, этот доклад — для вас.

Привет, Хабр! Я — Александр Казанцев, уже более десяти лет в разработке. Когда-то был инженером на пивзаводе и могу рассказать, из чего делают пенное; но сегодня — о другом.

Последние четыре года я работаю в команде SRE. На недавней DevOpsConf — 2025 я рассказывал на примере нашей компании, зачем нам вообще понадобился трейсинг и как мы внедряли его в hh.ru. Сегодня рассмотрим продакшн примеры разных решений со всеми их плюсами и минусами, раскладки по ресурсам, разные конфиги и как работают разные технологии на разных rps.

Этот доклад будет полезен инженерам, которые пытаются построить или перестроить собственную систему трейсинга. Посмотрим, почему отказались от jaeger, elastic, cassandra, opentelemetry agent/collector.

Зачем нам вообще понадобился трейсинг

У нас в hh.ru несколько сотен микросервисов, разные дата-центры, кластера Kafka, RabbitMQ, базы данных и т. д. — большой зоопарк, как и у многих из вас. В нашей красивой паутинке, наверное, никто не разберётся даже за несколько часов.

Для того, чтобы понять, что происходит в продашкене, можно выделить трёх китов: метрики, логи и трейсинг. Если что-то случается, мы смотрим в мониторинг и пытаемся разобраться в первопричинах ошибки — какой сервис или база упали первыми. Но по мониторингу это не всегда легко понять. Тогда мы задумались, что с этим можно сделать.

Как мы внедряли трейсинг в hh.ru

Наши коллеги в 2017 году создали свой самописный трейсинг на основе логов. Посмотрим, как он был сделан:

Когда во входной nginx приходит запрос, nginx генерирует уникальный Request-Id и прокидывает его через хедеры во все сервисы ниже. Сервис принимает запрос, достаёт из header этот Request-Id и складывает его в специальный контекст логирования:

Затем, если сервис захочет написать какой-то лог, он берёт уникальный Request-Id из этого контекста и с основным телом лога его записывает:

Точно также мы передаём запрос дальше. Следующий хедер также передаст Request-Id, сервис аналогичным образом напишет лог с этим уникальным для этого запроса Request-Id.

Схема сбора логов

Контейнеры записывают файлы на диск, затем их забирает FileBeat и проталкивает в Kafkа, а оттуда логи забирает ClickHouse. Просмотр логов (трейсов) через свой бэкенд — на Python, а UI — на React.

Можно было ввести Request ID и получить все логи с группировкой по сервисам. Это не совсем трейсинг — нет корректного порядка запросов. Тогда наши коллеги соорудили дополнительное псевдо-дерево на основе timestamp. Но в какой-то момент его тоже оказалось недостаточно.

Что не так:

  • Нет полноценного дерева запросов — не всегда можно понять, что и откуда приходит;

  • Высокая стоимость поддержки, так как UI и бэк для чтения — самописные;

  • Небольшой набор функций. Просто доставать лог по Request ID нам уже было мало. 

Тогда мы решили, что пора что-то с этим делать.

В 2020 году начали искать новое решение. Вариантов было несколько: можно было совершенствовать наши решения до более приемлемого трейсинга, либо выбрать из имеющихся рынке — например, JAEGER, Sentry, New Relic и т.д. Мы остановились на варианте с OpenTelemetry и Zipkin.

Почему они? Нам понравилось что это open-source решения, а значит, никакие санкции не должны помешать работе. В пользу Zipkin играло и то, что он написан на Java, а это как раз наш основной язык бэкенда — получается, что-то можно будет допиливать под себя.

На самом деле, Zipkin было уже устоявшимся приложением. OpenTelemetry в 2020 году был ещё не совсем стабильным решением — выходил только в «альфах» и «бетах». Но так как за ним стояло сообщество CNCF, за которым, в свою очередь — Google и Twitter, — мы решили, что ребята вложатся и работать всё это будет. Когда есть большое сообщество, развивающееся приложение, значит, оно будет жить.

Посмотрим подробнее, как это реализовали.

Итерация 1: OpenTelemetry + Zipkin

Сначала приложение с помощью instrumentation от OpenTelemetry отправляет данные в агент. Приложения с бэкендами на Java и Python у нас работали примерно одинаково. Затем агент по встроенной балансировке по протоколу OTLP помещает данные в коллекторы, а они передают информацию в бэкенды Zipkin. 

На иллюстрации выше неслучайно затесалась троица пар collector-openzipkin. У нас возникла проблема с использованием встроенной балансировки коллекторов, поскольку Zipkin не поддерживал протокол OTLP. Мы сделали в первом варианте неудобное решение, когда один коллектор привязан к одному бэкенду Zipkin.

Все данные Zipkin пишет в Cassandra — это его хранилище по умолчанию.

OpenTelemetry client instrumentation

Вот примеры на Java, как это выглядит в приложении:

Мы взяли стандартный SDK от OpenTelemetry и донастроили его под себя, добавили дополнительные параметры в серверные и клиентские спаны, чтобы упростить разбор ошибок с дополнительной информацией. Также пришлось немного настроить экспорт спанов.

Для улучшения производительности мы добавили батчевание и ограничили очередь спанов на отправку. В противном случае появляются проблемы с памятью — приложение вылетало с out of memory.

Напомню, к тому моменту у нас уже была настроена передача Request-Id через все приложения. И если уже есть свой Request-Id в логах, то почему бы его не переиспользовать в трейсинге.

Достаём из header Request-Id и, если он подходит по формату, используем его в качестве TraceId.

Подведём итоги первой итерации:

  • раскатили решение на первых трёх микросервисах;

  • получили данные о нагрузке — 30k спанов в секунду;

  • на выходе получили 250Gb в день.

Проблемы

А теперь о том, с какими неприятностями столкнулись.

  • Openzipkin не поддерживал протокол OTLP.

Из-за этого было некрасивое решение — один к одному коллектор и бэкенд Zipkin.

  • Нужно масштабироваться 10-15х.

Это основная проблема — нужно было масштабировать решение на несколько сотен микросервисов.

  • Железа нет.

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

  • Cassandra страдает.

Даже на небольшом количестве данных, которые мы сгенерировали с тремя микросервисами, Cassandra пожирает огромное количество ресурсов по сравнению с обычными продакшен нагрузками.

Оказалось, что Zipkin создаёт огромное количество индексов для своей работы. Настройками это не тюнилось, нужно было править исходники.

В 2021 году мы решили посмотреть, какие ещё решения существуют. OpenTelemetry в качестве основы решили оставить, а вот бэкенд поменять с Zipkin на Jaeger. 

Почему так? Мы по-прежнему решили сделать выбор в пользу open-source. Jaeger нам понравился тем, что он поддерживает OpenTelemetry Protocol (OTLP), а значит, не будет проблем с балансировкой. Также создалось впечатление, что Jaeger в сообществе OpenTelemetry больше продвигается и чуть дешевле обходится по железу, чем связь Zipkin с Cassandra.

Итерация 2: OpenTelemetry + Jaeger

Разберём, как мы это реализовали. Первые три кирпичика мы оставили на месте — от приложения до коллектора фактически ничего не изменилось. Мы просто убрали Zipkin и Cassandra.

На их место пришёл стек Jaeger, также добавились два контейнера Jaeger (бэкенд и UI) и дефолтное хранилище. У Jaeger в тот момент был ElasticSearch — его мы и взяли.

Появился красивый интерфейс Jaeger — субъективно, чуть интереснее, чем у Zipkin:

Результаты и «хотелки»

Подведём итоги этой итерации:

  • решили проблему с балансировкой за счёт замены бэкенда;

  • стали тратить чуть меньше ресурсов;

  • интерфейс стал удобнее.

Но мы хотели масштабироваться дальше. С заменой на Jaeger эту проблему не решили и пришли к семплированию, то есть, сохранять только часть данных, а ненужные — отбрасывать.

Итерация 3: Семплирование

Для начала немного пройдёмся по теории, чтобы синхронизироваться. Есть два вида семплирования:

  1. head-based семплирование 

В этом случае, когда запрос поступает в самый первый микросервис, вы принимаете решение, сохранять ли трейс. Затем решение прокидываете через хедеры во все остальные микросервисы, которые лежат на уровень ниже.

Это экономит ресурсы, но в нашем случае head-based семплирование не особо подходило, так как мы в первую очередь хотели использовать трейсинг для анализа ошибок. А этот подход не определяет, будет ошибка или нет.

  1.  tail-based семплирование

Сначала идёт весь запрос, собираются все куски трейса (спаны), и затем уже принимается решение о его сохранении.

В нашем случае это было то, что нужно — посмотрели, есть ошибка или нет, и сохранили трейс при необходимости.

А теперь разберёмся, как это реализовано.

Запрос приходит в первое приложение, которое отправляет данные в агент. Агент на основе хэша от TraceID принимает решение, в какой коллектор отправить информацию. То же самое происходит дальше и со вторым приложением. Сколько бы спанов здесь ни было (50-100), все они на основе хэша от TraceID отправят данные в один коллектор. Таким образом, у одного коллектора будет полный контекст запроса и он сможет принять корректное решение, есть там ошибка или нет — что нам и нужно.

А теперь погрузимся «в кишочки» — посмотрим на пример конфига семплирования:

Поскольку у нас почти всё межсервисное взаимодействие по HTTP, мы фильтруем запросы по статусам. Если пришёл запрос с ошибкой — это интересный нам трейс, мы его сохраняем.

А ещё есть второй конфиг, отвечающий за сохранение 1% любых запросов.

Из интересного я бы ещё отметил параметр decision_wait, который отвечает за то, сколько ждать кусочков трейсов.

Например, если все запросы короткие — происходят за 100 миллисекунд, — то даже 5 секунд нам будет многовато. Но если запросы более долгие, например, по 30 секунд, мы не дождёмся всех кусочков запросов и отбросим этот трейс раньше времени. Большинство запросов у нас влезает именно в 5 секунд. Исходим из данных мониторинга, в нашем случае это — идеальный параметр, чтобы хранить оптимальное количество трейсов и не переполнять память коллекторов.

Результаты

Подведём итоги:

  • в результате семплирования уменьшилась нагрузка на базу на 90%;

  • выросла нагрузка на CPU collector из-за того, что им приходится заниматься семплированием;

  • сохраняем 100% ошибок и 1% «хороших» запросов.

Нас эта схема устроила. Решили на ней жить и развиваться, а после — сделать дополнительные инструменты на основе трейсинга.

Дополнительные инструменты на основе трейсинга

Анализатор ошибок 

Первый инструмент, который мы создали — это анализатор ошибок. Его можно назвать downtime детектором или root cause детектором.

Если анализатор нашёл ошибку, он отправляет сообщение в чат — превышен трешхолд по количеству ошибок, и пишет, какой процент ошибок приходится на сервисы. Помимо этого, анализатор напишет, в каком дата-центре возникли неполадки. Например, если метеорит попал в определённый дата-центр, мы это увидим. Или если у хоста отвалилось питание и ошибки сыпятся только с него, анализатор подсветит это, чтобы уменьшить срок реагирования. Он помогает нашим инженерам быстрее найти причину.

Кроме того, будут написаны примеры трейсов, чтобы посмотреть по конкретным запросам, что произошло. 

Алгоритм работы анализатора довольно простой. Раз в минуту он ходит в базу, забирает все трейсы за этот период и считает ошибки. Если мы пробили трешхолд, то делаем более детальный анализ и пишем отчёт в чат. Если трешхолд не пробили — спим минуту и повторяем этот цикл.

Endpoints

Второй инструмент, который мы сделали на основе трейсинга — это поиск использования endpoints или ручек.

Представим, что есть обычный контроллер на Java, у которого есть какой-то путь, метод и т.д.

@GET 
@Path (‘ /myPath”)
@Produces (MediaType.APPLICATION_JSON)
public void persistent (String hash)   {

Задача — удалить метод или сделать рефакторинг. Есть несколько вариантов:

  • Grep по коду

В целом, это рабочая история. Единственный минус — вы на самом деле не узнаете, используется он в продакшене или нет. В коде можно всё что угодно написать, а кто его использует — непонятно. 

  • Grep по логам

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

  • Grep по трейсам

Годный способ, все трейсы хорошо структурированы. Но у нас в НН был минус в том, что включено семплирование — был риск что-то потерять.

  • Метрики

Можно посмотреть по метрикам, в какую ручку ходят, но кто именно это был — по нашим мониторингам посмотреть было невозможно. Слишком большой объём данных, чтобы хранить все ручки на все сервисы, которые туда обращаются.

Мы в итоге решили, что всё-таки сделаем поиск endpoints по трейсам, поскольку там данные хорошо структурированы. Несмотря на то, что включено семплирование, большая часть эндпоинтов туда попадёт. 

У сервиса есть прекрасный интерфейс — можно курлить, вбивать в браузере и т.д. Замечательный UI.

Вбиваем параметры в строку — пришёл запрос от какого-то сервиса.

У нас будет много таких блоков, сгруппированных по сервисам. Итак, он пришёл в определённый сервис на определённый URL. Метод, по которому он пришел — get, post и так далее, — можно отфильтровать. 

Также мы видим rps сервиса. У нас разработчики используют этот инструмент, чтобы найти неиспользуемые endpoints, так как есть довольно старая кодовая база и это интересно применять для рефакторинга.

На такой схеме мы прожили около двух лет.

Напомню, итоговая схема выглядела так: приложение, агент, коллектор и Jaeger. За это время мы подключили 95% сервисов к трейсингу и выросли по нагрузке примерно до 600k спанов в секунду.

Естественно, начались некоторые проблемы:

  • Начало расти время чтения и записи. 

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

  • Дорого масштабировать отдельные части.

Так, например, коллекторы начали сильно поджирать ресурсы. 

Тогда в 2023 году мы решили оптимизировать ситуацию и снова встал вопрос о выбора нового решения.

На этот раз мы оставили стек OpenTelemetry, но в бэкендах использовали ClickHouse и Grafana в качестве UI, ведь мы любим open-source решения. Тесты показали, что этот стек обойдётся дешевле по железу, а ClickHouse с этими данными работает значительно быстрее Elasticsearch, что для нас было важно.

Итерация 4: ClickHouse + Grafana 

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

Вот так выглядит красивый интерфейс Grafana — почти так же, как у Jaeger. Ещё ниже — пример спана.

Из плюсов перехода на Grafana — упрощённая возможность кастомизации дашбордов в сравнении с Jaeger. Например, мы сделали дашборд, в котором отображаются одновременно трейсы и логи, чтобы чуть проще и быстрее понять контекст по запросу. 

Ещё из кастомизации, которую привнесла Grafana, можно выделить инструмент с вечным хранением. Вероятно, вы сталкивались с тем, что у трейсинга или логов есть какой‑то TTL. И если приложить, например, ссылку на какой‑то трейс — через неделю он стухнет. В баге вы уже не можете его посмотреть на него, если не приложили скриншот.

Мы придумали следующее: при обращении к Grafana с определённым TraceID Grafana идёт в наш специальный сервис, который сохраняет этот трейс в вечные таблицы. Потом Grafana ходит в обе таблицы и смотрит, где этот трейс есть.

Итоги

По ресурсам был кусочек с Jaeger, стал с ClickHouse. Кроме того, ресурсов мы стали тратить меньше — и CPU, и RAM, и Disk.

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

Итак, из плюсов:

  • Latency 150 ms → 30 ms

С заменой хранилища значительно уменьшилось latency ClickHouse по сравнению с Elasticsearch. Для нас это было важно и критично.

  • Уменьшили ресурсозатраты.

  • Появилась возможность кастомизации интерфейса.

Из минусов:

  • Потеряли часть поиска, который был в Jaeger.

Мы не стали переносить в ClickHouse все индексы, которые были в Jaeger, поскольку это бы уменьшило производительность, увеличило место.

  • Дорого обходятся agent+collecor.

На этом поиски путей оптимизации не закончились.

Итерация 5: Vector + Kafka 

В 2024 году мы вновь встали перед выбором. На сей раз у нас от OpenTelemetry остался только протокол, а все бинарники мы заменили на другие приложения.

Как всегда, руководствовались тем, что выбираем open source. По тестам показалось, что такой стек дешевле по железу. Из минусов — на тот момент Vector ещё был немного сыроват для сбора трейсов. Но нас в целом устроило то, как он работал. 

В схеме использования мы заменили агент на Vector, а вот для приложения на Java и Python ничего не меняется — они всё так же пишут по стандартному протоколу OTLP, только теперь вместо агента OpenTelemetry запросы принимает Vector. Мы убираем коллектор, и прослойкой становится Kafka.

Из любопытного — раньше у нас коллектор занимался семплированием, а теперь мы его убрали и задачей никто не занимается, все данные пишутся как есть. Всё, что в приложении написали, попадает в ClickHouse. К проблемам на этой почве мы ещё вернёмся, а пока погрузимся в детали. 

Vector пришлось немного доработать, чтобы он смог отправлять данные в правильном формате в ClickHouse.

Готовых трансформаций не было — дописали свою. Это, на самом деле, маленькая часть. Вся трансформация — около сотни строк.

Так как данных стало значительно больше, пришлось тюнить ClickHouse:

Так, например, мы увеличили количество background потоков, которые в основном использовались для merge файлов таблицы. Также уменьшили максимальный размер файлов таблицы: по умолчанию стояло 150Gb, а мы подобрали удобный вариант под себя. Оптимально — 50Gb между скоростью чтения и тем, чтобы он тратил ресурсы на мерж этих кусков. Кроме того, мы поменяли максимальный размер вставки из Kafka. Это повлияло на запись — стало чуть быстрее.

Ещё можно отметить добавление projection. Это нужно, чтобы из Grafana был запрос по ServiceName и SpanName. Это тоже ускорило запрос.

Итоги

Подведём итоги по ресурсам. Напомню, мы заменили в нашей схеме агент-коллектор на Vector-Kafka, а остальное осталось без изменений.

Мы сэкономили огромное количество CPU. На самом деле, большая часть экономии здесь получилась за счёт замены агента OpenTelemetry на Vector — он потребляет примерно в 3−4 раза меньше ресурсов при той же самой нагрузке. При этом значительно вырос объём диска, но всё-таки он значительно дешевле CPU. Соответственно, мы в итоге выиграли.

Что в целом. Сейчас у нас общая нагрузка на сайт около 25k rps. Это всё преобразуется примерно в 1 млн спана в секунду, а на пике даже 1.2.

Мы покрыли трейсингом http, Kafka, Rabbit. JDBC можем включить по запросу. Нам очень помогают наши дополнительные инструменты (downtime анализатор, endpoints), а поиск endpoints значительно ускоряет и разработку, и локализацию проблем.

Все входящие запросы теперь сохраняются: разработчик может посмотреть в целом любой, который есть на сайте.

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

К чему мы пришли

В hh.ru мы прошли несколько вариантов реализации пайплайна сбора и хранения трейсов, и по ходу пьесы отказались от jaeger, elastic, cassandra, opentelemetry agent/collector. Архитектура перестраивалась несколько раз под большее количество данных — сейчас на входе у нас 24к RPS, 1 миллион спанов в сек, 5к инстансов сервисов.

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

Скрытый текст

К слову, вы сможете расширить свои познания в сентябре на профессиональной конференции по инженерии данных, базам данных и системам хранения и обработки данных Data Internals X — она пройдёт 23 сентября в Москве. На мероприятии вы узнаете о неочевидных оптимизациях и скрытых ограничениях современных СУБД от экспертов из ведущих IT-компаний России — не пропустите!

Комментарии (0)


  1. Dhwtj
    16.09.2025 09:22

    Один вопрос: зачем так много?
    Возможно, ответ на этот вопрос будет эффективней чем вся статья.

    Программист тоже должен это знать, не только продакт или заказчик.