Почему FastCGI, а не proxy_pass: механика nginx fastcgi
Когда ты делаешь proxy_pass на HTTP-апстрим, nginx говорит с бэкендом на HTTP. PHP-FPM (FastCGI Process Manager) HTTP не понимает - он понимает бинарный протокол FastCGI. Это не каприз, а оптимизация: HTTP-запрос - это текст, который надо парсить. FastCGI же передаёт уже разобранный запрос как набор пар ключ-значение (CGI-переменные: REQUEST_METHOD, QUERY_STRING, SCRIPT_FILENAME и десятки других) плюс тело. PHP-FPM получает готовые переменные окружения и сразу зовёт интерпретатор - парсить HTTP-заголовки ему не нужно.
Архитектурно это выглядит так. PHP-FPM - это master-процесс, который держит пул воркеров (готовых процессов PHP). Запрос от nginx прилетает по FastCGI, свободный воркер его берёт, исполняет скрипт, отдаёт результат и освобождается под следующий. Воркеры не умирают после каждого запроса (в отличие от древнего CGI), отсюда и буква F - Fast. nginx тут чистый клиент FastCGI: он формирует переменные, открывает соединение к php-fpm, шлёт запрос, читает ответ и отдаёт его браузеру.
Соединяться можно двумя способами, и это первый практический выбор. Через unix-сокет:
Код: Выделить всё
fastcgi_pass unix:/run/php/php8.3-fpm.sock;
Код: Выделить всё
fastcgi_pass 127.0.0.1:9000;

Минимальный рабочий location для nginx php-fpm
Соберём базовую конфигурацию и разберём каждую строку. Это тот самый location, который ты будешь писать в каждом PHP-vhost:
Код: Выделить всё
server {
listen 80;
server_name example.com;
root /var/www/example/public;
index index.php index.html;
location / {
try_files $uri $uri/ /index.php?$query_string;
}
location ~ \.php$ {
try_files $uri =404;
include fastcgi_params;
fastcgi_pass unix:/run/php/php8.3-fpm.sock;
fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
fastcgi_index index.php;
fastcgi_read_timeout 60s;
}
}
include fastcgi_params подтягивает стандартный набор CGI-переменных (он лежит в дистрибутиве рядом с nginx.conf). Там уже прописаны REQUEST_METHOD, QUERY_STRING, REMOTE_ADDR, HTTP-заголовки как HTTP_* и прочее. Чего там обычно нет или что задано неполно - это SCRIPT_FILENAME, поэтому его дописывают отдельно. fastcgi_index index.php подставляет имя индексного скрипта, если URI заканчивается слешем и ушёл в FastCGI.
Главная грабля: SCRIPT_FILENAME и путь к скрипту
Вот строка, из-за которой пролито море крови:
Код: Выделить всё
fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
Разберём переменные. $document_root - это значение директивы root, например /var/www/example/public. $fastcgi_script_name - имя скрипта из URI, например /index.php. Склеиваем - получаем /var/www/example/public/index.php. Правильно.
Где ломается. Классика номер один: люди прописывают root внутри location ~ \.php$ заново, причём с другим значением, и пути расходятся. Или пишут хардкод вроде fastcgi_param SCRIPT_FILENAME /var/www/html$fastcgi_script_name, а реальный root другой. Тогда PHP-FPM ищет файл не там. Правило простое: root задавай ОДИН раз на уровне server, а SCRIPT_FILENAME собирай через $document_root, чтобы он автоматически совпадал с root. Именно так сделано в эталонном проекте ngx-trace-gateway в global_fastcgi.conf:
Код: Выделить всё
include fastcgi_params;
fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
fastcgi_param SCRIPT_NAME $fastcgi_script_name;
Код: Выделить всё
location ~ ^(.+\.php)(/.+)$ {
fastcgi_split_path_info ^(.+\.php)(/.+)$;
try_files $fastcgi_script_name =404;
include fastcgi_params;
fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
fastcgi_param PATH_INFO $fastcgi_path_info;
fastcgi_pass unix:/run/php/php8.3-fpm.sock;
}
try_files перед FastCGI - это вопрос безопасности, а не удобства
Теперь самое важное в уроке. Строка try_files $uri =404; (или try_files $fastcgi_script_name =404;) внутри php-location - не косметика. Это защита от выполнения произвольных файлов, и без неё твой сервер можно взломать.
Механика дыры. Исторически в PHP была опция cgi.fix_pathinfo=1 (когда-то она шла по умолчанию). При ней, если PHP-FPM получает SCRIPT_FILENAME на несуществующий файл с хвостом, он отрезает хвост и пытается выполнить ближайший существующий файл. Сценарий атаки: пользователь загрузил на сайт картинку avatar.jpg (легально, аватарки разрешены). Затем запрашивает /uploads/avatar.jpg/x.php. Если nginx тупо матчит .php в конце URI и шлёт это в PHP-FPM без проверки существования, PHP с fix_pathinfo находит реальный avatar.jpg и ВЫПОЛНЯЕТ его как PHP-код. А внутри картинки спрятан PHP-шелл. Это классическая удалённая компрометация серверов на nginx, которой много лет.
try_files $uri =404; разрывает эту цепочку: nginx проверяет, что файл /uploads/avatar.jpg/x.php реально существует на диске. Не существует - сразу 404, до всякого FastCGI. PHP-FPM запрос вообще не видит. Дополнительно в современных PHP-FPM по умолчанию security.limit_extensions = .php, что отсекает попытку выполнить .jpg. Но полагаться на один слой нельзя - держи оба: try_files на стороне nginx плюс security.limit_extensions на стороне PHP-FPM. Глубокая защита.
Второй слой защиты на уровне путей - запретить выполнение PHP в каталогах загрузок прямо в конфиге. Эталонный ngx-trace-gateway делает это в app_redirects_security.conf:
Код: Выделить всё
# Запрет выполнения .php в /upload - тут только файлы данных.
location ~* ^/upload/.*\.php$ {
deny all;
}
location ~* ^/(cgi-bin|includes|uploads|tmp|php-cgi)/ {
deny all;
}
Буферы, таймауты и пулы: эксплуатация под нагрузкой
PHP бывает медленным: тяжёлый импорт, генерация отчёта, выгрузка. По умолчанию fastcgi_read_timeout 60 секунд - время ожидания ответа от php-fpm после отправки запроса. Если скрипт считает дольше, nginx рвёт соединение и отдаёт 504 Gateway Time-out. Поднимать вслепую до 600s - плохая идея: повисший воркер держит соединение и занимает слот в пуле. Лучше для конкретного тяжёлого эндпоинта поднять таймаут точечно:
Код: Выделить всё
location = /admin/export.php {
include fastcgi_params;
fastcgi_pass unix:/run/php/php8.3-fpm.sock;
fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
fastcgi_read_timeout 300s;
}
Буферы. Когда PHP отдаёт большой ответ (длинная HTML-страница, JSON-портянка), nginx буферизует его, чтобы быстро освободить воркер php-fpm и не держать его, пока медленный клиент по мобильной сети тянет ответ байт за байтом. Эталонные значения из global_fastcgi.conf - разумная база для веб-приложений:
Код: Выделить всё
fastcgi_connect_timeout 5s;
fastcgi_read_timeout 10s;
fastcgi_send_timeout 10s;
fastcgi_buffering on;
fastcgi_buffer_size 16k;
fastcgi_buffers 16 16k;
fastcgi_busy_buffers_size 32k;
fastcgi_temp_path /var/run/openresty/nginx-fastcgi;
fastcgi_next_upstream error timeout;
fastcgi_next_upstream_tries 3;
Со стороны PHP-FPM крутится пул процессов: pm = dynamic/static/ondemand, pm.max_children. Это потолок одновременных PHP-запросов. Если все воркеры заняты, новые запросы встают в очередь backlog сокета, а при переполнении nginx ловит 502. Правило прикидки: pm.max_children = (доступная память) / (память на один воркер). Перебор - сервер уйдёт в своп и ляжет; недобор - очередь и 502 под пиком. nginx это не лечит - тут тюнят PHP-FPM.
nginx fastcgi_cache: ускоряем PHP кэшированием ответа
Самый дешёвый способ ускорить PHP-сайт - не выполнять PHP. Если страница для анонима одинаковая (главная блога, карточка товара без персонализации), nginx может закэшировать готовый HTML и отдавать его напрямую с диска, минуя php-fpm. Это nginx fastcgi_cache, и на типичном WordPress он даёт кратный рост RPS - с десятков до тысяч запросов в секунду.
Сначала объявляем зону кэша на уровне http (по аналогии с proxy_cache_path в эталонном global_cache.conf):
Код: Выделить всё
fastcgi_cache_path /var/cache/nginx/fcgi levels=1:2
keys_zone=phpcache:64m inactive=60m max_size=4g use_temp_path=off;
Теперь в php-location включаем кэш. Эталон формирует ключ и политику так:
Код: Выделить всё
location ~ \.php$ {
try_files $uri =404;
include fastcgi_params;
fastcgi_pass unix:/run/php/php8.3-fpm.sock;
fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
fastcgi_cache phpcache;
fastcgi_cache_key "$scheme$request_method$host$request_uri$http_authorization";
fastcgi_cache_valid 200 1h;
fastcgi_cache_lock on;
fastcgi_cache_lock_timeout 5s;
fastcgi_cache_background_update on;
fastcgi_cache_revalidate on;
fastcgi_ignore_headers Expires Cache-Control Set-Cookie Vary;
fastcgi_hide_header Set-Cookie;
add_header X-FastCGI-Cache $upstream_cache_status always;
}
Критическая тонкость: НЕЛЬЗЯ кэшировать авторизованных и POST. Иначе залогиненный юзер увидит кэш гостя или чужой. Управляют этим парой fastcgi_cache_bypass (не отдавать ИЗ кэша) и fastcgi_no_cache (не класть В кэш) через map:
Код: Выделить всё
map $request_method $no_cache_method { default 0; POST 1; }
map $http_cookie $no_cache_cookie {
default 0;
"~*wordpress_logged_in|woocommerce_items_in_cart|PHPSESSID" 1;
}
# внутри location ~ \.php$:
set $skip_cache 0;
if ($no_cache_method) { set $skip_cache 1; }
if ($no_cache_cookie) { set $skip_cache 1; }
fastcgi_cache_bypass $skip_cache;
fastcgi_no_cache $skip_cache;
Симметрия политик: fastcgi, uwsgi, scgi - зеркало proxy
FastCGI - не единственный протокол приложений. Python-приложения (Flask, Django через uWSGI) общаются по протоколу uwsgi - и у nginx для него СВОЙ зеркальный набор директив: uwsgi_pass, uwsgi_read_timeout, uwsgi_buffers, uwsgi_cache, uwsgi_next_upstream. То же для протокола SCGI: scgi_pass и компания. Это ключевая идея архитектуры nginx - каждый протокол апстрима (proxy/fastcgi/uwsgi/scgi) имеет ОДИНАКОВЫЙ по смыслу набор директив с разным префиксом.
Эталонный проект использует это буквально: global_uwsgi.conf и global_scgi.conf - почти посимвольная копия global_fastcgi.conf, только с заменой префикса. Те же таймауты 5s/10s/10s, те же буферы 16 на 16k, тот же next_upstream tries 3, та же политика кэша и тот же ключ:
Код: Выделить всё
# global_uwsgi.conf (зеркало fastcgi)
uwsgi_connect_timeout 5s;
uwsgi_read_timeout 10s;
uwsgi_buffer_size 16k;
uwsgi_buffers 16 16k;
uwsgi_next_upstream error timeout;
uwsgi_next_upstream_tries 3;
include uwsgi_params;
uwsgi_cache_key "$scheme$request_method$host$request_uri$http_authorization";
Код: Выделить всё
location / {
include uwsgi_params;
uwsgi_pass unix:/run/uwsgi/app.sock;
}
Типичные грабли FastCGI
- 502 Bad Gateway + connect() failed Permission denied - неверные права на unix-сокет php-fpm. Чини listen.owner/listen.group/listen.mode в пуле.
- File not found. в браузере (не 404 от nginx) - неверный SCRIPT_FILENAME. Проверь, что root задан и $document_root совпадает с реальным путём. Это ответ php-fpm, а не nginx.
- Белый экран без ошибок - PHP упал с фаталом, но display_errors=off. Смотри php-fpm error log и логи приложения, а не nginx.
- Дыра RCE через загрузки - забыли try_files перед FastCGI или не запретили .php в /upload. Всегда оба слоя.
- Кэш отдаёт залогиненным гостевую страницу - не настроены fastcgi_cache_bypass/no_cache по кукам. Проверяй X-FastCGI-Cache в ответе.
- 504 Gateway Time-out на тяжёлом скрипте - мал fastcgi_read_timeout И/ИЛИ max_execution_time. Поднимай ОБА, точечно на location.
- Расходящийся root в location ~ \.php$ - не переопределяй root внутри php-location. Один root на server.
- Кэш не обновляется после деплоя - не забудь чистку: удалить файлы из fastcgi_cache_path или продумать инвалидацию (в open-source nginx нет встроенной очистки по ключу - это purge есть в коммерческом Plus и в Angie).
Повтори по шагам на тестовой машине или в контейнере.
- Шаг 1. Установи php-fpm (apt install php8.3-fpm) и проверь сокет: ls -la /run/php/php8.3-fpm.sock.
- Шаг 2. Создай /var/www/test/public и положи туда info.php с содержимым вызова phpinfo(). Сделай root каталог доступным nginx.
- Шаг 3. Напиши server с root /var/www/test/public, location / с try_files $uri $uri/ /index.php?$query_string и location ~ \.php$ с try_files $uri =404, include fastcgi_params, fastcgi_pass на сокет и SCRIPT_FILENAME через $document_root.
- Шаг 4. nginx -t && nginx -s reload. Открой /info.php - увидишь страницу phpinfo. Открой несуществующий /nope.php - должен прийти 404 от nginx, НЕ File not found от php-fpm. Если пришёл File not found - у тебя нет try_files, дыра открыта.
- Шаг 5. Положи в /var/www/test/public/uploads/shell.jpg текст с PHP-кодом. Запроси /uploads/shell.jpg/x.php. Без защиты возможно выполнение. Добавь location ~* ^/uploads/.*\.php$ { deny all; } ВЫШЕ php-location и повтори - получи 403.
- Шаг 6. Включи fastcgi_cache: объяви fastcgi_cache_path в http, добавь fastcgi_cache, ключ, valid 200 1h и add_header X-FastCGI-Cache $upstream_cache_status always. Дёрни curl -I http://localhost/ дважды - второй раз заголовок должен показать HIT.
- Почему try_files перед fastcgi_pass - это про безопасность, а не про удобство? Какую атаку он закрывает?
- Из чего собирается SCRIPT_FILENAME и почему нельзя переопределять root внутри location ~ \.php$?
- Чем отличаются fastcgi_cache_bypass и fastcgi_no_cache, и почему для nginx wordpress нужны оба?
- В чём смысл симметрии fastcgi/uwsgi/scgi с proxy и как это упрощает эксплуатацию?
nginx отдаёт PHP не по HTTP, а по FastCGI в php-fpm - через unix-сокет (быстрее, локально) или TCP (по сети/в контейнерах). Сердце настройки - правильный SCRIPT_FILENAME через $document_root$fastcgi_script_name и единственный root на server. try_files перед FastCGI и запрет .php в загрузках - это не удобство, а защита от выполнения чужого кода, держи оба слоя. fastcgi_cache даёт кратное ускорение, но требует аккуратного bypass/no_cache для авторизованных и POST. А вся связка fastcgi/uwsgi/scgi - зеркало proxy с одинаковой логикой буферов, таймаутов и кэша: понял один протокол - понял все четыре.