Всем привет! Меня зовут Яна Курышева, и я тимлид одной из команд разработки бэкенда в Спортсе’’.
Мы – спортивное медиа. Наш продукт – это сайт и приложения со спортивной статистикой, новостями, редакционным и пользовательским контентом, пушами, рекомендациями и комментариями.
За 25+ лет развития архитектура Спортса’’ стала достаточно разнообразной под капотом: десятки микросервисов на Go соседствуют с монолитными Perl- и PHP-приложениями, которые мы планомерно переводим на новый стек.
Чтобы вся система оставалась управляемой, мы активно используем трейсинг с помощью Elastic APM. Но существующие библиотеки не учитывали специфику нашей архитектуры и не решали всех задач.
В этой статье я поделюсь, как мы справились с задачей сбора трейсинга из старых монолитов и реализовали собственный APM-прокси.
Коротко об APM-трейсинге
Elastic APM – это инструмент для мониторинга производительности всей системы. Он позволяет получить множество полезных сведений: о времени выполнения и частотности запросов между сервисами, о запросах в БД и внешние API, о статистике ошибок и др.
Все эти данные агрегируются и предоставляются в удобном интерфейсе, что позволяет находить проблемы с производительностью. А после проведенных оптимизаций собирать пруфы в виде красивых графиков «до» и «после» :)
Стек ELK состоит из нескольких частей:
APM Agent – агент, встроенный в ваше приложение (библиотека для конкретного языка: Java, Python, Node.js, Go, .NET и др.). Он собирает данные по трейсам и отправляет их на APM-сервер.
APM Server – отдельный сервис, который принимает данные от агентов и передает их в Elasticsearch.
Elasticsearch – хранилище данных, где сохраняются все собранные трейсы.
Kibana APM UI – визуальный интерфейс.
Зачем нам понадобилось свое решение?
На официальном сайте Elastic APM приведен перечень языков, для которых существуют готовые APM-агенты: Go, Python, PHP и др. Если заглянуть в документацию и репозитории агентов, то можно заметить, что их реализация и интеграция сильно отличаются.
Посмотрим чуть детальнее на особенности.
Для языка Go официальный агент – это библиотека (модуль) для приложения, которая импортируется и подключается в коде, например, на уровне middleware. Зачастую требуется использование специальных оберток из модулей для разных инструментов или реализация своих кастомных врапперов.
Ключевая особенность архитектуры агента – механизм накопления и отправки событий, что сильно отличает его от агентов, написанных на интерпретируемых языках вроде PHP или Python.

После завершения запроса данные о транзакции не отправляются сразу на APM-сервер, а помещаются в отдельный канал. Оттуда в фоновом режиме события батчатся, и когда буфер достигает определенного размера, они отправляются одним HTTP-запросом.
Если говорить про метрики по работе самого агента, то Go-агент не документирует эти метрики как «официальные». Но на самом деле он отслеживает несколько показателей, полезных для понимания эффективности работы агента.
Например:
размер очереди буфера событий, ожидающих отправки;
количество событий, отброшенных из-за переполнения;
количество отправленных спанов, транзакций.
PHP-агент устроен иначе: его модель – «один запрос = одна отправка». Из-за идеологии самого языка он не может поддерживать постоянные очереди, фоновые потоки и делать батчинг. Агент инициализируется и выгружается в рамках одного запроса, поэтому и сбор событий возможен только в этот момент. В итоге PHP-агент генерирует в десятки раз больше сетевых запросов по сравнению с Go-агентом, создавая дополнительную нагрузку на инфраструктуру. APM-серверу приходится принимать тысячи мелких запросов, распаковывать и обрабатывать каждый из них, а также поддерживать множество открытых TCP-соединений – все это снижает его производительность.
Такая архитектура также не позволяет PHP-агенту экспортировать внутренние метрики – например, время отправки данных в APM-сервер, размер буфера, количество потерянных событий или статистику очереди.
С Perl-агентом ситуация еще печальнее – его нет, а разработка даже не входит в планы. Для нас трейсинг из монолита на Perl был критически важен, так как на него все еще поступает заметное количество трафика. Однако специфика языка намекает, что даже самописное решение приведет нас к тем же проблемам, что и в PHP.
Что ж делать, что ж делать… Подумали мы и пришли к таким вариантам:
Смириться;
Написать кастомное решение для Perl и принести в PHP существующий агент;
Придумать что-то универсальное.
В наших монолитных приложениях изначально мы решили пойти вторым путем: для приложения на Perl реализовали кастомный агент с полным циклом – создание пейлоадов и отправка напрямую в APM-сервер. Для PHP использовали существующий агент, добавив недостающую логику. Вдохновившись Go-агентом, сделали фоновый сбор и отправку событий на обоих языках – Perl и PHP.
Агенты работали – мы смогли получать трейсы из обоих монолитов. Но у Perl- и PHP-агентов были значительные ограничения:
Отсутствие внутренних метрик мешало качественно анализировать эффективность агентов и взаимодействие с APM-сервером.
Ограниченность удобных нативных инструментов для оптимизаций.
При появлении различных проблем: потеря трейсов, чрезмерная нагрузка на APM-сервер, переполнение агентов памяти и др. – приходилось исследовать каждый агент по отдельности.
Поддержка и развитие агентов требовали больше ресурсов разработки, чем универсальное решение на более современном языке.
Так появилась идея APM-прокси – сервиса, который возьмет на себя всю механику: будет централизовано собирать, обрабатывать и отправлять запросы в APM-сервер.
Проектируем APM-прокси
Критерии
Мы сформулировали для себя главные критерии, которые должны быть соблюдены при проектировании нового решения:
APM-прокси должен быть универсальным для использования в любых сервисах, независимо от языка.
Клиент (монолит) не должен ждать обработки трейса, отправки на APM-сервер.
Должна быть возможность гибкой настройки прокси, чтобы APM-сервер выдержал поток запросов.
Нужна возможность получать метрики по работе прокси – количество отправок в разрезе по источникам, размер буфера, частотность ошибок от сервера и другие показатели.
Архитектура решения. Сбор и обработка сообщений в фоновом режиме
Мы пришли к выводу, что прокси должен быть отдельным сервисом с HTTP-интерфейсом. Это позволит использовать его со старыми монолитами, где внедрять новые технологии (например, GRPC) сложнее. Для реализации выбрали Go, так как он уже является базовым языком нашей архитектуры и предоставляет много полезных инструментов.
Важно помнить, что прокси должен максимально быстро отвечать клиентам и не влиять на производительность запросов. Спасибо языку Go за каналы и горутины – на основе этих фичей языка мы спроектировали механизм фоновой обработки сообщений.
Посмотрим на архитектуру такого решения.
На вход ожидаются сообщения в виде NDJSON (Newline Delimited JSON) – формат, где каждый объект JSON находится на отдельной строке. Этот формат принимает сам APM-сервер, поэтому и в APM-прокси было удобно выбрать его.
Ниже пример простого ndjson-сообщения, которое должен отправлять клиент.
// Метадата с информацией о сервисе, окружении, процессе
{
"metadata": {
"service": {
"name": "my-service",
"environment": "production"
},
"process": {
"pid": 1234
},
"system": {
"hostname": "my-host"
}
}
}
// Информация о транзакции - какой был запрос, сколько он обрабатывался
// Какие внутри были действия по работе с базой или внешними API
{
"transaction": {
"id": "transaction-id-1",
"trace_id": "trace-id-1",
"name": "GET /posts",
"type": "request",
"duration": 123.45,
"timestamp": 1678886400000000,
"context": {
"request": {
"method": "GET",
"url": {
"full": "http://example.com/posts"
}
}
},
"spans": [
{
"id": "span-id-1",
"parent_id": "transaction-id-1",
"name": "SELECT FROM posts",
"type": "db",
"duration": 50,
"timestamp": 1678886400050000
}
]
}
}
В одном сообщении обязательно должна присутствовать одна метадата, а транзакций может быть несколько.
Тело запроса вычитывается прокси, проверяется размер, затем сообщение перекладывается в отдельный канал. Клиент сразу получает ответ 200 – ему не нужно ждать завершения обработки.
В это время в фоновом режиме работает воркер: он слушает канал, считывает новые сообщения и отправляет их на APM-сервер.

Воркер работает в горутине, это менее ресурсозатратно, чем полноценный отдельный процесс. Это дает нам гибкость в масштабировании: при добавлении нового клиента становится больше сообщений, и мы можем добавить и 100, и 200, и 1000 воркеров на обработку сообщений. Здесь нас в основном ограничивают лишь ресурсы, выделенные на сервис.
Наш прокси мы решили назвать просто – apm-sender.
Применяем Circuit Breaker
Принимающая сторона – APM-сервер – штука капризная. Если слать запросы слишком часто или перегружать его большими пейлодами, он начинает отвечать все медленнее или вовсе выдает ошибки, а бесконечное накидывание ресурсов – далеко не оптимальный путь. Нам необходим максимальный контроль отправки данных и реакция в зависимости от «самочувствия» APM-сервера.
Здесь на помощь приходит паттерн Circuit Breaker. Принцип работы прост: если сервис выдает ошибки, запросы к нему временно блокируются, чтобы сохранить работоспособность системы. Через заданный интервал «прощупываем» сервис, и если он стабилен, запросы в него возобновляются. Подробнее о паттерне можно прочитать по ссылке.
Основное преимущество для нас в применении этого паттерна – APM-сервер не перегружается лишними запросами, если ему и так плохо. Кроме того, мы хотели избежать потери трейсов во время проблем на стороне APM-сервера – ведь клиенты продолжают отправлять данные, даже если сервер временно не принимает запросы.
Перейдем к реализации:
Добавляется второй уровень воркеров с собственным каналом – channel 2. Он станет «накопителем» сообщений, а воркеры возьмут на себя отправку сообщений в APM.
Первые воркеры становятся транспортом между каналами, и первый канал channel 1 становится доступен для записи при обработке сообщений от клиентов всегда.

С помощью второго уровня воркеров реализуем паттерн Circuit Breaker.
При получении ошибки (timeout, unavailable и т.д.) от APM-сервера ставим таймер на несколько секунд и блокируем второй уровень воркеров-отправщиков, давая возможность APM-серверу восстановить работу.
В это время в channel 2 начнут копиться сообщения, пока работает блокировка. Время подбирается в зависимости от того, сколько ресурсов у вас выделено на накопление и как быстро в среднем оживает APM-сервер.
Необходим контроль ресурсов с помощью размера буфера: при добавлении сообщений в channel 2 проверяем, заполнился ли буфер. Если да – значит мы накапливаем слишком долго и сознательно новые события в канал не добавляем, избегаем переполнения по памяти.
После истечения таймера сигнализируем воркерам, что можно пробовать снова, и если APM-сервер отвечает ошибкой – опять ставим таймер, блокируемся и повторяем цикл.

Мы добавили метрики на разных этапах обработки трейсов: объем входящих и исходящих пейлоадов по клиентам, время блокировки, размер буфера (полезно при накоплении) и другие показатели. Тут у нас свобода творчества.
Переключение клиентов
Разработанный прокси принимает на вход уже готовый пейлоад с информацией о трейсе, поэтому создание транзакций при обработке запросов и формирование ndjson-пейлоада остается на стороне клиентов.
Так как на стороне клиентов уже была реализована отправка на APM-сервер, мы просто повторили его HTTP-эндпоинт в нашем APM-прокси – для переключения нужно было лишь изменить адрес отправки.

После переключения клиентов мы наконец получили единую точку сбора, валидации, обработки и последующей отправки трейсов. Это позволило убрать из сервисов дублирующий код и избыточную логику, а также сильно упростило исследование потери трейсов, проблем с чрезмерным/некорректным трафиком на APM-сервер. Теперь все взаимодействие с APM сосредоточено в одном компоненте, который можно конфигурировать, обновлять и развивать при необходимости.
Метрики и результаты
После переключения клиентов мы изучали кастомные метрики, которые добавили в нашем прокси и получили полный контроль над потоком трейсов из монолитных клиентов. Вот к каким выводам мы пришли:
Время обработки запросов зависит от размера пейлоада, что связано с чтением тела запроса. Так как мы добавили второй канал, а первый канал остается всегда доступным для записи.
На размер пейлоада влияет количество спанов в транзакции = глубина трейса. Важно контролировать объем создаваемых спанов и не перебарщивать, иначе рискуете создавать слишком большие пейлоады, которые будут обрабатываться APM-сервером медленнее. У нас, например, нашлись клиенты, которые отправляли гигантские пейлоады, так как туда помещалась избыточная информация. Большой поток таких запросов сильно нагружал APM-сервер, о чем мы не догадывались ранее.
На основе этих данных удалось подобрать оптимальные значения для количества воркеров и объема буфера канала, при которых мы утилизируем приемлемое количество ресурсов и можем хранить сообщения в буфере.
С помощью прокси мы смогли увидеть реальную нагрузку на APM-сервер по каждому из клиентов, что позволило нам наилучшим образом сконфигурировать сам APM-сервер.
Стоит упомянуть про реализованное накопление сообщений: если накапливать слишком много, то после возобновления работы APM-сервера в него полетит сильно больше трафика, чем обычно, к чему он может быть не готов. Поэтому настройка APM-прокси тесно связана с настройкой APM-сервера.
Прямо сейчас
Это как раз тот случай, когда собственное решение позволило убить двух зайцев: снять с монолитов всю ответственность за накопление, очистку, отправку сообщений и получить полный контроль и прозрачность работы всего механизма.
Мы имеем возможность конфигурировать наш APM-прокси – настраивать количество воркеров, увеличивать или уменьшать накопление сообщений, идентифицировать источник слишком большого количества данных.
Эти знания уже помогли нам обнаружить и устранить часть проблем по работе с APM, чему рады коллеги из команд разработки и DevOps отдела. Мы смогли не только приручить APM, но и лучше понять собственную архитектуру.
Если вы тоже живете с монолитами – возможно, наш опыт вдохновит вас не бояться внедрять туда новые инструменты ?