PHP виконує не nginx, а PHP-FPM; nginx тут лише FastCGI-клієнт. Коли запит підпадає під location ~ \.php$, nginx серіалізує метод, шлях, заголовки, тіло й набір CGI-змінних у бінарні записи протоколу FastCGI і пише їх у сокет пулу. Master-процес PHP-FPM слухає цей сокет, вільний воркер забирає запит, виконує файл і повертає stdout, який nginx розбирає на заголовки та тіло відповіді. Ключова змінна тут SCRIPT_FILENAME: FPM відкриває рівно той шлях, який у ній прийшов, тому помилка в root дає Access denied або Primary script unknown від FPM, а не 404 від nginx. Класична пастка: підключити fastcgi_params замість fastcgi.conf. У першому файлі SCRIPT_FILENAME немає взагалі, у другому вже прописано $document_root$fastcgi_script_name.
За маршрутизацію відповідає try_files. Для Laravel root дивиться в public/, і рядок try_files $uri $uri/ /index.php?$query_string читається так: є такий файл або тека, віддай з диску, і статика ніколи не турбує PHP; немає, заводь усе у фронт-контролер. Збережений ?$query_string потрібен, бо внутрішній редирект без нього втрачає GET-параметри. Другий try_files, уже всередині location ~ \.php$, виглядає зайвим, але саме він закриває path info: regexp \.php$ матчить і /uploads/avatar.jpg/x.php, а try_files $fastcgi_script_name =404 не пускає у FPM шлях, якого немає на диску. Дзеркальний випадок, коли у FPM намагаються протягнути .jpg як скрипт, прикриває security.limit_extensions (за замовчуванням .php).
Пропускну здатність задає група pm. Режим dynamic тримає від pm.start_servers процесів і дихає між pm.min_spare_servers і pm.max_spare_servers, не перевищуючи pm.max_children; розумний дефолт для сервера зі змінним трафіком. static форкає max_children одразу й ніколи їх не гасить: у контейнері з фіксованим лімітом памʼяті це навіть краще, бо немає витрат на fork під піком і поведінка передбачувана. ondemand не тримає жодного воркера, поки не прийде запит, і гасить простійні через pm.process_idle_timeout; він для машини з двома десятками малотрафікових сайтів, де памʼять важливіша за першу мілісекунду відповіді. Окремо стоїть pm.max_requests: воркер самознищується після N запитів, що прикриває тічки в сторонніх розширеннях. OPcache при цьому не страждає, бо його SHM-сегмент належить master-процесу, і новий воркер дістає вже прогрітий кеш.
pm.max_children виводять з памʼяті, а не з інтуїції. Спочатку виміряйте реальне споживання: ps -o rss,cmd -C php-fpm8.4 --sort=-rss покаже RSS, але він подвійно рахує спільні сторінки, тому чесніше число дає smem -P php-fpm у колонці PSS. Далі віднімайте від памʼяті машини все, що там уже живе (ОС, nginx, MySQL, Redis), лишайте 10-15% запасу й діліть на споживання воркера: 8 ГБ мінус 1.5 ГБ на MySQL, мінус 1 ГБ на решту, залишок 4.8 ГБ, і при воркері на 100 МБ виходить десь 45 процесів. У контейнері рахуйте від ліміту cgroup: памʼять хоста тут ні до чого, і воркери, які в неї не влізли, знімає OOM killer. Другий обмежувач, про який забувають, це CPU: для CPU-bound коду сотня процесів на чотирьох ядрах лише розтягне латентність усім, тоді як застосунок, що переважно чекає на БД і зовнішні API, справді виграє від більшої кількості воркерів. Коли ліміт вичерпано, запити не падають одразу: вони стоять у listen.backlog (за замовчуванням 511), сайт гальмує, а 502 з Resource temporarily unavailable приходить уже після переповнення черги.
Видимість тримається на двох таймаутах. request_slowlog_timeout = 5s разом із slowlog нікого не вбиває: FPM просто знімає бектрейс запиту, який перетнув межу, і пише його у файл з іменем файлу, функцією й номером рядка на кожному кадрі (глибина керується request_slowlog_trace_depth). Коли APM немає, це найдешевший спосіб дізнатися, що саме гальмує в продакшені. request_terminate_timeout натомість знімає воркера сигналом і лишає в логу execution timed out ... terminating. Він не дублює max_execution_time: PHP-ний таймер на Unix не рахує час, проведений у системних викликах, тобто зависла відповідь від зовнішнього API чи запит, що чекає на блокування в БД, для max_execution_time триває нуль секунд. За жорстке зняття платять тим, що код не добігає до finally й register_shutdown_function, а незакомічена транзакція відкатується вже по розриву зʼєднання; тому значення ставлять із запасом над реальним p99, а не «щоб швидше». І нехай fastcgi_read_timeout у nginx буде на кілька секунд більшим: інакше nginx відвалиться першим, у логах FPM не буде жодного сліду, а воркер продовжить працювати на нікого.
# /etc/nginx/sites-enabled/app.conf - усе, крім .php, віддає nginx
root /var/www/app/current/public;
index index.php;
location / {
# файл або тека є - віддати з диску; решта йде у фронт-контролер
try_files $uri $uri/ /index.php?$query_string;
}
location ~ \.php$ {
# без цього рядка /uploads/avatar.jpg/x.php дійде до FPM
try_files $fastcgi_script_name =404;
include fastcgi.conf; # саме fastcgi.conf: у ньому вже є SCRIPT_FILENAME
fastcgi_pass unix:/run/php/php8.4-fpm-app.sock;
fastcgi_read_timeout 35s; # більше за request_terminate_timeout
fastcgi_hide_header X-Powered-By;
}
# /etc/php/8.4/fpm/pool.d/app.conf - хто виконує PHP
[app]
user = app
listen = /run/php/php8.4-fpm-app.sock
listen.owner = www-data # інакше nginx: Permission denied
listen.mode = 0660
pm = dynamic
pm.max_children = 32 # 3200 МБ вільних / 100 МБ реального RSS
pm.start_servers = 8
pm.min_spare_servers = 4
pm.max_spare_servers = 12
pm.max_requests = 800 # перезапуск воркера: страховка від тічки
pm.status_path = /fpm-status # listen queue, max children reached
slowlog = /var/log/php-fpm/app-slow.log
request_slowlog_timeout = 5s # бектрейс живого запиту, воркер живий
request_slowlog_trace_depth = 30
request_terminate_timeout = 30s # а тут SIGTERM воркеру + запис у лог
Скажіть, що 502 під навантаженням майже ніколи не про nginx: він лише не дочекався сокета. Далі назвіть три місця, куди дивитесь по черзі - `max children reached` у `pm.status_path`, `listen queue` там само і рядки про `execution timed out` у логі FPM. Людина, яка розділяє «пул переповнений» і «PHP упав», звучить як та, що тримала продакшен, а не читала howto.