В этом уроке разберём nginx тюнинг как инженерную дисциплину: сначала измеряем, потом меняем одну вещь, снова измеряем. Пройдём по всей цепочке узких мест - воркеры, соединения, ядро, файлы, буферы, TLS, кэш - и в конце соберём чек-лист, по которому ты выжмешь из железа максимум без шаманства. Тема большая, поэтому держим темп: nginx производительность складывается из десятка слоёв, и каждый слой может стать бутылочным горлышком.
Главный принцип: сначала измерь, потом тюнь
Запомни это до того, как тронешь хоть одну директиву. У тебя есть три типа узких мест: CPU, IO (диск/сеть) и сам код бэкенда. nginx оптимизация одного слоя бессмысленна, если бутылочное горлышко в другом. Поднимать worker_connections, когда упирается диск - всё равно что расширять въезд на парковку, у которой забит выезд.
Как понять, где жмёт? Смотри метрики, не догадки.
Код: Выделить всё
top # CPU воркеров. Если nginx ест 100% одного ядра - упор в CPU (часто TLS или gzip)
iostat -x 1 # %util диска. Близко к 100 - упор в IO
ss -s # сводка по сокетам, сколько в TIME-WAIT
ss -ntl # слушающие сокеты и их Recv-Q (переполнение accept-очереди)

Воркеры и соединения: фундамент под нагрузку
nginx - это набор воркер-процессов, каждый из которых обрабатывает тысячи соединений в одном потоке через epoll. Базовая настройка:
Код: Выделить всё
# в главном контексте nginx.conf
worker_processes auto; # по числу ядер CPU, не больше
worker_rlimit_nofile 65535; # лимит файловых дескрипторов на воркер
events {
worker_connections 10240; # одновременных соединений на один воркер
multi_accept on; # забирать все готовые соединения за раз
use epoll; # на Linux - явно epoll
}
worker_connections 10240 - сколько соединений тянет один воркер. Тут главная грабля новичков: это НЕ число клиентов. Когда nginx проксирует, на одного клиента уходит два дескриптора - сокет к клиенту и сокет к апстриму. Плюс расход на статику, кэш, логи. Реальный потолок клиентов при проксировании - примерно worker_processes * worker_connections / 2.
worker_rlimit_nofile обязан быть больше worker_connections, иначе воркер упрётся в лимит ОС раньше, чем в свой лимит соединений, и ты получишь "Too many open files" в error_log. Эмпирика: ставь его в 2-4 раза больше worker_connections. И помни - этот лимит сверху ограничен системным (проверь "cat /proc/sys/fs/file-max" и ulimit для процесса).
Если в логах появилось "worker_connections are not enough" - это сигнал nginx worker_connections поднять и параллельно проверить, не утекают ли соединения к медленному апстриму.
Сеть и ядро: sysctl, reuseport и accept-очередь
nginx живёт поверх TCP-стека ядра. Можно идеально настроить воркеры, но если ядро роняет соединения на входе - всё впустую. При высоком rps первым переполняется accept backlog: очередь установленных соединений, которые ждут, когда воркер сделает accept().
Код: Выделить всё
# /etc/sysctl.d/99-nginx.conf
net.core.somaxconn = 65535 # потолок accept-очереди (по умолчанию мизерные 4096)
net.core.netdev_max_backlog = 65535 # очередь пакетов от сетевухи к стеку
net.ipv4.tcp_max_syn_backlog = 65535 # очередь полуоткрытых (SYN) соединений
net.ipv4.tcp_tw_reuse = 1 # переиспользовать сокеты в TIME-WAIT для исходящих
net.ipv4.ip_local_port_range = "1024 65535" # больше эфемерных портов к апстримам
fs.file-max = 2097152 # глобальный потолок дескрипторов
Код: Выделить всё
server {
listen 443 ssl reuseport backlog=65535;
http2 on;
# ...
}
Грабля: про somaxconn забывают, ставят backlog=65535 в nginx, а ядро молча режет его до своих 4096. Поднимай оба, и обязательно проверь Recv-Q слушающего сокета через "ss -ntl" - ненулевые значения там означают, что accept-очередь переполняется и соединения теряются.
Keepalive: дешёвый множитель, который часто забывают
Установка TCP-соединения и TLS-хендшейк - дорогие. Если на каждый запрос их делать заново, ты сжигаешь CPU и латентность на ровном месте. Keepalive переиспользует соединения. Есть две стороны - клиент и апстрим, и их часто путают.
Со стороны клиента:
Код: Выделить всё
http {
keepalive_timeout 65s; # держать клиентское соединение открытым
keepalive_requests 10000; # запросов на одно соединение, потом переоткрыть
}
Теперь апстрим - и тут важное обновление 2026 года. Начиная с nginx 1.29.7 (вошло в stable 1.30) соединение к бэкенду по умолчанию идёт по HTTP/1.1 с keepalive. Раньше дефолтом был HTTP/1.0 с закрытием соединения после каждого запроса - и это годами било по производительности у тех, кто не знал. Теперь из коробки лучше. Но для совместимости со старыми версиями и для явности всё равно держи в блоке upstream:
Код: Выделить всё
upstream backend {
server 10.0.0.1:9000;
server 10.0.0.2:9000;
keepalive 64; # пул idle-соединений к апстриму на воркер
}
server {
location /api/ {
proxy_pass http://backend;
proxy_http_version 1.1; # обязательно для keepalive к апстриму
proxy_set_header Connection ""; # убрать заголовок close
}
}
Файлы, буферы и сжатие: где утекают CPU и память
Отдача статики. Тут работают три директивы в связке:
Код: Выделить всё
http {
sendfile on; # отдавать файл из page cache в сокет без копий в userspace
tcp_nopush on; # копить данные и слать полными пакетами (только с sendfile)
tcp_nodelay on; # для keepalive - не задерживать мелкие пакеты (алгоритм Нейгла)
open_file_cache max=10000 inactive=60s; # кэш дескрипторов и метаданных
open_file_cache_valid 60s;
open_file_cache_min_uses 2;
open_file_cache_errors on;
}
open_file_cache держит открытые дескрипторы и метаданные статических файлов в памяти. Под высоким rps статики это убирает тысячи лишних syscall open()/stat() в секунду. Грабля: при inactive 60s обновлённый на диске файл может отдаваться из кэша до минуты - на проде с частыми деплоями статики уменьшай или сбрасывай кэш.
Для отдачи больших файлов (видео, дистрибутивы) sendfile блокирует воркер на время чтения с диска - один медленный диск тормозит всех клиентов воркера. Лечится асинхронным IO через thread pool:
Код: Выделить всё
aio threads; # читать с диска в отдельном пуле потоков, не блокируя воркер
directio 16m; # файлы больше 16м читать в обход page cache
output_buffers 2 1m;
Код: Выделить всё
client_body_buffer_size 16k;
client_header_buffer_size 1k;
client_max_body_size 10m; # отбивать гигантские аплоады
proxy_buffer_size 16k; # под заголовки ответа апстрима
proxy_buffers 16 16k; # под тело ответа: 16 буферов по 16к = 256к на соединение
proxy_busy_buffers_size 32k;
Сжатие - чистый размен CPU на трафик. gzip_comp_level от 1 до 9, но кривая нелинейная: с 1 до 5 размер падает заметно, а с 5 до 9 - почти не меняется, зато CPU растёт резко. Под нагрузкой держи уровень 4-5, не 9.
Код: Выделить всё
gzip on;
gzip_comp_level 5; # не 9 - на больших уровнях CPU не окупается
gzip_min_length 1024; # мелочь сжимать невыгодно
gzip_vary on;
gzip_types text/plain text/css application/json application/javascript application/xml;
TLS под нагрузкой: ECDSA вместо RSA и кэш сессий
TLS-хендшейк - одна из самых прожорливых операций по CPU. Два рычага дают наибольший эффект.
Первый - кэш TLS-сессий. Полный хендшейк делает асимметричную криптографию (дорого). Возобновлённая сессия из кэша - дёшево. Включай разделяемый кэш:
Код: Выделить всё
ssl_session_cache shared:SSL:50m; # ~200000 сессий, общий для всех воркеров
ssl_session_timeout 1d;
ssl_session_tickets off; # тикеты без ротации ключей - риск forward secrecy
ssl_protocols TLSv1.2 TLSv1.3;
Код: Выделить всё
ssl_certificate /etc/ssl/ecdsa/fullchain.pem; # современные клиенты возьмут ECDSA
ssl_certificate_key /etc/ssl/ecdsa/privkey.pem;
ssl_certificate /etc/ssl/rsa/fullchain.pem; # legacy-клиенты - RSA
ssl_certificate_key /etc/ssl/rsa/privkey.pem;
Кэш как множитель и нагрузочное тестирование
Самый дешёвый запрос - тот, что не дошёл до бэкенда. Кэш ответов проксирования - это не оптимизация на проценты, это множитель в разы. Запрос из кэша отдаётся за микросекунды без участия апстрима и БД.
Код: Выделить всё
proxy_cache_path /var/cache/nginx levels=1:2 keys_zone=app:128m
inactive=60m max_size=10g use_temp_path=off;
location /api/ {
proxy_pass http://backend;
proxy_cache app;
proxy_cache_valid 200 1h;
proxy_cache_lock on; # против dog-pile: один запрос греет кэш, остальные ждут
proxy_cache_background_update on; # отдавать просрочку, обновляя в фоне
proxy_cache_use_stale error timeout updating; # при сбое апстрима отдать устаревшее
add_header X-Cache-Status $upstream_cache_status always; # HIT/MISS/EXPIRED в ответе
}
Теперь главное - как понять, что тюнинг сработал. Только числами. И мерить надо НЕ среднее, а перцентили. Среднее (mean) прячет хвост: при среднем 50 мс твой p99 может быть 3 секунды, и именно эти 1% самых медленных запросов - это разъярённые пользователи. p50 (медиана) - типичный запрос, p95 - почти все, p99 - худший процент, где живёт реальная боль.
Код: Выделить всё
# wrk: 4 потока, 200 соединений, 30 секунд, с распределением latency
wrk -t4 -c200 -d30s --latency https://example.com/api/
# фрагмент вывода:
# Latency Distribution
# 50% 12.30ms
# 75% 18.40ms
# 90% 31.10ms
# 99% 145.00ms <- хвост: вот сюда смотри
# Requests/sec: 16240.55
k6 удобнее для сценариев со сложной логикой и порогами:
Код: Выделить всё
import http from 'k6/http';
export const options = {
vus: 200, duration: '30s',
thresholds: { http_req_duration: ['p(95)<200', 'p(99)<500'] }, // тест упадёт, если хуже
};
export default function () {
http.get('https://example.com/api/');
}
Мини-лаба: тюнинг под высокий rps по шагам
Повтори руками. Нужен nginx и вторая машина (или контейнер) под генератор нагрузки.
- Сними базовую линию ДО любых правок: с отдельной машины запусти wrk -t4 -c200 -d30s --latency на свой endpoint. Запиши Requests/sec, p50, p95, p99. Это твоя точка отсчёта.
- Параллельно на сервере держи открытыми top (CPU воркеров) и "ss -ntl" (Recv-Q слушающего сокета). Определи по ним, где жмёт: CPU, accept-очередь или апстрим.
- Поправь воркеры: worker_processes auto, worker_rlimit_nofile 65535, worker_connections 10240, multi_accept on. Сделай nginx -t и nginx -s reload. Прогони wrk снова, сравни числа.
- Включи keepalive к апстриму: в upstream добавь keepalive 64, в location - proxy_http_version 1.1 и proxy_set_header Connection "". Подними keepalive_requests 10000. Reload, замерь - latency хвоста должна просесть.
- Подними ядро: somaxconn и tcp_max_syn_backlog до 65535 через sysctl, добавь backlog=65535 и reuseport в listen. Reload, замерь - под высоким -c рост rps будет заметнее всего.
- Сравни RSA и ECDSA: прогони wrk по HTTPS-endpoint с RSA-сертификатом, потом переключи на ECDSA, снова прогони. Посмотри на CPU воркеров и rps на хендшейках.
- Включи proxy_cache на кэшируемый GET. Прогони wrk - rps по кэшируемому пути должен подскочить в разы, апстрим почти не нагружен. Проверь заголовок X-Cache-Status: HIT.
- Меняй по ОДНОМУ параметру за прогон. Если поменял три вещи разом и стало лучше - ты не знаешь, какая из них помогла, а какая навредила.
- Почему реальный потолок клиентов при проксировании - это примерно (worker_processes * worker_connections) / 2, а не worker_processes * worker_connections?
- Что изменилось в nginx 1.30 в поведении соединения к апстриму по умолчанию и почему директива keepalive в upstream всё равно обязательна?
- Ты выставил backlog=65535 в listen, но соединения под нагрузкой всё равно теряются. Какой sysctl ты забыл и как это проверить?
- Почему при тюнинге надо смотреть на p99, а не на среднее время ответа, и о чём говорит ситуация "p50 в норме, p99 улетел в небо"?
nginx производительность - это не один волшебный флаг, а отсутствие узких мест в цепочке: воркеры -> ядро/сеть -> keepalive -> файлы и буферы -> TLS -> кэш. Чек-лист тюнинга: worker_processes auto и worker_rlimit_nofile; keepalive к клиенту и к апстриму (помни про дефолт HTTP/1.1 с 1.30); sysctl somaxconn и backlog с reuseport; разумные буферы под твою память; sendfile и open_file_cache на статике; TLS с session cache и ECDSA вместо RSA; кэш как множитель. И главное правило, без которого весь список вредит: сначала измерь wrk или k6 по p95/p99, поменяй ОДНУ вещь, измерь снова. Не тюнь вслепую - меряй.