Проект — рекламная платформа с высокочастотным API. PHP-бэкенд на Laravel, Nginx как reverse proxy, PHP-FPM 8.3. PHP-код был оптимизирован ещё три месяца назад: OPcache включён, запросы к БД закэшированы через Redis, тяжёлые вычисления вынесены в воркеры. При 500 RPS всё летало. При 1000 RPS начинались 502 Bad Gateway. Я поехал разбираться.
Диагностика: где узкое горлышко
Сначала нагрузочное тестирование. Использовал wrk — простой и надёжный инструмент:
wrk -t 12 -c 500 -d 60s --latency http://api.example.com/v1/ads
# При 500 RPS:
# Latency Distribution
# 50% 12.45ms
# 75% 18.23ms
# 90% 25.67ms
# 99% 89.12ms
# При 1000 RPS:
# Non-2xx responses: 347
# 502 errors: 312При 1000 RPS — куча 502. Смотрю логи Nginx:
tail -f /var/log/nginx/error.log
# connect() failed (111: Connection refused) while connecting to upstream
# upstream prematurely closed connection while reading response headerConnection refused от upstream — PHP-FPM не принимает подключения. Смотрю конфиг pool:
[www]
pm = dynamic
pm.max_children = 20
pm.start_servers = 4
pm.min_spare_servers = 2
pm.max_spare_servers = 8
pm.max_requests = 500Вот оно. pm.max_children = 20. При latency одного запроса 12ms, 20 воркеров могут обслужить ~1666 RPS в теории. Но на практике — меньше, из-за TCP handshake, keepalive overhead и contention. Когда RPS превышает capacity — новые подключения к PHP-FPM получают connection refused, и Nginx отдаёт 502.
Шаг 1: Тюнинг PHP-FPM
Сервер: 8 vCPU, 32 GB RAM. PHP-процесс ест ~50MB. 32GB / 50MB = 640 процессов (теоретический максимум). Но Nginx, Redis, система тоже нужна память. Реалистично — 400 процессов для PHP-FPM:
[www]
pm = dynamic
pm.max_children = 300
pm.start_servers = 40
pm.min_spare_servers = 30
pm.max_spare_servers = 80
pm.max_requests = 1000
pm.process_idle_timeout = 10sКлючевые изменения: pm.max_children с 20 до 300, pm.start_servers с 4 до 40 (чтобы не ждать масштабирования при резком всплеске), pm.max_requests = 1000 (перезапуск процесса раз в 1000 запросов для борьбы с memory leaks).
После этого: 2000 RPS без ошибок. Но при 3000 RPS начали появляться latency spikes до 200ms. Проблема перешла в Nginx.
Шаг 2: Тюнинг Nginx
Исходный конфиг Nginx был дефолтный:
worker_processes auto;
events {
worker_connections 768;
}
http {
upstream php-fpm {
server 127.0.0.1:9000;
}
server {
location ~ \.php$ {
fastcgi_pass php-fpm;
fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
include fastcgi_params;
}
}
}Проблемы: worker_connections 768 — слишком мало при 3000+ одновременных подключений. Нет keepalive к upstream — каждое соединение к PHP-FPM открывается заново, а TCP handshake занимает ~0.5ms. При 5000 RPS это 2.5 секунды только на handshake. Нет buffering — Nginx ждёт ответа от PHP-FPM синхронно.
Исправленный конфиг:
worker_processes auto;
events {
worker_connections 8192;
multi_accept on;
use epoll;
}
http {
keepalive_timeout 65;
keepalive_requests 10000;
upstream php-fpm {
server 127.0.0.1:9000;
keepalive 200;
}
server {
listen 80 reuseport;
location ~ \.php$ {
fastcgi_pass php-fpm;
fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
include fastcgi_params;
# Keepalive к upstream
fastcgi_keep_conn on;
# Buffering
fastcgi_buffer_size 32k;
fastcgi_buffers 8 32k;
fastcgi_busy_buffers_size 64k;
}
}
}Что изменилось: worker_connections 8192 — теперь Nginx может держать 8192 одновременных подключений на каждый worker (а их 8, итого 65536). keepalive 200 в upstream — 200 постоянных соединений к PHP-FPM, которые переиспользуются. fastcgi_keep_conn on — Nginx не закрывает соединение к PHP-FPM после каждого запроса. reuseport on — все воркеры слушают на одном порту, ядро распределяет подключения равномерно.
Шаг 3: Оптимизация ядра
Ещё пара параметров sysctl, которые дали небольшой, но заметный эффект:
# /etc/sysctl.d/99-tuning.conf
net.core.somaxconn = 65535
net.ipv4.tcp_max_syn_backlog = 65535
net.ipv4.ip_local_port_range = 1024 65535
net.ipv4.tcp_tw_reuse = 1
net.ipv4.tcp_fin_timeout = 15
net.core.netdev_max_backlog = 65535somaxconn — размер очереди ожидающих подключений. Дефолт 128 — при резком всплеске подключения отбрасываются. tcp_tw_reuse = 1 — разрешает переиспользование TIME_WAIT сокетов.
Результаты
Финальный тест:
wrk -t 12 -c 2000 -d 60s --latency http://api.example.com/v1/ads
# 5000+ RPS sustained
# Latency Distribution
# 50% 8.12ms
# 75% 11.45ms
# 90% 15.23ms
# 99% 42.67ms
# 0 errorsОт 500 RPS с 502 ошибками до 5000+ RPS со стабильной latency. Ускорение в 10 раз. Самое важное: мы не трогали PHP-код. Вся проблема была в инфраструктурном слое — неправильный sizing пулов, отсутствие keepalive, маленькие буферы.
Главный совет: когда видите 502 — не спешите лезть в код. Сначала проверьте capacity вашего приложения-сервера и настройки reverse proxy. Часто проблема не в том, что код медленный, а в том, что инфраструктура не может пропустить через себя нужный объём запросов.
Пока нет комментариев
Будьте первым, кто оставит комментарий к этой статье