Привет, Хабр! Для моих задач Prometheus ел слишком много памяти, и я стал искать альтернативу. Нашёл Deckhouse Prom++ и решил проверить его сам: у Флант в статье про Prom++ заявлено 7,8 раза экономии памяти, но цифры сняты на их данных.
На 500 000 активных серий Prom++ 0.8.15 занял 255 МиБ резидентной памяти, Prometheus 3.15.0 занял 1,11 ГиБ, а 2.55.1 занял 1,09 ГиБ. Процессора при записи уходит в 3,8 раза меньше, запросы при 16 клиентах быстрее на 24%, диск почти такой же. Это медиана 12 прогонов, которые я гонял два дня подряд. По памяти вышло 4,4 раза, а не 7,8, ниже расскажу, почему.
Над стендом мне немного помогал Claude. Все цифры взяты из артефактов в репозитории, их можно перепроверить.

Как я мерил
Один узел k3s (AMD EPYC 7713, 8 ядер, 15,6 ГиБ). Движки идут по одному, в одной реплике, с лимитами 2 ядра и 6 ГиБ, данные в emptyDir, между движками всё удаляется. Сравниваю три версии: Prom++ 0.8.15 (форк Prometheus, где по описанию авторов на C++ переписаны построение блока в памяти и журнал записи WAL), Prometheus 3.15.0 и 2.55.1, от которой Prom++ отпочковался.
Данные идут через remote write. Генератор с фиксированным seed поднимает число серий ступенями до 50, 200 и 500 тысяч с интервалом 15 секунд, на движок уходит 27,4 млн точек, время первой точки закреплено, так что все получают одни и те же байты. Потом 38 запросов PromQL при 1, 4 и 16 клиентах, результат каждого сворачивается в sha256, чтобы сверить ответы.
Разбор VK Cloud показал, что цена хранения точки сильно зависит от профиля метрик. У меня синтетика с короткими лейблами, поэтому цифры могут не подойти вашим данным.
Где я чуть не ошибся
Первый прогон выглядел замечательно, и меня это насторожило. Я открыл сырые данные и увидел, что мой querybench гонял каждый запрос ровно один раз, так что все p50 и p99 были одним замером. Теперь запрос повторяется, пока не наберётся минимум замеров, и число замеров стоит рядом с перцентилями.
Потом Go. В документации я уверенно написал, что Prom++ сам ставит GOMEMLIMIT, а в исходниках этого нет: у него и у 2.55.1 автоподстройка лимита памяти и числа процессоров включается отдельными флагами, а 3.x делает это по умолчанию. Выходило, что 3.x ограничивал себе кучу, а Prom++ жил без лимита. Теперь всем трём я задаю GOMEMLIMIT=5529MiB и GOMAXPROCS=2 руками, а в отчёт попадает то, что движок сам отвечает в /api/v1/status/runtimeinfo.
Были и мелочи: нагрузочный под убивал OOM на запросе с 500 тысячами серий, а один прогон пропал, потому что скрипт удалял данные раньше, чем я их скопировал. Всё исправлено, подробности в репозитории.
Результаты
на 500 тысячах серий | Prom++ 0.8.15 | Prometheus 3.15.0 | Prometheus 2.55.1 |
|---|---|---|---|
резидентная память | 255 МиБ | 1,11 ГиБ | 1,09 ГиБ |
рабочий набор cgroup | 176 МиБ | 1,06 ГиБ | 1,03 ГиБ |
процессор при записи, ядер | 0,05 | 0,19 | 0,23 |
каталог данных | 281 МиБ | 303 МиБ | 303 МиБ |
Между самым быстрым и самым медленным прогоном разброс меньше 8%, кроме рабочего набора Prom++ (12%). На серию выходит около 0,5 КБ у Prom++ и 2,8 КБ у Prometheus. На меньших ступенях разрыв скромнее: на 50 тысячах серий 104 МиБ против 185 у 3.15 и 168 у 2.55.1, на 200 тысячах 159 против 482 и 439. По процессору разрыв даже больше, чем по памяти, а диск почти не отличается: сжатие точек у всех одинаковое.

У Prometheus память растёт ступенями и заметно скачет вверх и вниз, у Prom++ идёт ровной пилой с небольшой амплитудой. Видимо, из-за того, что основная часть данных у него лежит вне кучи Go, но я это не проверял.
Запросы, геометрическое среднее медиан по всем 38:
параллельных клиентов | Prom++ 0.8.15 | Prometheus 3.15.0 | Prometheus 2.55.1 |
|---|---|---|---|
1 | 298 мс | 348 мс | 345 мс |
4 | 682 мс | 828 мс | 839 мс |
16 | 2,53 с | 3,35 с | 3,46 с |
Prom++ быстрее на 14% при одном клиенте и на 24% при шестнадцати. Самый тяжёлый запрос, range_quantile, идёт 4,5 секунды против 5,0. Есть пара запросов, где он чуть медленнее: quantile_over_time (639 мс против 592 у 3.15) и regex_name (826 против 781).

Пять запросов, где ответы разошлись
33 из 38 запросов дали у всех трёх движков побайтно одинаковый результат. Пять остальных (alerting_shape, double_subquery, range_quantile, range_sum_rate и recording_rule_shape) разошлись так: Prom++ совпал с Prometheus 3.15, а 2.55.1 ответил иначе. Это странно, ведь Prom++ форк как раз 2.55.1.
Во всех пяти есть rate или _over_time по диапазону, а в Prometheus 3.0 диапазон [5m] стал открытым слева: точка ровно на левой границе окна больше не считается. В promql/engine.go у 2.55.1 стоит floats[drop].T < mint, у 3.x и у Prom++ 0.8.15 уже <=. Мои синтетические точки лежат ровно на границах окон, поэтому 2.55.1 видит на одну точку больше. При переезде с 2.x на Prom++ алерты с rate могут чуть иначе вести себя на границах окон. На реальных данных точки редко попадают точно на границу, но знать об этом стоит.
Почему 4,4, а не 7,8
Точно назвать главную причину я не могу. Скорее всего, дело в данных: во Флант мерили на телеметрии настоящих кластеров с длинными лейблами, а выигрыш Prom++ растёт вместе с их длиной, у моего генератора лейблы короткие. Кроме того, у них Prometheus 2.53, а я выровнял GOMEMLIMIT и GOMAXPROCS, и при разных настройках Go разрыв выглядит иначе.
Что остаётся за кадром
Это один узел и одна реплика, ступени по несколько минут, синтетические данные и общий узел с компонентами управляющего слоя (поэтому прогоны разнесены по времени суток). WAL у Prom++ в своём формате, а у Prometheus по умолчанию сжатие snappy, так что я сравнивал то, что получаешь из коробки. GOMEMLIMIT не покрывает память C++, которую Prom++ берёт через jemalloc. Репликацию, долгое хранение и Thanos я не мерил.
Повторить у себя
Код стенда: xdearboy/prompp-vs-prometheus, нагрузочный код отдельно: xdearboy/prom-loadgen. Один прогон идёт около двух с половиной часов:
export BENCH_NODE=<узел> scripts/node-overlay.sh scripts/harness.sh scripts/run.sh
Серию из 12 прогонов можно запустить из пода внутри кластера (scripts/runner.sh). Если собираетесь переезжать с Prometheus, прогоните стенд на своих метриках и проверьте алерты с rate по диапазону.

