1. Основы протокола HTTP и многопроцессорная архитектура Master-Worker
Основы протокола HTTP и многопроцессорная архитектура Master-Worker
В 1999 году инженер Дэн Кегель сформулировал проблему, которая на долгие годы определила вектор развития веб-серверов: «Проблема C10K». Суть заключалась в том, что серверное оборудование того времени уже обладало достаточной вычислительной мощностью, чтобы обрабатывать 10 000 одновременных соединений, но программное обеспечение с этой задачей не справлялось. Традиционные веб-серверы падали под нагрузкой, исчерпывая оперативную память или полностью утилизируя процессор исключительно на переключение контекста между задачами. Решение этой проблемы потребовало радикального пересмотра того, как сервер взаимодействует с операционной системой и сетью.
Анатомия HTTP-запроса на уровне сокетов
Прежде чем разбирать архитектуру сервера, необходимо понимать природу данных, с которыми он работает. На прикладном уровне протокол HTTP/1.1 выглядит как простой текстовый обмен. Клиент отправляет стартовую строку, заголовки и опциональное тело запроса:
Для веб-разработчика это статичный блок текста. Однако для разработчика серверного ядра (и будущих модулей Nginx) HTTP — это непрерывный поток байтов, поступающий из TCP-сокета. TCP не гарантирует, что весь HTTP-запрос придет одним пакетом. В результате системного вызова recv() сервер может прочитать только часть запроса, например: GET /index.h.
Это создает фундаментальную сложность: сервер должен уметь сохранять состояние парсинга между сетевыми чтениями. Если использовать регулярные выражения для разбора заголовков, производительность катастрофически упадет. Поэтому внутри Nginx HTTP-парсер реализован как жестко оптимизированный конечный автомат (state machine) на языке C. Он читает данные побайтово, переходя из состояния в состояние.
Каждый символ \r (Carriage Return) и \n (Line Feed) играет критическую роль. Автомат Nginx отслеживает переходы: от чтения метода к чтению URI, затем к версии протокола, и далее к ключам и значениям заголовков. Если клиент использует медленное соединение и отправляет по одному байту в секунду (атака Slowloris), традиционный сервер, выделяющий поток на каждое соединение, будет держать этот поток заблокированным. Nginx, благодаря своей архитектуре, просто сохранит текущее состояние автомата в легковесной структуре памяти и переключится на обслуживание других клиентов, вернувшись к этому соединению только когда в сокет поступят новые данные.
Традиционная модель: процесс на каждое соединение
Чтобы оценить изящество архитектуры Nginx, рассмотрим классическую модель, которую использовал Apache (в режиме mpm_prefork).
Когда сервер запускается, он открывает слушающий сокет (listening socket) на порту 80 или 443. При поступлении нового соединения от клиента сервер вызывает системный вызов fork(), создавая точную копию своего процесса. Этот новый дочерний процесс занимается исключительно одним клиентом: читает запрос, обращается к диску или базе данных, формирует ответ и завершается.
Математика этой модели беспощадна. Пусть — общий объем потребляемой памяти, — количество одновременных соединений, а — базовый размер памяти одного процесса (включая стек и загруженные библиотеки).
Если один процесс потребляет скромные 2 МБ оперативной памяти, то для обслуживания 10 000 соединений потребуется:
В начале 2000-х годов сервер с 20 ГБ оперативной памяти был астрономически дорогим. Но проблема не только в памяти. Операционная система должна распределять процессорное время между этими 10 000 процессами. Процесс смены активной задачи в CPU называется переключением контекста (context switch). При огромном количестве процессов ядро ОС начинает тратить больше времени на само переключение (сохранение регистров, сброс TLB-кэша, загрузку новых регистров), чем на полезную работу по обработке HTTP-запросов. Возникает состояние пробуксовки (thrashing).
Архитектура Master-Worker в Nginx
Игорь Сысоев, создатель Nginx, выбрал принципиально иной путь. Nginx не создает новый процесс или поток для каждого соединения. Вместо этого он использует фиксированное, очень небольшое количество процессов, которые асинхронно обрабатывают тысячи соединений каждый.
Эта архитектура разделена на два уровня ответственности: один Master-процесс и несколько Worker-процессов.
!Схема взаимодействия Master и Worker процессов
Роль Master-процесса
Master-процесс — это управляющий центр. Он запускается от имени суперпользователя (root). Это необходимо по двум причинам:
root имеет право открывать привилегированные порты (с номерами до 1024, включая стандартные 80 и 443).root может читать конфигурационные файлы, содержащие чувствительные данные (например, приватные ключи SSL-сертификатов), и менять пользователя для дочерних процессов.Master-процесс никогда не обрабатывает сетевые запросы клиентов. Его задачи строго ограничены:
nginx.conf.Роль Worker-процессов
Вся тяжелая работа по обработке HTTP-трафика ложится на Worker-процессы. После того как Master-процесс открыл сокеты, он порождает Worker-ы. Благодаря механике системного вызова fork(), дочерние процессы наследуют файловые дескрипторы родителя. Таким образом, все Worker-процессы имеют доступ к одним и тем же слушающим сокетам на портах 80 и 443.
Worker-процессы работают от имени непривилегированного пользователя (обычно nginx или nobody). Если злоумышленник найдет уязвимость в парсере HTTP и сможет выполнить произвольный код (RCE) внутри Worker-а, он получит права только этого ограниченного пользователя, а не root. Это важнейший рубеж безопасности.
Количество Worker-процессов обычно задается директивой worker_processes auto;, что заставляет Nginx создать по одному процессу на каждое физическое или логическое ядро процессора.
Почему именно по ядру? Если Worker-ов будет больше, чем ядер, операционной системе придется вытеснять их с процессора, тратя время на переключение контекста. При равенстве числа процессов и ядер, каждый Worker может монопольно занять свое ядро и работать непрерывно. Более того, Nginx поддерживает директиву worker_cpu_affinity, которая жестко привязывает (pinning) конкретный Worker к конкретному ядру CPU. Это позволяет максимально эффективно использовать L1/L2 кэши процессора: данные, с которыми работает процесс, не мигрируют между ядрами, что радикально снижает задержки доступа к памяти.
Проблема Thundering Herd (Громящее стадо)
Поскольку все Worker-процессы слушают одни и те же сокеты, возникает классическая проблема параллельного программирования.
Представим, что у нас 4 Worker-процесса, и все они находятся в состоянии ожидания новых соединений. По сети приходит один новый SYN-пакет (запрос на соединение). Операционная система видит, что на этот сокет подписаны 4 процесса, и будит их все.
Все 4 процесса пытаются вызвать accept(), чтобы забрать соединение. Но соединение только одно. Один процесс успешно его забирает, а остальные три получают ошибку EAGAIN (ресурс временно недоступен) и возвращаются в спящий режим.
Это явление называется Thundering Herd. Пробуждение процессов впустую тратит такты процессора. В ранних версиях Nginx эта проблема решалась на уровне самого сервера с помощью директивы accept_mutex. Worker-процессы использовали специальную блокировку в разделяемой памяти: только тот Worker, который захватил мьютекс, имел право вызывать accept(). Остальные даже не пытались.
В современных ядрах Linux (начиная с версии 3.9) и современных версиях Nginx эта проблема решается на уровне операционной системы с помощью флага EPOLLEXCLUSIVE. ОС сама гарантирует, что при появлении одного соединения она разбудит ровно один процесс. Тем не менее, понимание механизма accept_mutex критически важно для разработчиков модулей, так как это яркий пример того, как архитектура сервера адаптируется под ограничения ОС.
Разделяемая память (Shared Memory) и IPC
Хотя Worker-процессы изолированы друг от друга на уровне виртуальной памяти ОС, им необходимо обмениваться данными. Например:
limit_req) должен знать, сколько запросов сделал конкретный IP-адрес за последнюю секунду, независимо от того, к какому Worker-у попали эти запросы.Для этого Master-процесс при старте выделяет блоки разделяемой памяти (shared memory zones) с помощью системного вызова mmap(). После fork() все Worker-ы получают доступ к этим блокам.
Так как несколько процессов могут одновременно пытаться записать данные в одну зону памяти, Nginx использует механизмы синхронизации — спинлоки (spinlocks) на основе атомарных операций процессора. В отличие от тяжелых системных мьютексов, которые усыпляют процесс при неудаче, спинлок заставляет процесс активно крутиться в цикле, проверяя доступность ресурса. Это оправдано, так как операции с памятью в Nginx происходят за наносекунды, и усыпление процесса через ядро ОС заняло бы в тысячи раз больше времени.
Управление жизненным циклом: Graceful Reload
Одно из самых мощных следствий архитектуры Master-Worker — возможность обновлять конфигурацию, ротировать логи или обновлять бинарный файл сервера без потери ни одного клиентского соединения (zero-downtime).
Взаимодействие администратора с сервером происходит через отправку POSIX-сигналов Master-процессу. Самый частый сценарий — применение новой конфигурации с помощью сигнала SIGHUP (часто вызывается через команду nginx -s reload).
!Процесс бесшовной перезагрузки конфигурации (Graceful Reload)
Пошаговый механизм Graceful Reload выглядит так:
nginx.conf и отправляет сигнал SIGHUP Master-процессу.SIGQUIT (graceful shutdown).Этот процесс позволяет обслуживать долгие загрузки файлов или потоковое видео, плавно переводя новые запросы на обновленную логику. Аналогичным образом через сигналы SIGUSR2 и WINCH можно обновить сам исполняемый файл Nginx (бинарник) на лету.
Разделение на управляющий слой (Master) и слой обработки данных (Worker) делает Nginx невероятно устойчивым. Если из-за ошибки в кастомном модуле один Worker падает (Segmentation Fault), соединения, которые он обрабатывал в этот момент, разрываются, но слушающий сокет остается открытым. Master-процесс мгновенно замечает смерть дочернего процесса и порождает новый Worker ему на замену. Пропускная способность сервера восстанавливается за миллисекунды, а большинство клиентов даже не замечают сбоя.