Rate limit обычно объясняют как защиту от нагрузки, и это принижает его смысл. Обычному трафику ограничения не нужны. Лимит существует для одного клиента, который сделает десять тысяч запросов в минуту - по злому умыслу или из-за цикла, который кто-то написал по ошибке, - и для атакующего, который перебирает список утёкших паролей по одному аккаунту за раз. Вся аудитория лимита - эти двое.
Лимит - это четыре решения: что вы считаете, по чему считаете, за какое окно и что делаете, когда счётчик превышен. Ошибитесь со вторым - и лимит либо не делает ничего, либо запирает целый офис. В этой статье разобраны все четыре, алгоритмы, которые за ними стоят, конфигурация nginx и приложения, значения, с которых стоит начинать, и атаки, до которых rate limit не дотягивается.
Где нужны лимиты, в том порядке, в каком их стоит добавлять#
Лимит нужен не на каждом маршруте. Он нужен там, где один клиент может стоить вам денег, репутации или аккаунта. Примерно в порядке того, сколько хлопот приносит отсутствие лимита:
- Вход. Сюда приходит credential stuffing: ботнет прогоняет по вашей форме утёкшие пары «почта и пароль». Без лимита для атакующего это бесплатно.
- Всё, что отправляет письмо, SMS или push. Сброс пароля, приглашения, формы обратной связи. Каждое такое действие стоит денег, а несколько тысяч штук приводят к блокировке вашего домена-отправителя.
- Регистрация. Если только вам не нравится модерировать аккаунты, которые никто не создавал намеренно, и убирать за ними контент.
- Тяжёлые чтения. Поиск, выгрузки, отчёты - всё, что сканирует таблицу или рендерит PDF. Один клиент в цикле здесь неотличим от аварии.
- Эндпоинты записи вообще. Комментарии, загрузки, сообщения. Спам - это сначала проблема частоты и только потом проблема содержимого.
- Всё остальное - как широкая страховка, чтобы сорвавшийся скрипт упёрся во что-то раньше, чем в вашу базу данных.
Таблица стартовых значений для обычного веб-приложения с несколькими тысячами пользователей. Они намеренно щедрые: задача первого лимита - поймать злоупотребление, а не контролировать энтузиазм.
| Эндпоинт | Стартовый лимит | Ключ |
|---|---|---|
POST /login | 5 неудачных попыток за 15 минут | IP и имя пользователя вместе |
POST /register | 3 в час | IP |
| Сброс пароля, приглашение, контакты | 3 в час | Аккаунт, затем IP |
| Поиск, выгрузка, отчёт | 10 в минуту | Аккаунт |
| Публичный read API | 60 в минуту | API-ключ |
| Всё остальное | 300 в минуту | IP |
Обратите внимание: в первой строке считаются неудачи, а не запросы. Пользователь, который двадцать раз правильно вошёл, ничего плохого не сделал; клиент, который пять раз ошибся и продолжает, - подбирает. Подсчёт неудач ещё и позволяет сбрасывать счётчик после успешного входа, а это убирает большую часть ложных срабатываний.
Четыре алгоритма и какой выбрать#
Любой лимитер - это один из них, как бы его ни называла библиотека.
Fixed window (фиксированное окно). Считаете запросы на ключ за календарную минуту и обнуляете в начале минуты. Один счётчик, один срок жизни, хранить почти ничего не нужно. Изъян - граница: клиент может отправить весь лимит в 10:59:59 и ещё раз весь лимит в 11:00:00, так что реальный худший случай вдвое больше настроенного числа. Для лимитов на вход это неважно. Для платного API, где число - это обещание, важно.
Sliding window log (скользящее окно с журналом). Хранится метка времени каждого запроса, а считаются те, что попали в последние N секунд. Точно, но память растёт вместе с трафиком, который вы как раз пытаетесь ограничить, то есть в неправильную сторону. Годится для счётчика входов на пять записей, не годится для глобального лимита.
Sliding window counter (скользящее окно со счётчиком). Вы держите счётчик текущего окна и предыдущего и взвешиваете предыдущий по тому, как далеко вы зашли в текущее. Два счётчика на ключ, заметного всплеска на границе нет, точность - в пределах процента-двух. Именно так работает большинство CDN и большинство хороших библиотек, и это разумный вариант по умолчанию.
Token bucket (корзина токенов). В корзине помещается до N токенов, и она пополняется со скоростью r токенов в секунду. Каждый запрос забирает один. Простаивавший клиент накапливает до N и может потратить их разом; занятой выходит на уровень r. Это единственный алгоритм, который выражает то, чего обычно хочется: «шестьдесят в минуту, но десять разом - не страшно». Его родственник leaky bucket считает так же, но лишнее ставит в очередь, а не отклоняет, сглаживая трафик, а не отказывая.
Token bucket - для API, sliding window counter - для общей защиты, fixed window - для счётчиков входа. Этого хватает на все случаи небольшого сервиса.
Выбор ключа - задача сложнее, чем выбор числа#
Ключ - это то, по чему вы считаете, и от него зависит, справедлив ли ваш лимит.
- IP-адрес - единственный ключ, доступный до аутентификации, поэтому лимиты на вход и регистрацию обязаны использовать его. Он наказывает за общие адреса: офис, школу, мобильного оператора за CGNAT и целую страну за некоторыми корпоративными прокси. Держите числа щедрыми и считайте неудачи, а не запросы.
- Для IPv6 нужен префикс, а не адрес. Одному клиенту обычно выдают
/64, часто/56или/48. Лимит на каждый/128означает, что у атакующего с одним подключением фактически неограниченное число ключей. Обрезайте до/64перед хешированием. - ID пользователя или аккаунта справедлив и точен, но появляется только после аутентификации. Используйте его для всего, что находится за логином.
- API-ключ - правильная единица для API, потому что это ещё и единица тарификации, и единица, которую можно отозвать.
- Комбинации. Вход лучше всего ограничивать сразу тремя счётчиками: по IP, по имени пользователя и по паре «IP и имя пользователя». Пара ловит обычного атакующего, счётчик по имени - распределённую атаку на один аккаунт, а счётчик по IP - сканирование по многим аккаунтам.
Сценарий, из-за которого rate limiting втихую становится бесполезным, - это обратный прокси. Если ваше приложение стоит за прокси, балансировщиком или CDN, адрес, с которого оно видит подключение, - это адрес прокси, и поэтому все посетители мира делят один ключ. Настоящий адрес приходит в X-Forwarded-For, и фреймворку нужно явно сказать, чтобы он его читал. Тот же заголовок - самый легко подделываемый заголовок на свете, поэтому доверять ему можно только тогда, когда соединение пришло с прокси, которым вы сами управляете.
// Express: trust exactly one proxy hop, not "true"app.set("trust proxy", 1);# nginx: replace $remote_addr with the last untrusted hopset_real_ip_from 10.0.0.0/8;real_ip_header X-Forwarded-For;real_ip_recursive on;На RE:NODE в тарифы для приложений и сайтов входит слот прокси, а настоящий адрес клиента приходит в X-Forwarded-For, так что обе настройки выше применимы ко всему, что вы там развернёте. В статье что делает обратный прокси разобраны остальные заголовки, которые меняются по дороге.
Как правильно вернуть 429#
Разорвать соединение - худший из возможных ответов. Воспитанный клиент не отличит лимит от аварии и сразу повторит запрос; невоспитанный всё равно повторил бы; а в логе у вас нет строки, объясняющей причину.
Правильный ответ - 429 Too Many Requests с заголовком Retry-After, коротким телом, которое клиент может разобрать, и записью в логе. Retry-After принимает либо число секунд, либо дату HTTP.
HTTP/1.1 429 Too Many RequestsRetry-After: 30Content-Type: application/json{"error":"rate_limited","retry_after":30}Ещё две вещи, которые стоит сделать правильно:
- Сообщайте клиентам, где они находятся, ещё до стены. Давняя договорённость -
X-RateLimit-Limit,X-RateLimit-RemainingиX-RateLimit-Resetв каждом ответе, а не только в отклонённых. Черновик IETF стандартизирует ту же идею под именамиRateLimit-*, но его форма менялась не раз, так что выберите один набор, задокументируйте и не меняйте без предупреждения. - Добавляйте jitter к сбросу. Если тысяче клиентов сказано повторить ровно через тридцать секунд, все вернутся в одну и ту же миллисекунду. Варьируйте отправляемое значение на несколько процентов для каждого клиента и просите собственных клиентов отступать экспоненциально с jitter.
Используйте 429, когда виноват клиент, и 503 Service Unavailable, когда виноваты вы. Различие важно, потому что мониторинг, клиенты и поисковые роботы ведут себя с ними по-разному. 429 на эндпоинте входа - это ваша система работает; 503 - это ваша система отказывает.
Rate limiting в nginx#
Если перед приложением стоит веб-сервер, самый дешёвый лимит во всём стеке - тот, что вообще не доходит до вашего кода. nginx реализует leaky bucket в limit_req и прямое ограничение параллелизма в limit_conn.
# Shared memory zones live in the http block. 1 MB holds roughly# 16,000 states keyed by $binary_remote_addr.limit_req_zone $binary_remote_addr zone=general:10m rate=10r/s;limit_req_zone $binary_remote_addr zone=login:10m rate=10r/m;limit_conn_zone $binary_remote_addr zone=conns:10m;server { limit_req_status 429; limit_conn_status 429; location / { limit_req zone=general burst=20 nodelay; limit_conn conns 20; } location = /login { limit_req zone=login burst=5; }}Моменты, на которых спотыкаются:
- Статус отказа по умолчанию -
503, а не429. Задайтеlimit_req_status 429, иначе мониторинг будет сообщать, что сайт лежит, каждый раз, когда кто-то запускает скрапер. rate=10r/sприменяется с точностью до миллисекунды: это один запрос раз в 100 мс, а не десять в начале каждой секунды. Безburstодиннадцатый запрос за секунду отклоняется, хотя секунда ещё не закончилась.burst=20разрешает двадцать запросов в очереди сверх лимита. Безnodelayих задерживают, чтобы уложить в темп, - это то, что нужно форме входа. Сnodelayих обслуживают сразу и только потом учитывают - это то, что нужно API.- Отказы по умолчанию пишутся на уровне
error. Понизьте его черезlimit_req_log_level notice, если шум заглушает настоящие ошибки, но не выключайте совсем: по логу вы и узнаёте, какой ключ ведёт себя плохо. В статье логи, которые стоит хранить описано, что с ними делать дальше. limit_conn- другой инструмент против другого злоупотребления: клиента, который открывает сотни медленных соединений и держит их. Двадцати на адрес браузеру более чем достаточно.
Rate limiting в вашем приложении#
Всё, что должно знать о пользователях, аккаунтах или неудачах, относится к приложению - там, где лежит состояние.
// Express, with express-rate-limitimport rateLimit from "express-rate-limit";const loginLimiter = rateLimit({ windowMs: 15 * 60 * 1000, limit: 5, // called max in older versions skipSuccessfulRequests: true, // count failures, not attempts standardHeaders: true, legacyHeaders: false,});app.post("/login", loginLimiter, handler);# Django REST framework: rates in settings, throttles per viewREST_FRAMEWORK = { "DEFAULT_THROTTLE_CLASSES": [ "rest_framework.throttling.AnonRateThrottle", "rest_framework.throttling.UserRateThrottle", ], "DEFAULT_THROTTLE_RATES": {"anon": "60/minute", "user": "600/hour"},}Деталь, от которой зависит, заработает ли всё это, - где живёт счётчик. Каждая из этих библиотек по умолчанию использует хранилище внутри процесса, то есть у каждого воркера свой счёт. Четыре процесса Node с лимитом пять - это лимит двадцать, а перезапуск его обнуляет. Для одного процесса на одном сервере это нормально, и ничего добавлять не нужно. Для нескольких процессов счётчик должен быть общим - стандартный ответ это Redis INCR со сроком жизни, а в статье Redis и нужен ли он вам уже сейчас разобрано, как запустить его, не купив вторую проблему. Существующая база данных тоже подойдёт - ценой одной записи на запрос.
Для счётчика входа есть и более простой вариант: храните failed_attempts и locked_until в строке пользователя, которую вы всё равно читаете, чтобы проверить пароль. Никакого нового сервиса, никакого нового вида отказа, и он переживает перезапуск.
Злоупотребления не по HTTP#
Игровые серверы получают то же самое с другой стороны, и большую часть этого не решить кодом.
- Query-порты. Небольшой UDP-порт рядом с игровым отвечает на вопрос «какая карта, сколько игроков» любому, кто спросит. Поскольку ответ больше вопроса, протоколы запросов использовали для атак с отражением: ваш сервер служит усилителем, а жертвой становится кто-то другой. Серверы на движке Source в актуальных сборках теперь требуют challenge перед ответом на
A2S_INFO. Практический совет: держите версии свежими и не выделяйте query-порт, который вам не нужен; в статье порты игрового сервера перечислено, каким играм какие нужны. - Наплывы подключений. Боты, которые в цикле подключаются и отключаются, съедают реальный CPU на пути аутентификации. В Minecraft есть встроенный ограничитель пакетов:
rate-limitв server.properties задаёт, сколько пакетов в секунду может отправить одно соединение до того, как его выкинут, а0отключает его. Paper добавляет отдельные ограничители спама для автодополнения и запросов рецептов. - RCON. В большинстве реализаций нет ни лимита, ни блокировки вообще, и поэтому единственное, что стоит между атакующим и вашей консолью, - это пароль. Не выставляйте его в интернет и прочтите как безопасно пользоваться RCON, прежде чем делать с ним что-либо ещё.
- SSH и панели администрирования. Для этого и нужен
fail2ban: он следит за логом и банит адрес на файрволе после N неудач за M минут. Это rate limiter, который просто написан на правилах iptables. В статье руководство по fail2ban разобрано, какие jail стоит включить, а в правилах файрвола, которые важны - что вообще должно быть доступно.
Панель RE:NODE ограничена так же: перед входом стоят капча и лимиты, пароли хранятся как хеши bcrypt, API-ключи можно ограничить конкретными адресами, а все сессии перечислены, так что остальные можно завершить. Включение двухфакторной аутентификации полностью убирает эту категорию для вашего собственного аккаунта.
Чего rate limit не может#
Честно назвать потолок - в этом и смысл того, что он есть.
- Объёмные потоки. Если канал забит, ваш лимитер не запускается, потому что пакеты с запросами просто не доходят. Это проблема вышестоящей сети, и решать её нужно там. На RE:NODE формулировка намеренно узкая: фильтрация выше по цепочке, которая отбрасывает очевидные объёмные потоки, отражённый и искажённый трафик. Атаки, выглядящие как настоящие игроки, не фильтруются, потому что отличить их можно, только выбросив заодно настоящих игроков. В статьях что мы делаем с атаками и DDoS-атаки на игровые серверы простыми словами разобрано, что здесь возможно, а что нет.
- Распределённые медленные злоупотребления. Десять тысяч адресов по одному запросу в секунду - для лимитера по IP это оживлённый день, а для вашей базы - авария. Защита от этого - это поведенческие признаки, а не счётчики.
- Ускорение медленного эндпоинта. Лимит не даёт медленному эндпоинту утащить за собой всё остальное. Он не делает его быстрым, и тот, кто использует лимит вместо индекса, получает в итоге обе проблемы.
- Защита от самих себя. Ваши собственные cron-задачи, циклы повторов и health check обходят любой написанный вами лимит, потому что находятся внутри. Закладывайте их отдельно.
Ещё одна цена, которую стоит назвать: сам лимитер. Проверка, стоящая сетевого обращения к общему хранилищу, добавляет это обращение к каждому запросу, включая подавляющее большинство тех, что в порядке. Держите горячий путь локальным, где можете: token bucket внутри процесса - для широкой страховки, общее хранилище - только для эндпоинтов, где нужна точность.
Как подобрать числа и проверить их#
Не гадайте дважды. Измерьте один раз, а затем установите лимит выше того, что делают настоящие пользователи.
- Посмотрите, что происходит сейчас. Возьмите неделю логов доступа и найдите 99-й перцентиль запросов в минуту на адрес, а также самого активного законного клиента, которого можете определить. Ваш лимит должен быть заметно выше этого числа.
- Начните в режиме наблюдения. Пишите в лог то, что было бы отклонено, не отклоняя. Погоняйте так несколько дней. Почти каждая первая попытка ловит что-то неожиданное: мобильное приложение, которое опрашивает сервер, интеграцию партнёра, ваш собственный мониторинг.
- Затем включайте и смотрите на отказы. Постоянная тонкая струйка
429- это нормально. Всплеск - это либо атака, либо клиент, которого вы только что сломали, и строка лога подскажет, что именно, потому что в ней есть ключ. - Проверьте намеренно. Отправьте лимит плюс один запрос с тестового адреса и убедитесь, что получаете
429,Retry-Afterи запись в логе. Убедитесь со второго адреса, что глобального лимита у вас нет - это классический симптом неверно настроенного заголовка прокси. - Оповещайте по доле, а не по количеству. Отказы как доля запросов - число, которое значит одно и то же при любом уровне трафика. В статье мониторинг, который что-то говорит - про выбор метрик, которые ведут себя именно так.
Пересматривайте числа, когда меняется форма трафика: запуск, новый мобильный клиент, партнёрская интеграция, - и записывайте, почему каждый лимит именно таков. Лимит без указанной причины удвоит следующий человек, увидевший 429 в логе.
FAQ#
Какой код статуса должен вернуть запрос, превысивший лимит?
429 Too Many Requests с заголовком Retry-After, который говорит, сколько ждать. 503 используйте только тогда, когда неисправность на вашей стороне. nginx по умолчанию возвращает 503 для limit_req, поэтому задайте limit_req_status 429 явно.
Ограничивать по IP-адресу или по пользователю?
По пользователю везде, где пользователь есть, потому что это справедливо и точно. По IP там, где его нет - вход, регистрация, публичные эндпоинты, - с щедрыми числами, потому что адреса общие. Для входа включите оба варианта плюс счётчик по имени пользователя и считайте неудачи, а не попытки.
Почему мой лимитер блокирует всех сразу?
Почти всегда потому, что приложение стоит за прокси и видит адрес прокси в каждом запросе, так что у всех посетителей один общий счётчик. Настройте в фреймворке доверенный прокси и читайте X-Forwarded-For - но доверяйте этому заголовку только с адресов, которые вы контролируете.
Останавливают ли лимиты DDoS-атаку?
Нет. Лимит защищает работу, стоящую за ним, после того как запрос дошёл. Объёмный поток заполняет канал до запуска вашего кода, поэтому его нужно отбрасывать выше по цепочке. Лимиты останавливают злоупотребление, которое приходит с обычной скоростью: credential stuffing, скрапинг, спам и сорвавшихся клиентов.
Где хранить счётчик, если у меня несколько процессов?
Там, где его видят все. Обычный выбор - Redis с INCR и сроком жизни; существующая база данных тоже подойдёт ценой записи на каждый запрос. Счётчик внутри процесса корректен только для одного процесса, а в остальных случаях он втихую умножает ваш лимит на число воркеров.
Как ограничивать игровой сервер?
В основном никак, в коде. Вы не выставляете query- и RCON-порты, которые вам не нужны, держите сборку сервера свежей, чтобы работали его собственные защиты, используете ограничитель пакетов самой игры там, где он есть, и полагаетесь на фильтрацию выше по цепочке для потоков. Лимиты в стиле приложений относятся к веб-частям вокруг сервера, а не к игровому протоколу.




Комментарии
Полностью анонимно: без аккаунта, без почты, без cookie. Мы храним имя, которое вы ввели, текст и время - больше ничего. Количество ссылок ограничено, разметка не отображается.