Двадцать четыре воркера PHP-FPM, каждый из которых занимал около 105 МБ после одного запроса, создавшего большой массив, в сумме составили 2 515 МБ резидентной памяти в пуле, настроенном с помощью pm.max_children = 24 . FPM ничего об этом не залогировал, потому что ничто внутри FPM не выполняет такое умножение.
Методика
PHP 8.5.9 fpm-fcgi, NTS, arm64, сборка Homebrew, на ноутбуке Apple M4 Pro под управлением macOS с 24 ГБ ОЗУ и 12 логическими ядрами (8 из них — производительные). Без специальной изоляции системы. Клиент FastCGI не был установлен, поэтому я написал небольшой собственный клиент, который открывает заданное количество одновременных соединений и записывает каждый запрос от отправки до FCGI_END_REQUEST .
Вот базовый пул, записанный с путем к сокету, который читатель действительно бы использовал. Пулу, который я тестировал, потребовался более короткий путь: sun_path ограничивает путь к Unix-сокету 104 байтами, и моя временная директория превысила этот лимит. FPM сообщает об этом, усекая путь, а не отказываясь от запуска. Менялись только параметры, указанные в каждом разделе ниже, и перед замером каждая конфигурация перезапускалась и прогревалась:
[www]
listen = /run/php-fpm.sock
listen.backlog = 511
pm = static
pm.max_children = 8
pm.start_servers = 2
pm.min_spare_servers = 1
pm.max_spare_servers = 3
pm.max_requests = 0
pm.process_idle_timeout = 10s
pm.status_path = /status
php_admin_value[opcache.enable] = 1
php_admin_value[opcache.jit] = disable
php_admin_value[opcache.file_update_protection] = 0
Три настройки spare-server неактивны при pm = static . При pm = dynamic каждая из них управляет отдельным этапом: pm.start_servers — с чего пул начинает, pm.min_spare_servers — где он прекращает расти под нагрузкой, и pm.max_spare_servers — к чему он возвращается после спада нагрузки. Вот почему в соответствующем разделе все три директивы приводятся повторно рядом с замерами, которые их разделяют. Директива pm.process_idle_timeout управляет только ondemand , а значения ondemand ниже используют 10 с из примеров выше с pm.max_children , как там и указано.
Тестовый скрипт выполняет фиксированный объём арифметических операций вместо паузы (sleep), поэтому выводимое им время выполнения реагирует на конкуренцию за ресурсы, а не возвращает всегда одно и то же число. Калибровка на этой машине была линейной, и используемая ниже настройка занимает около 19,6 мс. Резидентная память считывается через ps для каждого процесса-воркера. Учёт памяти в macOS отличается от Linux, поэтому относитесь к цифрам памяти как к ориентировочной форме графиков, а не как к точным значениям для переноса на сервер.
За что отвечает pm.max_children
pm.max_children — это максимальное количество процессов-воркеров, которое может запустить пул, и, следовательно, максимальное количество запросов, которое он может обрабатывать одновременно. Вот и всё. Директива ничего не говорит о памяти, и FPM не считывает объем ОЗУ на сервере, ни с чем его не сравнивает и не выдает предупреждений.
Важное умножение — это pm.max_children , умноженное на память, которую воркер занимает на пике, и ни одна часть конфигурации его не выполняет. Двадцать четыре воркера сразу после перезапуска суммарно занимали 52,1 МБ (по 2,2 МБ каждый). После того как небольшой трафик прогрел их, объём составил 124,8 МБ. После одного запроса на каждый воркер, сформировавшего большой массив, суммарный объём достиг 2 514,6 МБ (около 104,8 МБ на процесс) — тот же пул, та же настройка, в сорок восемь раз больше памяти, чем в простое, на машине, где ничего не менялось. Это медианные значения трех прогонов, каждый с только что запущенным пулом; итоговые значения под нагрузкой составили 2 485,0, 2 514,6 и 2 520,3 МБ.
Три режима работают в разных временных масштабах
При использовании pm = static пул запускает pm.max_children воркеров сразу при старте и удерживает их количество. Использование памяти предсказуемо, так как оно не меняется от нагрузки, а простаивающие воркеры потребляют свою резидентную память независимо от наличия трафика.
При pm = dynamic пул начинает с pm.start_servers и добавляет воркеров, чтобы удерживать запасную емкость в заданных рамках. Платой за это становится время ответа. При конфигурации с pm.max_children = 8 , pm.start_servers = 2 , pm.min_spare_servers = 1 и pm.max_spare_servers = 4 (два воркера в состоянии покоя) всплеск из 48 одновременных запросов длительностью около полусекунды был обслужен двумя-тремя воркерами с медианным временем 287,66 мс по наблюдениям клиента — против 73,17 мс для того же всплеска в статическом пуле из восьми воркеров.
При pm.min_spare_servers = 1 скорость составляет один воркер в секунду, и она поразительно постоянна. При насыщающей нагрузке пул рос как 2, 3, 4, 5, 6, 7, 8 в каждую последующую секунду, достигая pm.max_children примерно за шесть секунд и логируя server reached pm.max_children setting (8) . Эта скорость сохраняется и при более высоком лимите: та же конфигурация с pm.max_children = 16 поднималась ровно на один воркер в секунду до шестнадцати, укладываясь в диапазон от 14,06 до 15,06 секунд за три прогона.
Считайте эти абсолютные значения времени точными с погрешностью около секунды. Интервалы между созданием процессов стабильны; то, куда припадёт запуск первого, зависит от фазы сервисного тика FPM относительно момента поступления нагрузки, что в тесте никак не контролируется.
Эта скорость относится к min_spare_servers , а не к dynamic . FPM создает воркеры в ответ на дефицит свободных процессов, и более крупный дефицит восполняется быстрее: при pm.min_spare_servers = 4 тот же пул из шестнадцати воркеров развивался как 4, 5, 7, 11, 15, 16 (шагами в один, два, четыре, четыре и затем финальный шаг, ограниченный лимитом), достигая шестнадцати примерно за пять секунд. Здесь отличаются два момента, а не один: пул также стартует с четырёх, а не с двух процессов, так что он выполнил двенадцать запусков вместо четырнадцати. Скорость объясняет большую часть разницы во времени, но не всю.
Точка остановки — это текущий спрос плюс pm.min_spare_servers или pm.max_children , в зависимости от того, что меньше. Обе части измерены, а не просто выведены логически. Фиксируем pm.start_servers на уровне 3 и меняем только pm.min_spare_servers :
| Предложенный параллелизм | min_spare_servers | Воркеры |
|---|---|---|
| 3 | 1 | 4 |
| 3 | 3 | 6 |
| 5 | 1 | 6 |
| 5 | 3 | 8 |
Каждая ячейка — это сумма. Директива pm.max_spare_servers не влияет на то, где пул прекращает расти: при значениях 2, 4 и 8, удерживая параллелизм на уровне 3 и min_spare на уровне 1, пул каждый раз останавливался на четырёх.
Рост — это только половина того, что делает пул. Директива pm.max_spare_servers управляет второй половиной: к какому числу процессов он возвращается после прекращения нагрузки, и именно эта половина важна для бюджета памяти. Разгоняем тот же пул под насыщающей нагрузкой (он достиг семи или восьми воркеров в зависимости от момента остановки нагрузки), а затем полностью снимаем нагрузку:
| pm.max_spare_servers | Воркеры в простое |
|---|---|
| 2 | 2 |
| 4 | 4 |
| 8 | 8 |
Пул сбрасывает примерно по одному воркеру в секунду — с той же скоростью, с какой их добавлял, — а затем останавливается: 20 секунд мониторинга без трафика не показали дальнейших изменений.
Это важно из-за того, что именно удерживает простаивающий воркер. Повторим этот замер с запросами, выделяющими память, считывая резидентную память пула вместо количества воркеров:
| pm.max_spare_servers | RSS пула на пике | Спустя 6 сек простоя |
|---|---|---|
| 2 | 1 228,0 МБ на 6 воркеров | 408,8 МБ на 2 |
| 8 | 1 228,3 МБ на 6 воркеров | 1 228,3 МБ на 6 |
Три повтора с расхождением в пределах 0,7 МБ. Всплеск одинаков в обеих строках, как и максимальный уровень памяти на воркер (около 205 МБ); различается лишь то, сколько воркеров остаются активными, удерживая эту память. Это затухание, описанное ранее, но с другой стороны: воркер возвращает память только при обработке новых запросов, поэтому пул, переживший всплеск и затихший, сохраняет свой пик до возврата трафика, а pm.max_spare_servers определяет, сколько воркеров останется его удерживать. Восемьсот девятнадцать мегабайт в этом пуле из-за одной лишь этой настройки.
Настройка pm.start_servers должна меняться вместе с двумя параметрами spare-server, так как FPM валидирует все три параметра друг относительно друга и иначе просто отказывается запускаться:
ALERT: [pool www] pm.start_servers(2) must not be less than pm.min_spare_servers(3) and not greater than pm.max_spare_servers(8)
Обе части этой строки накладывают ограничения и тянут в противоположные стороны. В таблице для min_spare параметр pm.start_servers зафиксирован на 3, чего требует строка с min_spare = 3 ; запуски для max_spare удерживают его на уровне 2, потому что значение 3 при pm.max_spare_servers = 2 отклоняется второй частью правила. Таким образом, эти два эксперимента начинаются с трёх и с двух воркеров соответственно. Точки старта у пулов различаются, а точки остановки — нет.
Решающим фактором являются секунды, поскольку всплеск трафика происходит за миллисекунды. Достижение лимита в восемь воркеров заняло около шести секунд при min_spare = 1 , а в шестнадцать — около пятнадцати; даже агрессивному варианту с min_spare = 4 потребовалось пять секунд. Всплеск длительностью менее нескольких секунд обслуживается почти исключительно уже запущенными воркерами, что и показали замеры полусекундного всплеска выше.
При pm = ondemand ни один воркер не работает, пока не поступит запрос. Запущенный мною пул имел ноль процессов в покое и один после одиночного запроса. До какого количества он вырастет под нагрузкой, зависит не столько от того, сколько запросов пришло, сколько от интервала между ними. Восемь соединений к pm.max_children = 8 с варьированием только интервала между ними:
| Интервал поступления 8 соединений | Воркеры |
|---|---|
| 0.03 ms | 2 |
| 1.23 ms | 6 |
| 5.38 ms | 8 |
| 21.15 ms | 8 |
По три повтора на строку, каждый на свежезапущенном пуле; все три совпали для каждой строки. Строке с 1,23 мс не стоит доверять безоговорочно: она находится на изгибе графика между двумя и восемью, поэтому на более загруженной машине значение может сместиться.
Восемь одновременных соединений получили два воркера; те же восемь соединений, растянутые на пять миллисекунд, получили восемь воркеров. Лимит по-прежнему действует: восемь поступлений с таким же интервалом при pm.max_children = 4 получили четыре воркера и сгенерировали предупреждение в логе. Но ниже этого лимита количество воркеров отражает то, сколько времени было у мастер-процесса на реакцию. Это то же расхождение секунд и миллисекунд, которое показывает dynamic , только с более высокой точностью. Такой режим подходит для сервера, где размещено множество в основном простаивающих пулов, и платит вызовом fork за запросы, не нашедшие свободного воркера.
Память воркера — это максимальный уровень, который постепенно снижается
Воркер, обработавший один запрос с пиком 266,02 МБ памяти под управлением PHP, после этого остался на уровне 105,28 МБ резидентной памяти. В полном простое он удерживал ровно 105,28 МБ на протяжении четырёх секунд опроса во всех трёх повторах: завершение запроса не вернуло память, и само по себе время тоже её не освободило.
Возвращают память новые запросы. В отдельном прогоне со свежим воркером (почему начальная цифра и оказалась на 0,1 МБ ниже указанной выше) я отправлял простые запросы воркеру, который только что обработал тяжёлый запрос, и считывал его резидентную память после каждого:
| Запрос после тяжёлого | RSS воркера |
|---|---|
| пока нет | 105.19 MB |
| 1 | 55.19 MB |
| 2 | 29.19 MB |
| 3 | 17.19 MB |
| 4 | 11.19 MB |
| 5 | 7.19 MB |
| 6 и далее | 7.12 MB |
Три повтора, каждый начинается с только что прогретого воркера. Наибольший разброс в любой строке составил 0,02 МБ, поэтому колонка диапазона не дала бы никакой информации: эта последовательность практически детерминирована.
Избыток памяти сокращается вдвое при каждом последующем запросе, и пять запросов вернули его к базовому уровню. Аллокатор освобождает закэшированную память на основе некоторой динамической оценки недавней нагрузки, а не в момент завершения запроса. Правило оказалось буквальным делением пополам: avg_chunks_count при завершении запроса смещается на полпути к пику этого запроса, и кэш чанков подгоняется под это значение — этот процесс разбран в исходном коде на C в отдельном посте.
Отсюда следуют два вывода. Воркер, перешедший в состояние покоя после тяжёлого запроса, удерживает эту память до тех пор, пока остаётся без нагрузки, поэтому суммарная резидентная память пула определяется его недавними пиками, а не тем, что он делает прямо сейчас. И каждый воркер может оказаться на своем пике одновременно с остальными, что и произошло в начальном примере с 2 515 МБ: это не редкое совпадение, а обычный результат всплеска ресурсоёмких запросов. Число, на которое нужно ориентироваться при расчёте объёма — это пик, умноженный на max_children , а сам пик зависит от того, что выделяет код в памяти: затраты на каждый элемент большого массива — это обычно то, куда на самом деле уходит память запроса.
Очередь запросов невидима для таймера внутри самого запроса
Зафиксируем входящую нагрузку — 48 запросов по 24 одновременно, примерно по 19,6 мс работы на каждый — и будем менять только pm.max_children . Медианные значения в миллисекундах по трём повторам:
| pm.max_children | Задержка с точки зрения клиента | Длительность по отчёту скрипта |
|---|---|---|
| 2 | 263.93 / 270.67 / 276.75 | 20.90 / 21.19 / 24.66 |
| 4 | 146.63 / 137.46 / 139.50 | 26.24 / 23.19 / 22.76 |
| 8 | 63.82 / 73.57 / 73.17 | 25.34 / 25.57 / 25.91 |
| 16 | 62.35 / 62.11 / 61.68 | 37.45 / 37.82 / 39.69 |
Контрольный замер — по одному запросу за раз на пуле из восьми воркеров — показал 22,30 мс на клиенте против 21,97 мс в скрипте.
При двух воркерах клиент ждал примерно в двенадцать раз дольше контроля, тогда как приложение сообщало о 21 мс — своем контрольном значении. Пул недостаточного размера не сообщает о медленном коде. Он вообще ничего не сообщает, а длительность запроса, фиксируемая вашей системой мониторинга, измеряется с момента, когда воркер взял запрос в работу, то есть уже после ожидания в очереди.
Шестнадцать воркеров — это другой сбой. Задержка клиента перестала улучшаться после восьми (количество производительных ядер на этой машине), а собственная длительность скрипта выросла до 38 мс, так как шестнадцать нагружающих процессор воркеров конкурировали за двенадцать ядер. Завышение размера пула превращает ожидание в очереди в конкуренцию за CPU и ухудшает показатели времени работы приложения.
Собственная диагностика FPM здесь помогла меньше, чем ожидалось. Поля listen queue и max listen queue на странице статуса показывали ноль на протяжении всего времени, включая моменты, когда двадцать два соединения наглядно находились в ожидании. Поле max children reached оставалось нулевым при pm = static , где нет процессов для создания через fork, в которых можно было бы отказать. Оно показало 1 в обоих режимах, использующих fork: при pm = dynamic , доведённом до предела, и при pm = ondemand ; оба режима записали предупреждение в журнал ошибок, хотя и разное. Путь dynamic логирует server reached pm.max_children setting (8) ; путь ondemand логирует server reached max_children setting (4) без префикса pm. . Алерты, ищущие по grep только форму с префиксом, никогда не сработают для пула с ondemand . Таков характер самодиагностики FPM во всём: он сообщит вам, когда у него закончатся воркеры, но никогда — когда имеющиеся воркеры исчерпают ресурсы машины. Являются ли счётчики очередей платформенным ограничением этой сборки для macOS, я не устанавливал. Измерением, которое сработало на всех конфигурациях, было сравнение таймера на стороне клиента со временем, переданным скриптом — это то, что вы можете запустить в продакшене с одного хоста.
pm.max_requests перезапускает процессы, а не исправляет проблемы
С pm.max_requests , установленным в 5, и одним воркером ID процесса менялся после каждого пятого запроса, а резидентная память сбрасывалась до базового уровня. Первый запрос на новом воркере занимал 3,426 мс против примерно 3,3 мс в установившемся режиме — разница, которую данный метод измерения не позволяет точно оценить: OPcache хранит скомпилированные опкоды в разделяемой памяти, поэтому новый воркер наследует их, а не компилирует заново.
Чего мне не удалось сделать, так это добиться утечки памяти между запросами в принципе. При отключенном перезапуске воркер стабильно удерживал 5,70 МБ на протяжении шестнадцати запросов, и каждый выделенный мной массив освобождался при завершении запроса. Рост памяти, сохраняющийся после запроса, происходят из сущностей, живущих за пределами его арены (постоянные соединения, аллокации расширений, реальные утечки в C), ни одну из которых я здесь не измерял. Перезапуск ограничивает их. Но он не помогает их обнаружить, и низкое значение max_requests так хорошо скрывает этот рост, что его никто не расследует.
Расчёт размера пула на основе измерений
Измеряйте резидентную память воркера под реальным продакшен-трафиком, а не на синтетическом скрипте, и берите пиковое значение, а не среднее — аргументы в пользу измерения реального сценария из статьи как тестировать производительность PHP, не обманывая себя применимы и здесь, только вместо времени выполнения используется резидентная память.
# Resident memory of every worker in a pool, largest first.
# The bracket stops awk from matching its own command line.
ps -o rss=,command= -ax \
| awk '/php-fpm: pool ww[w]/ { printf "%8.1f MB\n", $1/1024 }' \
| sort -rn
На пуле из восьми воркеров, три из которых недавно обработали крупный запрос, это вывело:
65.5 MB
65.4 MB
65.4 MB
5.7 MB
4.9 MB
4.8 MB
4.8 MB
4.8 MB
Восемь строк для восьми воркеров. Разброс между ними — причина, по которой расчёт нужно вести по верхнему значению этого списка, а не по среднему.
Решите, какую часть ресурсов сервера вы готовы отдать пулу, оставив место для всего остального на машине, а затем разделите этот объём на пиковое значение памяти. Полученное частное — это верхний предел, который должен соблюдать pm.max_children . Если результат меньше, чем требуется для вашего трафика, решение состоит в уменьшении памяти на запрос или добавлении серверов, а не в увеличении этого числа.
Сверьте полученное значение с количеством ядер. Пул, превышающий то, что машина может выполнять параллельно, превращает ожидание в конкуренцию за CPU, что выше выразилось в ухудшении показателей приложения без какого-либо выигрыша в задержке. Для CPU-bound задач полезный диапазон ограничивается количеством производительных ядер; для I/O-bound задач, где воркеры проводят время в блокировках, он выше, и найти его можно, повышая значение настройки до тех пор, пока задержка, наблюдаемая клиентом, не прекратит улучшаться.
Затем измерьте этот разрыв. Один таймер снаружи запроса и один внутри с фиксацией разницы — это сигнал, указывающий на слишком маленький пул, и это единственный сигнал, который профилировщик на стороне приложения, находящийся целиком внутри воркера, не способен зафиксировать.
Комментарии (0)
Пока нет комментариев — будьте первым.