Два сайта на одном VPS при базовой настройке PHP-FPM живут как соседи в коммуналке: общий пул воркеров, общий пользователь www-data и общий котел ресурсов. Сайт с кривым плагином подвешивает все штатные воркеры - и второй проект отвечает 502, хотя сам по себе здоров. Хуже другое: код одного сайта спокойно открывает конфиги соседа и читает пароли от его базы. Раздельные пулы PHP-FPM решают обе проблемы силами самой операционной системы: каждому сайту свой пользователь, свои воркеры, свои лимиты памяти и свои логи. Ниже полный разбор на примере двух проектов на Ubuntu Server 24.04 LTS с PHP 8.3: от создания пользователя до практической проверки, что сайты не видят файлов друг друга.
Чем один общий пул PHP-FPM опасен для сервера с несколькими сайтами
Штатная конфигурация php8.3-fpm на Ubuntu выглядит минимально: один пул www, пять воркеров под пользователем www-data, сокет /run/php/php8.3-fpm.sock. Для единственного тестового сайта это честный минимум. Проблемы приходят вместе со вторым проектом.
Первая - ресурсы. Пул держит ограниченное число процессов, каждый процесс занят ровно одним запросом. Зависший импорт или тяжёлый отчёт занимает воркеры на минуты, очередь у сокета растёт, и nginx возвращает 502 всем сайтам подряд. Понять, кто виноват, тяжело: логи общие на всех.
Вторая - безопасность. Каталоги всех проектов принадлежат пользователю www-data, под которым работает код PHP. Утёкшая тема WordPress или загруженный веб-шелл получают чтение соседних каталогов: конфигов с паролями баз, файлов с токенами, выгрузок клиентов. Один скомпрометированный сайт превращается в ключ ко всему серверу.
Раздельные пулы меняют расклад на уровне ядра системы. Воркеры каждого пула работают от отдельного пользователя, права на файлы чужого сайта им просто не выданы, лимиты и логи у каждого свои. Сайт может тормозить и даже попасть под взлом - соседям от этого ни холодно ни жарко.
Устройство пула PHP-FPM и режимы process manager под разные задачи
Пул - это секция в файле из каталога /etc/php/8.3/fpm/pool.d/. Главный процесс читает их все и порождает воркеры: сам он работает от root, чтобы занять сокеты, а воркеры сразу сбрасывают права на пользователя из настроек пула. Штатный файл пула задаёт знакомую картину:
[www]
user = www-data
group = www-data
listen = /run/php/php8.3-fpm.sock
pm = dynamic
pm.max_children = 5
pm.start_servers = 2
pm.min_spare_servers = 1
pm.max_spare_servers = 3
Число 5 в max_children часто становится сюрпризом: пять одновременных PHP запросов на все сайты сервера, шестой ждёт в очереди. За количеством воркеров следит process manager, у него три режима.
pm = static держит ровно max_children процессов без изменений. Память расходуется предсказуемо, реакция ровная, режим выбирают сайтам с постоянной нагрузкой без резких всплесков.
pm = dynamic создаёт start_servers воркеров при старте, добавляет новые при росте очереди и убирает лишние, удерживая запас между min_spare_servers и max_spare_servers. Золотая середина для большинства проектов и заводской выбор Ubuntu.
pm = ondemand держит ноль воркеров, пока запросов нет. Экономия памяти для заброшенных визиток и тестовых площадок, но первый посетитель получает задержку холодного старта, поэтому живым магазинам режим не подходит.
Параметр pm.max_requests задаёт, после скольких запросов воркер пересоздаётся. В конфиге Ubuntu строка со значением 500 закомментирована, то есть по умолчанию воркеры живут вечно. Скриптам с медленными утечками памяти перезапуск каждые 300-500 запросов возвращает предсказуемость.
Создание пользователя и каталога сайта с правами только на своё
Изоляция начинается не в конфиге PHP, а в учётных записях системы. Каждому сайту создают отдельного пользователя без пароля и без возможности входа, каталоги проекта выдают только ему:
sudo useradd -r -m -d /var/www/site1 -s /usr/sbin/nologin site1
sudo mkdir -p /var/www/site1/public /var/www/site1/logs
sudo mkdir -p /var/www/site1/sessions /var/www/site1/tmp
sudo chown -R site1:site1 /var/www/site1
sudo chmod -R u=rwX,g=rX,o= /var/www/site1
Ключ -m назначает каталог домашним, значение nologin отключает командную оболочку: даже с паролем войти по SSH от такого пользователя не выйдет. Режим u=rwX,g=rX,o= отбирает доступ у остальных: владелец читает и пишет, группа читает, посторонние не видят ничего.
Остаётся тонкость: nginx работает от www-data и обязан читать статику из каталога сайта. Классическое решение - включить www-data в группу каждого сайта:
sudo usermod -aG site1 www-data
sudo usermod -aG site2 www-data
Теперь nginx раздаёт файлы через групповые права, а писать в чужие каталоги не может. Воркеры PHP работают от самого site1, и всё созданное движком - кэш, загрузки, сессии - сразу получает правильного владельца без танцев с chmod после каждой выгрузки.
Файл пула site1 с лимитами воркеров и настройками php только для него
Создать пул - значит положить файл в /etc/php/8.3/fpm/pool.d/. Имя секции станет именем пула, настройки применяются только к его воркерам:
sudo nano /etc/php/8.3/fpm/pool.d/site1.conf
[site1]
user = site1
group = site1
listen = /run/php/site1.sock
listen.owner = www-data
listen.group = www-data
listen.mode = 0660
pm = dynamic
pm.max_children = 12
pm.start_servers = 4
pm.min_spare_servers = 3
pm.max_spare_servers = 6
pm.max_requests = 400
php_admin_value[error_log] = /var/www/site1/logs/php-error.log
php_admin_flag[log_errors] = on
php_admin_value[open_basedir] = /var/www/site1
php_admin_value[upload_tmp_dir] = /var/www/site1/tmp
php_admin_value[session.save_path] = /var/www/site1/sessions
slowlog = /var/www/site1/logs/php-slow.log
request_slowlog_timeout = 5s
Разбор по строкам. Сокет site1.sock живёт рядом с сокетом штатного пула, а владельцем сокета назначен www-data, ведь писать в него будет nginx. Значения через php_admin_value и php_admin_flag код сайта не переопределит: ни вызов ini_set, ни файл .user.ini их не отменит. Граница open_basedir разрешает PHP открывать файлы только внутри каталога проекта, путь до соседа в неё не входит. Сессии, временные загрузки и ошибки каждого сайта лежат отдельно, и поиск виновника деградации занимает минуты, а не часы.
Вместо сокета пул может слушать и локальный TCP порт:
listen = 127.0.0.1:9001
listen.allowed_clients = 127.0.0.1
Сокет быстрее за счёт меньших накладных расходов и не занимается портами, поэтому для соседних пулов на одном сервере выбирают его. TCP выручает в связке с контейнерами или chroot, где общий файловый путь недоступен: порт легко пробросить между изолированными окружениями, а директива allowed_clients отсекает подключения откуда-либо, кроме localhost.
Пара slowlog и request_slowlog_timeout достойна отдельного упоминания: воркер, обрабатывающий запрос дольше пяти секунд, пишет в лог стек вызовов вплоть до конкретной функции. По этому логу зависший плагин находится с первой попытки.
Расчёт лимитов воркеров по памяти сервера и весу каждого сайта
Числа max_children из воздуха - источник загадочных 502 в час пик. Считают их от бюджета памяти. Средний воркер PHP на WordPress с десятком плагинов занимает 50-80 МБ, лёгкий лендинг укладывается в 25-40 МБ. Реальный размер своего проекта виден прямо в процессах:
ps -C php-fpm8.3 -o user,pid,rss,cmd --sort=-rss
Колонка rss показывает резидентную память в килобайтах, по ней выводят среднее по воркерам пула. Дальше арифметика для сервера на 8 ГБ.
MariaDB с буфером Innodb откусит около гигабайта, nginx и прочие службы - до полгигабайта, системе и кэшу страниц оставляют ещё около полутора гигабайт. Бюджет PHP выходит порядка 5 ГБ. При среднем воркере 60 МБ это 85 одновременных процессов на все пулы вместе. Осталось поделить их по весу проектов: магазину отдают 30 воркеров, корпоративному сайту 15, оживлённому форуму 25, и 15 держат в резерве под всплески.
Главное правило закрывает типовой сценарий: сумма max_children всех пулов не должна превышать бюджет памяти PHP. Когда воркеров больше, чем влезает, сервер уходит в нехватку памяти, деградирует до состояния кирпича, и ядро начинает сбрасывать самые прожорливые процессы. Запас в расчёте всегда дешевле, чем ловить пики ночью.
Nginx направляет каждый сайт в свой сокет и закрывает статус от чужих
Виртуальный хост каждого сайта указывает fastcgi_pass на собственный сокет пула. Для site1 это выглядит так:
server {
listen 80;
server_name site1.example.com;
root /var/www/site1/public;
index index.php;
location / {
try_files $uri $uri/ /index.php?$query_string;
}
location ~ \.php$ {
include snippets/fastcgi-php.conf;
fastcgi_pass unix:/run/php/site1.sock;
}
location = /fpm-status {
include fastcgi_params;
fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
fastcgi_pass unix:/run/php/site1.sock;
allow 127.0.0.1;
deny all;
}
}
Пулу для страницы состояния нужна пара директив в файле конфигурации. Первая включает страницу с живой статистикой, вторая даёт простой адрес для проверок живости:
pm.status_path = /fpm-status
ping.path = /ping
Страница показывает числа active processes, idle processes, listen queue. Очередь больше нуля означает, что воркеры закончились и запросы ждут - первый звоночек, что max_children пора пересчитывать. Директивы allow и deny прячут страницу от интернета, стучаться в неё можно только с самого сервера:
curl http://127.0.0.1/fpm-status -H "Host: site1.example.com"
Перечитать конфигурацию после правок помогает короткая связка:
sudo php-fpm8.3 -t
sudo systemctl reload php8.3-fpm
sudo nginx -t && sudo systemctl reload nginx
Команда php-fpm8.3 -t проверяет синтаксис всех пулов до перезапуска, а reload вместо restart обновляет настройки без разрыва обработки текущих запросов.
Проверка изоляции между сайтами и типичные ошибки после включения пулов
Доверяй, но проверяй - про изоляцию это сказано прямым текстом. Первый тест показывает, от кого реально работает код. В корень site1 кладут короткий скрипт:
<?php
echo 'script owner: ' . get_current_user() . PHP_EOL;
echo 'php version: ' . PHP_VERSION . PHP_EOL;
Ответом будут две строки, а в списке процессов виден сам воркер:
ps -o user,pid,cmd -C php-fpm8.3
Строки с site1 в колонке user - прямое доказательство: код работает от пользователя сайта, а не от всеобщего www-data.
Второй тест - попытка чтения чужого файла из кода site1:
<?php
var_dump(
file_get_contents('/var/www/site2/public/config.php')
);
Ожидаемый ответ - предупреждение open_basedir restriction in effect, а не содержимое чужого конфига. Защита держится на двух рубежах: даже если права на файл однажды выставят криво, граница open_basedir пула запрос не пропустит.
Ошибки после включения пулов почти всегда из короткого списка. Ошибка 502 сразу после переключения означает, что сокет не создан или права на него не совпали, и первый подозреваемый - listen.owner. Записи сессий не пишутся, когда каталог не принадлежит пользователю пула или выпал из open_basedir. Сообщение cannot get gid for group site1 при старте службы - опечатка в имени группы. Статика отдаётся, а PHP отвечает ошибкой доступа - www-data забыли включить в группу сайта.
Для новых проектов порядок действий один и тот же:
- создать системного пользователя и каталоги сайта с правами только для него;
- положить файл пула в /etc/php/8.3/fpm/pool.d и проверить синтаксис командой php-fpm8.3 -t;
- перечитать конфигурацию php8.3-fpm и убедиться в появлении нового сокета в /run/php;
- добавить виртуальный хост с fastcgi_pass на этот сокет и перечитать nginx;
- открыть сайт и скриптом проверить пользователя, от которого работает код.
Через полчаса такой рутины каждый проект на сервере живёт в собственной песочнице: ресурсы поделены, логи разделены, ночной инцидент на одном сайте больше не будит владельцев остальных. Рецепт повторяется для десятков хостов без сюрпризов, и рост нагрузки превращается из аварии в плановую правку одной строки max_children.