Привет! На связи Владимир Гурьянов, технический директор Deckhouse Observability Platform в компании «Флант». В своём докладе на DevOpsConf 2024 я провёл небольшое расследование и выяснил, кто виноват в том, что Prometheus «съел» 64 ГБ оперативной памяти на сервере. А главное — я разобрался, что нужно делать, чтобы избегать этого в будущем. В этой статье приведу основные размышления и выводы из доклада.
«Флант» в целом начинался с DevOps-as-a-Service, и сейчас мы обслуживаем в этом направлении более 300 клиентов с несколькими тысячами серверов и десятками тысяч приложений. Как можно догадаться, здесь не обошлось без мониторинга.
Я работаю в команде Deckhouse Observability, которая создаёт собственную платформу мониторинга «Фланта». В SaaS-версии система обрабатывает более 100 миллионов метрик в минуту. За основу в разработке мы берём Prometheus.
За последние годы у нас появился обширный опыт работы с Prometheus — могу с уверенностью сказать, что это отличная система мониторинга с массой возможностей. Однако у Prometheus есть одно свойство, которое меня сильно огорчает, — потребление ресурсов. Если это расстраивает вас не меньше, чем меня, давайте разберёмся, чем вызвана эта проблема и как её решить.
Сначала кратко поговорим про сам Prometheus: обсудим его устройство, то, как его компоненты расходуют ресурсы, и начнём искать «главного подозреваемого» в их растрате.
Содержание:
Начинаем расследование: архитектура Prometheus и расход ресурсов
В 2012 году SoundCloud вдохновились Google Borgmon и решили создать нечто подобное, но в формате Open Source — так появился Prometheus. Его первый публичный релиз состоялся в 2015 году. Система написана на языке Go.
Prometheus — это один бинарный файл. Однако внутри него работают несколько взаимосвязанных процессов, которые можно чётко разделить по решаемым задачам. Давайте посмотрим, что у Prometheus «под капотом»:
Прежде всего система мониторинга должна понимать, откуда получать метрики. За это отвечает Service Discovering. Он взаимодействует с внешними API, получает из них метаданные и строит список источников данных — таргеты.
Далее запускается процесс получения данных — Scraping. Он собирает данные с источников по pull-модели и протоколу HTTP. Scraping может получать данные от экспортеров, которые умеют извлекать метрики и преобразовывать их в понятный для Prometheus вид. Второй источник — сами приложения, в которые можно встроить метрики с помощью стандартных библиотек.
После сбора метрик их необходимо куда-то сохранять. В качестве базы данных (Storage) в Prometheus используется TSDB (Time Series Database).
Чтобы считывать сохранённые метрики, используется UI. У Prometheus есть собственный UI, однако он малофункционален, поэтому чаще используют Grafana.
Кроме того, в Prometheus есть два типа Rules: recording и alerting. Recording rules позволяют выполнить запрос и сохранить его результат в виде новой метрики. Alerting rules проверяют выполнение определённых условий и, как понятно из названия, отправляют уведомления во внешние системы, например в Alertmanager.
Мы рассмотрели архитектуру Prometheus, а также внешние элементы, с которыми он взаимодействует, чтобы напомнить общий принцип его работы. На самом деле, чтобы выяснить, какой компонент Prometheus потребляет больше всего ресурсов, достаточно и более лаконичной схемы:
Поскольку Prometheus написан на Go, с помощью встроенного инструмента pprof можно узнать, сколько памяти потребляет каждый компонент. Я проанализировал около десятка инсталляций и получил следующее примерное распределение:
Service Discovering — малозатратная операция и не расходует практически ничего. Rules обычно выполняются на часовом окне, поэтому не требуют большого объёма данных и ресурсов. Удивительно, но запросы от UI тоже потребляют всего около 10% ресурсов. В случае со Scraping 30% оправданы тем, что он получает данные в виде plain-текста, который нужно преобразовать из строк во внутренние структуры, а операции со строками в Go всегда дорогие. На первое место однозначно выходит Storage (TSDB) — посмотрим на него подробнее.
Ищем подозреваемого: особенности работы TSDB
На DevOpsConf 2023 я рассказывал о концепции TSDB, в том числе о TSDB в Prometheus. Чтобы создать общий контекст, приведу основные результаты из того доклада.
Представим, что у нас есть несколько градусников — источников данных. Каждые 30 или 60 секунд мы собираем с них значения и далее для каждого градусника записываем время получения значения (timestamp) и само значение (value).
Поскольку градусников много, нужно как-то их идентифицировать. Для этого используются наборы лейблов (далее — labelsets), которые представляют собой пары «ключ — значение». Ниже — пример labelset:
{ |
В Prometheus, как и в большинстве систем мониторинга, labelset, как правило, указывают в фигурных скобках. Кроме того, поскольку это длинные строки, напрямую с ними не работают — каждому labelset’у назначается ID. Дальше уже хранятся ID labelset’а и его значения (они же временные ряды или серии):
Вернёмся к происходящему в Prometheus. Допустим, у нас есть миллион источников данных, жёсткий диск и область памяти.
Важный момент
В TSDB есть активный блок — в случае с Prometheus обычно двухчасовой. Prometheus сначала хранит его в памяти и только потом записывает на диск.
Схема работы в этом случае будет такой:
-
Получаем значение от какого-то источника и сохраняем в память mapping labelset’а в ID.
-
Записываем этот же mapping в журнал. Журнал нужен, чтобы в случае нештатной ситуации (перезагрузка сервера, нехватка памяти) можно было восстановить все данные в память.
-
Записываем в журнал значение и время его получения.
-
Записываем в память значение и время его получения.
-
Повторяем операции 1–4 для следующих источников.
В итоге накапливается некоторый объём данных. По истечении определённого времени они записываются на диск в виде отдельного блока, который включает как значения метрик, так и полный набор labelsets. По сути, блок — это такая мини-база данных, которая содержит всю необходимую информацию, чтобы определить, какое значение было у конкретной метрики в период времени, к которому относится этот блок:
Теперь ту часть данных, которая находится в памяти, можно условно разделить на две части: в одной находятся данные, а вторая работает с labelsets. Сначала рассмотрим первую и определим, что в ней может приводить к избыточному потреблению памяти.
Ищем подозреваемого: работа с данными
Вернёмся к исходному примеру с градусником. Мы всё так же снимаем с него значения и записываем timestamps. Данные в памяти уже находятся в закодированном, сжатом виде.
Для значений используется Gorilla-кодирование, которое позволяет максимально эффективно хранить в памяти временные ряды. Важная особенность этого алгоритма: чтобы прочитать любую точку, сначала нужно декодировать весь объём данных, то есть в нашем случае — весь двухчасовой блок.
Timestamps кодируются с помощью Delta-Delta-кодирования. Если мы снимаем значения через регулярные промежутки, например каждые 30 или 60 секунд, для хранения одного timestamp нужен всего один бит.
Последовательность timestamps и values называется chunk (далее — чанк). По умолчанию в Prometheus в чанк помещаются данные за два часа. По истечении двух часов чанк записывается на диск, и начинается новый сбор данных.
Если источников станет больше, например миллион, сформируется миллион чанков (один источник — один чанк). Через два часа мы получим миллион файлов на диске — в целом это не так уж много. Однако через 24 часа их станет 12 миллионов — а вот это уже многовато. Если бы источников было больше и/или мы хранили данные не за день, а за месяц, то данные хранились бы непоследовательно и запросы на чтение были бы очень тяжёлыми.
В Prometheus эта проблема решена так: чанки записываются на диск последовательно в один файл. Благодаря этому уменьшается число файлов, данные хранятся последовательно, а чтение быстрое и удобное. Кроме того, известно смещение внутри файла, а значит, можно легко найти нужный чанк, не читая файл целиком. Максимальный размер файла — 128 МБ. Механизм записи такой: сначала в файл записываются первые чанки от всех источников, затем, если осталось место, записываются вторые и т. д. Когда место в одном файле заканчивается, открывается другой и запись продолжается в него.
Потенциальный минус здесь в том, что чанки ограничены только по времени. Например, если чанк двухчасовой и Scrape Interval равен 60 секундам, то через два часа будет 120 точек. Однако если снимать данные каждые 15 секунд, то через два часа будет уже 480 точек. Из-за этого становится трудно предсказать, сколько места займут данные в чанке, и очевидно, что это влияет на потребление памяти. Кроме того, чтобы декодировать данные в чанке, нужно прочитать его целиком — это тоже расходует память.
В Prometheus с этим борются, ограничивая размер чанка: в него можно записать только 120 точек. После этого он сохраняется на диск. При этом важно сохранить скорость работы с ним, чтобы можно было оперативно читать из него данные. Поэтому чанки m-map’ятся в память. Примерно так же работает swap: в памяти создаются структуры данных, при обращении к такой структуре операционная система быстро считывает данные с диска и подкладывает их в память. Со стороны приложения всё выглядит так, будто данные всегда были в памяти. Однако на самом деле их там нет, и это позволяет существенно экономить объём занятого места в оперативной памяти.
Спустя три часа (один полный блок + ½ блока) можно выделить чанки, которые входят в блок, в отдельную часть. При Scrape Interval в 15 секунд это будет четыре чанка по 120 точек. Добавляем к чанкам индекс, чтобы получилась законченная БД. Дальше процесс повторяется.
Подведём промежуточный итог.
Все данные от каждого источника записываются в отдельные чанки. Размер чанка — 120 точек. При этом чанки последовательно записываются в один файл, а при достижении 128 МБ открывается новый файл, в который продолжается последовательная запись. По истечении трех часов часть чанков «отрезается» и сохраняется в блок.
Данные хранятся в памяти в закодированном, сжатом виде. Для values используется Gorilla-кодирование. Его особенность в том, что для чтения любой точки нужно декодировать чанк целиком. Для timestamps используется Delta-Delta-кодирование, и это позволяет тратить всего один бит на хранение одного timestamp.
Кажется, работа с данными хорошо оптимизирована и эффективна. Тогда, возможно, в излишнем потреблении памяти виноваты не данные, а та область, где хранятся labelsets. Давайте посмотрим.
Ищем подозреваемого: работа с labelsets
Ниже — пример labelset:
{_name_="http_requests_total", job="ingress", method="GET"}
Здесь три лейбла: name
, job
и method
.
Labelsets хранятся в Index. Index нужен, чтобы обслуживать запросы на чтение, примерно как в реляционных БД. В случае с Prometheus мы, как правило, не ищем по полному labelset, а берем один или несколько лейблов и составляем запрос, возвращающий все серии, в которых есть эти лейблы.
Важный момент
Если вы зайдёте в исходный код, чтобы узнать, как устроен Index, то увидите более сложную картину. Здесь я в несколько упрощённой форме расскажу, как это работает.
Для реализации Index в Prometheus предусмотрена отдельная область памяти, в которой есть несколько сущностей — о них ниже.
Symbols. В эту сущность мы записываем все ключи и значения в виде отдельных строк и назначаем им ID:
Это позволяет максимально эффективно работать с labelset’ами, так как в них часто повторяются ключи и значения.
Postings. Благодаря этой структуре можно быстро вести поиск по парам «ключ — значение». Для каждой пары мы указываем список серий, в которых есть эта пара, но используем уже ID и массив серий:
Например, чтобы найти все серии, в которых есть лейбл job
и его значение “ingress”
, нужно сначала преобразовать пару «ключ — значение» с помощью символов. В нашем примере это символы 3 и 4. Затем необходимо найти пару 3,4 и прочитать массив серий.
Series. Эта сущность позволяет связать labelsets с данными. Здесь есть ID серий и массив, в котором с помощью ID описан весь labelset. Чтобы восстановить labelset, нужно с помощью символов превратить ID в массиве в реальные значения и вернуть их. Кроме того, в Series есть ссылка на данные — фактически ссылка на смещение в файле с чанками, которое позволяет получить доступ к чанку. Поскольку чанков, как правило, несколько, таких ссылок тоже несколько:
Мы рассмотрели работу с labelsets на примере одной серии. Посмотрим, что будет, если добавить ещё одну.
Возьмём серию, у которой labelset отличается только значением для лейбла method
: в исходной был GET
, в этой — POST
:
{_name_="http_requests_total", job="ingress", method="POST"}
Разберём шаги добавления серии в Index:
Шаг 1. Добавить запись POST
в Symbols:
Шаг 2. Добавить в Postings новую пару «ключ — значение» method=“POST”
и серию, в которой она встречается. Также необходимо обновить записи для уже имеющихся пар «ключ — значение», так как пары _name_="http_requests_total"
и job=“ingress”
уже встречались:
Шаг 3. Добавить ещё одну запись в Series, так как это новая серия и у неё свои ссылки на чанки, свой labelset:
Итак, Symbols, Postings и Series позволяют оптимально хранить labelsets: избавиться от дублирования строк, хранить ID в виде цифр и смещение чанков и при этом сохранять скорость поиска. Таким образом, работа с labelsets тоже вряд ли подходит на роль «главного подозреваемого» в растрате ресурсов.
Тут, как бывает в детективах, приходит время взглянуть на тех, кому до этого удавалось оставаться в тени и избегать подозрений. В нашем случае это cardinality и churn. Рассмотрим их по очереди.
Кто виноват: cardinality
Cardinality — это количество уникальных labelsets для метрики.
Рассмотрим пример. Допустим, есть метрика с именем http_requests_total
. У нее есть два лейбла: instance
— с тремя значениями и job
— с одним:
Чтобы вычислить cardinality этой метрики, нужно перемножить числа 1, 3 и 1 — получится 3. Таким образом, для этой метрики есть три уникальных набора лейблов. Чанков тоже будет три.
Добавим еще один лейбл (method
) уже с пятью значениями:
В этом случае cardinality равно 15. Число чанков тоже увеличится, но кажется, что разница между 3 и 15 не такая уж большая.
Добавим еще один лейбл — пусть это будет эндпоинт, на который приходит запрос. Предположим, у нас 1000 таких эндпоинтов:
В результате значение cardinality и число чанков увеличатся до 15 000. Очевидно, чтобы хранить такое количество данных в чанках, нужен большой объём памяти. Также требуется больше ресурсов на вставку данных в эти чанки и на чтение. Например, если мы хотим посчитать, сколько всего пришло запросов, нужно декодировать 15 000 чанков.
Значение 15 000 в cardinality — это столько же новых серий и labelsets в Index. Их нужно описать в Symbols и Postings, причем последние увеличиваются как по количеству записей, так и «в ширину». Массивы в Series также растут, так как явно есть повторяющиеся пары «ключ — значение». Серии тоже прибывают. Всё это приводит к росту потребления памяти. Кроме того, требуется больше ресурсов, чтобы обновить Index, а также чтобы прочитать и зарезолвить запрос.
Кажется, мы нашли одного «виновного». Теперь посмотрим на churn.
Кто виноват: churn
Churn — это мера скорости, с которой временные ряды добавляются в систему мониторинга или удаляются из неё. Churn отражает динамику изменений в метриках.
Начнём с чанков. Допустим, у нас есть четыре градусника, из которых стабильно присылает значения только четвёртый. Спустя некоторое время накапливается 120 точек, чанк записывается на диск, и сбор данных продолжается:
Через три часа данные можно записывать на диск. Однако стоит помнить, что в течение этих трех часов сохраняются в том числе и чанки от источников, которые перестали присылать данные. Поскольку есть чанки, в которых мало значений, сжатие работает не так эффективно, как на больших данных. Это приводит к увеличению потребления ресурсов.
Размеры Symbols, Postings и Series также увеличиваются. Это требует больше ресурсов на обновление Index и на чтение.
Поскольку могут потребоваться исторические данные, нельзя удалять из Index серии — то есть в нём нужно хранить данные по всем сериям, которые когда-либо появлялись.
Несмотря на то что работа с чанками и Index достаточно эффективна, как правило, в излишнем расходовании ресурсов виноваты именно cardinality и churn. При этом у churn есть ещё одна неприятная особенность: он не отслеживает, какие метрики появляются, а какие — исчезают, поэтому изначальная 1000 уникальных метрик в блоке через несколько часов может превратиться в 3000. Это бывает нелегко обнаружить.
Кто виноват: remote_write (бонус)
Prometheus remote_write — это механизм, который позволяет отправлять данные в какой-то внешний storage.
Если remote_write один, это особо не отражается на ресурсах. Если их несколько, а источники, которые принимают данные, недоступны, потребление памяти на каждый remote_write может составлять 500–700 МБ в зависимости от количества метрик в кластере.
Таким образом, remote_write тоже может потреблять ресурсы, но, как правило, не столько, сколько может потребляться из-за высокой cardinality и при высоком проценте churn. Поэтому я вынес его в бонусный раздел и дальше не буду его упоминать.
Что делать: анализируем потребление ресурсов
Мы разобрались, что может увеличивать расход ресурсов. Теперь обсудим инструменты, которые позволяют выявить метрики, приводящие к излишнему потреблению.
Начнем с churn, так как он более капризный и не всегда очевидный.
В Prometheus есть утилита ./tsdb
с ключом analyze
. Для завершённых блоков она позволяет найти те пары «ключ — значение», которые приводят к максимальному churn:
./tsdb analyze
Label pairs most involved in churning:
17 job=node
11 __name__=node_systemd_unit_state
6 instance=foo:9100
4 instance=bar:9100
3 instance=baz:9100
Начиная с версии 2.14 появилась специализированная метрика — с помощью запроса ниже можно посмотреть серии, которые чаще всего появлялись и исчезали за последний час:
topk(10, sum without(instance)(sum_over_time(scrape_series_added[1h])))
Для cardinality инструментов побольше. Помимо утилиты ./tsdb analyze
, есть Prometheus Status — страница в Prometheus, которая показывает лейблы с наибольшим значением cardinality. Ещё одна полезная утилита — mimirtool. Остановимся на ней подробнее.
Mimirtool позволяет получить:
-
из Grafana — список метрик, которые используются в дашбордах;
-
из Prometheus — общий список метрик и метрики, которые используются в Rules.
Далее mimirtool вычисляет пересечение этих подмножеств и выделяет в отдельное подмножество неиспользуемые метрики. Конечно, это не повод сразу их удалять, но стоит оценить полезность таких метрик.
Что делать: избавляемся от лишнего
Приведу небольшой кейс. У коллег был кластер, в котором Prometheus собирал почти 10 миллионов метрик и потреблял почти 64 ГБ памяти:
Когда они избавились от лишних метрик и labelsets, тот же самый Prometheus стал выглядеть так:
Итого — 877 тысяч метрик и потребление памяти на уровне 5 ГБ. Эта картина уже явно лучше.
Есть несколько способов избавиться от лишнего.
Стараться изначально не добавлять лишние лейблы к метрикам.
В этом могут помочь гибкие настройки некоторых экспортеров и большинства библиотек для инструментирования.
Удалять лишние лейблы или метрики.
Для этого есть механизм релейблинга (relabel_configs):
- source_labels: [requestID]
action: labeldrop
- source_labels: [__name__]
regexp: not_important_metric
action: drop
Выставлять лимиты на количество сэмплов, которые собираются с источника.
Это делается для того, чтобы не «перегреть» Prometheus, если вдруг какое-то приложение начнёт отправлять слишком много метрик.
Выставлять лимиты на количество источников.
Это нужно на случай, если кто-то, например, решит масштабировать свой под с 50 000 метрик до 1000 подов.
Как теперь расследовать инциденты
Итак, мы удалили всё лишнее, и теперь Prometheus расходует гораздо меньше ресурсов.
Предположим, мы получили ошибку 500. Раньше связанная с ней метрика выглядела так:
{__name__="http_requests_total", code="500", user_agent="google Chrome 17", uri="/books/buy/17", method="POST", request_id="XXXX-XX-XX", backend="backend-32", user_id="17"}
Здесь всё предельно понятно: есть бэкенд, ID запроса, эндпоинт, на который пришёл запрос, и даже пользователь, который пришёл с этим запросом.
Вот та же метрика после «генеральной уборки»:
{__name__="http_requests_total", code="500", method="POST"}
Основная задача мониторинга — уведомить нас о том, что что-то пошло не так, позволить быстро разобраться и исправить ошибку. Кажется, что вид метрики выше не решает эту задачу, но это не так. Данные для анализа инцидента можно взять из логов и трейсов. Как правило, в логах есть и user_agent
, и backend
, и вся остальная необходимая информация.
Связать метрики с логами и трейсами позволяет механизм exemplars.
Обычная метрика в Prometheus выглядит так:
http_requests_total{method="POST", code="500"} 3
Та же метрика с exemplars:
http_requests_total{method="GET", code="500"} 3 # {trace_id="abc123"}
По сути, exemplars — это возможность добавлять метаданные к метрикам. Как правило, это trace_id
, через который можно эффективно и удобно связывать метрики с трейсами.
Стоит иметь в виду, что exemplars хранятся (во всяком случае в Prometheus) отдельно. Очевидно, что хранение не бесплатное, поэтому для exemplars действует то же правило, что и для метрик: прежде чем добавлять exemplar, стоит подумать, нужен ли он. Например, если за 30 секунд произошло 100 событий, которые закончились кодом 500 на одном эндпоинте, скорее всего, у этих событий одна причина. Поэтому стоит добавить exemplar только к одной метрике, в крайнем случае — только к каждой десятой или сотой. Таким образом, нужно продумывать добавление exemplars, иначе есть риск вернуться к тому, с чего все начиналось, — к дефициту ресурсов.
Коллеги из кейса выше утверждали, что удаление лишних метрик и labelsets никак не отразилось на продуктивности мониторинга и работы с инцидентами: у них остались те же дашборды и алерты, а exemplars они используют уже давно.
Выводы
Prometheus — продвинутая система со множеством инструментов и функций для мониторинга. Его известный недостаток — чрезмерное потребление ресурсов, но, как мы выяснили, зачастую это связано с недостаточным пониманием инструмента и, как следствие, его неверным использованием.
Решение кроется в грамотном управлении метриками и лейблами: можно удалять или не добавлять лишние, выставлять лимиты на количество сэмплов от источников или количество самих источников. Чтобы найти лишние метрики, можно использовать как встроенные в Prometheus инструменты (./tsdb analyze
и Prometheus Status), так и стороннюю утилиту mimirtool.
При этом удаление лишнего не должно влиять на эффективность мониторинга и работы с инцидентами. Поэтому нужно внимательно анализировать используемые и даже неиспользуемые метрики, прежде чем удалять их.
Видео и слайды
P. S.
Читайте также в нашем блоге:
Автор: Magvai69