Всем привет! Меня зовут Яна Курышева, и я тимлид одной из команд разработки бэкенда в Спортсе’’.
Мы – спортивное медиа. Наш продукт – это сайт и приложения со спортивной статистикой, новостями, редакционным и пользовательским контентом, пушами, рекомендациями и комментариями.
За 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, но и лучше понять собственную архитектуру.
Если вы тоже живете с монолитами – возможно, наш опыт вдохновит вас не бояться внедрять туда новые инструменты 😉
