АйТи Фреш
Главная / Статьи / Серверы и хостинг
Серверы и хостинг

Почему limit_req замедлил страницу и начал отдавать 503

Автор: Семёнов Евгений Сергеевич, директор ООО «АйТи-Фреш» · · ~15 мин чтения
Почему limit_req замедлил страницу и начал отдавать 503
Иллюстрация к статье «Почему limit_req замедлил страницу и начал отдавать 503».

После включения `limit_req` сайт может выглядеть так, будто не справляется приложение: страница открывается рывками, API отвечает через секунду-другую, в журнале появляются 503. Но процессор свободен, база не перегружена, а upstream работает как раньше. Я, Семёнов Евгений Сергеевич, обычно начинаю проверку не с PHP или базы, а с математики самого ограничителя. В этой статье покажу, почему средняя частота запросов — не то же самое, что допустимый всплеск, как `burst` превращается в очередь и почему для интерактивного веб-интерфейса я почти всегда выбираю `nodelay`.

Откуда берётся медленная загрузка при свободном сервере

Браузер не загружает страницу одним запросом. Даже сравнительно простой интерфейс получает HTML, стили, JavaScript, шрифты, изображения, данные пользователя, уведомления и справочники. HTTP/2 позволяет отправить эти запросы параллельно, но для limit_req они всё равно остаются отдельными запросами. Если ограничитель установлен в общем location /, под него попадает весь веер. Средняя нагрузка пользователя может быть два запроса в секунду, а при открытии страницы за первые 100–300 мс прилетит двадцать.

Самая частая ошибка, которую я вижу: администратор смотрит на среднее значение в мониторинге, видит 3 r/s с одного адреса и задаёт rate=5r/s. Кажется, что запас есть. На самом деле rate задаёт скорость, с которой nginx будет освобождать накопившийся избыток. При 5 r/s расчётный интервал равен 200 мс. Несколько запросов, пришедших почти одновременно, сразу становятся избыточными, хотя минутное среднее осталось низким.

Без параметра nodelay nginx задерживает избыточные запросы внутри допустимого burst. При rate=5r/s и burst=10 крайний запрос в заполненной очереди может ждать около двух секунд. Когда очередь уже занята, следующий избыток отклоняется. По умолчанию limit_req_status равен 503 — отсюда совершенно правдоподобная картина «медленный сервер иногда падает», хотя upstream этих запросов даже не видел.

Если после включения `limit_req` время ответа выросло почти ступеньками — на 100, 200, 400 мс, — сначала ищите очередь ограничителя. Масштабировать приложение в этот момент рано.
Цифры и версии: Откуда берётся медленная загрузка при свободном сервере — схема
Цифры и версии: Откуда берётся медленная загрузка при свободном сервере. Открыть схему в полном размере

Как я читаю rate, burst, nodelay и delay

Я мысленно представляю не счётчик «запросов за секунду», а протекающее ведро. rate=10r/s означает, что накопленный избыток убывает примерно со скоростью десять запросов в секунду. burst=20 разрешает временно накопить до двадцати избыточных запросов. Если nodelay не указан, nginx растянет их обработку по времени. Если указан, допустимый всплеск пройдёт сразу, но занятое место в ведре не исчезнет: оно будет освобождаться с заданным rate, а новый ранний всплеск может получить отказ.

Именно здесь nodelay часто понимают неверно. Он не выключает ограничение и не превращает rate=10r/s burst=20 в постоянные 30 r/s. Он убирает искусственное ожидание для запросов, которые ещё помещаются в burst. Для пользовательского интерфейса я считаю это правильным поведением: нормальный короткий веер пропускаем быстро, а действительно лишнее отклоняем явно. Медленно выполнять легитимный запрос обычно хуже, чем быстро вернуть управляемый 429 клиенту, умеющему повторить операцию.

Параметр delay=N появился в nginx 1.15.7 и взаимоисключающе заменяет nodelay. По документации значение по умолчанию равно нулю, то есть задерживаются все избыточные запросы. Он даёт промежуточный режим: первые N избыточных запросов не задерживаются, последующие внутри burst ставятся на расписание. Режим полезен для фоновой выгрузки или интеграции, которой допустимо подождать. В браузерном API я его применяю редко. Скрытая очередь увеличивает время жизни соединений, портит пользовательские таймауты и маскирует недостаточную пропускную способность upstream.

Размер `burst` я выбираю по измеренному легитимному вееру, а не по красивому круглому числу. Сначала считаю запросы одного действия, затем добавляю небольшой запас и проверяю повторный всплеск до полного восстановления лимита.
Почему limit_req замедлил страницу и начал отдавать 503 — схема
Схема к статье. Открыть схему в полном размере

Что случилось в «ТрастЛайн»

«ТрастЛайн» — трастовая компания на 43 рабочих места; название условное, данные обезличены. Сотрудники центрального офиса (29 мест) и отдела доверительного управления в соседнем здании (14 мест) работали с внутренним порталом клиентских договоров и отчётности, выходя в него через два публичных NAT-адреса — по одному на площадку. На входе стоял nginx 1.30.4 stable из репозитория nginx.org на Ubuntu Server 24.04 LTS, виртуальная машина с 2 vCPU и 4 ГБ RAM. За ним работали два экземпляра приложения на PHP 8.3. Пиковая входящая нагрузка была около 60 r/s, загрузка CPU nginx не превышала 15 %. Для такого потока железо здесь вообще не было проблемой.

Стартовая страница делала 23 обращения: HTML, восемь статических ресурсов и четырнадцать запросов API. В понедельник утром управляющие портфелями и операционисты почти одновременно открывали рабочий день: сверка остатков, очередь поручений клиентов, уведомления. До ограничения p95 времени запроса составлял 310 мс. После изменения p95 вырос до 2,1 секунды, а 5,8 % запросов получили 503. При этом p95 $upstream_response_time для дошедших запросов остался около 240 мс. Это был сильный признак того, что задержку создаёт nginx до обращения к приложению.

Проблемный фрагмент выглядел безобидно, поэтому его сначала и не заподозрили:

http {
    limit_req_zone $binary_remote_addr zone=per_ip:10m rate=5r/s;

    server {
        listen 443 ssl;
        server_name portal.trustline.example;

        location / {
            limit_req zone=per_ip burst=10;
            proxy_pass http://app_backend;
        }
    }
}

Лимит применили ко всему порталу, nodelay не указали, а ключом сделали внешний адрес. Получилось сразу три наложившихся дефекта: один человек создавал короткий веер, все 29 сотрудников офиса делили одно ведро, а разрешённый избыток nginx выдавал порциями по одному запросу каждые 200 мс.

NAT — не ошибка nginx. Ограничитель честно применяет правило к ключу, который вы ему дали. Если десятки пользователей имеют один внешний IP, лимит «на IP» фактически становится лимитом на офис. Та же ловушка возникает за балансировщиком без модуля realip: тогда ключом становится адрес самого балансировщика, и одно ведро делят вообще все клиенты.

Как отличить limit_req от тормозящего upstream

Первым делом я добавляю $limit_req_status в отдельный формат access log. Переменная появилась в nginx 1.17.6 и принимает значения PASSED, DELAYED, REJECTED, а в режиме наблюдения — DELAYED_DRY_RUN и REJECTED_DRY_RUN. Одновременно пишу $request_time и $upstream_response_time. Если запрос отклонён до проксирования, upstream-время обычно будет -. Если он задержан ограничителем, общее время вырастет, хотя время upstream останется нормальным.

Конфигурация диагностики у нас была такой:

log_format limits escape=json
    '{"time":"$time_iso8601","ip":"$remote_addr",'
    '"request":"$request","status":$status,'
    '"rt":$request_time,"urt":"$upstream_response_time",'
    '"limit":"$limit_req_status"}';

access_log /var/log/nginx/portal_limit.log limits;
limit_req_log_level notice;

После изменения я обязательно выполняю nginx -t, затем штатную перезагрузку systemctl reload nginx. В error log сообщения о задержке имеют уровень на одну ступень ниже настроенного уровня отказов, поэтому слепо искать только строки уровня error недостаточно.

Для первого включения есть безопасный режим limit_req_dry_run on, присутствующий с nginx 1.17.1. Он ведёт состояние зоны и отмечает запросы, которые были бы задержаны или отклонены, но скорость запросов фактически не ограничивает. Я держу его хотя бы один полный рабочий цикл, включая утренний вход и закрытие операционного дня. Затем сравниваю распределение по URI, ключам и времени. Среднесуточный график здесь бесполезен: интересуют интервалы в секунды и доли секунды.

Не пытайтесь диагностировать проблему только по коду 503. Такой ответ мог вернуть upstream. Надёжное доказательство — `REJECTED` в `$limit_req_status` и отсутствие обращения к upstream.
Порядок действий: Как отличить limit_req от тормозящего upstream — схема
Порядок действий: Как отличить limit_req от тормозящего upstream. Открыть схему в полном размере

Конфигурация, которую я выбираю для интерактивного портала

Я не ограничиваю весь location /. Статику и обычный HTML оставляю в покое, а правила ставлю на дорогие или атакуемые маршруты. Для входа нужен строгий лимит, для API — достаточно широкий легитимный всплеск, плюс общий предохранитель на виртуальный сервер. В пользовательском трафике использую nodelay и меняю код отказа на 429. Это не делает систему неуязвимой для DDoS, но защищает приложение от грубых всплесков без искусственного замедления штатной страницы.

Итоговый сокращённый конфиг для проекта выглядел так:

http {
    log_format limits escape=json
        '{"time":"$time_iso8601","ip":"$remote_addr",'
        '"request":"$request","status":$status,'
        '"rt":$request_time,"urt":"$upstream_response_time",'
        '"limit":"$limit_req_status"}';

    limit_req_zone $binary_remote_addr zone=auth_per_ip:10m rate=10r/m;
    limit_req_zone $binary_remote_addr zone=api_per_ip:20m rate=30r/s;
    limit_req_zone $server_name zone=api_total:10m rate=300r/s;

    upstream app_backend {
        server 10.20.0.21:8080;
        server 10.20.0.22:8080;
    }

    server {
        listen 443 ssl;
        server_name portal.trustline.example;
        access_log /var/log/nginx/portal_limit.log limits;
        limit_req_status 429;
        limit_req_log_level notice;

        location = /api/auth/login {
            limit_req zone=auth_per_ip burst=5 nodelay;
            proxy_pass http://app_backend;
        }

        location /api/ {
            limit_req zone=api_per_ip burst=60 nodelay;
            limit_req zone=api_total burst=120 nodelay;
            proxy_pass http://app_backend;
        }

        location / {
            proxy_pass http://app_backend;
        }
    }
}

При rate=30r/s burst=60 мы разрешаем короткий запас примерно на две секунды расчётной скорости, но не заставляем браузер ждать его выдачи. Общая зона не даёт одному или нескольким адресам бесконтрольно занять весь backend.

Зоны разделены намеренно: вход нельзя оценивать по той же шкале, что загрузку справочника. Размер зоны я считаю по документации: состояние занимает 64 байта на 32-битных платформах и 128 байт на 64-битных, поэтому зона 1 МБ вмещает около 16 тысяч 64-байтных или около 8 тысяч 128-байтных состояний. Для 20 МБ на 64-битном сервере это порядка 160 тысяч ключей — для портала на 43 рабочих места с огромным запасом. Переполнение тоже стоит держать в голове: nginx удаляет наименее востребованное состояние, а если и это не помогает, завершает запрос с ошибкой — тем же кодом из limit_req_status. Ключ $binary_remote_addr выбран не случайно: он занимает 4 байта для IPv4 и 16 байт для IPv6, тогда как текстовый $remote_addr длиннее.

Отдельная тема — балансировщик перед nginx. $binary_remote_addr берётся из адреса TCP-соединения, поэтому без восстановления реального IP все запросы получают ключ балансировщика. Модуль ngx_http_realip_module по умолчанию не собирается (нужен --with-http_realip_module); в пакетах nginx.org он есть, проверить можно по выводу nginx -V. Доверять X-Forwarded-For от всего интернета нельзя: клиент сможет подменять ключ и обходить лимит. Поэтому доверенные адреса перечисляю явно:

set_real_ip_from 10.20.0.5;          # внутренний балансировщик
real_ip_header X-Forwarded-For;
real_ip_recursive on;

По умолчанию real_ip_header равен X-Real-IP, а real_ip_recursive выключен: тогда берётся последний адрес из заголовка. С on nginx возьмёт последний адрес, не входящий в доверенные. Исходный адрес соединения остаётся в $realip_remote_addr — его полезно писать в тот же JSON-лог, чтобы видеть, откуда пришёл запрос на самом деле.

Не копируйте эти числа вслепую. 30 r/s и `burst=60` подошли измеренному вееру этого проекта. Для вашего интерфейса исходными данными должны быть реальные журналы и запас производительности upstream.

Чем закончилась настройка и что делать в первую очередь

После разделения маршрутов и включения nodelay p95 вернулся к 340 мс. На обычной навигации 429 не появлялись, а синтетический параллельный поток сверх запаса быстро получал 429 и не занимал PHP-процессы. Утренние одновременные входы обеих площадок прошли без очереди в nginx. Небольшая разница с исходными 310 мс укладывалась в обычное колебание приложения; я не стал «дотюнивать» её ради красивого отчёта.

Приоритет действий у меня жёсткий. Сначала определить, что именно защищаем: логин, тяжёлый поиск, формирование отчёта или весь backend от аварийного пика. Затем найти правильный ключ и измерить легитимный всплеск. После этого включить dry run, добавить наблюдаемость и только потом переводить правило в боевой режим. Изменение burst наугад — последняя вещь, которую стоит делать. Так можно убрать симптом и одновременно открыть приложению слишком широкий удар.

На что можно временно забить? На попытку подобрать математически идеальный лимит с первого раза. Трафик меняется, фронтенд получает новые API-вызовы, отдел переезжает за другой NAT. Нужен не вечный коэффициент, а понятная политика пересмотра и алерт по REJECTED. И ещё: единичные 429 при реальном превышении — нормальная работа защиты. А вот регулярные DELAYED на штатной странице я считаю дефектом конфигурации, даже если формально ошибок нет.

Главный практический вывод: `burst` отвечает за терпимость к короткому всплеску, а `nodelay` — за то, превратится ли эта терпимость в ожидание. Для интерактивной страницы я предпочитаю запас с `nodelay`, наблюдаемым 429 и отдельным глобальным ограничителем.
Порядок действий: Чем закончилась настройка и что делать в первую очередь — схема
Порядок действий: Чем закончилась настройка и что делать в первую очередь. Открыть схему в полном размере

Частые вопросы

Почему nginx возвращает именно 503?

Это стандартное значение директивы `limit_req_status`. Оно не доказывает перегрузку upstream. Я обычно задаю `limit_req_status 429`, чтобы причина отказа была понятнее клиенту и мониторингу.

Отключает ли nodelay ограничение частоты?

Нет. `nodelay` немедленно пропускает избыток, пока он помещается в `burst`, но состояние зоны продолжает восстанавливаться с заданным `rate`. Слишком ранний следующий всплеск будет отклонён.

Можно ли считать rate числом разрешённых запросов за календарную секунду?

Нет. Это скорость обработки накопленного избытка в алгоритме leaky bucket. Запросы оцениваются по моментам поступления, поэтому низкое минутное среднее не гарантирует прохождение параллельного веера.

Как подобрать burst для страницы?

Сначала измерить максимальное число запросов одного штатного действия в коротком окне, учесть NAT и параллельных пользователей, затем проверить значение в dry run. Запас должен пропускать легитимный веер, но оставаться меньше всплеска, опасного для upstream.

Почему лимит «на IP» режет всех сразу, если nginx стоит за балансировщиком?

Потому что `$binary_remote_addr` — это адрес соединения, то есть балансировщика. Нужно восстановить реальный адрес клиента модулем realip: `set_real_ip_from` с адресом балансировщика, `real_ip_header X-Forwarded-For` и при цепочке прокси `real_ip_recursive on`. Доверять заголовку от любых адресов нельзя.

Какой размер зоны limit_req_zone задавать?

По документации состояние занимает 128 байт на 64-битной платформе, 1 МБ вмещает около 8 тысяч таких состояний (около 16 тысяч 64-байтных на 32-битной). Для небольшой компании 10 МБ хватает с запасом; при переполнении nginx вытесняет наименее востребованные состояния, а если места всё равно нет — отклоняет запрос.

Защитит ли limit_req от полноценной DDoS-атаки?

Только частично. Он полезен как предохранитель приложения, но крупную атаку лучше отсекать раньше: на внешнем балансировщике, WAF, CDN или у оператора связи.

Столкнулись с похожей задачей? Обращайтесь — решим

Если у вас происходит что-то из описанного в этой статье — или любая другая проблема с ИТ-инфраструктурой, — обращайтесь в любое время. Мои специалисты и я лично разберём ситуацию, найдём настоящую причину и доведём до решения.

Возьмёмся и за разовую задачу, и за постоянное обслуживание. Первичная консультация — бесплатно и без обязательств.

📞 +7 903 729-62-41 💬 MAX: +7 903 729-62-41 ✈ Telegram @ITfresh_Boss

С уважением, Семёнов Евгений Сергеевич, директор «АйТи Фреш» — IT-аутсорсинг для компаний до 50 рабочих мест, 15+ лет практики

Источники

© ООО «АйТи-Фреш» · Москва · Все статьи