MAATRIX / Блог / Как работает rate limiting и почему честные клиенты страдают первыми

Как работает rate limiting и почему честные клиенты страдают первыми

MAATRIX

Сервер падает не только от целенаправленных DDoS-атак — иногда достаточно одного клиента, который слишком часто дёргает API, или бота, который вместо вежливого сканирования долбит эндпоинт в десять потоков. Rate limiting — стандартный ответ на эту проблему: посчитать, сколько запросов пришло с одного источника за единицу времени, и обрезать всё, что превышает порог. Звучит просто, но именно в этой простоте прячется парадокс, из-за которого наивная настройка лимитов чаще наказывает ваших реальных пользователей, чем реальных злоумышленников.

Что такое rate limiting и как он считает запросы

Rate limiting — это механизм, который отслеживает частоту запросов от каждого отдельного источника и отклоняет или задерживает те, что превышают заданный порог за интервал времени. Источник в подавляющем большинстве реализаций — это IP-адрес клиента, потому что это единственное, что сервер видит гарантированно и без дополнительной аутентификации: любой HTTP-запрос несёт IP отправителя в самом соединении (или в заголовке X-Forwarded-For, если между клиентом и сервером есть прокси).

Базовая логика такая:

  • сервер держит счётчик запросов для каждого IP;
  • счётчик привязан к окну времени (например, «за последние 60 секунд»);
  • как только счётчик для IP превышает порог, следующие запросы либо отклоняются (обычно HTTP 429 Too Many Requests), либо ставятся в очередь с задержкой.

Задача, которую решает rate limiting — защита от простой перегрузки, случайной или намеренной. Случайная — это когда клиентский код по ошибке ушёл в цикл и долбит ваш API без паузы (частая причина всплесков нагрузки, не менее частая, чем целенаправленные атаки). Намеренная — это DoS-подобная активность с одного источника: один скрипт, один IP, много запросов в секунду.

На уровне nginx это выглядит как модуль limit_req:

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

    server {
        location /api/ {
            limit_req zone=api_limit burst=20 nodelay;
            proxy_pass http://backend;
        }
    }
}

Здесь $binary_remote_addr — это ключ учёта, то есть IP-адрес клиента. Зона api_limit размером 10 МБ хранит счётчики примерно для 160 тысяч уникальных IP (по ~64 байта на запись). rate=10r/s — порог: 10 запросов в секунду с одного IP в устойчивом режиме. burst=20 разрешает кратковременный всплеск до 20 запросов сверх порога, nodelay — не откладывать их, а сразу пропускать, если укладываются в burst-буфер, и только после его исчерпания отдавать 429.

Алгоритмы: fixed window, sliding window, token bucket

От выбора алгоритма подсчёта зависит, насколько предсказуемо ведёт себя лимит на границах временных окон.

Fixed window (фиксированное окно). Самый простой вариант: счётчик обнуляется в начале каждой минуты. Проблема — эффект «двойного всплеска» на границе: если лимит 100 запросов в минуту, клиент может отправить 100 запросов в последнюю секунду окна и ещё 100 в первую секунду следующего — итого 200 запросов за 2 секунды, хотя формально лимит не нарушен ни в одном из окон.

Sliding window (скользящее окно). Считает запросы за последние N секунд от текущего момента, а не от начала «круглого» интервала. Точнее отражает реальную нагрузку, но требует хранить временные метки запросов, что дороже по памяти.

Token bucket (корзина токенов). Механизм, который использует и nginx: у клиента есть «ведро» токенов, которое пополняется с постоянной скоростью (например, 10 токенов в секунду) и имеет ограниченную ёмкость (burst). Каждый запрос тратит токен; если токенов нет — запрос отклоняется. Это позволяет пропускать краткие всплески (за счёт накопленных токенов), но держать устойчивую среднюю скорость. Именно эта модель стоит за параметрами rate и burst в конфиге выше.

Leaky bucket (дырявое ведро) — похожий принцип, но с фиксированной скоростью «вытекания» запросов из очереди: сглаживает burst-трафик до равномерного потока, ценой добавления задержки для запросов, которые попали в очередь.

Для большинства веб-API token bucket — разумный баланс между простотой реализации и удобством для легитимного трафика, который редко идёт идеально равномерно.

Нужен сервер под эту задачу?

Разверните VPS MAATRIX за пару минут: NVMe, AMD EPYC, root-доступ, локации UK, США, Франция и РФ. Оплата картой РФ и по СБП.

Арендовать VPS

Парадокс: почему за общим IP страдают невинные, а распределённая атака проходит незамеченной

Вот тут начинается главный нюанс, который часто упускают при внедрении лимитов «по учебнику».

Если лимит считается просто по IP-адресу без какого-либо дополнительного контекста, возникает два зеркальных эффекта:

Реальный злоумышленник, атакующий целенаправленно, обычно не использует один IP. DDoS и брутфорс-атаки в 2026 году почти всегда распределённые — ботнет из сотен или тысяч заражённых устройств, каждое со своим IP. Каждый отдельный IP из такой атаки может отправлять по 2-3 запроса в секунду — совершенно безобидную с точки зрения простого IP-лимита цифру. Суммарно ботнет может генерировать десятки тысяч запросов в секунду на сервер, но ни один отдельный IP не превышает порог в 10 r/s из примера выше. Лимит по IP такую атаку просто не видит.

Реальные законопослушные пользователи нередко физически сидят за одним общим адресом. Разберём конкретные сценарии:

  • Корпоративный NAT. Компания на 200 сотрудников выходит в интернет через один пограничный маршрутизатор с одним публичным IP (классический PAT/NAPT — подробнее о механизме здесь). Если 30 сотрудников одновременно открывают ваш сайт или пользуются вашим API-клиентом в рабочих приложениях, для вашего сервера это выглядит как один IP, отправляющий в 30 раз больше запросов, чем обычный одиночный пользователь.
  • Мобильные операторы и CGNAT. Операторы сотовой связи (и многие домашние провайдеры, перешедшие на carrier-grade NAT из-за нехватки адресов IPv4) пропускают тысячи абонентов через пул из небольшого числа публичных IP одновременно. С точки зрения вашего сервера тысяча разных людей с разными телефонами могут «принадлежать» одному-двум IP-адресам в конкретный момент.
  • Публичный Wi-Fi и VPN-выходы. Кафе, аэропорты, корпоративные VPN-концентраторы — та же история: много разных реальных людей, один исходящий адрес.

Итог: наивная реализация rate limiting «просто по IP» парадоксально бьёт сильнее по случайно оказавшимся за одним адресом невинным людям, чем по реальному распределённому злоумышленнику, который специально размазал нагрузку по множеству источников именно для того, чтобы обойти такие лимиты. Заголовок статьи не преувеличение — это реальная и хорошо задокументированная проблема наивных реализаций: чем больше у вас легитимных пользователей за общими шлюзами (а в 2026 году это скорее правило, чем исключение — IPv4-адресов физически не хватает, CGNAT и корпоративные прокси повсеместны), тем больше вероятность ложных срабатываний именно на самом безобидном трафике.

Наглядно: при лимите rate=10r/s на /api/search ботнет из 5000 IP, где каждый шлёт по 3 запроса в секунду, суммарно кладёт бэкенд 15 тысячами запросов в секунду — и при этом ни один отдельный IP не превышает порог, лимит его попросту не видит. А офис на 40 человек за одним NAT-шлюзом, где в обед половина сотрудников одновременно открыла ваш сервис плюс внутренний скрипт мониторинга раз в секунду дёргает API, легко выдаёт с этого единственного IP 12-15 запросов в секунду — и упирается в тот же порог 10r/s, получая 429 на ровном месте. Ситуация ровно обратная тому, что должен делать защитный механизм.

Более продуманные подходы к снижению коллатерального ущерба

Полностью убрать проблему невозможно — IP как единица учёта всегда будет грубым приближением к «одному пользователю». Но есть несколько подходов, которые заметно смягчают именно этот побочный эффект:

1. Учёт дополнительного контекста, а не только IP. Там, где у вас есть авторизация, разумнее считать лимит по идентификатору пользователя (API-ключ, токен сессии, ID аккаунта) вместо или в дополнение к IP. Аккаунт куда точнее отражает реального «актора», чем общий адрес — 40 сотрудников офиса с 40 разными аккаунтами не столкнутся друг с другом, если лимит считается на аккаунт, а не на IP шлюза:

# Ключ - заголовок с ID пользователя, который выставляет ваш auth-слой
map $http_x_user_id $limit_key {
    ""      $binary_remote_addr;   # неавторизованные - по IP как раньше
    default $http_x_user_id;       # авторизованные - по ID аккаунта
}

limit_req_zone $limit_key zone=api_limit:10m rate=10r/s;

Это не панацея для неавторизованного трафика (посадочная страница, публичный поиск), но для API с обязательной аутентификацией снимает большую часть проблемы NAT. Кстати, если вы работаете с внешними LLM-провайдерами, знакомая по духу ошибка 429 разбирается отдельно — как это выглядит на стороне LiteLLM.

2. Более мягкая деградация вместо жёсткого блока. Вместо мгновенного 429 на весь превышающий трафик — замедление (добавить задержку перед ответом), временное понижение приоритета в очереди или дополнительная проверка (капча, повторный запрос через N секунд) только для той части трафика, что превышает порог. burst=20 nodelay в примере выше — это уже шаг в сторону мягкости: 20 запросов сверх стабильного порога пропускаются без задержки, и только следующие получают отказ. Ещё мягче — заменить nodelay на выдержанную задержку (без nodelay запросы в пределах burst не отклоняются, а ставятся в очередь и обрабатываются с растягиванием по времени):

location /api/ {
    limit_req zone=api_limit burst=40;   # без nodelay - лишние запросы в очередь, не в отказ
    proxy_pass http://backend;
}

Разница ощутима для случайно задетого легитимного пользователя: вместо ошибки он получает более медленный, но успешный ответ.

3. Явные исключения для известных легитимных источников. Если вы знаете IP корпоративного шлюза крупного клиента или партнёрского сервиса, который постоянно обращается к вашему API — есть смысл занести его в отдельную зону с повышенным порогом:

geo $limit_bypass {
    default         0;
    203.0.113.10/32 1;   # известный офисный шлюз клиента
}

map $limit_bypass $limit_key {
    1       "";               # пустой ключ - lua/nginx не считает лимит вовсе
    default $binary_remote_addr;
}

Это точечное решение — работает, только если вы действительно знаете свои крупные легитимные источники заранее (мониторинг-система, известный партнёр, офис постоянного клиента). Для анонимного публичного трафика оно неприменимо.

Практическая настройка порога с поправкой на реальность NAT

Главный практический вывод: при выборе конкретного порога стоит закладывать реалистичную вероятность того, что за одним IP окажется не один человек, а несколько десятков.

Что это значит на практике:

  • Не проектируйте лимит из модели «один IP = один пользователь». Эта модель не соответствует современному интернету — CGNAT у мобильных операторов, NAT в офисах, общий Wi-Fi. Для массовой аудитории закладывайте запас на то, что легитимный трафик с одного IP может быть в 10-50 раз выше, чем трафик одного человека — конкретный множитель зависит от вашей аудитории, и его стоит уточнять по логам, а не брать из общих рекомендаций.
  • Смотрите на реальное распределение нагрузки по IP в логах, прежде чем ставить порог. Грубая, но рабочая проверка — посчитать, сколько запросов в минуту реально приходит с самых активных IP за представительный период (без атак):
awk '{print $1}' /var/log/nginx/access.log | sort | uniq -c | sort -rn | head -30

Если среди топ-30 IP по количеству запросов регулярно попадаются адреса с в разы большей активностью, чем «средний» IP, и это не боты (проверьте User-Agent и паттерн запросов) — вероятно, это NAT-шлюзы легитимных пользователей, и порог нужно ставить выше текущего разброса, а не по «круглой» цифре из документации фреймворка.

  • Разделяйте лимиты по типу эндпоинта. Публичная страница поиска и эндпоинт авторизации/сброса пароля требуют разных порогов: первый терпит более высокий трафик с одного IP (это норма для NAT), второй — критичен для защиты от брутфорса и может позволить себе более жёсткий лимит, потому что легитимный пользователь физически не будет вводить пароль 20 раз в минуту.
  • Используйте суммарный (глобальный) лимит как вторую линию защиты против распределённых атак. Раз IP-лимит бессилен против ботнета, добавьте отдельный, куда более высокий, лимит на весь эндпоинт целиком (или через circuit breaker на уровне backend) — это не заменяет анти-DDoS на уровне сети, но снижает риск, что бэкенд захлебнётся раньше, чем сработает более серьёзная защита.
  • Логируйте отклонённые по лимиту запросы отдельно. Без этого вы не увидите проблему постфактум — только жалобы пользователей «у нас перестало работать в обед». Отдельный лог 429-ответов с разбивкой по IP быстро покажет, бьёте вы по одному наглому клиенту или по офисному шлюзу.

Для сравнения, как по-разному стоит настраивать пороги в зависимости от типа эндпоинта:

Тип эндпоинтаЛегитимный паттернРекомендация по порогу
Публичный поиск/каталогПачками с одного NAT-шлюзаВысокий порог по IP + burst, либо лимит по сессии
Логин / сброс пароляЕдиницы попыток в минутуНизкий жёсткий порог по IP, независимо от NAT
Авторизованный APIПривязана к конкретному клиентуЛимит по ID аккаунта/API-ключа, а не по IP
Вебхуки партнёровПостоянный предсказуемый источникЯвное исключение/повышенный лимит по IP

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

Нужен сервер под эту задачу?

Разверните VPS MAATRIX за пару минут: NVMe, AMD EPYC, root-доступ, локации UK, США, Франция и РФ. Оплата картой РФ и по СБП.

Арендовать VPS

Нужны сами нейросети для контента?

Генерируйте изображения, видео и озвучку нейросетями на falapi.io — десятки моделей в одном окне. Оплата картой РФ и по СБП.

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

Rate limiting защищает от DDoS-атак?

Частично и с оговорками. Он неплохо справляется с атакой с одного источника или с небольшого числа IP, но против по-настоящему распределённой атаки с тысяч уникальных адресов лимит по IP почти бесполезен — каждый отдельный IP может не превышать порог. От серьёзного DDoS нужна отдельная защита на уровне сети (фильтрация на upstream-провайдере или в специализированном сервисе — подробный разбор в статье про защиту от DDoS на выделенном сервере), rate limiting на сервере — это последний рубеж, а не единственный.

Что лучше — лимит по IP или по аккаунту?

Там, где есть обязательная авторизация — лимит по аккаунту точнее, потому что не путает разных людей за одним NAT-шлюзом. Для полностью анонимного публичного трафика (например, главная страница сайта) аккаунта просто нет, и приходится опираться на IP или на дополнительные признаки вроде cookie-сессии.

Как понять, что легитимных пользователей режет лимит, а не бот-трафик?

Смотрите на паттерн: боты обычно бьют строго равномерно и по одному-двум эндпоинтам, легитимные пользователи за NAT — неравномерно и по широкому спектру страниц/эндпоинтов, с характерными для человека паузами и разными User-Agent. Если отклонённые по лимиту запросы разнообразны по User-Agent и путям — вероятно, это реальные люди за общим адресом.

Нужно ли настраивать rate limiting, если сервер не публичный API, а обычный сайт?

Да, хотя бы базовый — на формы (логин, обратная связь, поиск), которые чаще всего становятся целью спама и брутфорса. Для статических страниц риск ниже, но полностью его не стоит игнорировать, если сайт когда-либо получал нежелательный трафик.

Можно ли обойтись без rate limiting, если есть fail2ban?

Это разные уровни защиты. Fail2ban обычно реагирует постфактум — по паттернам в логах (например, серия неудачных попыток логина) — и банит IP на уровне firewall на какое-то время. Rate limiting работает превентивно, на уровне каждого запроса, ещё до того, как что-то попало в лог как «подозрительное». Их разумно использовать вместе, а не как замену друг другу.

Обсудить статью, задать вопрос или начать новую тему

Есть вопрос по этой статье, идея для обсуждения или просто хотите поделиться опытом? Сообщество MAATRIX ждёт. Для общения, пожалуйста, зарегистрируйтесь в нашем личном кабинете.

Перейти в сообщество →