Рецепт приготовления непрерывного профайлера в 2к24
Всем привет! Меня зовут Газимагомед, я занимаюсь разработкой внутреннего распределённого профайлера Vision в Ozon. В этой статье я раскрою понятие профиля, расскажу о том, что такое распределённый профайлинг, чем отличается автоматический сбор профилей от ручного. А также рассмотрим проблемы, возникающие при построении профайлера. Что ж, усаживайтесь поудобнее, мы начинаем.
Глава 1. Закладываем фундамент
Что такое профиль
Начнём с базы, а именно с того, что же такое профиль. Если без нудятины из Википедии, то профиль — это состояние программы в какой-то момент времени. Этим «состоянием» может быть потребление CPU памяти, количество активных потоков, время работы GC и много-много других зависящих и независящих от языка показателей. Профиль нужен для нахождения узких мест в работе программы, например места, на которое CPU тратит больше всего ресурсов. На основании этой информации можно быстро найти причину проблемы и своевременно её пофиксить.
Что такое непрерывный профайлер
Если коротко, то это сервис, позволяющий упростить профайлинг приложений, крутящихся в каком-либо окружении, будь то K8s или любое другое облачное решение. Чаще всего функциональность профайлера состоит из сбора профилей с некоторым интервалом времени, их обработки и добавления в хранилище, а также предоставления пользователям интерфейса для быстрого поиска.
Ручной vs автоматический сбор профилей
Очевидно, что для анализа профиля его нужно сначала собрать :) Сбор профиля вручную — самый простой и привычный способ. Заключается он в запуске профайлера, получении файла профиля и его последующем анализе либо в командной строке, либо в каком-нибудь визуализаторе. Например, в Go можно использовать стандартный пакет runtime/pprof, у которого есть возможность собирать профиль при обращении к API-хендлеру.
Наряду с простотой этого способа можно выделить тот факт, что, в отличие от автоматического сбора, при котором производительность приложения незначительно уменьшается из-за постоянного профилирования, ручной сбор влияет на производительность только в тот момент, когда нам нужно собрать профиль.
Минус заключается в том, что мы можем упустить момент, когда приложение начинает аномально себя вести, то есть потребление памяти или CPU резко увеличивается. Автоматический же сбор позволяет нам всё время быть начеку. В случае возникновения неполадок мы всегда можем обратиться к истории снятых профилей и посмотреть, что происходило в тот злополучный момент.
Глава 2. Дилеммы, вопросы… Да они бесконечны.
Теперь, когда у нас есть вся необходимая база, мы можем начать разбирать важные вопросы, возникающие на этапе проектирования профайлера. В этой главе мы рассмотрим различия между моделями взаимодействия Pull и Push, решим проблему идентификации сервисов, обсудим выбор хранилища, а также затронем тему эффективного хранения профилей.
Pull или Push, вот в чём вопрос…
Здесь я приведу свои мысли относительно достоинств и недостатков каждой модели. Заранее отмечу, что лучшей модели не существует - необходимо выбирать исходя из требований, поставленных на этапе проектирования сервиса.
Достоинства Push-модели
По сравнению с Pull отсутствует необходимость создавать большое количество соединений. Действительно, в Push модели у нас есть какое-то ограниченное количество агентов, которые отправляют центральному серверу пачки профилей раз в какой-то промежуток времени. Агент может создать одно долгоживущее TCP соединение и передавать данные по нему. В Pull- модели сервер вынужден раз в какой-то промежуток времени обращаться к эндпоинтам. Проблемы возникают, если эндпоинтов несколько десятков тысяч, что является классическим сценарием для больших компаний.
Недостатки Push-модели
Одной из главных проблем Push- модели является поддержание в актуальном состоянии информации о том, куда отправлять профили. При любых изменениях работы сервера, например, перезапуске в результате нового релиза, один из инстансов находится в неактивном состоянии, и агенты должны актуализировать информацию на своей стороне. В противном случае это чревато потерей профилей.
Недостатком также можно выделить сложность дебага. Например, рассмотрим проблему, когда с сервиса не приходят профили. Тогда у нас формируется следующий план действий:
Если приложение использует библиотеку для пуша профилей агенту, то разработчикам приложения надо посмотреть на своей стороне, что происходило.
Если спустя какое-то время выяснится, что проблема не на стороне приложения, то уже разработчикам профайлера надо дебажить агента, посмотреть, не было ли проблем с сетью, посмотреть в сам сервис профилирования. Всё это занимает очень много времени.
Но на мой взгляд, этот недостаток может быть также присущ Pull-модели. Всё зависит от реализации.
Достоинства Pull-модели
Достоинство Pull по сравнению с Push заключается в простоте реализации. В Push-модели нам наряду с созданием сервиса для хранения профилей нужно позаботиться ещё и о разработке агента, который будет в случае K8s находиться в ноде и профилировать доступные ему приложения, может быть, ещё и анализировать полученные профили, как-то обрабатывать их, сохранять в in-memory-буфере для последующей отправки в центральный сервер. Также надо продумать логику ретраев. Агент может также быть библиотекой, которую подключают сервисы, но тогда может возникнуть проблема обновления этой библиотеки у всех сервисов. В случае же с Pull-моделью всё можно реализовать в пределах одного сервиса, что является лучшим решением на первых порах.
Недостатки Pull-модели
Чтобы избежать дублирования, напишу лишь, что недостаток Pull-модели можно вывести из достоинства Push-модели, описанного выше.
Безусловно, это лишь моя субъективная точка зрения и у вас может быть иной взгляд на достоинства и недостатки обоих подходов. Я буду рад подискутировать с вами в комментариях на эту тему.
Как идентифицировать сервисы
Тут я расскажу о том, как идентификация сервисов реализована в нашем профайлере.
Мы не предоставляем профайлеру информацию о каждом сервисе в облаке — это сделало бы наш конфигурационный файл очень большим, а также усложнило бы процесс изменения конфигурации в случаях, когда нам нужно, скажем, изменить интервал сбора профилей для всех сервисов в окружении env1 в кластере cluster1. Вместо этого мы даём профайлеру информацию о группе сервисов с определёнными метаданными, а он уже с помощью K8S API получает список этих сервисов. Под определёнными метаданными понимаются окружение, кластер и язык, на котором написаны сервисы.
Пример такого конфига, который мы используем в Vision:
- job_name: goapi-prod # имя джобы
enabled: true # включена ли джоба
environment: prod # окружение, в котором джоба ищет сервисы
scrape_interval: 2m # интервал сбора профилей
scrape_start_min_delay: 10s # минимальная задержка перед запуском скрейпа
scrape_start_max_delay: 1m30s # максимальная задержка перед запуском скрейпа
worker_stop_timeout: 10s # тайм-аут на процесс остановки воркера
scraper:
type: "golang" # язык, на котором написаны сервисы
options:
Profiles: # профили, которые нужно собирать с сервисов
- cpu
- heap
- goroutine
- mutex
cpu_duration: "30s" # время сбора профиля CPU
timeout: "10s" # тайм-аут на получение профиля с API хендлера, для CPU будет равен 40 секундам
sd:
kubernetes: # настройки для K8S API
context: "prod" # кластер, в котором будем искать сервисы
port_names: [ "debug" ] # наличие необходимого имени порта у сервиса
label_selector:
profiling_scope: goapi # наличие необходимого лейбла
label_mapping: # маппинг K8S-лейблов в лейблы внутри Vision
app: service
app.kubernetes.io/name: service
app.kubernetes.io/version: version
app.kubernetes.io/instance: service_k8s
kubernetes.io/service-name: service_k8s
Единицей такой информации у нас является джоба. Джобой мы называем сущность, которая объединяет в себе множество сервисов, соответствующих заданным параметрам. За каждым таким сервисом джоба закрепляет воркер, который ответственен за сбор профилей.
Выбор хранилища
Выбор хранилища (или хранилищ) всегда зависит от множества обстоятельств, таких как нагрузка на чтение/запись, необходимость агрегации и обновления данных, частота обновления данных и т. д. В этой главе я расскажу, какие хранилища используются в существующих решениях и какие данные там хранятся.
Vision
В Vision мы используем два хранилища: ClickHouse и S3. ClickHouse применяется для хранения метаданных, а в S3 хранятся профили в сыром виде. В каждом хранилище установлен TTL, равный трём дням, то есть профили, которые находятся там три дня или больше, удаляются фоновой джобой.
Parca
Здесь у нас тоже два хранилища: Badger и FrostDB. Первое используется как хранилище для семплов, то есть числовых данных в профиле, а второе — для метаданных. FrostDB — разработка авторов Parca. Её создание они мотивировали следующим образом:
First, we needed something embeddable for Go, as we want to provide the Prometheus-like experience of a single statically linked binary that just works. This is definitely the weaker argument as there are other embeddable columnar databases, such as DuckDB that while using CGO, could have been embedded. It does however rule out external databases such as ClickHouse.
The second and more pressing argument was: in order for us to be able to translate the label-based data-model to a table-based layout, we needed the ability to create columns whenever we see a label-name for the first time. This is necessary so we have the ability to exploit the characteristics of columnar layout when searching or aggregating by label-values. If we used a map-type, which many existing columnar databases have, to represent labels we would not be able to exploit the characteristics of the columnar layout, we would essentially be loading the entire map, even when only needing to process a single key.
В какой-то момент разработчики нашли возможное решение — InfluxDB, но после общения с ее разработчиками поняли, что оно им не подходит. В статье не написано, какие именно проблемы повлияли на окончательное решение. Я даже написал письмо одному из разработчиков Parca, чтобы узнать это, а также поинтересоваться, может ли InfluxDB в её текущем состоянии использоваться для хранения профилей, но он мне ещё не ответил.
Pyroscope
Pyroscope хранит профили в виде блоков, каждый из которых содержит файл TSDB-индекса, файл с метаданными, а также таблицы в формате Parquet. Блоки могут жить как на диске, так и в объектных хранилищах, поддерживаемых Pyroscope: Amazon S3, Google Cloud Storage, Microsoft Azure Storage, OpenStack Swift.
Безусловно, выбор не ограничивается перечисленными хранилищами — вы можете использовать любое другое, если оно соответствует вашим требованиям. Ниже представлены хранилища, которые могут использоваться для хранения метаданных и числовых данных профилей.
Cassandra
Cassandra — популярное NoSQL-хранилище, оптимизированное для больших нагрузок на запись. Имеет смысл использовать его в тех случаях, когда частота записи гораздо выше частоты чтения. База данных отлично скейлится — достаточно при необходимости добавлять новые ноды. Из минусов можно выделить то, что есть сложности с написанием сложных запросов, — подзапросов и джоинов нет. Cassandra поощряет денормализацию данных. Также минусом является то, что необходимо заранее проектировать таблицу с учётом запросов, которые вы собираетесь в ней прогонять. Производительность ad hoc-запросов будет печальной.
Больше про слабые и сильные стороны Cassandra можно узнать здесь.
InfluxDB vs TimescaleDB
Следующими претендентами на использование являются InfluxDB и TimescaleDB. Обе БД рассчитаны на работу с time series-данными. В отличие от первой, вторая построена на базе уже существующей РСУБД — PostgreSQL, что является плюсом для тех, кто хочет перенести TS-данные в более подходящее хранилище, но не хочет отказываться от всех плюшек, которые даёт PostgreSQL.
До определённого момента главным языком запросов в InfluxDB был InfluxQL — SQL-like-язык с поддержкой функций, необходимых для комфортной работы с TS-данными. Но недавно разработчики выпустили кастомный язык запросов Flux, который сильно отличается от SQL. Поэтому для людей, не горящих желанием учить новый язык, TimescaleDB может стать фаворитом.
Что касается нашей любимой производительности, можете прочитать вот эту статью разработчиков TimescaleDB, где они поделились результатами измерений производительности записи и чтения на разных конфигурациях. В качестве датасета они использовали данные, создаваемые девайсами, пишущими десять уникальных метрик каждые десять секунд. Количество девайсов варьировалось от 100 до 10 000 000. В результате записи выяснилось, что с конфигурацией из 100 девайсов TimescaleDB заметно отставала от своего оппонента, но с увеличением количества девайсов стала выигрывать. Причиной стало увеличение кардинальности данных, оказавшейся ахиллесовой пятой InfluxDB.
Для измерения производительности чтения был выбран другой датасет, где количество девайсов варьировалось от 100 до 4000 и каждый из них генерировал от одной до десяти метрик каждые десять секунд. Получилась вот такая картина:
Под rollup понимаются запросы с groupby. Можно заметить, что при выборке данных на основании порогового значения и при сложных запросах, включающих в себя groupby-, orderby- и limit-выражения, TimescaleDB сильно выигрывает в производительности.
На момент оценки производительности использовалась версия TimescaleDB 1.7.1 Community Edition на базе PostgreSQL 12 и версия InfluxDB версии 1.8.0 Open Source Edition. На мой взгляд было бы интересно провести измерения на последних версиях обоих продуктов, а также узнать результаты измерений в пределах одного датасета.
Вы можете спросить: «При чём тут метрики, если мы говорим о профилях?». А я отвечу, что профили вполне себе можно представить в виде метрик.
И многие другие…
В этот список также могут попасть Apache Druid, Prometheus TSDB, kdb+ и многие другие.
Можете написать в комментариях своё мнение относительно выбора БД для сontinuous profiling, а также порекомендовать другие решения.
А как, собственно, хранить профили?
В этой главе я расскажу о том, как разработчики существующих решений подходят к вопросу хранения профилей.
Vision
Как уже было сказано выше, Vision использует связку ClickHouse и S3. Первое применяется для хранения метаданных (уникального идентификатора профиля, типа профиля, времени получения профиля, IP-адреса пода, с которого профиль был получен, названия сервиса, ссылки на профиль в S3 и других). А в S3 мы храним сырой профиль, сжатый gzip’ом. Данные сохраняются архивами, в одном архиве хранится количество профилей размером от 1 Мб и больше.
Parca
Процесс сохранения профиля состоит из двух этапов: нормализации и сохранения семплов (числовых значений).
На этапе нормализации происходит трансляция виртуальных адресов в symbol table-адреса, цель которой заключается в обхождении ASLR для переиспользования локаций, преобразование pprof-лейблов в кастомные лейблы и сохранение метаданных в Badger. Под метаданными в Parca понимаются такие сущности, как маппинги, функции и локации, а также стек-трейсы, которые являются массивами идентификаторов локаций. Если хотите подробнее узнать о том, что происходит на этапе нормализации, вам сюда. Нормализованный профиль сохраняется во FrostDB-таблицу, которая содержит такие данные, как лейблы, единицы измерения (например, для CPU это могут быть наносекунды), стек-трейс, время получения профиля и числовое значение. FrostDB в свою очередь поддерживает сбрасывание данных во внешнее объектное хранилище типа S3.
При получении профиля из FrostDB сохранённые стек-трейсы символизируются, используя ранее сохранённые метаданные в Badger, и отдаются на фронт для дальнейшей визуализации.
Pyroscope
Способ хранения данных, как и в целом архитектура, в Pyroscope сложнее, чем в Vision и Parca. Вся информация о профиле разбита по блокам. Каждый блок содержит несколько файлов, где хранятся числовые значения и метаданные. У блока есть уникальный идентификатор типа ULID, что позволяет держать блоки в отсортированном по времени состоянии. В блоке хранятся следующие файлы:
meta.json — содержит информацию о том, что блок в себе содержит. Пример такого файла:
{
"ulid": "01HSK6N52MKFXHERGCADRDBSW4",
"minTime": 1711115229.512,
"maxTime": 1711115994.583,
"stats": {
"numSamples": 17387,
"numSeries": 11,
"numProfiles": 553
},
"files": [
{
"relPath": "index.tsdb",
"sizeBytes": 4723,
"tsdb": {
"numSeries": 11
}
},
{
"relPath": "profiles.parquet",
"sizeBytes": 103085,
"parquet": {
"numRowGroups": 1,
"numRows": 553
}
},
{
"relPath": "symbols/functions.parquet",
"sizeBytes": 53437,
"parquet": {
"numRowGroups": 2,
"numRows": 1310
}
},
{
"relPath": "symbols/index.symdb",
"sizeBytes": 180
},
{
"relPath": "symbols/locations.parquet",
"sizeBytes": 77077,
"parquet": {
"numRowGroups": 2,
"numRows": 1785
}
},
{
"relPath": "symbols/mappings.parquet",
"sizeBytes": 1770,
"parquet": {
"numRowGroups": 2,
"numRows": 1
}
},
{
"relPath": "symbols/stacktraces.symdb",
"sizeBytes": 14017
},
{
"relPath": "symbols/strings.parquet",
"sizeBytes": 98444,
"parquet": {
"numRowGroups": 2,
"numRows": 1723
}
}
],
"compaction": {
"level": 1,
"sources": [
"01HSK6N52MKFXHERGCADRDBSW4"
]
},
"version": 3,
"labels": {},
"downsample": {
"resolution": 0
}
}
Index.tsdb — индекс Prometheus TSDB, который мапит внешние лейблы в профили, находящиеся в таблице профилей.
profiles.parquet — таблица профилей в формате Parquet. Parquet — это колоночный формат хранения данных, изначально созданный для Hadoop. Больше о нём можно прочитать здесь.
Также имеется внутренняя директория symbols, внутри которой:
index.symdb — содержит метаинформацию, помогающую найти символы для профиля.
stacktraces.symdb — содержит стек-трейсы, хранящиеся в формате parent pointer tree, или спагетти-стек, где у элемента есть ссылка на родительский узел, но нет ссылок на дочерние узлы.
locations.parquet, functions.parquet, mappings.parquet, strings.parquet — метаданные, на которые ссылаются стек-трейсы.
За хранение данных в Pyroscope отвечают два компонента: Ingester и Store-gateway. Первый хранит данные в памяти, а также на диске, а второй работает с поддерживаемыми объектными хранилищами. Можно настроить компоненты так, чтобы они работали в HA-режиме. Между Ingester’ами происходит репликация для решения проблемы потери профилей. Периодически данные переезжают в одно из поддерживаемых Pyroscope внешних хранилищ. Для получения профилей запросы отправляются параллельно в два компонента, результаты двух запросов сливаются в единый результат и проходят дополнительную обработку, например на базе результата строится flamegraph, а фронтенд потом отрисовывает для нас красивую картинку.
Представление профилей
Вот мы и дошли до момента, когда у нас есть готовая реализация профайлера, который собирает профили по одной из двух моделей (или умеет переключаться между ними), эффективно хранит их в хранилищах и может отдавать в сыром или обработанном виде через API. Но надо понимать, что необходим ещё и простой и удобный интерфейс для пользователей. И тут встаёт вопрос о том, как мы собираемся представлять профили конечным пользователям.
Один из способов заключается в том, что пользователям предоставляется выбор сервиса, пода, версии релиза и прочих параметров, в результате чего даётся список профилей за последний час. При выборе какого-либо из профилей на бэк отдаётся его идентификатор, по которому в базе находятся метаданные, содержащие путь к сырому профилю в S3. Профиль загружается из S3 и отдаётся на обработку отдельному контейнеру, который в случае Go визуализирует его с помощью инструмента go tool pprof для визуализации pprof-профилей, а полученный HTML отдаётся обратно на фронт и превращается в знакомую нам картинку. Так делает Vision.
Второй способ более сложен в реализации, но более интересен и удобен для анализа профилей. Заключается он в представлении профиля в виде метрик. Так делают Parca и Pyroscope. В этом случае нам необходим какой-нибудь язык запросов наподобие PromQL, чтобы мы могли писать что-то типа
app:cpu{service_name=”service”, pod=”pod”}
И нам в виде графика красиво покажут потребление CPU у сервиса “service” и пода “pod” за какой-либо промежуток времени. Преимуществом такого подхода может быть ещё и возможность использовать сложные агрегирующие функции для получения более детальной информации о поведении программы.
Заключение
Что ж, дорогой читатель, мы прошли долгий путь. Создание профайлера — отнюдь не простая задача и требует креативности. Надеюсь, что мне удалось более-менее полно раскрыть эту тему и вдохновить тебя на новые идеи. Теперь самое время закрыть вкладку со статьёй и отдохнуть. А если не устал, то можешь прочитать ещё парочку материалов, ссылки на которые я оставлю внизу.
P. S.
Этой статьи не было бы здесь без помощи коллег из моей и других команд. Спасибо им большое за то, что находили время, чтобы обсудить со мной некоторые вопросы, и оставляли комментарии для улучшения статьи.