Поиск оптимальных настроек PHP-FPM

Источник: «A deeper dive into optimal PHP-FPM settings»
Файлы конфигурации PHP-FPM обычно не привлекают к себе особого внимания, пока ничего не происходит. Но что делать, когда это произошло?

В большинстве случаев настройки PHP-FPM — это не то что нужно исследовать обычному разработчику. Это нормально; не все хотят или должны тратить время на то, чтобы заниматься подобными настройками на сервере.

Кроме того, существуют управляемые решения сторонних разработчиков (Laravel Forge, Ploi.io и т.д.), позволяющие развернуть сервер, установить все зависимости, включая PHP-FPM, и останется только позаботиться о развёртывании кода из панели управления. Возможно, в вашей компании есть DevOps или старший разработчик, занимающийся подобными задачами. Если вы сами занимались настройкой PHP-FPM, то, скорее всего, пролистали несколько статей, внесли небольшие коррективы или использовали настройки по умолчанию. Этого и следовало ожидать: обычно не хватает времени на углублённое изучение каждой настройки сервера, особенно если это лишь часть вашего основного задания.

Но по мере развития вашего приложения и его кода, а также по мере увеличения числа пользователей, можно заметить, что сервер стал медлительным, запросы требуют больше времени на обработку, потребление памяти достигает предела, а может быть, сервер просто упал.

Поскольку подобное произошло на одном из моих серверов недавно, я лучше понять, как работает PHP-FPM и как различные настройки на него влияют. Я просмотрел множество статей на эту тему, обсуждений и комментариев, а затем провёл несколько собственных тестов, чтобы перепроверить некоторые утверждения. И вот что я узнал.

Устранение неполадок

Если проблема связана с PHP-FPM, можно сделать несколько вещей. Прежде всего, необходимо проверить логи PHP-FPM на наличие предупреждений. В частности, нас интересуют предупреждения о параметре max_children. Главный процесс PHP-FPM порождает столько дочерних процессов, сколько необходимо, пока не будет достигнуто значение параметра max_children. Каждый дочерний процесс способен обрабатывать, к примеру, один запрос к приложению за раз. Таким образом, если для параметра max_children установлено значение 5, а 10 пользователей одновременно взаимодействуют с приложением и отправляют запросы, скорее всего, в логах появится что-то вроде этого:

WARNING: [pool www] server reached pm.max_children setting (5), consider raising it

Это приведёт к тому, что некоторые запросы будут отложены пока не освободится достаточное количество дочерних процессов. Это простая команда, с помощью которой можно проверить, появляется ли подобное предупреждение в логах. Например, если используется PHP-FPM 8.2, это может выглядеть так:

sudo grep max_children /var/log/php8.2-fpm.log.1 /var/log/php8.2-fpm.log

Имейте в виду, что путь к файлу лога в вашей системе может быть другим, поэтому проверьте его дважды. Кроме того, помимо замены версии PHP в команде, возможно, что в вашем случае версия не указана, и нужно опустить версию и просто использовать php-fpm, например, так:

sudo grep max_children /var/log/php-fpm.log.1 /var/log/php-fpm.log

Подобная логика будет применяться каждый раз, когда я буду упоминать команду, связанную с php-fpm. Я всегда буду использовать php-fpm8.2 или php8.2-fpm, поскольку это моя версия, но в вашем случае это может быть что-то вроде php-fpm7.4 (php7.4-fpm) или просто php-fpm.

Самый простой способ получить текущие значения настроек PHP-FPM без чтения файла конфигурации — использовать эту команду:

sudo php-fpm8.2 -tt

С её помощью можно легко найти строку pm.max_children и убедиться, что параметр max_children действительно установлен на 5.

[19-Mar-2024 22:48:10] NOTICE:  pm.max_children = 5

Ещё одна вещь, которая может нас заинтересовать, — это проверка потребления памяти на сервере. Используя команду типа htop и сортируя процессы по памяти, можно увидеть, не превышены ли лимиты памяти сервера и не являются ли процессы PHP-FPM теми, которые потребляют больше всего памяти.

Например, это может произойти из-за слишком высокого значения max_children. Это значит, что было порождено слишком много дочерних процессов, все они используются, и на сервере для них просто не хватает памяти. Если после перезапуска PHP-FPM использование памяти падает, а затем постепенно возрастает до предела, это свидетельствует об утечке памяти в коде приложения. Идеально было бы обнаружить утечку памяти и решить проблему, но иногда найти утечки памяти непросто, особенно в больших проектах, и они могут исходить от сторонних библиотек, жизненно важных для приложения.

Первая проблема может быть решена с помощью оптимальных настроек, а на стороне PHP-FPM можно кое-что сделать в отношении утечек памяти, о чем мы расскажем чуть позже.

Кстати, команда для перезапуска PHP-FPM выглядит следующим образом (но в вашем случае она может быть другой, поэтому перепроверьте её):

sudo service php8.2-fpm restart

Как говорилось ранее, перезапуск PHP-FPM может дать быструю (но не постоянную) победу в случае утечки памяти и выиграть немного времени, пока не устраните утечку или не внесёте необходимые изменения.

Настройка менеджера процессов

Наконец, мы готовы заняться файлами конфигурации PHP-FPM и посмотреть, что можно сделать для улучшения нашей конкретной установки. Для редактирования главного файла конфигурации можно использовать такую команду:

sudo nano /etc/php/8.2/fpm/pool.d/www.conf

В нём можно найти разнообразные настройки, но мы рассмотрим самые важные из них, влияющие на производительность. Первое, что необходимо решить, это как менеджер процессов будет контролировать количество дочерних процессов. Есть 3 варианта: static, dynamic и ondemand. В большинстве случаев dynamic будет установлен по умолчанию. Чем эти варианты отличаются друг от друга? Допустим, вы определили, что для вашего сервера необходимо не более 10 дочерних процессов.

static будет постоянно поддерживать все 10 процессов в рабочем состоянии и считается самым быстрым, поскольку все процессы уже запущены, и нет необходимости создавать новые при увеличении нагрузки. Но это также означает, что они будут занимать память 10 процессов, даже если никто не посещает веб-сайт.

При использовании dynamic возможна тонкая настройка: например, сразу запустить 3 процесса, при увеличении нагрузки форкнуть до 10 дочерних процессов, а при снижении нагрузки сократить количество до 6 процессов, ожидающих подключения. Этот вариант — нечто среднее между потреблением памяти и скоростью ответа приложения на любые запросы, по крайней мере, в теории.

И последний вариант, ondemand, означает, что вначале не будет порождено ни одного дочернего процесса; по мере увеличения нагрузки будет создано до 10 таких процессов, а когда нагрузка снизится, снова может оказаться, что в фоновом режиме не будет запущено ни одного дочернего процесса. Этот вариант идеально подходит (опять, теоретически) для небольших и средних приложений, не имеющих такого большого трафика, для стендовых сред или для серверов, где несколько владельцев совместно используют ресурсы. Поскольку дочерние процессы постоянно перезапускаются, эта опция может помочь контролировать утечки памяти, с которыми можно столкнуться, поскольку процесс будет завершён до того, как успеет занять память. Недостаток этой опции в том, что необходимо постоянно создавать новые процессы, а это может повлиять на производительность и способность быстрее отвечать на запросы.

Прежде чем установить одну из трёх опций, необходимо определить максимальную нагрузку для всех процессов PHP-FPM. Необходимо определить максимальное количество дочерних процессов, с которыми сможет справиться сервер, и установить соответствующее значение max_children. Как это сделать? Оказывается, это непросто, потому что в идеале необходимо определить, сколько памяти в среднем использует один дочерний процесс. Дело в том, что несколько процессов могут и обычно будут совместно использовать часть памяти, поэтому трудно точно определить реальное использование памяти одним процессом.

Существует множество сценариев и статей, объясняющих, как рассчитать среднее потребление памяти для процесса PHP-FPM, но большинство из них не имеют смысла, поскольку в результате получались гораздо более высокие значения, чем ожидалось.

Один из них, показавшийся подходящим, — это скрипт на Python ps_mem, упоминаемый в нескольких статьях. Можно запустить эту команду, чтобы вычислить total (общий) объем памяти, используемый программой:

cd ~ &&
wget https://raw.githubusercontent.com/pixelb/ps_mem/master/ps_mem.py &&
chmod a+x ps_mem.py &&
sudo python3 ps_mem.py

Обратите внимание, что в последней строке используется python3; в вашем случае это может быть просто python. После выполнения сценария может получиться что-то вроде этого:

2.1 GiB + 127.5 MiB = 2.2 GiB       php-fpm8.2 (31)

Это означает, что 31 процесс PHP-FPM использует 2,2 ГБ памяти. Это, в свою очередь, означает, что один процесс использует около 73 МБ. Ещё одна полезная команда, которую всегда можно запустить для проверки количества незадействованных и активных процессов, это:

sudo service php8.2-fpm status -l

Просмотрев результат выполнения, можно узнать текущее состояние дочерних процессов:

Status: "Processes active: 0, idle: 30, Requests: 56116, slow: 0, Traffic: 0req/sec"

Нет ни одного активного процесса, есть 30 бездействующих, а также 1 основной процесс, итого 31, что соответствует данным скрипта Python.

Имейте в виду, что сценарий Python также сообщает об общем использовании памяти. Всегда можно запустить htop или free -hl, чтобы проверить текущее использование памяти на сервере и посмотреть, имеют ли эти цифры смысл и совпадают ли они.

Ещё один момент: если происходит утечка памяти, то память для одного процесса может заполниться до значения memory_limit, определённого в файле php.ini, по умолчанию это 128 MB. Поэтому, если хотите подстраховаться, можете использовать это значение как среднее для одного процесса PHP-FPM.

Итак, наконец-то можно рассчитать значение max_children. Предположим, есть сервер с 8 ГБ оперативной памяти. Известно, что все остальные программы на сервере используют 2 ГБ. Таким образом, остаётся 6 ГБ. И, допустим, нужно оставить 1 ГБ буфера на случай непредвиденных обстоятельств, если некоторые процессы начнут использовать больше памяти в будущем, когда приложение вырастет или будут добавлены новые процессы. Таким образом, остаётся 5 ГБ для перераспределения в пользу PHP-FPM. Итак, как мы подсчитали ранее, один процесс использует около 73 МБ памяти. Нужно разделить 5 ГБ на 73 МБ, чтобы получить значение max_children:

5120 (МБ) / 73 (МБ) = 70.14

Итак, мы получили, что значение max_children на сервере должно быть 70. Это отлично; независимо от того, какую опцию менеджера процессов (pm) выберем, PHP-FPM будет порождать максимум 70 дочерних процессов.

Теперь, если использовать опцию pm = static, не нужно задавать никаких дополнительных параметров. Сразу будет порождено 70 дочерних процессов, и они будут готовы обрабатывать запросы. Но помните о компромиссах: это означает, что эти процессы будут постоянно использовать 5 ГБ оперативной памяти.

Далее, если планируется использовать опцию pm = ondemand, есть только одна дополнительная настройка, которую необходимо рассмотреть, и это pm.process_idle_timeout. Поскольку при использовании опции ondemand дочерние процессы постоянно порождаются и завершаются, параметр process_idle_timeout указывает PHP-FPM, когда завершать дочерний процесс, если он простаивает (фактически не используется). По умолчанию этому параметру установлено значение 10 seconds, но его можно изменить по своему усмотрению, хотя значение по умолчанию вполне разумно.

И, наконец, если используется pm = dynamic, есть несколько дополнительных настроек, которые необходимо пересмотреть. Во-первых, pm.start_servers — количество дочерних процессов, порождаемых при запуске или перезапуске PHP-FPM. Далее, параметр pm.min_spare_servers, задающий минимальное количество незадействованных дочерних процессов. И параметр pm.max_spare_servers, определяющий максимальное количество простаивающих дочерних процессов.

Предположим, мы установили эти значения:

pm = dynamic
pm.max_children = 70
pm.start_servers = 20
pm.min_spare_servers = 20
pm.max_spare_servers = 40

Вот как это работает на практике. Итак, в примере выше мы установили start_servers на 20. Как только PHP-FPM запускается, порождается 20 дочерних процессов, и они занимают память, обычно используемую 20 процессами. Если к приложению поступают запросы, некоторые или все из этих 20 процессов будут активны и будут обрабатывать эти запросы. Если трафика нет, эти 20 процессов будут простаивать, в ожидании запросов; они не будут завершены и по-прежнему будут потреблять память.

Устанавливать min_spare_servers значение, меньшее, чем start_servers (например, 15), не имеет смысла, поскольку 20 дочерних процессов будут порождены немедленно, и даже если они будут простаивать, главный процесс не завершит 5 дочерних процессов, чтобы достичь этого минимума в 15. И не стоит задавать значение min_spare_servers больше, чем start_servers, поэтому лучшим вариантом, будет установка min_spare_servers равным значению start_servers.

Теперь, если поступает много запросов и 20 дочерних процессов не хватает для их обработки, главный процесс породит дополнительные дочерние процессы до значения max_children, в данном случае 70. И, допустим, 70 дочерних процессов были порождены, чтобы справиться с наплывом запросов. Через некоторое время ситуация нормализуется, и 70 процессов больше не нужны; большинство или все из них простаивают. В этом случае главный процесс завершит простаивающие дочерние процессы до значения max_spare_servers, в данном случае 40. И тогда останется 40 простаивающих процессов, которые не будут завершены в дальнейшем и будут использовать память, которую использует 40 дочерних процессов.

Это один из моментов, который следует учитывать при установке этих значений: если необходимо породить больше процессов, чем start_servers, то после завершения всплеска останется много дочерних процессов (вплоть до значения max_spare_servers). Если нужно породить 30 дочерних процессов, у вас останется 30 из них; если нужно породить 50 дочерних процессов, через некоторое время у вас останется 40 (из-за значения max_spare_servers) из них. Поэтому, если вы не хотите получить 40 дочерних процессов, работающих в фоновом режиме, стоит уменьшить это значение или даже оставить его таким же, как start_servers. Всё вышесказанное будет верно до тех пор, пока вы не перезапустите PHP-FPM. После перезапуска снова будут порождены 20 дочерних процессов на основе start_servers.

Во многих статьях приводится формула, предлагающая установить значения start_servers, min_spare_servers и max_spare_servers в зависимости от количества ядер процессора для достижения оптимальной производительности. Формула выглядит следующим образом:

pm.start_servers = number of CPU cores x 4
pm.min_spare_servers = number of CPU cores x 2
pm.max_spare_servers = number of CPU cores x 4

Предположительно, формула была основана на некотором предположении о том, сколько процессов может одновременно обрабатывать одно ядро процессора. Я не уверен, кто запустил эту тенденцию и как именно были получены эти множители, но одна вещь кажется неправильной, и это установка min_spare_servers в значение меньшее, чем start_servers. Это, как говорилось выше, приведёт к тому, что значение min_spare_servers никогда не будет использовано. Кроме того, в своих тестах (о которых поговорим позже) при использовании этого подхода не наблюдалось значительного прироста производительности. Таким образом, не думаю, что эту формулу следует слепо принимать, а скорее корректировать в соответствии с конкретными обстоятельствами.

Лучший совет, который можно дать по поводу max_children и связанных с dynamic настроек, — отслеживать и настраивать их по мере продвижения. Каждая ситуация будет отличаться, ваши ресурсы будут отличаться, ваша нагрузка будет отличаться, и ваша общая стратегия будет отличаться. Используя приведённые выше рекомендации, начните со значений, которые имеют для вас смысл, и по мере роста и изменения приложения корректируйте настройки.

В этом разделе стоит обратить внимание ещё на одну опцию — pm.max_requests. Если есть утечка памяти, эта настройка может быть полезна для перезагрузки дочерних процессов после определённого количества запросов. По умолчанию установлено значение 0, это означает, что процессы не будут завершаться на основании этой опции. Разумным значением может быть 500 или 1000 запросов, в зависимости от ситуации. Например, если установить значение 500, то после обработки 500 запросов дочерний процесс будет завершён (освободив таким образом всю накопленную память), а затем создан заново.

Тестируем всё это

Я решил провести несколько тестов производительности, чтобы подтвердить теорию, что static должен быть самым быстрым при обработке запросов, поскольку дочерние процессы не порождаются на лету. dynamic должен занимать среднюю позицию, так как часть процессов уже запущена, а часть порождается по мере необходимости, а ondemand должен быть самым медленным, так как он порождает и завершает процессы без остановки. Вот результаты.

Для проведения тестов использовался ApacheBench, и запросы отправлялись с другого сервера, как и рекомендуется, то есть тестовые запросы отправлялись не с того же сервера, на котором проводилось тестирование. Параметры тестового сервера 16 ГБ оперативной памяти и 4 ядра процессора. PHP-FPM был подключён к NGINX. Запросы проходили через приложение Laravel. Значение max_children было равно 80 во всех тестовых случаях. Я сравнивал время ответа на 90 % запросов. Далее в таблицах я буду указывать только разницу в миллисекундах между различными опциями pm, 0 мс — самый быстрый из них.

Пример команды, использованной для тестирования:

ab -n 1000 -c 10 https://example.com

В приведённом примере мы отправляем 1000 запросов с одновременным выполнением 10 запросов.

Давайте рассмотрим первый тест. Я хотел посмотреть, как различные параметры pm повлияют на время отклика, когда явно достигается ограничение max_children. Итак, я отправил 25000 запросов с уровнем параллелизма 1000 запросов.

pm.start_servers = 20
pm.min_spare_servers = 20
pm.max_spare_servers = 40

Результаты оказались следующими:

Оказалось, что опция ondemand, которая теоретически должна быть самой медленной, в этом тесте оказалась самой быстрой, а опция static неожиданно оказалась самой медленной, отстав от ondemand более чем на секунду.

В следующем тесте я отправил 10000 запросов с уровнем параллелизма 100. Я тестировал с двумя различными значениями параметрами dynamic, одно было взято из первого теста (20/20/40), а другое использовало формулу, основанную на ядрах процессора (16/8/16). Вот результаты:

На этот раз самым быстрым оказался static, за ним следует dynamic, основанный на формулах, а самым медленным, как и следовало ожидать (исходя из теории), оказался ondemand. Но в целом для большинства сайтов +24 мс — это не такой уж большой прирост производительности, и разница между разными вариантами не так уж велика.

И в последнем тесте я отправил всего 2000 запросов с уровнем параллелизма 16. Опять, я использовал две настройки dynamic (20/20/40 и 16/8/16). Давайте посмотрим на результаты:

Согласно данным показателям, снова победила опция ondemand, на втором месте — static, а на последнем — dynamic, основанный на формулах. На этот раз различия между опциями оказались ещё менее значительными. Самая большая разница между первым и последним местом составила всего 10 мс.

В итоге получился неожиданный результат. Тесты показали, что опция ondemand была лучшим выбором, когда количество одновременных запросов значительно превышало значение max_children и когда количество одновременных запросов было значительно меньше значения max_children. static был лучшим выбором, если количество одновременных запросов было относительно близко к значению max_children. Но стоит учесть, что разница во времени отклика во втором и тем более в третьем тесте была весьма незначительной. Кроме того, при повторном выполнении этих же тестов вполне возможно, что места в таблице поменяются.

Так что эти результаты, не стоит рассматривать как нечто незыблемое, и то же самое относится к теории, описывающей, как различные настройки влияют на производительность. Думаю, что на современных серверах создание нового дочернего процесса уже не является дорогостоящей операцией, которая могла бы существенно повлиять на время отклика. По крайней мере, не в тех масштабах, в которых проводились тесты. Поэтому видно, что от настройки ondemand не стоит так просто отказываться, даже если речь идёт о скорости обработки запросов.

Лучше провести собственные тесты, поскольку нагрузка, настройки и операции, выполняемые на запрос, могут быть совершенно разными, а затем, на основе этих тестов, применить настройки, которые выглядят наиболее производительными.

Дополнительные настройки

Также есть несколько дополнительных настроек, которые могут оказаться полезными в случае, если что-то пойдёт не так с PHP-FPM или если нужно будет отследить медленные запросы.

Чтобы включить slowlog, регистрирующий медленные запросы, необходимо отредактировать тот же файл конфигурации, что и раньше:

sudo nano /etc/php/8.2/fpm/pool.d/www.conf

Затем найдите секцию slowlog:

slowlog = /var/log/php8.2-fpm.log.slow

И раскомментируйте его. Там есть ещё несколько связанных опций, которые вам следует раскомментировать. Первая из них — request_slowlog_timeout, по умолчанию установленная на 5 секунд. Если вы хотите регистрировать запросы, занимающие, например, 3 секунды и более, раскомментируйте и измените это значение. Вторая опция — request_slowlog_trace_depth, по умолчанию установленная на 20. В приложениях Laravel это значение может быть слишком малым, чтобы пройти через все функции вендоров и добраться до фрагмента кода, который на самом деле вызывается, например, в вашем контроллере. Думаю, в большинстве случаев 50 будет вполне достаточно, но проверьте, работает ли это у вас.

Вот как в конечном итоге может выглядеть вся настройка slowlog:

slowlog = /var/log/php8.2-fpm.log.slow
request_slowlog_timeout = 3s
request_slowlog_trace_depth = 50

Наконец, есть ещё один файл конфигурации, который можно редактировать и управлять тем, что произойдёт в случае, если дочерние процессы по какой-либо причине начнут выходить из строя. Вот пример, как можно отредактировать этот файл:

sudo nano /etc/php/8.2/fpm/php-fpm.conf

В этом файле нас интересуют 3 взаимосвязанные опции, и все они по умолчанию установлены в 0 и закомментированы. Если планируете их использовать, убедитесь, что они раскомментированы. После этого можно установить эти значения:

emergency_restart_threshold = 10
emergency_restart_interval = 1m
process_control_timeout = 10s

Используемые значения — это то, что вы, скорее всего, встретите в других статьях. Первые две настройки указывают PHP-FPM, что если 10 дочерних процессов не работают в течение одной минуты, то PHP-FPM должен перезапуститься автоматически. Третья настройка означает, что дочерний процесс будет ждать 10 секунд, прежде чем действовать по сигналу, отправленному от главного процесса. Таким образом, если главный процесс пошлёт дочернему процессу сигнал KILL, у него будет 10 секунд, на завершение своих задач перед выходом. Конечно, можно настроить эти значения в соответствии с вашими требованиями.

Перезапуск PHP-FPM в случае сбоя может решить некоторые проблемы. Но если проблема связана с чем-то, повторяющимся даже после перезапуска PHP-FPM, он будет перезапускаться до тех пор, пока вы не выясните, что именно произошло. Поэтому вы должны решить, хотите, чтобы PHP-FPM полностью отключался, когда происходит что-то непредвиденное, или же хотите, чтобы он самостоятельно перезапускался.

Заключение

Ну вот! Мы рассмотрели довольно много вещей. Мы обсудили лучшие значения для параметра max_children. Мы рассмотрели преимущества и недостатки различных настроек менеджера процессов и способы их проверки. Также рассмотрели дополнительные настройки, которые могут оказаться полезными при отладке медленных запросов или обработке сбоев. Надеюсь, это было полезно, и вы используете информацию из этой статьи в качестве отправной точки для лучшего мониторинга и тонкой настройки PHP-FPM на ваших серверах.

Дополнительные материалы

Предыдущая Статья

Масштабируемый CSS с архитектурой ITCSS

Следующая Статья

Используйте EXISTS вместо COUNT при проверке существования записей