Любой сайт, проживший в интернете больше пары недель, рано или поздно встречается с непрошенными гостями. Это могут быть поисковые роботы, забывшие о хороших манерах, парсеры конкурентов, агрессивно выкачивающие каталог, или откровенно вредоносные скрипты, ищущие уязвимости перебором. Все они объединены одной чертой - бьют по серверу гораздо чаще, чем нужно живому человеку. Несколько запросов в секунду от одного клиента, помноженные на десяток таких клиентов, и вот уже база захлёбывается, процессор кипит, а реальные посетители смотрят на крутящийся индикатор загрузки.
Лекарство от этого недуга существует, и оно встроено в Nginx по умолчанию. Модуль ngx_http_limit_req_module позволяет задавать предельное количество запросов в единицу времени для каждого клиента и автоматически отбрасывать всё, что выходит за рамки. Никаких сторонних плагинов, никакой дополнительной установки - инструмент идёт в стандартной поставке популярного веб-сервера и готов к работе через несколько строк в конфигурации.
Дальше разобран процесс настройки этого ограничителя на практике, с разбором всех нюансов, которые сберегут часы отладки и помогут не наломать дров.
Принцип работы модуля и логика хранения состояния клиентских сессий в общей памяти Nginx
Прежде чем хвататься за конфиги, имеет смысл понять, как устроена эта механика изнутри. Модуль работает по алгоритму leaky bucket - дырявое ведро. Воображаемое ведро у каждого клиента наполняется запросами с заданной скоростью, а выливается через дырочку с постоянной интенсивностью. Если запросы льются быстрее, чем ведро успевает опорожняться, лишнее проливается через край и попадает в категорию отброшенных.
Состояние всех этих воображаемых вёдер нужно где-то хранить, причём очень быстро. Дисковая база тут не подойдёт - запросы летят сотнями в секунду, и любой ввод-вывод убил бы производительность. Поэтому Nginx использует общую разделяемую память между рабочими процессами. Это область, к которой имеют доступ все воркеры одновременно, и любой из них может проверить или обновить счётчик клиента без задержек.
Для идентификации клиента обычно используется IP-адрес. Но не сам адрес как строка, а его двоичное представление - оно занимает всего 4 байта для IPv4 вместо 7-15 символов в текстовом виде. Это ощутимо экономит место в зоне памяти и позволяет уместить больше клиентов в один и тот же объём.
Подготовка зоны хранения состояний и базовая правка главного конфигурационного файла
Открываем основной конфиг Nginx:
nano /etc/nginx/nginx.conf
Внутрь блока http {} добавляем директиву объявления зоны:
http {
[...]
limit_req_zone $binary_remote_addr zone=one:10m rate=1r/s;
[...]
}
Эта строчка делает сразу несколько вещей. Параметр $binary_remote_addr указывает, какая переменная будет ключом для подсчёта - двоичная форма IP-адреса клиента. Можно использовать и текстовую $remote_addr, но это раздует таблицу состояний почти в четыре раза без какой-либо пользы для логики ограничения.
Конструкция zone=one:10m задаёт имя зоны (one - это просто метка, можно назвать как угодно) и её размер. Десять мегабайт памяти - это много или мало? Расчёт прост. Одна запись о состоянии клиента занимает порядка 64 байт. Значит, в одном мегабайте умещается около 16 тысяч записей, а в десяти - примерно 160 тысяч уникальных клиентов одновременно. Для подавляющего большинства проектов это с запасом.
Параметр rate=1r/s задаёт желаемую скорость - один запрос в секунду. Тут есть тонкость, на которой спотыкаются почти все, кто настраивает limit_req впервые. Скорость указывается только целыми числами в выбранной единице времени. Если хочется настроить лимит в полрехвал запроса в секунду, нельзя написать 0.5r/s - такая запись не пройдёт. Вместо этого нужно перевести в минуты и записать как 30r/m, что эквивалентно тридцати запросам в минуту или тому же половинчатому значению в секундной записи.
Сама по себе зона ничего не ограничивает. Она просто резервирует память и описывает правила. Чтобы лимит реально начал работать, нужно применить его к какому-то участку конфигурации через отдельную директиву.
Применение ограничения к запросам PHP через директиву limit_req в location-блоке
Директиву limit_req можно ставить в http {}, server {} или location {}. На первый взгляд кажется, что проще всего поместить её на самый верхний уровень и накрыть весь трафик одной шапкой. Но это плохая идея, и вот почему.
Когда браузер открывает обычную современную страницу, он выкачивает с сервера не один файл, а десятки - сама HTML-страница, несколько таблиц стилей, JavaScript-бандлы, шрифты, картинки, иконки. Если ограничить весь трафик одним запросом в секунду, такая страница будет грузиться полминуты или вообще не загрузится из-за отбрасывания части ресурсов. Пользователь увидит сломанный сайт без стилей и картинок и решит, что у проекта проблемы с хостингом.
Поэтому разумнее применять ограничение точечно - только к тем запросам, которые реально нагружают приложение. Картинки, CSS и шрифты Nginx раздаёт с дисковой скоростью почти бесплатно. А вот вызов PHP-обработчика или прокси-передача в бэкенд - дело тяжёлое, и тут как раз стоит фильтровать поток.
Прописываем директиву в location-блок для PHP-файлов:
[...]
location ~ \.php$ {
try_files $uri =404;
fastcgi_split_path_info ^(.+\.php)(/.+)$;
fastcgi_pass unix:/var/run/php5-fpm.sock;
fastcgi_index index.php;
include fastcgi_params;
limit_req zone=one burst=5;
}
[...]
Конструкция limit_req zone=one burst=5 ссылается на ранее созданную зону one (отсюда наследуется скорость 1r/s) и задаёт допустимый всплеск (burst) в пять запросов. Параметр burst - это та самая очередь, в которой лишние запросы могут подождать своего часа.
Логика такая. Когда клиент превышает заданную скорость, его запросы не отбрасываются сразу, а становятся в очередь. Если скорость превышена немного и очередь не переполняется, всё в итоге обрабатывается - просто с задержкой. И только если в очереди скапливается больше указанных в burst запросов, новые получают тот самый 503 Service Unavailable.
Размер очереди стоит подбирать исходя из реального паттерна нагрузки. Современные веб-приложения часто отправляют пачку AJAX-запросов одновременно при открытии страницы - например, при загрузке дашборда или сложной формы с автозаполнением. Если поставить burst слишком маленьким, легитимный пользователь получит ошибку при обычной работе. Слишком большой burst, наоборот, размывает смысл ограничения и пропускает короткие, но мощные всплески.
Режим немедленного отказа без очереди ожидания через параметр nodelay
Иногда задержка запросов в очереди нежелательна. Например, для API, где клиент ждёт быстрого ответа и при долгом ожидании сам отвалится по таймауту. В таких сценариях очередь только маскирует проблему - запрос фактически уже обречён, но висит и зря занимает ресурсы воркера.
Для подобных случаев есть параметр nodelay:
[...]
location ~ \.php$ {
try_files $uri =404;
fastcgi_split_path_info ^(.+\.php)(/.+)$;
fastcgi_pass unix:/var/run/php5-fpm.sock;
fastcgi_index index.php;
include fastcgi_params;
limit_req zone=one burst=5 nodelay;
}
[...]
С этим флагом поведение меняется заметно. Запросы в пределах burst-окна обрабатываются мгновенно без задержки. Но как только окно заполняется, любой новый запрос сверху лимита получает немедленный 503, не ожидая, пока счётчик медленно опорожнится. Получается жёстче, но честнее - клиент сразу понимает, что превысил лимит, и может отреагировать на это разумно (повторить через паузу, показать сообщение пользователю и так далее).
Какой режим выбрать - вопрос архитектурный, и он зависит от характера трафика. Для пользовательских веб-страниц обычно лучше работает мягкий режим с очередью. Для API и автоматизированных интеграций чаще подходит nodelay, поскольку программные клиенты умеют корректно обрабатывать 503 и реализовывать backoff-стратегии повторов.
Применение настроек через перезагрузку Nginx и проверка работоспособности ограничителя
После всех правок не забываем перечитать конфигурацию. Полный рестарт здесь не нужен - reload достаточно, чтобы новые настройки вступили в силу без разрыва текущих соединений:
systemctl nginx reload
Перед самим reload полезно сначала прогнать проверку синтаксиса через nginx -t. Если в конфиге есть ошибка, reload в systemd завершится неудачей, но без чёткого указания на проблему. А отдельный nginx -t выдаст конкретную строку с описанием синтаксической промашки.
Проверить работу ограничителя можно через любой инструмент нагрузки - ab (Apache Benchmark), siege, hey или даже простой цикл с curl в bash. Достаточно отправить десяток запросов к защищённому location-блоку быстрее, чем заданный rate, и посмотреть на коды ответов. Если всё настроено правильно, часть запросов вернётся с 503 - это и есть подтверждение того, что лимит сработал.
В логе ошибок Nginx (обычно /var/log/nginx/error.log) при срабатывании ограничителя появляются записи вида limiting requests, excess: ... by zone "one", client: ... - по ним удобно отслеживать, какие именно адреса бьются о потолок и насколько часто. Если обнаружится, что в лог сыплется один и тот же IP сотнями раз в минуту, перед нами очевидный кандидат на бан через брандмауэр.
Сценарии тонкой настройки нескольких зон для разных типов трафика и разделения логики защиты
Реальный продакшен редко обходится одной зоной с единым правилом. Чаще встречается схема с несколькими зонами, каждая под свой класс трафика. Например, для входа в админку имеет смысл задать жёсткий лимит вроде 5 запросов в минуту - перебирать пароли всё равно никто не будет с такой скоростью. Для обычных страниц подходит мягкий лимит 10-20 запросов в секунду на клиента. Для API можно поставить отдельную зону на 100 запросов в секунду с большим burst.
Несколько зон объявляются в http {} блоке параллельно с разными именами:
limit_req_zone $binary_remote_addr zone=login:10m rate=5r/m;
limit_req_zone $binary_remote_addr zone=general:10m rate=10r/s;
limit_req_zone $binary_remote_addr zone=api:10m rate=100r/s;
И дальше в каждом location используется своя зона через limit_req zone=login burst=2 nodelay; для админской формы, limit_req zone=general burst=20; для обычных страниц и так далее.
Бывают и более хитрые схемы. Если нужно ограничивать не по IP, а по сессионной cookie или по полю заголовка, можно использовать соответствующие переменные Nginx как ключ зоны. Например, $http_authorization выделит каждому API-токену свой счётчик независимо от IP - удобно для систем, где много клиентов сидят за одним NAT.
Распределение по нескольким зонам решает ещё одну проблему - так называемый shared fate. Если все типы трафика крутятся через один лимит, всплеск API-запросов может оставить без ответа обычных пользователей и наоборот. Разделение зон делает каждый класс трафика независимым.
Распространённые ошибки настройки и практические наблюдения из реальной эксплуатации
В копилку наблюдений из практики стоит добавить несколько моментов, на которых обжигаются почти все, кто впервые работает с limit_req.
Первая частая проблема - неучтённая работа через прокси или CDN. Если Nginx стоит за CloudFlare, балансировщиком провайдера или корпоративным реверс-прокси, то $binary_remote_addr будет содержать адрес этого промежуточного звена, а не реального клиента. В итоге все пользователи мира делят один счётчик, и сайт быстро ловит блокировки на ровном месте. Лекарство - использовать $binary_remote_addr только при прямом доступе клиентов к серверу, а в схеме с прокси переключаться на разбор заголовка X-Forwarded-For через переменную $http_x_forwarded_for или специальную $realip_remote_addr с предварительной настройкой модуля ngx_http_realip_module.
Вторая популярная грабля - слишком жёсткие лимиты в начале работы. Установив 1r/s на боевом сайте без тестов, легко получить шквал жалоб от реальных пользователей. Нормальная практика - сначала включить мягкий лимит с большим burst и режимом без nodelay, неделю последить за логами, понять реальные паттерны нагрузки, и только потом затягивать гайки до боевых значений.
Третий момент касается ботов поисковых систем. Слишком агрессивное ограничение может уронить индексацию сайта в Google и Yandex. Краулеры этих поисковиков ходят по сайту с приличной частотой, и если они начнут массово получать 503, страницы будут вылетать из индекса. Решение - либо вынести IP-адреса известных поисковиков в исключения через директиву geo и ключ зоны на основе условной переменной, либо просто настраивать лимиты с учётом типичной активности роботов.
Четвёртая тонкость связана с памятью под зоны. На очень нагруженных проектах с миллионами уникальных посетителей в день 10 мегабайт может оказаться мало, и старые записи начнут вытесняться раньше, чем нужно. В таких случаях имеет смысл расширить зону до 50-100 мегабайт - памяти на современных серверах хватает, а корректность работы ограничителя важнее экономии нескольких десятков мегабайт.
Где такая защита особенно полезна? Сценариев масса. Защита формы входа от перебора паролей. Ограничение API для бесплатных пользователей с разными лимитами для платных тарифов. Сдерживание агрессивных парсеров каталога интернет-магазина. Снижение нагрузки на тяжёлые поисковые запросы и фильтры товаров. Защита форм отправки сообщений от спам-ботов. Контроль скорости вызова дорогих эндпоинтов вроде генерации PDF или выгрузки больших отчётов.
Освоение limit_req даёт инженеру не просто навык работы с одним модулем, а понимание целого класса задач rate limiting в распределённых системах. Те же концепции встречаются в API-шлюзах, корпоративных балансировщиках, облачных WAF и многих других продуктах. Принципы leaky bucket и token bucket лежат в основе большинства подобных систем, и человек, разобравшийся с этим в Nginx, без особого труда настроит аналогичные механизмы в HAProxy, Traefik, Kong, Envoy или собственной самописной прослойке. И пусть встроенный в Nginx limit_req не самое продвинутое решение на рынке, по соотношению простоты и эффективности он даёт фору многим тяжеловесам - именно поэтому его настройка остаётся обязательным навыком для каждого, кто всерьёз держит веб-проект на Linux.