Представим следующую ситуацию. Ваш python веб-сервер собирает какие-то метрики prometheus_client-ом: счётчики, гистограммы и т. д, например, количество входящих запросов. Вы также настроили приложение, чтобы /metrics возвращал все ваши метрики. Если режим работы prometheus_client-a оставить по умолчанию, при запуске приложения gunicorn-ом с более чем одним воркером вы сталкиваетесь с проблемой консистентности метрик.  В такой конфигурации получается следующее: каждый воркер хранит собственные метрики изолированно; запрос /metrics обрабатывается случайным воркером. На примере http_requests_total это приводит к тому, что Prometheus вместо общего счётчика запросов получает счётчик запросов воркера, причем каждый раз случайного. Как следствие, получаем сброс метрики.   Напомню, что такая метрика как Counter должна быть неубывающей. В нашем случае по ней технически хоть и можно построить график RPS, но доверять его значениям невозможно.  Функция rate умеет определять такие сбросы, что невольно может завышать значения на графике. Например, ваш сервер мало нагружен. Фактических запросов в течение нескольких периодов скрапинга не было. Однако ввиду того, что у разных воркеров накопленные значения счётчика запросов могут отличаются, график может показывать сотни RPS.  В свою очередь предположим, что все воркеры на каждый момент получения метрик скрапером будут иметь одинаковые возрастающие значения счётчиков. В этом случае значения на графике будут занижаться в N раз, где N — количество воркеров. В конечном итоге получаем абсолютный рандом и полное недоверие значениям. Ситуация ухудшается, если запуск сервера сопровождается опцией max_requests. Считается хорошей практикой после определённого количества обработанных запросов перезапускать воркер. Однако это приводит к тому, что все накопленные метрики начиная с последнего скрапинга до момента перезапуска будут утеряны. В комплексе с предыдущими проблемами тот же график в большем масштабе будет выглядеть следующим образом:  Могут быть и другие причины перезапуска воркеров: timeout воркера; время жизни воркера, если запускать под uWSGI; ручной перезапуск; просто непредвиденные обстоятельства. Любая из этих причин приводит к потере накопленных метрик, которые скрапер не успел забрать.  Не так уж много решений этой проблемы. Универсального, на мой взгляд, не существует, каждое имеет какой-то недостаток. В конце покажу решение, на котором мы остановились. Но перед этим пройдёмся по остальным, что пришли на ум. Эту статью я начал с проблемы консистентности, когда случайный воркер отдаёт свои изолированные метрики. Казалось бы, нет причины — нет проблем. Можно запустить приложение с одним воркером. Однако это не решает проблему потери метрик при перезапуске. Поэтому сразу этот подход подвергается критике.  Дополнительно появляется ряд других вопросов в зависимости от конфигурации: иррациональное использование ресурсов; пропускная способность; отказоустойчивость. Этот подход можно применить самостоятельно. Однако если ваше приложение написано на Django и оно использует библиотеку django‑prometheus, то такое решение есть из коробки. Оно подразумевает запуск экспортера в daemon thread-е, который захватывает порт из указанного диапазона PROMETHEUS_METRICS_EXPORT_PORT_RANGE. Как следствие, нужно настраивать скрапер на разные порты. В случае запуска приложения в Kubernetes не уверен, что с помощью аннотаций этого вообще возможно добиться. Также нужно будет продумать вопрос с label-ами получаемых метрик. Очевидно, что это повлияет и на построение графиков. В общем, подход имеет право на жизнь, но, на мой взгляд, слишком сложный. И он всё так же не решает проблему потери метрик при перезапуске воркеров.  Сходу данный режим накладывает ограничения. Коротко: Дублирование метрик при неправильной настройке MultiProcessCollector. Кастомные сборщики не работают (метрики cpu и memory). Метрики Info и Enum не работают. Pushgateway не может быть использован. Особенности использования Gauge. Exemplar не поддерживается. Забегая вперёд, именно на этом подходе мы и остановились, за исключением некоторых доработок.  В качестве примера построим данное решение на FastAPI и посмотрим, что из этого выйдет.     Запускаем gunicron. Опция PROMETHEUS_MULTIPROC_DIR включает режим мультипроцессинга у prometheus_client-а. Также следуем рекомендациям по использованию MultiProcessCollector (cмотри server.py). Чуть подробней поговорим об этом позже. Запускаем ab. Сделаем тысячу запросов с concurrency равным 2: Так как мы указали настройки max_requests и max_requests_jitter в server_config.py, ожидаем, что каждый воркер перезагрузится примерно четыре раза. Что, собственно, и показывают логи gunicorn:  Далее смотрим, что нам вернёт /metrics: Наконец-то вернулся общий счётчик запросов вместо счётчика случайного воркера. И даже перезапуски не привели к потере метрик. Более того, если мы остановим сервер и запустим заново, все метрики сохранятся. Это достигается благодаря тому, что теперь каждый воркер пишет метрики в соответствующие файлы. А MultiProcessCollector считывает файлы от всех воркеров и агрегирует результат.  Вот что сейчас лежит в директории prometheus-multiprocess: counter_17596.db counter_17597.db counter_17627.db counter_17643.db counter_17649.db counter_17663.db counter_17677.db counter_17691.db Во время тестирования было создано восемь файлов, по четыре каждым воркером. Название файла состоит из типа метрики и результата вызова функции process_identifier внутри функции MultiProcessValue. То есть, если бы в нашем примере было использование Histogram, Summary или Gauge, то был бы ещё один набор таких файлов для соответствующих типов метрик. Напомню, что согласно первоначальным ограничениям этого подхода метрики Info и Enum не работают.  Обращаем внимание на файлы и их количество. Как вы уже догадались, каждый воркер создаёт и пишет в файл, соответствующий его идентификатору процесса. А /metrics возвращает итоговый результат.  Так как MultiProcessCollector сканирует все файлы в PROMETHEUS_MULTIPROC_DIR, накопленные метрики старых воркеров также учитываются в общем результате, что наконец-то решает проблему потери метрик при перезапуске воркера. Однако нельзя не обратить внимание на тот факт, что если всё пустить на самотёк, то количество файлов будет постоянно расти. Если по историческим соображениям ваше приложение запускается под uWSGI, у вас есть уникальная возможность в несколько строк кода решить эту проблему. Для этого достаточно переопределить process_identifier следующим образом: Важно выставить значение опции lazy-apps в true (в версии 2.0 по умолчанию значение false). Если этого не сделать, произойдёт утечка файловых дескрипторов (от десятков до сотен FDS в одном файле) из-за того, как uWSGI разветвляет процессы для создания рабочих процессов. uWSGI в том числе хорош тем, что предоставляет из коробки механизм идентификации воркера. И если у вас появилась идея перетащить запуск вашего приложения на uWSGI, нужно иметь ввиду несколько важных моментов. Цитата из документации:  The project is in maintenance mode (only bugfixes and updates for new languages apis). Do not expect quick answers on github issues and/or pull requests (sorry for that) A big thanks to all of the users and contributors since 2009. В общем, проект слабо поддерживается. Это отражается, к примеру, на такой опции как max-requests-delta. Хоть она описана в документации к версии 2.0, на самом деле её там нет. Таким образом получается, что в режиме мультипроцессинга с включённой опцией max-requests воркеры будут перезапускаться практически одновременно. Но если ваш выбор неизменен, рекомендую почитать о других особенностях опций в версии 2.0 на Bloomberg. К сожалению, gunicorn не обладает таким волшебным методом worker_id, как uWSGI. И чтобы решить проблему роста количества файлов метрик, нужно что-то придумать. Идея в том, чтобы удалять все файлы метрик при запуске нового воркера, предполагая, что другой живой воркер при обновлении метрик будет пересоздавать файл. Но, как выяснилось, этого не происходит. В таком виде становится всё очень плохо. А что если нам попытаться понять, живой ли ещё воркер, который когда-то создал файл метрик, прежде чем удалять его? Есть парочка вариантов, как это можно сделать. Например, проверить, является ли pid ключом словаря server.WORKERS. Обновим немного server_config.py:  Снова запускаем ab. Сделаем тысячу запросов с concurrency равным 2.  Вот что сейчас лежит в директории prometheus-multiprocess: counter_52368.db counter_52370.db И теперь количество файлов в директории остаётся постоянным.  Посмотрим, что нам вернёт /metrics: Как и ожидалось, здесь будет значение в диапазоне от 0 до 200 (max-requests + max-requests-jitter). Но это и немудрено, ведь мы получили метрики после всех перезапусков воркеров.  Если присмотреться, решение достаточно неплохое. С задачей организовать консистентность справляется. Теряет накопленные метрики после последнего скрапинга точно так же, как и все описанные ранее решения. Если настроить max-requests-jitter таким образом, чтобы в рамках одного периода скрапинга был всего один перезапуск воркера, то мы потеряем всего лишь 1 / N-ую часть метрик, где N — количество воркеров. На графиках это может отобразиться как небольшая просадка в RPS.  Простота, прозрачность и предсказуемость делает этот подход жизнеспособным.  Что у нас есть в арсенале? Не так уже много:pid и age.  age — постоянно увеличивающееся число. По сути, является абсолютным во времени номером воркера. Как вы уже догадались, если использовать его в качестве process_identifier, то будут те же проблемы, что и в поведении по умолчанию.  Были следующие идеи как использовать age: process_identifier = age % N  process_identifier = age % (2 * N) Обе подвергаются критике. На примере с двумя воркерами, остаток от деления на N ломается, когда второй воркер становится третьим при живом первом. Оба воркера будут писать в одинаковые файлы, получаем коллизию.  На примере с тремя и более воркерами, даже с остатком от деления на (2 * N) ситуация может повториться только при других условиях и с меньшей вероятностью. Но в конечном итоге коллизии не избежать. Поэтому эти варианты нам не подходят. Идея возникла из необходимости назначить воркерам некоторый идентификатор из диапазона от 1 до N, где  N — количество воркеров. Будем пробовать захватить lock  файла из соответствующего набора. Кто первый захватит, тому этот worker_id и принадлежит. И, самое главное, lock будет захвачен на всё время жизни воркера. Таким образом перезапуски других воркеров не смогут на него повлиять.   Добавим в зависимости filelock:  Реализуем задуманное в server_config.py:  Запускаем ab: Смотрим логи:  Смотрим директорию prometheus-multiprocess: counter_1.db counter_2.db Смотрим /metrics: Метрики собираются консистентно, перезапуск воркеров не приводит к их потере. Нет бесконтрольного роста файлов метрик. Теперь счётчик запросов строго возрастает. Нет сброса метрик из-за разницы накопленных значений между воркерами. Нет сброса метрик из-за перезапуска воркеров. На этом решении мы и остановились. Считается хорошей практикой создавать новый CollectorRegistry в контексте запроса /metrics и передавать его в MultiProcessCollector. Собственно, именно так мы и сделали в server.py.  Фишка в том, что когда вы создаёте объекты классов Count, Histogram и т. д., эти метрики регистрируются во встроенном REGISTRY по умолчанию. Если передать этот REGISTRY в MultiProcessCollector,  то столкнёмся с ошибкой дублирования метрик.  Можете сами попробовать запустить следующий server.py и запросить /metrics:  Вы увидите: Происходит попытка экспортировать multiprocess-метрики и метрики конкретного воркера. Получаем соответствующую ошибку. Поэтому, чтобы такого не происходило, создают новый CollectorRegistry, в котором нет зарегистрированных метрик.  К слову, такие библиотеки как django‑prometheus и starlette-prometheus тоже учитывают этот момент. Но всё равно нужно быть внимательными. Не все ещё используют переменную окружения PROMETHEUS_MULTIPROC_DIR в верхнем регистре. starlette-prometheus версии v0.9.0 не исключение.  В этой статье до сих пор не упоминались проблемы, связанные с записью в файлы. Если приложение запускается на сервере с HDD, не трудно представить, что это будет ощутимо влиять на производительность запросов. Даже если у вас SSD, в случае запуска асинхронного приложения диск будет выступать некоторой точкой синхронизации. Эту тему не раскрыть в двух словах. На мой взгляд, тут не обойтись без вмешательства в prometheus_client, если эта проблема начнёт себя проявлять. Существует рекомендация по уменьшению зависимости от диска: можно смонтировать директорию как tmpfs. С точки зрения приложения ничего не поменяется. Однако файлы будут храниться в памяти. Думаю, это сможет заметно помочь даже в ситуации с SSD.  Если ваше приложение запущено в Kubernetes, то можно воспользоваться специальным типом volume-а emptyDir. Если параметру emptyDir.medium установить значение Memory, то получится смонтировать tmpfs. Но нужно быть осторожным: размер этой директории учитывается в общем потреблении памяти. В более старых версиях Kubernetes ещё не было возможности указать лимит на размер этой директории. Поэтому, если ваше решение будет подразумевать бесконтрольный рост файлов, вы можете исчерпать выделенную поду память. Он упадёт в CrashLoop и вы сможете восстановить работоспособность сервиса только после ручного удаления пода, простой перезапуск не поможет. emptyDir не очищается между перезапусками пода.  Также необходимо понимать, что бесконтрольный рост файлов метрик влияет на производительность /metrics. Последовательное чтение большого количества файлов с диска с последующей агрегацией может заметно увеличить время отдачи метрик а также косвенно повлиять на общую производительность в моменте. Также в этой статье не было подробного описания по работе с Gauge в режиме мультипроцессинга. Ему требуется отдельное внимание.  Как я уже говорил, мы остановились на решении с запуском prometheus_client-a в режиме мультипроцессинга с доработкой идентификации воркеров силами filelock-ов. Для ряда наших сценариев это решение близко к идеалу. Те ограничения, что накладывает prometheus_client в таком режиме, не затрагивают наши потребности.  Метрики cpu и memory мы получаем из Kubernetes. Метрики Info и Enum нам не пригодились. Pushgateway не используем. С Gauge, при желании, можно подружиться. Всем спасибо за внимание! Пишите в комментариях, сталкивались ли вы с подобной проблемой и как вы её решали в своих проектах.