infra
September 15, 2023

Prometheus

В интернетах легко можно найти статьи о том, как из Ruby on Rails приложения отдавать метрики в формате, совместимом с Prometheus, и есть готовая библиотека Prometheus::Client, которая решает эту задачу. Однако, если вы делаете не какую-то курсовую работу или простенький MVP проект, то вы обнаружите, что в реальном боевом продакшене кишки намотает на вентилятор всё не так просто. Вот об этом я расскажу в статье и в конце дам ссылку на шикарнейший гем :)

Ruby on Rails и все-все-все

Сначала давайте опишу, что есть "Ruby on Rails приложение в боевом продакшене", и как мы в RNDSOFT это понимаем. Приложение - это не только процесс, который обслуживает HTTP-запросы, это еще и отдельные процессы и сервисы, исполняющие фоновые задачи (ActiveJob), выполняющие дополнительную логику - например слушающие RabbitMQ очереди или Kafka топики, а также всякие фоновые штуки, вроде очистки или архивации. И конечно в современном мире это всё упаковано в Docker-контейнеры, исполняется в распределённой инфраструктуре и оркестрируется оркестратором. Почему же это называется Rails-приложением? Да потому что всё это имеет единую кодовую базу, БД, использует все подсистемы Rails да и запускается зачастую через rails runner ./jobs.rb. Итого в нашем приложении есть:

  • Rails web-сервер. В нашем случае это Phusion Passenger в multiprocess режиме;
  • Фоновые (отложенные) задачи ActiveJob. В нашем случае на базе DelayedJob;
  • Дополнительный “НЕ HTTP” сервис (запуск через rails runner). У нас зачастую это RabbitMQ слушатель;
  • cron-сервис, выполняющий регулярные задачи вроде экспорта статистики. Мы для этого используем Rufus Scheduler. Про Rufus я писал раньше и статья не потеряла актуальности;

Само собой, что все эти сервисы запущены не в единственном экземпляре и размазаны по нескольким виртуалкам (для оркестрации мы используем Nomad, но это уже совсем другая история (c)).

Сразу к проблемам - от простых к сложным

  1. Фоновые задачи и другие “НЕ HTTP” сервисы не имеют HTTP-интерфейса для того, чтобы Prometheus мог собрать (scrape) с них метрики;
  2. Прометеевские счётчики хранятся в памяти процесса, и поэтому с multiprocess или pre-fork серверов не так просто собрать эти метрики. Сюда попадает наш любимый Passenger, Puma (в кластерном режиме) да и любой другой Rack-херак сервер, который порождает внутри себя процессы;
  3. DelayedJob, являясь однопоточным, в базовом варианте лишен предыдущего недостатка, но мы в целях оптимизации и ускорения используем Delayed Job Worker Pool, который внутри себя порождает пул процессов, обрабатывающих задачи, и проблема встаёт в полный рост;
  4. Ну и теперь вишенка (на решение которой и направлена эта статья) - вытекает из пунктов 2 и 3 - структура хранения данных счётчиков прометея забивает диск числом файлов, что сильно сказывается на производительности, особенно если вы редко рестартуете (например деплоите) приложение.

Проблема 1 - “у меня в сервисе нет HTTP!”

Тут всё просто - запускайте в сервисе простой Rack-сервер (обычного WebBrick хватит с головой) и экспортируйте метрики, как белые люди . Если кто-то хочет предложить "push-gateway или его аналог" - пусть отправляется гореть в Ад 😈вместе с этим самым "push-gateway или его аналогом". Спрашивайте отдельно, почему так… Если не боитесь…

Проблема 2 - multiprocess и pre-fork и прочие cluster-mode

В этом вопросе сосредоточен основной корень проблемы, я постараюсь описать его подробно. Схему в студию!

Итого, на схеме видно, что мы имеет 1 мастер-процесс (в случае Passenger Standalone это модифицированный Nginx), который принимает запросы, спавнит/форкает в отдельных процессах Rails-приложения и отдаёт им запрос на обработку. А сами счётчики-то находятся в памяти Rails-приложения, и каждый форк имеет только свой счётчик, ничего не зная о соседях. Когда прилетает scrape-запрос на сбор метрик, он попадает в какой-то один процесс и не может отдать результат, накопленный другими.

💡 Есть проблема - есть решение! Разработчики библиотеки Prometheus::Client - не дураки, и предусмотрели это через абстракцию Data Store, которая отвечает за хранение метрик. По умолчанию используется DataStores::Synchronized, которая работает в однопроцессном приложении и хранит данные в памяти, но есть и готовая реализация DataStores::DirectFileStore. Поскольку все порождённые процессы разделяют общую файловую систему, то нет никаких проблем (почти, но об этом в пункте 4) синхронизировать счётчики между ними. Именно этим и занимается DirectFileStore - хранит данные счётчиков на файловой системе. И когда любой из Rails-процессов получает на обработку scrape-запрос, он спокойно собирает все метрики (свои и своих соседей) и отдаёт их. ✅ Проблема решена.

Проблема 3 - DelayedJob и Pool процессов

Тут тоже всё просто как в первой проблеме - запускаем простенький Rack-сервер прямо внутри DelayedJob через его систему плагинов или в master-процессе, если вы используете Delayed Job Worker Pool. Затем, как в проблеме 2, используется DirectFileStore. ✅ Проблема решена.

Проблема 4 - если всё хорошо, то почему всё плохо?

В этом разделе нам придётся немного погрузиться в потроха DirectFileStore, в то, как он хранит данные на диске. А раз к данным имеют доступ несколько процессов, то быть беде: вас ожидают гонки и конкуренция. Погнали!

Допустим процесс 1 (из первой схемы) хочет сохранить значение своего счётчика, процесс 2 страстно желаете того же. Как же им записать это всё в файл и не затереть данные друг друга? К этому надо добавить еще оптимизацию структур данных, чтоб не тормозила ни запись, ни чтение. Можно конечно использовать какие-то блокировки, но это зашквар - метрики не должны влиять на производительность приложения и задержки. Разработчики библиотеки Prometheus::Client использовали хорошее решение - каждый процесс пишет данные строго в свой файл, и таким образом, нет никакой конкуренции. Процесс, который читает сразу из всех файлов (по маске), ведь оно ему так и положено - отдавать данные всех соседей. А как процессам узнать, какой файл их лично-персональный? Достаточно просто - надо использовать PID своего процесса в качестве составной части имени файла:

prometheus_metrics/
  ├── metric_http_server_request_duration_seconds___18072.bin
  ├── metric_http_server_requests_total___18072.bin
  ├── metric_sql_query_duration___18072.bin
  ├── metric_http_server_request_duration_seconds___18054.bin
  ├── metric_http_server_requests_total___18054.bin
  └── metric_sql_query_duration___18054.bin

Всё работает на ура. Но внимательный 👀 и дотошный разработчик сразу почувствует тут неладное… Почувствовали? Вот и мы не сразу почувствовали…

Что будет с этими файлами, если ваш контейнер проработает без перезапусков, например, неделю? Будет сральник! У нас ведь multiprocess/pre-fork сервер, и он периодически порождает новые процессы и убивает старые, и вы легко получите несколько тысяч таких файлов за неделю. А если у вас зрелое приложение, обмазанное метриками с ног до головы, то и несколько сотен тысяч файлов. А бедный процесс, которому выпало счастье отрендерить ответ на scrape-запрос, вынужден прочитать все эти файлы и агрегировать данные… Раз за разом… Снова и снова... Каждые 30 секунд… Как вспомню, так вздрогну.

Какой же выход? Неужели придётся продать душу и использовать push-gateway? Хрен там плавал!

Решение

pre-fork, не pre-fork в каждый момент времени (внутри одного контейнера) у вас не так много процессов - обычно от 2х до 10ти - если надо больше, то вы скорее всего будете масштабироваться контейнерами. Кроме того, запуск/остановка процессов достаточно редкое явление - мы у себя используем настройку Passenger "max_requests": 4000 - то есть каждый наш процесс будет перезапускаться через каждые 4000 запросов. На проде под нагрузкой это происходит примерно каждые 15 минут - ооочень редко. Таким образом, можно развить идею лично-персональных файлов по принципу "рассчитайсь!", не сильно заботясь о накладных расходах.

Алгоритм: процесс при старте анализирует запущенные процессы и выбирает свой порядковый номер - 1, 2, 3 и т.д. Если "первому" пришла пора завершиться, то его место освобождается, и следующий может занять его. Этот идентификатор теперь надо использовать вместо PID-процесса в имени файла. Готово!

Осталось решить небольшую проблему, а именно синхронизацию процесса выбора номера. Тут именно из-за редкости перезапуска можно использовать межпроцессные блокировки, они не окажут никакого влияния на производительность, поскольку выполняются только 1 раз при старте процесса (как мы выяснили не чаще 1 раза в 15 минут). Встречаем системный вызов flock! Теперь можно перейти к коду.

# Собственно блокировка
def with_lock
  File.open(lock_path, File::RDWR | File::CREAT, 0o644) do |file|
    file.flock(File::LOCK_EX)
    Dir.chdir(@dir) do
      tmpfilename = ".tmp-#{$}-#{rand(0x100000000).to_s(36)}.json"
      yield(tmpfilename)
    ensure
      File.delete(tmpfilename) rescue nil
    end
  end
end
# Ну и весь процесс
def obtained
  @obtained ||= with_lock do |tmpfilename|
    raw_data = read_instances
    data = JSON.parse(raw_data) || {}
    data = clean_dead_instances(data)

    obtain_instance_number(data)
    File.write(tmpfilename, data.to_json)
    File.rename(tmpfilename, @instances_path)
  end
end

Как это работает:

  1. процесс захватывает блокировку;
  2. читает файл, где процессы распределили номера между собой в формате {pid => порядковый номер}. Например: {18054=>0; 18072=>1};
  3. занимает следующий номер;
  4. чистит номера, процессы которых уже умерли;
  5. переписывает файл;
  6. отпускает блокировку.

Есть несколько неочевидных моментов, на которые хочу обратить внимание, поскольку они могут попортить вам кровь.

Запись в файл данных - операция не атомарная

Таки да - если с вами (имеется в виду ваш процесс) что-то случится, то файл останется битый, другие процессы не смогут его прочитать, всё сломается и приведёт к отказу в лучшем случае метрик, в худшем - к краху приложения (да, такое у нас бывало). А вот операция переименования - атомарна, поэтому мы пишем данные в новый временный файл, а потом переименовываем его в целевой.

Моё второе имя - скорость!

Для того, чтобы flock был еще более быстрый, чем он есть, мы через оркестратор монтируем tmpfs в каждый контейнер, и в данном случае как сами метрики, так и файл с блокировкой находится в tmpfs.

It's Alive!!!

Проверить, жив процесс или нет, можно по пиду:

def alive?(pid)
  return nil if pid <= 1

  Process.getpgid(pid)
  true
rescue Errno::ESRCH
  false
end

Monkeypatching

К сожалению, DataStores::DirectFileStore не рассчитан на наследование и переопределение метода формирования имени файла, но у нас старый добрый Ruby, в котором есть monkeypatching:

class Store < Prometheus::Client::DataStores::DirectFileStore
  attr_reader :pid_enumerator

  def initialize(*_args, dir:, **_kwargs)
    super
    @pid_enumerator = PidEnumerator.new(dir: dir)
    @store_settings[:pid_enumerator] = @pid_enumerator
  end

  MetricStore.class_eval do
    # Monkeypatch! there is no normal method to overload filename generation
    def process_id
      @store_settings[:pid_enumerator].obtained
    end
  end
end

🍒 Но вам не стоит об этом беспокоиться - мы сделали для вас гем и внимательно следим за его совместимостью!

Перед списком литературы

Я понял, что пишу статью, чтоб скорее перейти к списку литературы... Но сначала небольшой итог. Мы не используем push-gateway или промежуточные экспортеры, мы отдаём метрики прямо из наших контейнеров/сервисов. Это очень удобно и максимально надёжно. Описанное выше решение работает в распределённой нагруженной системе больше года - пользуйтесь! Наконец-то: