There is no memory leak! Again!
Вернёмся к оптимизациям. И сегодня немного про память - про потребление, утечки и решение этих проблем. Раньше у нас уже была статья https://blog.rnds.pro/015-workerkiller и сейчас используемый инструмент получил очередное развитие.
На самом деле, инструмент получил развитие не "сейчас", а несколько месяцев назад, был успешно обкатан на проде, и с тех пор мы постепенно распространяем его на различные наши сервисы. Чтобы рассказать об улучшениях и результатах, надо сначала коротко напомнить о нашем основном тезисе прошлой статьи (или же перечитать её полностью):
Самым надёжным способом "отдать" память системе является завершение процесса. Тут следует оговориться — если у вас в приложении есть утечки памяти, то такой подход замаскирует проблему, не решив её окончательно, и проблема всё равно вас догонит и наподдаст вам в виде падения производительности, чрезмерной прожорливости приложения или редких плавающих багов. Так что если вы знаете, что у вас есть серьёзные утечки — займитесь профилированием, а не тушите огонь пирогами.
Вот теперь можно перейти к развитию и улучшениям :)
Первое улучшение - динамические ограничения на память.
Итак, раньше мы выделяли некоторый лимит для потребления памяти внутри приложения (так называемая RSS память) и при превышении этого порога запускали процедуру мягкой перезагрузки. На тот момент мы поддерживали мягкую перезагрузку для следующих подсистем:
- Phusion Passenger (через встроенную возможность
passenger-config detach-process
); - Delayed Job (через плагин и штатный перезапуск воркера) ;
config.middleware.tap do |middleware| killer = PassengerKiller.new # Max requests per worker middleware.insert_before( Rack::Sendfile, Middleware, limiter_klass: RequestLimiter, killer: killer, min: 3000, max: 4000) # Max memory size (RSS) per worker middleware.insert_before( Rack::Sendfile, Middleware, limiter_klass: MemoryLimiter, killer: killer, min: 300 * (1024**2), max: 400 * (1024**2)) end
Всё работало прекрасно до некоторых пор, пока общая нагрузка на систему не выросла (в несколько раз), и нам не пришлось поднимать статические лимиты. Тут и пришло понимание, что статические лимиты - это очень не гибкая вещь. Решение оказалось вполне очевидное - рассчитывать допустимую границу в процентах "на лету". Алгоритм прост:
- Запускаемся и работаем несколько циклов/запросов. По умолчанию выбрали 16;
- Замеряем текущее потребление памяти (RSS) и высчитываем указанный процент, например 50%;
- Каждые N циклов/запросов проверяем собственное потребление, и если оно превысило рассчитанный порог - мягонько завершаемся;
Эти ограничения и мягкое завершение позволяют приложению потреблять столько памяти, сколько ему нужно - иногда бывают большие всплески, при которых потребление памяти вырастает более чем в 2 раза, но после этого такой толстенький воркер выводится из балансировки (спасибо Passenger) и завершается. Теперь конфигурация выглядит так:
# Max memory size (RSS) per worker is 150% of the # initial value (on 16 request) config.middleware.insert_before( Rack::Sendfile, Middleware, limiter_klass: MemoryLimiter, killer: killer, min: 0, max: 0.5)
Для примера возьмём наш боевой Object Store сервис для сохранения данных в различные S3 совместимые хранилища под кодовым названием "filator". Он запущен с использованием сервера приложений Phusion Passenger. Нагрузка на него составляет от 30 до 60 запросов в секунду, в зависимости от фазы луны.
- Число запущенных контейнеров: 2;
- Число воркеров внутри Passenger: 5;
- Потребление памяти после прогрева: 120МБ;
- Порог по памяти (+50%): 180МБ;
- Порог запросам: 3000-4000 штук;
В результате таких настроек мы имеем не более 2х перезапусков воркеров в минуту. Может ли это влиять на производительность? Ничуть!
Passenger очень качественно обрабатывает перезапуск (detach process
) — сначала он запускает новый процесс-обработчик, потом останавливает выделение запросов старому, дожидается обработки всей его очереди и только потом завершает процесс.
Ну и каков же результат? Вот момент выкатки на прод такой конфигурации:
Если посмотреть на весь интервал (7 месяцев с момента запуска), то можно будет увидеть, что с ростом нагрузки (примерно в 2 раза) среднее потребление памяти на контейнер немного подросло и процентное ограничение показывает себя прекрасно:
Аналогичная ситуация для совершенно другого сервиса, который использует Delayed Job для обработки фоновых запросов. Хорошо видно что, в течение нескольких месяцев происходит плавный рост потребления памяти (и установленных нами лимитов) и резкое сокращение, когда в сервисе включили динамический лимит в 130%:
Второе улучшение - поддержка Puma
Phusion Passenger, конечно, хорош, но периодически нам требуется чуть больше контроля над сокетом входящего запроса (об этом надеюсь подробно рассказать отдельно). В общем, мы наконец сделали очень качественный Killer
для Puma! В этой части я расскажу о проблемах, с которыми нам пришлось столкнуться, и их решениях.
Проблемы
- Puma имеет несколько режимов работы в разных, так сказать, "осях":
Cluster / Single; - Phased Restart / Hot Restart;
- С прогрузкой (preload) кода приложения / без прогрузки;
- Особый экспериментальный (я бы сказал психоделический) режим
Fork-Worker
;
Все эти режимы в разных вариантах могут сочетаться или не сочетаться между собой. Например, Fork-Worker
всегда имеет один (рабочий!) экземпляр воркера, от которого форкает следующие по мере необходимости. Это приводит к накоплению утечек в самом этом воркере. Также в различных режимах Puma по-разному реагирует на системные сигналы HUP
, USR1
, USR2
и пр.
Но основная проблема заключается в том, что находясь "внутри" воркера, невозможно мягко и без потерь выполнить собственный перезапуск, поскольку мастер-процесс ничего не знает о ваших желаниях. В Passenger эта проблема решается утилитой passenger-config detach-process PID
, которая как раз и сообщает мастер-процессу о необходимости снять нагрузку с определённого воркера, запустить новый и дождаться корректного завершения.
Решение
Для решения этой проблемы пришлось разобраться в системе плагинов для Puma и написать собственный, реализующий простой и надёжный механизм IPC для уведомления мастер-процесса о необходимости перезапуска воркера.
Суть плагина простая - в мастер-процессе запускаем слушатель unix-сокета, а из воркеров можем послать свой собственный номер. Получив номер, слушатель запускает процедуру мягкого рестарта. Код его очень простой - можно целиком разместить тут, чтоб дотошные читатели могли задать вопросы:
module WorkerKiller class PumaPlugin include Singleton attr_accessor :ipc_path, :killer, :thread def initialize @ipc_path = File.join('tmp', "puma_worker_killer_#{Process.pid}.socket") @killer = ::WorkerKiller::Killer::Puma.new(worker_num: nil, ipc_path: ipc_path) log "Initializing IPC: #{@ipc_path}" end def config(puma) puma.on_worker_boot do |num| log "Set worker_num: #{num}" @killer.worker_num = num end end def start(launcher) @runner = launcher.instance_variable_get('@runner') launcher.events.on_booted do @thread ||= start_ipc_listener end end def start_ipc_listener log 'Start IPC listener' Thread.new do Socket.unix_server_loop(ipc_path) do |sock, *args| if (line = sock.gets) worker_num = Integer(line.strip) if (worker = find_worker(worker_num)) log "Killing worker #{worker_num}" worker.term! end end rescue StandardError => e log("Exception: #{e.inspect}") ensure sock.close end end end def find_worker(worker_num) worker = @runner.worker_at(worker_num) unless worker log "Unknown worker index: #{worker_num.inspect}. Skipping." return nil end unless worker.booted? log "Worker #{worker_num.inspect} is not booted yet. Skipping." return nil end if worker.term? log "Worker #{worker_num.inspect} already terminating. Skipping." return nil end worker end def log(msg) warn("#{self.class}[#{Process.pid}]: #{msg}") end end end
Профит!
Само собой нужны пруфы полученного профита при переходе на Puma, и их есть у меня:
С 28-го по 4-е смотреть не стоит - там происходил замер и тюнинг 😁
А вот так выглядит от самого первого момента, когда перешли на динамические лимиты памяти:
Заключение
Как обычно, мы делимся нашим опытом, который уже обкатан на проде и дал свои результаты. Наш гем ❤️💎 WorkerKiller 💎❤️ не исключение - используйте на здоровье!
PS
Очень ждём, когда наши коллеги из соседних отделов запилят в него интеграцию с Sidekiq :)