Представь старый Apache prefork: пришёл клиент - выделили процесс. Пришла тысяча клиентов - тысяча процессов, каждый со своим стеком, своим контекстом, своим куском памяти. Ядро мечется между ними, переключая контекст десятки тысяч раз в секунду, и большую часть времени эти процессы просто СПЯТ, ожидая, пока клиент дошлёт байты по медленному мобильному каналу. Память кончается, планировщик задыхается, сервер падает на нескольких тысячах соединений. Это и есть та боль, ради которой родился nginx.
Архитектура nginx устроена принципиально иначе. Тут не "один клиент - один поток", а горстка процессов-воркеров, каждый из которых неблокирующе жонглирует тысячами соединений одновременно. Один воркер на одно ядро CPU, никакой возни с тредами на каждый запрос, минимум переключений контекста. Именно поэтому связка master worker nginx спокойно тянет десятки тысяч одновременных соединений на железе, на котором тредовый сервер захлебнулся бы. В этом уроке разберём по косточкам: кто такой master, кто такой worker, как внутри воркера крутится event loop, и почему все вместе это масштабируется.

Master и worker: кто за что отвечает
После старта ты увидишь два сорта процессов. Master (главный) - один. Он НЕ обрабатывает клиентские запросы вообще. Его задачи привилегированные и редкие: прочитать и распарсить конфиг, забиндить слушающие сокеты на порты 80/443 (для этого нужен root, привязка к портам ниже 1024), а затем породить воркеров и присматривать за ними. Если воркер упал - master поднимет новый. Master читает твои сигналы (reload, остановка) и дирижирует.
Worker (рабочий) - их обычно несколько. Вот они и делают всю работу: принимают соединения с уже открытых master-ом сокетов, читают запросы, проксируют на бэкенд, отдают ответы, пишут логи. Воркеры запускаются под непривилегированным пользователем (обычно nginx или www-data) - даже если в воркере найдут дыру, атакующий не получит root. Слушающие сокеты унаследованы от master через fork(), поэтому воркеру уже не нужны привилегии, чтобы принимать на них соединения.
Посмотрим на реальный nginx процессы вывод. Запусти ps и сам увидишь иерархию:
Код: Выделить всё
$ ps -eo pid,ppid,user,%cpu,rss,cmd --sort=ppid | grep -E 'nginx' | grep -v grep
1 0 root 0.0 6400 nginx: master process /usr/sbin/nginx -g daemon on; master_process on;
842 1 nginx 0.3 12800 nginx: worker process
843 1 nginx 0.4 13056 nginx: worker process
844 1 nginx 0.2 12544 nginx: worker process
845 1 nginx 0.5 13312 nginx: worker process
846 1 nginx 0.0 5120 nginx: cache manager process
Сколько воркеров и сколько соединений: worker_processes и worker_connections
Главная директива - nginx worker_processes. Она задаёт, сколько рабочих процессов поднять. Канонический ответ на 2026 год:
Код: Выделить всё
# в корне nginx.conf, вне http{}
worker_processes auto;
Вторая директива живёт уже внутри блока events и отвечает на вопрос "сколько соединений тянет ОДИН воркер":
Код: Выделить всё
events {
worker_connections 10240;
multi_accept on;
use epoll;
}
Код: Выделить всё
max_connections = worker_processes * worker_connections
# например 4 * 10240 = 40960
И ещё одна обязательная связка. Каждое соединение в Linux - это файловый дескриптор. Если worker_connections 10240, а лимит дескрипторов на процесс 1024 (дефолт многих систем), воркер упрётся в потолок ОС задолго до своего номинала, и в логах посыплется "Too many open files". Лечится директивой:
Код: Выделить всё
worker_rlimit_nofile 65535;
Event loop вглубь: epoll, kqueue и почему это масштабируется
Вот сердце всего. nginx event loop - это бесконечный цикл внутри каждого воркера, который вместо "жди на этом сокете" говорит ядру "разбуди меня, когда ХОТЬ ЧТО-ТО из тысяч моих сокетов будет готово". Механизм этого опроса - мультиплексор событий ОС.
На Linux это epoll (директива use epoll, как в эталоне). На FreeBSD и macOS - kqueue. На старых системах был медленный select/poll, но их давно не используют. Современный nginx и сам выбирает лучший доступный метод, но явный use epoll в events делает намерение читаемым и убирает любые сомнения.
В чём фокус epoll. Старый select работал за O(n): чтобы узнать, какой из тысячи сокетов готов, ядро каждый раз линейно перебирало весь список. На 10 тысячах соединений это смерть. epoll работает за O(1) относительно числа простаивающих соединений: ты один раз регистрируешь сокеты через epoll_ctl, а потом epoll_wait возвращает только те, на которых РЕАЛЬНО что-то случилось. Тысячи дремлющих keepalive-соединений не стоят почти ничего - они просто висят в ядре и не отъедают CPU.
Псевдокод того, что крутится в воркере, помогает увидеть суть:
Код: Выделить всё
// упрощённая модель event loop одного воркера
for (;;) {
events = epoll_wait(epfd, ...); // спим, пока ядро не разбудит
for (e in events) {
if (e.listen_ready) accept_new_connection(); // новый клиент
if (e.read_ready) read_request_nonblocking(); // байты пришли
if (e.write_ready) send_response_nonblocking();// можно слать
if (e.timer_expired) handle_timeout(); // таймаут
}
process_timers(); // proxy_read_timeout, keepalive_timeout и т.п.
}
multi_accept on (тоже из эталона) меняет поведение при пробуждении: без неё воркер за один проход цикла принимает одно новое соединение, с ней - принимает в цикле ВСЕ ожидающие в очереди accept, пока она не опустеет. На всплесках подключений это снижает число итераций event loop и уменьшает задержку приёма. Для нагруженного шлюза - разумный дефолт.
Осталось разобраться, как несколько воркеров делят ОДИН слушающий сокет, не передравшись. Тут два механизма. Старый - accept_mutex: воркеры по очереди берут мьютекс, и только держатель принимает новые соединения; это лечило "громовое стадо" (thundering herd), когда новое соединение будило ВСЕ воркеры разом, а доставалось одному. На 2026 год accept_mutex по умолчанию OFF, потому что современные ядра дают флаг EPOLLEXCLUSIVE: ядро само будит только один воркер, мьютекс в userspace больше не нужен.
Новый и более мощный механизм - SO_REUSEPORT через параметр reuseport:
Код: Выделить всё
http {
server {
listen 443 ssl reuseport; # reuseport ставится ОДИН раз на сокет
http2 on;
# ...
}
}
Graceful reload: как менять конфиг без обрыва соединений
Самая ценная практическая суперсила этой архитектуры - перечитать конфиг без единого сброшенного запроса. Команда:
Код: Выделить всё
$ nginx -t && nginx -s reload
nginx: the configuration file /etc/nginx/nginx.conf syntax is ok
nginx: configuration file /etc/nginx/nginx.conf test is successful
- master перечитывает и валидирует новый конфиг; если там ошибка - НИЧЕГО не меняется, старые воркеры работают как ни в чём не бывало, ты просто получишь ошибку в stderr/логе;
- если конфиг валиден, master заново биндит сокеты (если порты сменились) и порождает НОВЫЕ воркеры уже с новой конфигурацией;
- новым воркерам master отдаёт все свежие входящие соединения;
- СТАРЫМ воркерам master шлёт сигнал на изящное завершение - они перестают принимать новые соединения, но ДОРАБАТЫВАЮТ уже начатые запросы до конца;
- как только старый воркер дообслужил последнего клиента, он сам тихо умирает.
Код: Выделить всё
$ ps -eo pid,ppid,user,cmd | grep nginx | grep -v grep
1 0 root nginx: master process /usr/sbin/nginx
842 1 nginx nginx: worker process is shutting down
843 1 nginx nginx: worker process is shutting down
1501 1 nginx nginx: worker process
1502 1 nginx nginx: worker process
Сигналы управления: HUP, USR1, USR2, QUIT
Под красивыми командами nginx -s ... лежат обычные unix-сигналы master-у. Знать их полезно, потому что в скриптах и при отладке ты будешь слать их напрямую через kill:
- HUP - reload конфига (то самое graceful, разобрано выше). Эквивалент nginx -s reload;
- USR1 - переоткрыть лог-файлы. Главный сценарий: ротация логов. logrotate переименовал access.log в access.log.1, ты шлёшь USR1, nginx закрывает старые дескрипторы и открывает файлы заново по их именам - без этого он продолжал бы писать в уже переименованный файл по старому inode. Эквивалент nginx -s reopen;
- USR2 - горячее обновление бинарника nginx без обрыва соединений. Master форкает новый master из нового бинарника, оба работают параллельно, потом старый гасится. Так апгрейдят сам nginx на лету;
- QUIT - изящная остановка: воркеры дорабатывают запросы и завершаются. Эквивалент nginx -s quit. Его злой брат TERM/INT - это быстрая остановка с обрывом всего здесь и сейчас (nginx -s stop).
Код: Выделить всё
$ cat /run/nginx.pid
1
$ kill -HUP $(cat /run/nginx.pid) # то же, что nginx -s reload
$ kill -USR1 1 # переоткрыть логи после ротации
- worker_connections без worker_rlimit_nofile. Ставят 10240 соединений, а лимит дескрипторов ОС остаётся 1024 - и сервер ложится с "Too many open files" задолго до проектной нагрузки. Всегда поднимай worker_rlimit_nofile вместе.
- Считают worker_connections как число клиентов на проксе. Забывают про второй сокет к апстриму и удивляются, почему ёмкость вдвое меньше ожидаемой. На реверс-прокси дели потолок на два.
- worker_processes больше числа ядер "чтобы быстрее". Эффект обратный: воркеры конкурируют за CPU, растут переключения контекста. auto почти всегда правильный ответ.
- restart вместо reload в проде. Рвут живые соединения там, где хватило бы бесшовного HUP. Restart нужен только когда меняешь то, что нельзя подхватить на лету (например worker_processes на некоторых сборках или загруженные модули).
- reload без nginx -t. Если новый конфиг битый, на reload master его отвергнет и продолжит на старом - но если ты вместо reload сделал restart с битым конфигом, nginx просто не поднимется и ляжет весь сайт. Привычка nginx -t && nginx -s reload спасает.
- reuseport в каждом server-блоке. Параметр на адрес:порт ставится РОВНО один раз, дубли дают ошибку запуска.
Повтори по шагам на любой тестовой машине с nginx (можно в Docker, образ как в эталоне - openresty/openresty:latest или nginx:stable).
- Шаг 1. Запусти nginx и посмотри иерархию: Найди master (root, PPID других процессов) и воркеры (непривилегированный user, PPID = pid master). Запиши, сколько воркеров - оно должно совпасть с числом ядер (nproc).
Код: Выделить всё
ps -eo pid,ppid,user,cmd | grep nginx | grep -v grep - Шаг 2. Открой nginx.conf, поставь явно и в events
Код: Выделить всё
worker_processes 2;Проверь конфиг:Код: Выделить всё
worker_connections 4096; use epoll; multi_accept on;Код: Выделить всё
nginx -t - Шаг 3. Перечитай конфиг бесшовно: Тут же снова сделай ps. Если успеешь в первую секунду - поймаешь старые воркеры со статусом "is shutting down" рядом с новыми. Убедись, что теперь воркеров ровно два.
Код: Выделить всё
nginx -s reload - Шаг 4. Проверь лимит дескрипторов воркера: возьми PID любого воркера и глянь Добавь в nginx.conf
Код: Выделить всё
cat /proc/<PID>/limits | grep "open files"сделай reload и убедись, что Max open files у нового воркера вырос.Код: Выделить всё
worker_rlimit_nofile 65535; - Шаг 5. Симулируй ротацию логов: переименуй access.log, пошли (это USR1) и проверь, что nginx создал новый access.log и пишет в него.
Код: Выделить всё
nginx -s reopen
Контрольные вопросы
- Почему один воркер на event loop с epoll обслуживает тысячи соединений эффективнее, чем тысяча потоков, каждый на своём соединении? В чём именно экономия?
- У тебя worker_processes 4 и worker_connections 10240. Сколько примерно ОДНОВРЕМЕННЫХ клиентов выдержит сервер, если он работает реверс-прокси, и почему не 40960?
- Чем graceful reload (HUP) отличается от restart с точки зрения активных соединений? Что произойдёт, если новый конфиг при reload окажется синтаксически битым?
- Зачем нужна worker_rlimit_nofile и что случится, если задрать worker_connections, забыв про неё?
Архитектура nginx держится на трёх китах. Master - привилегированный дирижёр: читает конфиг, биндит порты, рулит воркерами и обрабатывает сигналы, но сам запросы не трогает. Worker - неблокирующая рабочая лошадка: один на ядро (worker_processes auto), каждый через event loop на epoll/kqueue (use epoll, multi_accept on) тянет тысячи соединений практически бесплатно по памяти и CPU. Потолок ёмкости - worker_processes умножить на worker_connections, не забывая делить на два для прокси и поднимать worker_rlimit_nofile под число дескрипторов. И венчает всё graceful reload по сигналу HUP: меняешь конфиг без единого оборванного запроса, потому что старые воркеры дорабатывают своё, а новые принимают свежий трафик. Это фундамент - дальше мы будем строить на нём location-роутинг, проксирование и кэш.