Продвинутая конфигурация Nginx: фазы обработки запросов и подводные камни

Исходная ситуация

SaaS-платформа «АппМейкер» строит конструктор мобильных приложений. Инфраструктура выросла до 8 бэкенд-сервисов: API, WebSocket-сервер для реального времени, файловое хранилище, SSR-рендерер, админка, биллинг, аналитика и CDN-прокси. Всё это проксировалось через один Nginx, конфигурация которого росла хаотично и достигла 1 200 строк.

Проблемы были следующими:

  • Запросы попадали не в те location-блоки — 3-4 инцидента в месяц
  • WebSocket-соединения разрывались через 60 секунд
  • SSL Labs показывал оценку B из-за устаревших настроек TLS
  • Отсутствие условной маршрутизации — все запросы шли через одну цепочку
  • Логи писались для всех запросов одинаково — 50 ГБ/день, невозможно анализировать

Фазы обработки запросов в Nginx

Nginx обрабатывает каждый HTTP-запрос, проводя его через 11 фаз. Понимание этого порядка — ключ к предсказуемой конфигурации:

# Фазы обработки запроса в Nginx (в порядке выполнения)

1. NGX_HTTP_POST_READ_PHASE
   → Модуль: ngx_http_realip_module
   → Задача: Определение реального IP клиента (real_ip_header)

2. NGX_HTTP_SERVER_REWRITE_PHASE
   → Модуль: ngx_http_rewrite_module
   → Задача: Rewrite/return в блоке server (до выбора location)

3. NGX_HTTP_FIND_CONFIG_PHASE
   → Внутренняя фаза: Выбор подходящего location
   → Здесь работает алгоритм сопоставления location

4. NGX_HTTP_REWRITE_PHASE
   → Модуль: ngx_http_rewrite_module
   → Задача: Rewrite/return внутри выбранного location

5. NGX_HTTP_POST_REWRITE_PHASE
   → Внутренняя: Перезапуск поиска location после rewrite

6. NGX_HTTP_PREACCESS_PHASE
   → Модули: limit_req, limit_conn
   → Задача: Rate limiting и ограничение соединений

7. NGX_HTTP_ACCESS_PHASE
   → Модули: access, auth_basic, auth_request
   → Задача: Авторизация и контроль доступа

8. NGX_HTTP_POST_ACCESS_PHASE
   → Внутренняя: Обработка результата satisfy

9. NGX_HTTP_PRECONTENT_PHASE
   → Модули: try_files, mirror
   → Задача: Проверка существования файлов

10. NGX_HTTP_CONTENT_PHASE
    → Модули: proxy_pass, fastcgi_pass, static, index
    → Задача: Генерация ответа

11. NGX_HTTP_LOG_PHASE
    → Модуль: ngx_http_log_module
    → Задача: Запись в лог

Практическое следствие: limit_req (фаза 6) всегда выполняется до auth_basic (фаза 7). Нельзя сделать rate limiting только для неавторизованных запросов через стандартные директивы — нужен auth_request или Lua.

Порядок сопоставления location

Алгоритм выбора location — одна из самых частых причин ошибок. Вот точный порядок приоритетов:

# Приоритет (от высшего к низшему):

# 1. Точное совпадение — прекращает поиск
location = /api/health { return 200 'ok'; }

# 2. Приоритетный префикс — прекращает поиск
location ^~ /static/ { root /var/www/assets; }

# 3. Регулярные выражения — первое совпадение в порядке конфигурации
location ~ \.php$ { fastcgi_pass backend; }
location ~* \.(jpg|png|gif)$ { expires 30d; }

# 4. Обычный префикс — выбирается самый длинный
location /api/ { proxy_pass http://api_backend; }
location /api/v2/ { proxy_pass http://api_v2_backend; }  # Этот для /api/v2/*
location / { proxy_pass http://default_backend; }

Типичная ошибка «АппМейкера» — регулярное выражение перехватывало запросы, предназначенные для другого блока:

# ОШИБКА: /api/upload.php попадал в .php$, а не в /api/
location /api/ { proxy_pass http://api_backend; }
location ~ \.php$ { fastcgi_pass php_backend; }

# ИСПРАВЛЕНИЕ: точный контроль через вложенные location
location /api/ {
    proxy_pass http://api_backend;

    # Внутри /api/ обрабатываем PHP отдельно, если нужно
    location ~ \.php$ {
        fastcgi_pass php_backend;
    }
}

Директивы try_files и proxy_pass: подводные камни

Директива try_files проверяет файлы в порядке аргументов и перенаправляет на последний как fallback:

# Классический SPA-паттерн
location / {
    root /var/www/appmaker/dist;
    try_files $uri $uri/ /index.html;
}

# API-запросы — fallback на бэкенд
location /api/ {
    try_files /maintenance.html @api_backend;
}

location @api_backend {
    proxy_pass http://api_cluster;
}

Подводный камень proxy_pass с URI и без:

# БЕЗ URI: запрос /api/users → бэкенд получает /api/users
location /api/ {
    proxy_pass http://backend;         # ← нет URI после хоста
}

# С URI: запрос /api/users → бэкенд получает /users (отрезает /api/)
location /api/ {
    proxy_pass http://backend/;        # ← есть слэш = URI
}

# ЛОВУШКА с regex: URI в proxy_pass игнорируется!
location ~ ^/api/(.*)$ {
    # НЕЛЬЗЯ: proxy_pass http://backend/$1;  ← ошибка!
    # ПРАВИЛЬНО: используем переменную
    proxy_pass http://backend/$1$is_args$args;
}

SSL/TLS оптимизация и HTTP/2

Мы довели оценку SSL Labs с B до A+:

server {
    listen 443 ssl http2;
    server_name app.appmaker.ru;

    # Сертификат и ключ
    ssl_certificate /etc/letsencrypt/live/appmaker.ru/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/live/appmaker.ru/privkey.pem;

    # Протоколы и шифры
    ssl_protocols TLSv1.2 TLSv1.3;
    ssl_ciphers 'ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384';
    ssl_prefer_server_ciphers on;

    # OCSP Stapling — ускоряет TLS handshake на 100-300 мс
    ssl_stapling on;
    ssl_stapling_verify on;
    ssl_trusted_certificate /etc/letsencrypt/live/appmaker.ru/chain.pem;
    resolver 1.1.1.1 8.8.8.8 valid=300s;

    # Сессионный кэш — избегаем полного handshake
    ssl_session_cache shared:SSL:50m;
    ssl_session_timeout 1d;
    ssl_session_tickets off;  # Отключаем для Forward Secrecy

    # HSTS — принудительный HTTPS
    add_header Strict-Transport-Security "max-age=63072000; includeSubDomains; preload" always;

    # Early hints (HTTP/2 push замена)
    # HTTP/2 Server Push устарел, используем 103 Early Hints
    location / {
        add_header Link "; rel=preload; as=style" always;
        add_header Link "; rel=preload; as=script" always;
    }
}

Время TLS handshake до оптимизации: 320 мс (cold), 180 мс (resumption). После: 140 мс (cold), 0 мс (resumption с ssl_session_cache). OCSP stapling убрал дополнительный запрос к CA, экономя 100-300 мс на каждом новом соединении.

WebSocket-проксирование и map-директива

WebSocket-соединения использовались для real-time обновлений в конструкторе приложений. Стандартная конфигурация разрывала их через 60 секунд:

# Карта для определения заголовка Upgrade
map $http_upgrade $connection_upgrade {
    default upgrade;
    ''      close;
}

upstream ws_backend {
    server 10.0.2.10:3000;
    server 10.0.2.11:3000;
    # ip_hash для sticky sessions — WebSocket требует
    ip_hash;
}

server {
    location /ws/ {
        proxy_pass http://ws_backend;
        proxy_http_version 1.1;

        # Ключевые заголовки для WebSocket
        proxy_set_header Upgrade $http_upgrade;
        proxy_set_header Connection $connection_upgrade;
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;

        # Увеличенные таймауты для долгоживущих соединений
        proxy_read_timeout 3600s;    # 1 час
        proxy_send_timeout 3600s;

        # Отключаем буферизацию для real-time
        proxy_buffering off;
    }
}

Директива map — мощный инструмент для условной логики без if (который в Nginx работает непредсказуемо):

# Маршрутизация по версии API через заголовок
map $http_x_api_version $api_backend {
    default     http://api_v1;
    "2"         http://api_v2;
    "3"         http://api_v3;
    "~^beta"    http://api_staging;
}

# Определение мобильного клиента
map $http_user_agent $is_mobile {
    default 0;
    ~*android 1;
    ~*iphone  1;
    ~*ipad    1;
}

# Geo-блокировка по странам
geo $remote_addr $blocked_country {
    default 0;
    # CIDR-диапазоны заблокированных стран
    45.133.0.0/16   1;
    103.214.0.0/16  1;
}

Условное логирование и оптимизация логов

50 ГБ логов в день — результат записи каждого запроса к статике, health check и мониторингу. Мы внедрили условное логирование:

# Отдельные форматы для разных типов запросов
log_format api_log '$remote_addr [$time_local] '
    '$request_method $uri $status '
    'rt=$request_time us=$upstream_response_time '
    'body=$request_body';

log_format minimal '$remote_addr $request $status $request_time';

# Условие: не логировать health check и статику
map $request_uri $loggable {
    default 1;
    ~*^/health 0;
    ~*^/nginx_status 0;
    ~*\.(ico|css|js|gif|jpg|png|woff2)$ 0;
}

# Условие: логировать только медленные запросы
map $request_time $slow_request {
    default 0;
    ~^[1-9]    1;  # >= 1 секунда
    ~^0\.[5-9] 1;  # >= 0.5 секунды
}

server {
    # Основной лог — только полезные запросы
    access_log /var/log/nginx/api.log api_log if=$loggable buffer=64k flush=5s;

    # Отдельный лог для медленных запросов
    access_log /var/log/nginx/slow.log api_log if=$slow_request;

    # Ошибки — всегда
    error_log /var/log/nginx/error.log warn;
}

Объём логов сократился с 50 ГБ/день до 8 ГБ/день. Лог медленных запросов занимал 200 МБ/день, но содержал именно те данные, которые нужны для оптимизации.

Результаты и архитектурные выводы

После рефакторинга конфигурации:

МетрикаДоПосле
Инциденты маршрутизации3-4/мес0
SSL Labs оценкаBA+
TLS handshake (cold)320 мс140 мс
WebSocket uptime99.1%99.97%
Объём логов50 ГБ/день8 ГБ/день
Размер конфигурации1 200 строк680 строк

Главный архитектурный вывод: понимание фаз обработки запросов и приоритетов location устраняет 90% ошибок конфигурации Nginx. Директива map заменяет большинство if-конструкций, а условное логирование превращает логи из мусора в инструмент.

Часто задаваемые вопросы

Директива if в Nginx — не полноценное условие, а отдельная фаза обработки. Она создаёт внутренний подзапрос и может вести себя непредсказуемо: директивы из родительского блока могут не наследоваться внутри if. Официальная документация называет это «if is evil». Используйте map для условной логики и try_files для проверки файлов.
Если после хоста в proxy_pass есть хоть что-то (даже просто слэш /), это URI, и Nginx заменяет часть запроса, совпавшую с location. Без URI — запрос передаётся как есть. Пример: location /api/ + proxy_pass http://back/ → запрос /api/users уходит как /users. Без слэша proxy_pass http://back → уходит как /api/users.
По умолчанию proxy_read_timeout = 60s. Если WebSocket-соединение не передаёт данных в течение 60 секунд, Nginx закрывает его. Увеличьте proxy_read_timeout и proxy_send_timeout до 3600s или более. Также реализуйте ping/pong на уровне приложения каждые 30 секунд.
Session tickets шифруются одним ключом, который хранится в памяти сервера. Если злоумышленник получит этот ключ, он сможет расшифровать все прошлые сессии — нарушается Forward Secrecy. При отключении tickets используется ssl_session_cache, который обеспечивает PFS. На кластере из нескольких Nginx синхронизировать session cache сложнее, но безопаснее.

Нужна помощь с проектом?

Специалисты АйТи Фреш помогут с архитектурой, DevOps, безопасностью и разработкой — 15+ лет опыта

📞 Связаться с нами
#nginx#request phases#location matching#proxy_pass#ssl optimization#websocket#map directive#geo module
Комментарии 0

Оставить комментарий

загрузка...