- Опубликовано: 13 июл 2026
- 39
Тормозит сайт — виноват не хостинг: тюним nginx и PHP-FPM
Часть 9 из 14 · Серия «Сервер в бою»
«Сайт тормозит — пора переезжать на хостинг помощнее» — самый дорогой рефлекс в мире маленьких серверов. Дорогой дважды: сначала вы платите за тариф в два раза толще, а потом обнаруживаете, что сайт тормозит и там. Потому что в большинстве случаев тормозил не хостинг — тормозило то, что на нём крутится, а лишние ядра не лечат медленный SQL-запрос и невключённый opcache.
Вторая крайность не лучше: «потюнить сервер» по первой попавшейся статье «50 настроек nginx для максимальной производительности». Половина таких настроек — карго-культ, переписываемый из блога в блог с 2012 года: ритуальные строчки, которые не вредят, не помогают и создают ощущение проделанной работы.
Эта статья — против обеих крайностей. Метод всё тот же, серийный: сначала измеряем, где именно теряется время, потом крутим ровно ту ручку, которая на это время влияет. По дороге разберём четыре ручки из подзаголовка — pm.max_children, opcache, gzip/brotli, keepalive — честно, с ожидаемым эффектом в цифрах. А в конце — галерея карго-культа, чтобы вы узнавали его в лицо.
Шаг ноль: где тормозит? Одна команда и одна строчка в конфиге
Слово «тормозит» описывает минимум четыре разные болезни, и лечатся они в разных местах. Разложить время ответа по полочкам умеет обычный curl:
curl -o /dev/null -s -w "DNS: %{time_namelookup}s\nTCP+TLS: %{time_appconnect}s\nTTFB: %{time_starttransfer}s\nВсего: %{time_total}s\n" https://ваш-сайт/
DNS: 0.021s
TCP+TLS: 0.183s
TTFB: 1.914s
Всего: 2.187s
Читаем: до сервера долетели быстро, а потом почти две секунды ждали первого байта (TTFB — time to first byte). Это время сервер думал: исполнял PHP и ходил в базу. Вот вам и диагноз в первом приближении:
- Большой TTFB — тормозит бэкенд: PHP, база, — 90% реальных случаев. Наша сегодняшняя (и следующая) статья.
- TTFB маленький, а грузится долго — тяжёлая страница: мегабайты картинок и скриптов. Это лечится сжатием (ниже) и работой над фронтендом, сервер тут почти ни при чём.
- Долгий TCP+TLS — сеть/география или проблемы с TLS-сессиями; редкий зверь.
Чтобы видеть эту картину постоянно, а не разово, научим nginx записывать в access.log, сколько думал бэкенд. В nginx.conf:
log_format timed '$remote_addr [$time_local] "$request" $status '
'rt=$request_time urt=$upstream_response_time';
и access_log ... timed; в нужном хосте. Теперь у каждого запроса видно rt (всё время) и urt (сколько из него занял PHP-FPM). Если rt=2.1 urt=2.0 — nginx невиновен, всё время съел бэкенд; тюнить nginx в этой ситуации — красить забор горящего дома. Это, кстати, главный вывод статьи, вынесу его жирным: на типичном PHP-сайте почти всё время ответа живёт в PHP и базе. Nginx отдаёт статику и проксирует за микросекунды — там почти нечего ускорять.
Ручка 1: opcache — самый большой выигрыш за пять минут
PHP — интерпретатор: на каждый запрос он читает исходники, парсит и компилирует в байткод. Для приложения на фреймворке это сотни файлов на каждый клик. Opcache кеширует байткод в памяти — компиляция происходит один раз, а не тысячу раз в минуту. Эффект на реальных приложениях — ускорение в разы; это самая недооценённая галочка в PHP просто потому, что все уверены, что она уже включена. Проверяем:
php -i | grep opcache.enable
В современных пакетах он обычно включён, но со скромными лимитами. Рабочий набор в /etc/php/8.3/fpm/conf.d/10-opcache.ini:
opcache.enable = 1
opcache.memory_consumption = 192 ; МБ под байткод; фреймворку 128 мало
opcache.max_accelerated_files = 20000 ; файлов в кэше; у Laravel их тысячи
opcache.validate_timestamps = 1 ; проверять, не изменился ли файл
opcache.revalidate_freq = 60 ; ...не чаще раза в 60 секунд
Про последнюю пару — важная развилка. validate_timestamps = 0 — режим максимальной скорости: PHP вообще не проверяет файлы на изменения, кэш живёт до перезапуска. Минус: после деплоя изменения «не применяются», пока вы не сбросите кэш (systemctl reload php8.3-fpm) — источник многочасовых недоумений «я же исправил, почему не работает?!». Честная рекомендация: держите 1 с revalidate_freq = 60, а на 0 переходите, когда у вас появится автоматический деплой со сбросом кэша в конце — как раз тема тринадцатой статьи.
Ручка 2: pm.max_children — не «побольше», а по расчёту
В прошлой статье мы раздали пулам лимиты воркеров на глазок; пришло время науки. Симптом голодания по воркерам ищется в логе PHP-FPM одной строкой:
grep max_children /var/log/php8.3-fpm.log
WARNING: [pool site1] server reached pm.max_children setting (8), consider raising it
Вот она — очередь у кассы: посетителей больше, чем воркеров, лишние ждут (растёт TTFB) или получают 502/504. Рефлекс «поставлю 50 и забуду» — ловушка: 50 воркеров по 60 МБ — это 3 ГБ только на один пул, и здравствуй, OOM killer из первой статьи. Расчёт на салфетке:
# сколько реально ест один воркер этого пула (RSS, в МБ):
ps -o rss= -C php-fpm8.3 | awk '{sum+=$1; n++} END {print sum/n/1024 " MB"}'
Дальше арифметика: (RAM всего − MySQL − nginx − система, ~25% про запас) / размер воркера = потолок суммы max_children по всем пулам. VPS на 4 ГБ, MySQL просит полтора, воркер весит 60 МБ: (4096 − 1536 − 512) / 60 ≈ 34 воркера на всех. Распределяете по важности сайтов — и это число обосновано, а не нагугленно.
И второй уровень той же ручки — режим менеджера. pm = dynamic (дефолт) держит запас тёплых воркеров — хорош для основного сайта. pm = ondemand поднимает воркеры только под запросы и убивает после простоя — идеален для staging и сайтов-визиток из нашей «коммуналки»: не занимают память впустую. pm = static — все воркеры всегда — имеет смысл на выделенном под одно приложение сервере, на общем VPS это расточительство.
Но вот важное: если воркеры упираются в потолок постоянно, правильный вопрос не «как поднять лимит», а «почему воркеры заняты так долго?» Воркер, отвечающий за 100 мс, обслужит 10 запросов в секунду; воркер, висящий 3 секунды, — 0,3. Достаём свидетеля обвинения, заготовленного в прошлой статье, — slowlog:
tail -50 /var/log/php/site1.slow.log
Там — трейсы запросов дольше 5 секунд, и в подавляющем большинстве трейс замрёт на строчке с mysqli_query/PDO->execute. Воркеры не работают — они ждут базу. Поднимать их число в этой ситуации — ставить больше касс, в которых кассиры звонят на склад. Лечить надо склад — и это целиком следующая, десятая статья.
Ручка 3: gzip и brotli — честный, но скромный выигрыш
Теперь про «TTFB маленький, грузится долго». Сжатие текстовых ответов (HTML, CSS, JS, JSON) режет их в 3–5 раз — для посетителя с мобильным интернетом это ощутимо. Nginx из коробки обычно сжимает только HTML; полноценный конфиг:
gzip on;
gzip_comp_level 5; # выше 6 — рост CPU без заметного выигрыша
gzip_min_length 1024; # мелочь сжимать дороже, чем отдать
gzip_types text/css application/javascript application/json
image/svg+xml application/xml;
gzip_vary on;
Три оговорки, отделяющие пользу от культа. Первая: не сжимайте картинки и видео — jpeg/png/webp уже сжаты, гонять их через gzip — греть CPU ради +0%. Вторая: brotli жмёт на 15–20% лучше gzip, но модуля нет в стандартном пакете nginx — придётся ставить сторонний. Стоит ли? Если ставится легко — да; если ради него надо пересобирать nginx — окупаемость сомнительна, лучше включите режим brotli_static/gzip_static: сжать файлы заранее при деплое и отдавать готовые, ноль CPU в рантайме. Третья: сжатие ускоряет доставку, но никак не лечит медленный бэкенд — если TTFB две секунды, gzip сэкономит вам полсекунды на передаче и оставит все две на ожидании.
Ручка 4: keepalive — почти всегда уже в порядке
Включаю в разбор, потому что он в каждом «топ-10 настроек», — и именно тут больше всего культа. Keepalive — это переиспользование TCP-соединения для нескольких запросов, экономия на рукопожатиях (особенно TLS). Правда состоит из двух частей:
Клиентский keepalive (браузер ↔ nginx) в nginx включён по умолчанию (keepalive_timeout 65). Ритуальное прописывание этой строчки в конфиг ничего не меняет — она уже там. Единственный осмысленный тюнинг тут — HTTP/2 (listen 443 ssl http2;), который мультиплексирует запросы в одном соединении: вот это реально ускоряет загрузку страниц с кучей ресурсов, одна строчка — включите, если ещё нет.
Апстримный keepalive (nginx ↔ бэкенд) имеет смысл, когда nginx проксирует по TCP на другой сервис. Наш PHP-FPM подключён через unix-сокет на той же машине — рукопожатий там и так нет, экономить нечего. Если же вы проксируете на node/python-бэкенд по proxy_pass http://... — вот там upstream с keepalive 16; даёт измеримый эффект.
Галерея карго-культа
Настройки, кочующие из статьи в статью. Общее свойство: на маленьком VPS их эффект — от нуля до отрицательного, но выглядят они внушительно.
| Ритуал | Реальность |
|---|---|
worker_processes 8; руками |
Дефолт auto уже ставит по числу ядер. Больше ядер — не быстрее, а конкуренция |
worker_connections 65535; |
Дефолтных 768×ядра хватает на тысячи одновременных клиентов. Ваша проблема не здесь |
Простыня *_buffer_size из чужого блога |
Подобраны под чужой сайт 2014 года. Дефолты nginx разумны; менять — только от конкретной ошибки в error.log |
access_log off; «для скорости» |
Экономит микросекунды, отнимает диагностику. После второй статьи серии — просто кощунство |
multi_accept on; use epoll; |
Epoll и так используется; multi_accept на малой нагрузке неотличим от плацебо |
| Отключить swap «чтобы не тормозил» | Swap — страховка от OOM. Без него вместо замедления получите убийство процессов |
| tcp_nopush/tcp_nodelay «магические пары» | Включены/уместны по умолчанию с sendfile; переписывание их — чистый ритуал |
«Поднять memory_limit PHP до 2G» |
Лечит симптом кривого кода и приближает OOM. Лимит — предохранитель, а не педаль газа |
Как отличать культ от пользы в будущем, без меня: у осмысленной настройки всегда есть измеримый до/после и симптом, который она лечит. «Все так делают» симптомом не является.
Итоговая иерархия: куда смотреть по порядку
Если выстроить всё по убыванию эффекта на типичном PHP-сайте, получится честный список приоритетов:
- База данных — медленные запросы, отсутствие индексов: секунды. (Статья 10.)
- Кэширование — не считать заново то, что не изменилось: превращает секунды в миллисекунды. (Статья 11.)
- Opcache — разы, пять минут работы.
- pm.max_children по расчёту — лечит очереди и 502 под нагрузкой.
- HTTP/2 + gzip — десятки процентов на доставке.
- Всё остальное из «топ-50 настроек» — проценты или плацебо.
Заметьте: два первых места занимают темы, которых мы ещё не касались, — и обе впереди. Сервер, где включён opcache, посчитаны воркеры и настроено сжатие, честно отдаёт всё, что позволяет приложение. Дальше ускорять надо само приложение — и начинается это со склада, на который звонят все кассиры. В следующей статье идём в MySQL: почему база на маленьком VPS съедает всю память, что такое innodb_buffer_pool и как slow query log находит те самые запросы, на которых висели наши воркеры.
- 1 Сервер упал в три ночи: разбор полётов без паники
- 2 Логи, которые говорят: учимся читать journalctl, nginx и syslog
- 3 Fail2ban и брутфорс: как перестать кормить ботов
- 4 Мониторинг для одного сервера: Uptime Kuma, Netdata и здравый смысл
- 5 Диск закончился внезапно: куда деваются гигабайты на VPS
- 6 Обновления без страха: apt upgrade, который ничего не сломает
- 7 Свой VPN на VPS за полчаса: WireGuard без магии
- 8 Несколько сайтов на одном VPS: делим ресурсы без драки
- 9 Тормозит сайт — виноват не хостинг: тюним nginx и PHP-FPM вы здесь
- 10 MySQL на маленьком VPS: почему база съедает всю память скоро
- 11 Кэширование на пальцах: Redis, FastCGI cache и когда хватит просто заголовков скоро
- 12 Cron, systemd-таймеры и скрипты: автоматизируем рутину сервера скоро
- 13 Деплой без FTP в 2026-м: git pull, хуки и простой CI/CD на своём VPS скоро
- 14 Переезд на другой VPS без даунтайма: чек-лист миграции скоро
Была статья полезной: