Архитектура и разработка под Nginx: от основ до создания модулей

Глубокое погружение во внутреннее устройство Nginx, событийную модель и механизмы обработки трафика. Курс готовит специалистов к высокоуровневой оптимизации систем и разработке собственных расширений на языке C.

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.
  • Открытие слушающих TCP/UDP сокетов.
  • Порождение (fork) Worker-процессов.
  • Управление жизненным циклом Worker-ов (перезапуск упавших, обновление конфигурации).
  • Обработка сигналов от операционной системы.
  • Роль 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-у попали эти запросы.
  • Кэширование SSL-сессий (TLS session resumption) требует, чтобы ключи сессии были доступны всем процессам.
  • Кэширование ответов бэкенда (FastCGI/Proxy cache) требует единого индекса кэша.
  • Для этого 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-процессу.
  • Master-процесс читает новый конфиг и проверяет его синтаксис. Если есть ошибка, Master пишет в лог и продолжает работать со старой конфигурацией. Ничего не ломается.
  • Если конфиг валиден, Master-процесс открывает новые слушающие сокеты (если в конфиге появились новые порты) и порождает новую группу Worker-процессов с новой конфигурацией.
  • В этот момент в системе работают две группы Worker-ов: старые и новые. Новые сразу начинают принимать новые соединения.
  • Master-процесс отправляет старым Worker-ам сигнал SIGQUIT (graceful shutdown).
  • Старые Worker-ы перестают принимать новые соединения (закрывают свои слушающие сокеты), но продолжают обслуживать уже установленные соединения.
  • Как только старый Worker завершает обработку последнего клиента, он тихо завершает свою работу (exit).
  • Этот процесс позволяет обслуживать долгие загрузки файлов или потоковое видео, плавно переводя новые запросы на обновленную логику. Аналогичным образом через сигналы SIGUSR2 и WINCH можно обновить сам исполняемый файл Nginx (бинарник) на лету.

    Разделение на управляющий слой (Master) и слой обработки данных (Worker) делает Nginx невероятно устойчивым. Если из-за ошибки в кастомном модуле один Worker падает (Segmentation Fault), соединения, которые он обрабатывал в этот момент, разрываются, но слушающий сокет остается открытым. Master-процесс мгновенно замечает смерть дочернего процесса и порождает новый Worker ему на замену. Пропускная способность сервера восстанавливается за миллисекунды, а большинство клиентов даже не замечают сбоя.

    2. Синтаксис конфигурационных файлов и управление жизненным циклом сервера

    Представьте, что вы добавили директиву add_header X-Frame-Options DENY; в глобальный блок http, чтобы защитить весь сервер от clickjacking-атак. Затем для одного конкретного маршрута /api/ вам потребовалось добавить CORS-заголовок: add_header Access-Control-Allow-Origin *;. Вы перезагружаете конфигурацию, API работает, но проверка безопасности показывает: заголовок X-Frame-Options на этом маршруте бесследно исчез. Проблема не в опечатке, а в фундаментальном принципе того, как Nginx управляет памятью и структурами данных в C.

    Для разработчика модулей конфигурация Nginx — это не просто текстовый файл, а декларативное описание графа объектов. Понимание того, как парсер превращает строки в структуры данных и как сигналы управляют жизненным циклом этих структур, — первый шаг к написанию собственного кода под Nginx.

    Анатомия конфигурации: от текста к структурам C

    Конфигурация Nginx — это иерархический набор директив. С точки зрения парсера, директива — это элементарная единица управления.

  • Простые директивы: Имя и параметры, разделенные пробелами, заканчивающиеся ;. Пример: worker_processes auto;.
  • Блочные директивы: Вместо точки с запятой используют фигурные скобки { }. Если блок может содержать другие директивы, он называется контекстом.
  • Контексты и модульность

    Архитектура Nginx построена на модулях, и контексты — это способ разграничить зоны ответственности этих модулей. Ядро (Core) сервера само по себе крайне аскетично. Когда парсер встречает блок http { }, он вызывает модуль ngx_http_module. Тот выделяет память и начинает интерпретировать содержимое блока.

    !Иерархическая структура конфигурации Nginx, отражающая вложенность основных директив.

    Иерархия контекстов определяет область видимости настроек: * main (глобальный): Все, что находится вне фигурных скобок. Здесь живут настройки самого процесса: user, worker_processes, error_log. * events: Настройки механизмов обработки соединений (epoll, kqueue) и лимитов (worker_connections). * http: Здесь начинается мир веб-сервера. Настройки MIME-типов, кэширования и логирования. * server: Аналог виртуального хоста. Определяет, на каком IP/порту слушать трафик (listen) и на какие домены отвечать (server_name). * location: Самый глубокий уровень, где описывается логика обработки конкретных URI.

    Механика наследования: почему «исчезают» настройки

    В Nginx наследование работает сверху вниз: от http к server, от server к location. Но важно понимать, как это реализовано в коде. Для каждой директивы в модулях C определены функции создания (create_conf) и слияния (merge_conf).

    Когда Nginx видит вложенный контекст, он создает для него новую структуру в памяти. Если в дочернем контексте директива не указана, функция слияния просто копирует указатель на значение из родительского контекста.

    Ловушка массивов

    Главная архитектурная особенность: директивы-массивы (array-type directives) не объединяются, а полностью замещаются.

    Директива add_header — это типичный массив. Если в http вы определили 10 заголовков, а в location добавили всего 1, Nginx увидит, что в дочернем контексте массив не пуст. Функция слияния решит: «О, тут есть свои настройки, родительские мне не нужны». В итоге все 10 глобальных заголовков будут отброшены.

    > Важно для разработчика: Это поведение зашито в логику большинства стандартных модулей. К таким «капризным» директивам относятся add_header, access_log, fastcgi_param, proxy_set_header, error_page. Чтобы сохранить родительские настройки, их придется продублировать в дочернем блоке.

    Алгоритм выбора location: Radix-дерево и Regex

    Когда Worker-процесс получает запрос, его первая задача — найти правильный location. Это критический путь исполнения (hot path), поэтому Nginx использует оптимизированные структуры данных.

    Существует два типа сопоставления:

  • Префиксные (строковые): Сверяются с началом URI.
  • Регулярные выражения (Regex): Проверяют URI на соответствие паттерну.
  • Внутренняя оптимизация

    На этапе загрузки конфигурации Master-процесс строит из всех префиксных location Radix-дерево (префиксное дерево). Это позволяет искать совпадения со скоростью , где — длина URI, независимо от того, сколько тысяч правил у вас прописано. Порядок префиксных директив в файле не важен.

    Регулярные выражения, напротив, хранятся в обычном списке. Их поиск — это , где — количество правил. Они всегда проверяются последовательно.

    Пятишаговый алгоритм поиска

  • Direct Match: Поиск точного совпадения (модификатор =). Если найдено — поиск окончен.
  • Longest Prefix: Поиск самого длинного префиксного совпадения в Radix-дереве. Сервер запоминает его как «кандидата».
  • Priority Check: Если у «кандидата» есть модификатор ^~ (приоритетный префикс), поиск прекращается.
  • Regex Stage: Если приоритета нет, Nginx начинает проверять регулярные выражения (~ или ~*) строго в том порядке, в котором они написаны в конфиге.
  • Final Choice: Если первое же регулярное выражение совпало — используется оно. Если ни одно не подошло — используется «кандидат» из шага 2.
  • !Интерактивная визуализация алгоритма выбора location в Nginx: пошаговая анимация обхода Radix-дерева и проверки regex-правил с подсветкой каждого шага и итоговым результатом.

    Пример для закрепления: Запрос: /static/image.png Конфиг:

  • location /static/ { ... } (префикс)
  • location ~* \.(png|jpg)$ { ... } (regex)
  • Результат: Сначала будет найден префикс /static/ (шаг 2). Затем Nginx перейдет к regex (шаг 4). Поскольку .png совпадает, запрос уйдет во второй блок. Если вы хотите, чтобы префикс победил, используйте location ^~ /static/.

    Жизненный цикл и управление сигналами

    Nginx — это многопроцессная система. Управление ею происходит через сигналы POSIX, которые отправляются Master-процессу. Понимание этих сигналов необходимо для автоматизации и написания модулей, работающих с фоновыми задачами.

    Плавная перезагрузка (SIGHUP / reload)

    Когда вы выполняете nginx -s reload, происходит следующее:

  • Master-процесс проверяет синтаксис нового конфига.
  • Если всё верно, он открывает новые лог-файлы и сокеты, но не закрывает старые.
  • Master запускает новый набор Worker-ов с новой конфигурацией.
  • Master отправляет старым Worker-ам сигнал к плавному завершению.
  • Старые Worker-ы перестают принимать новые соединения, дообслуживают текущие и завершаются.
  • Ротация логов и файловые дескрипторы (SIGUSR1)

    В Linux открытый файл идентифицируется номером — файловым дескриптором (FD). Когда вы переименовываете файл лога на диске (mv access.log access.log.old), FD в памяти Nginx всё ещё указывает на тот же самый участок данных (inode). Сервер продолжит писать в переименованный файл.

    Чтобы заставить Nginx переоткрыть файлы, используется сигнал USR1. Master-процесс заново смотрит на пути в конфиге, открывает новые файлы и передает новые FD своим Worker-ам.

    Бинарное обновление без простоя (Hot Upgrade)

    Это уникальная черта Nginx, позволяющая обновить сам исполняемый файл (например, с версии 1.24 на 1.26) без обрыва соединений.

    Алгоритм «танца» процессов:

  • Вы заменяете файл nginx на диске.
  • Отправляете старому Master-у сигнал USR2.
  • Старый Master переименовывает свой .pid файл и запускает второй Master-процесс (новую версию).
  • Новый Master запускает своих Worker-ов. Теперь у вас две независимые генерации сервера, делящие одни и те же порты (80/443).
  • Вы отправляете старому Master-у сигнал WINCH. Его Worker-ы плавно завершаются.
  • Если всё хорошо, вы убиваете старый Master сигналом QUIT. Если новая версия сбоит — убиваете новую, и старый Master (который всё это время ждал) снова запустит своих Worker-ов.
  • !Интерактивная симуляция бинарного обновления Nginx: визуализирует процесс передачи трафика от старого Master A к новому Master B через сигналы SIGUSR2, WINCH и SIGQUIT без потери соединений.

    Резюме для разработчика

    При проектировании модулей важно помнить, что Nginx стремится к максимальной предсказуемости. Использование Radix-деревьев для путей и сигнальной модели для управления процессами позволяет серверу сохранять стабильность при колоссальных нагрузках. Наследование через полное замещение массивов — это сознательный выбор в пользу упрощения управления памятью в C, что минимизирует риск утечек и неопределенного поведения при слиянии сложных структур данных.