Представьте два endpoint одного API. Первый, GET /v1/cards/42, отдаёт готовую карточку: запись лежит в кэше или читается одним индексным запросом, ответ уходит за несколько миллисекунд. Второй, POST /v1/reports, собирает отчёт: читает много строк, группирует, сортирует, может сходить во внешний сервис и держит процессор занятым заметно дольше. Для HTTP-счётчика это два одинаковых события. Для процессора, диска и базы данных это разные по весу события.
Поэтому правило «сто запросов в минуту на всех» выглядит справедливым только на бумаге. Клиент, который сто раз запросил карточки, почти не потревожил сервер. Клиент, который сто раз запустил отчёты, может занять все воркеры, и тогда пострадают те, кто пришёл за быстрой карточкой. Эта статья о том, как построить лимиты, которые учитывают и личность клиента, и цену операции, и как проверить, что они не бьют по добросовестным пользователям.
Две ручки с одинаковым счётчиком и разной ценой
Начнём с условного примера; цифры ниже выдуманы для рассуждения, а не измерены ни на чьём сервере. Пусть карточка в среднем обрабатывается за 5 мс процессорного времени, а отчёт за 800 мс и ещё держит соединение с базой. Тогда один отчёт «стоит» как сто шестьдесят карточек. Если разрешить сто запросов в минуту на клиента, то в худшем случае один клиент получает право на 80 секунд процессорной работы за минуту. На машине с несколькими vCPU это уже серьёзная доля ресурса, а клиентов у публичного API обычно много.
Отсюда первый практический вывод: лимит частоты нельзя назначать на весь сервис одной цифрой. Его нужно привязать к классу операции. Удобно разделить endpoint хотя бы на три класса:
- лёгкие чтения: карточки, справочники, статусы; лимит щедрый, главная задача — остановить явный перебор;
- записи и операции средней цены: создание объектов, поиск с фильтрами; лимит умеренный;
- тяжёлые операции: отчёты, экспорт, массовые пересчёты; лимит по частоте низкий, а сверх него вводится отдельный предел одновременного выполнения (о нём ниже).
Важно понимать, чего частотный лимит не умеет. Он считает запросы, а не секунды процессора, не строки в SQL и не внешние вызовы. Один и тот же POST /v1/reports с параметрами «за день» и «за пять лет» пройдёт через счётчик одинаково, хотя нагрузка будет отличаться на порядки. Если стоимость зависит от параметров, часть защиты приходится переносить в само приложение: ограничивать максимальный диапазон дат, размер страницы, число полей в выборке, глубину вложенности. Rate limit остаётся внешним ограждением, а не заменой дисциплины внутри кода.
Как работает ограничение на уровне nginx и чего от него ждать
На VPS публичный API обычно стоит за reverse-proxy, и nginx здесь удобное место для первого рубежа. По документации модуля limit_req, он ограничивает скорость по методу leaky bucket, а состояние хранит в разделяемой зоне памяти, поэтому все рабочие процессы видят общие счётчики. Избыточные запросы могут задерживаться в пределах burst, а за его границей отклоняются.
Практический смысл «дырявого ведра» такой: запросы вытекают с заданной скоростью, короткий всплеск помещается в burst и обслуживается с задержкой, а то, что не поместилось, получает отказ. Отдельно стоит помнить, что режим nodelay меняет поведение: запросы в пределах burst обслуживаются сразу, без выравнивания по времени, а слот в «ведре» при этом освобождается по обычной скорости. Для API с интерактивными клиентами часто выбирают именно такой вариант, чтобы небольшой пакет запросов не растягивался искусственно. Для тяжёлых операций, наоборот, задержка иногда полезнее мгновенного пропуска, потому что она сглаживает нагрузку.
Там же документация говорит о возможности применять несколько зон одновременно. Это ключевой приём для нашей задачи: один запрос может проходить сразу несколько проверок, и отказ любой из них останавливает его. Так мы и получим многослойный лимит: по адресу, по ключу, по классу операции.
Минимальный набросок, чтобы показать идею (значения условные, подбираются замером):
limit_req_zone $binary_remote_addr zone=per_ip:10m rate=20r/s;
limit_req_zone $http_x_api_key zone=per_key_light:10m rate=10r/s;
limit_req_zone $http_x_api_key zone=per_key_heavy:10m rate=6r/m;
location /v1/cards/ {
limit_req zone=per_ip burst=40 nodelay;
limit_req zone=per_key_light burst=20 nodelay;
limit_req_status 429;
proxy_pass http://api_backend;
}
location /v1/reports {
limit_req zone=per_ip burst=5 nodelay;
limit_req zone=per_key_heavy burst=2;
limit_req_status 429;
proxy_pass http://api_backend;
}Обратите внимание, что здесь нет «одной директивы на всё». Лёгкий и тяжёлый маршруты получили разные зоны и разные профили burst. У отчётов шесть запросов в минуту на ключ и очень малый запас, и без nodelay, чтобы лишнее не пролетало мгновенно. Но и этого мало: частота ничего не говорит о том, сколько отчётов выполняется прямо сейчас.
Кто такой «клиент»: IP, ключ, пользователь
Вопрос «по чему считать» важнее вопроса «сколько считать». У запроса есть как минимум три возможных идентификатора, и у каждого свои слабости.
IP-адрес
Это единственный идентификатор, который есть всегда, даже у анонимного запроса. Поэтому IP хорош как грубый нижний рубеж: он останавливает слепой перебор и сломанные циклы до того, как мы начнём разбирать заголовки и обращаться к базе. Но он плохо описывает человека или организацию. За одним адресом на выходе корпоративного NAT могут сидеть сотни сотрудников и несколько разных интеграций, и все они разделят один бюджет. Бывает и обратное: один клиент с пулом адресов получает множество независимых бюджетов. Мобильные операторы и публичный Wi-Fi дают ту же картину. Поэтому IP-лимит должен быть широким и служить предохранителем, а не основным механизмом справедливости.
Есть и техническая оговорка: если перед nginx стоит ещё один прокси или балансировщик, $binary_remote_addr будет содержать адрес этого прокси, и все клиенты превратятся в одного. Реальный адрес нужно восстанавливать из доверенного заголовка, причём только от известных прокси, иначе клиент подставит чужой адрес сам.
API-ключ
Ключ лучше описывает «арендатора» сервиса: у каждой интеграции свой бюджет, независимо от того, откуда она ходит. Для клиентов за общим NAT это основной способ не наказывать соседей. Но здесь легко ошибиться в другом месте. Значение заголовка X-Api-Key, которое прислал клиент, ещё ничего не доказывает: nginx просто берёт строку из запроса и делает её ключом зоны. Если лимит применяется до проверки ключа, любой может присылать случайные значения и каждый раз получать свежий пустой счётчик, то есть полностью обходить ограничение и заодно раздувать память зоны мусорными записями.
Это то самое место, где подставленный идентификатор нельзя принимать за проверенную учётную запись. Рабочие варианты такие:
- проверять ключ в приложении или в отдельном шаге авторизации, а лимиту по ключу отдавать уже проверенный идентификатор, например через заголовок, который выставляет доверенный слой, а не клиент;
- для непроверенных или отсутствующих ключей использовать общий жёсткий бюджет «анонимов» и бюджет по IP, а не персональный счётчик на присланную строку;
- не считать «по ключу» то, что в запросе вообще не прошло проверку подлинности.
Практическая схема получается двухступенчатой. Сначала грубый IP-лимит, который дёшев и не требует разбора учётных данных. Затем проверка ключа. И только после неё лимит по проверенному клиенту. Тогда подделка заголовка бьёт лишь по бюджету своего адреса.
Пользователь внутри ключа
Если за одним ключом стоит сервис, обслуживающий множество конечных пользователей, иногда нужен третий уровень: лимит на пользователя внутри клиента. Он защищает от ситуации, когда один шумный конечный пользователь расходует весь бюджет интеграции и остальные пользователи того же клиента остаются без ответа. Но этот идентификатор клиент обычно передаёт сам, поэтому доверять ему можно ровно настолько, насколько вы доверяете клиенту: как средству справедливого деления внутри его же бюджета, но не как границе безопасности. Общий потолок ключа должен оставаться выше.
| Идентификатор | Сильная сторона | Слабость | Роль в схеме |
|---|---|---|---|
| IP | Есть у любого запроса, не требует разбора учётных данных | NAT объединяет разных клиентов, пул адресов размножает бюджет | Широкий предохранитель |
| Проверенный ключ | Соответствует арендатору независимо от адреса | Бесполезен, если считать присланную строку без проверки | Основной лимит справедливости |
| Пользователь внутри ключа | Делит бюджет интеграции между её пользователями | Идентификатор передаёт сам клиент | Внутреннее выравнивание под общим потолком |
Клиенты за одним NAT: считаем на примере
Возьмём условную ситуацию. Организация подключила к вашему API три внутренних сервиса: синхронизацию каталога, виджет на сайте и ночной экспорт. Все они выходят в интернет через один адрес. IP-лимит на этот адрес, скажем, 20 запросов в секунду с запасом. Ночной экспорт запускает всплеск, и бюджет адреса выедается. В тот же момент виджет на сайте получает отказы, хотя сам ведёт себя идеально. Для организации это выглядит как «ваш API нестабилен», а для вас в логах это обычное срабатывание лимита.
Что помогает в такой схеме:
- IP-лимит поднят выше суммы разумных бюджетов нескольких клиентов и понижается только для явно подозрительных адресов, а не для всех;
- каждому сервису выдан собственный ключ, и лимит считается по ключу, так что экспорт и виджет не делят бюджет;
- ночной экспорт идёт по тяжёлому классу с собственным малым лимитом и ограничением параллельности, поэтому он не может выесть общий ресурс даже внутри своего ключа.
Здесь видно, зачем нужны несколько зон: IP отвечает за грубую защиту от шума, ключ за справедливость между арендаторами, а класс операции за то, чтобы тяжёлый работник не мешал лёгкому. Менять нужно не только цифры, но и сами оси счёта.
Тяжёлые операции: предел параллельности и очередь
Частотный лимит отвечает на вопрос «как часто». Для отчётов важнее другой: «сколько выполняется одновременно». Шесть отчётов в минуту выглядят скромно, но если каждый длится две минуты, одновременно будут работать двенадцать. Для воркеров приложения и для пула соединений с базой важна именно эта одновременность.
Модуль limit_req тут не помощник: он работает со скоростью поступления запросов. Ограничение параллельности реализуют иначе, и вариантов несколько:
- в приложении: семафор или пул воркеров фиксированного размера для тяжёлых задач; когда он занят, новые задания либо ждут, либо получают отказ;
- через очередь задач: запрос на отчёт только ставит задание и сразу возвращает идентификатор; отдельные воркеры берут задания с ограниченной параллельностью, а клиент опрашивает статус или получает уведомление;
- отдельный пул обработчиков: тяжёлые маршруты уходят на отдельный upstream с собственным небольшим числом процессов, и лёгкие запросы не делят с ними воркеры.
Последний вариант часто самый наглядный для VPS. Если у вас, допустим, четыре vCPU, можно оставить большую часть воркеров на быстрые запросы, а для отчётов выделить один-два. Отчёты станут выполняться медленнее и вставать в очередь, зато карточки продолжат отвечать мгновенно. Конкретные числа зависят от замера вашего кода и базы; универсального соотношения не существует, а ресурсы тарифа сами по себе не говорят, сколько клиентов вы обслужите.
Очередь ожидания: сколько ждать и кому
Как только появляется предел параллельности, появляется и вопрос, что делать с лишними заданиями. Возможны три политики:
- Отказать сразу. Просто, предсказуемо, но заставляет клиента повторять запрос.
- Короткая очередь. Небольшое число заданий ждёт своей очереди, остальные получают отказ. Очередь ограничена по длине и по времени ожидания; безграничная очередь превращает перегрузку в накопление мёртвых заданий, которые никому уже не нужны.
- Асинхронная модель. Задание принято и поставлено в очередь, клиент получает идентификатор и приходит за результатом позже. Для долгих отчётов это обычно самый честный вариант: соединение не держится минутами, а прокси не обрывает ответ по тайм-ауту.
Важна и справедливость внутри очереди. Если один клиент положил в неё сорок заданий, остальные будут ждать за ним. Разумный минимум: ограничить число активных и ожидающих заданий на одного клиента, например два выполняются и три ждут, а сверх этого отказ с понятным сообщением. Тогда большой отчёт одного арендатора занимает свою долю очереди, а не всю.
Нужен сервер под эту задачу?
Разверните VPS MAATRIX за пару минут: NVMe, AMD EPYC, root-доступ, локации Россия, США, Франция и Великобритания. Оплата картой РФ и по СБП.
Отказ, который можно понять и обработать
Когда лимит срабатывает, клиенту нужно сообщить три вещи: что произошло, чей это лимит и когда пробовать снова. Для этого в HTTP есть статус 429 Too Many Requests и заголовок Retry-After. В примере выше мы задали limit_req_status 429, потому что по умолчанию nginx отвечает кодом 503, который клиенты читают как «сервер сломался», и повторяют запросы с совсем другой логикой. Различать перегрузку клиентом и неисправность сервера важно как для их кода, так и для вашей статистики.
Хороший ответ в теле содержит машиночитаемый код причины и человеческое пояснение. Например, разные коды для превышения лимита по ключу, по адресу и для занятого пула тяжёлых операций. Тогда клиент поймёт, что виновата частота, а не параллельность, и соответственно изменит поведение. Если вы можете указать, через сколько секунд освободится слот, передавайте Retry-After с этим значением; если не можете точно, лучше дать честную оценку, чем постоянное «1».
Как должен вести себя добросовестный клиент
Отказ лишь половина разговора, вторая половина — поведение клиента при повторе. Наивный цикл «получил 429, сразу повторил» превращает один отказ в шквал: все клиенты, получившие отказ в одну секунду, возвращаются в одну и ту же. Правильная стратегия простая:
- уважать
Retry-After, если он есть; - иначе применять экспоненциальную задержку с небольшим случайным разбросом, чтобы повторы не синхронизировались;
- ограничить число повторов и общее время, после чего сообщить об ошибке выше, а не крутить цикл бесконечно;
- для неидемпотентных операций, таких как создание отчёта или платежа, повторять только с механизмом, который не создаёт дубликат. Для этого нужен стабильный ключ операции, который сервер реально проверяет; просто отправить запрос ещё раз с новым идентификатором попытки дубликатов не исключает.
Эти правила стоит записать в документацию API рядом с описанием лимитов. Лимит, о котором клиент узнал из первого же отказа в продакшене, воспринимается как поломка, а лимит, описанный заранее, как часть контракта.
Что стоит проверить на стенде до выката
Лимиты настраивают не по ощущению, а на стенде, где можно спокойно нагрузить сервис и посмотреть, что произойдёт. Хорошо подходит отдельный VPS или копия окружения, чтобы эксперименты не задели живых клиентов. Для такой роли годится небольшая конфигурация: для репетиции правил обычно хватает машины с парой vCPU, например тариф Uab (2 vCPU, 4 ГБ RAM, 80 ГБ NVMe). Но важно помнить, что цифры тарифа не равны обещанию ёмкости: сколько запросов вынесет ваше приложение, показывает только замер. Если нужен общий порядок измерений всей цепочки, полезен разбор о том, как искать узкое звено API за реверс-прокси.
Сценарии стоит прогнать именно такие, какие описаны выше:
- Смешанная нагрузка. Одновременно запускайте поток лёгких запросов и пачку отчётов. Следите за задержкой лёгких запросов: если при росте числа отчётов она растёт, значит тяжёлые всё ещё делят воркеров или базу с лёгкими.
- Два клиента за одним адресом. С одного IP пошлите запросы с двумя разными проверенными ключами. Один должен исчерпать свой бюджет, не затронув второй, пока не сработал общий IP-предохранитель.
- Подделка идентификатора. Шлите каждый запрос со случайным значением заголовка ключа. Лимит по нему не должен давать свежий бюджет на каждый запрос, а непроверенные запросы должны упираться в IP-лимит или общий лимит анонимов.
- Поведение при повторе. Запустите клиента с наивным немедленным повтором и клиента с задержкой и разбросом. Сравните, как быстро система выходит из перегрузки.
- Предел тяжёлых. Отправьте больше отчётов, чем разрешено параллельно, и проверьте, что лишние получают очередь или осмысленный отказ, а не тайм-аут прокси.
Результаты этих прогонов нужно читать не по среднему времени ответа, а по хвосту: высоким перцентилям задержки лёгких запросов и доле отказов среди добросовестных клиентов.
Не слишком ли дорог сам контроль
Защита, которая сама съедает заметную долю ресурсов, плохая защита. У лимитирования есть собственные расходы, и их стоит оценить.
- Память зоны. Каждый уникальный ключ занимает запись в разделяемой зоне. Если ключом служит непроверенная строка из заголовка, атакующий может заполнять зону бесконечным разнообразием значений. Размер зоны надо подбирать с запасом и ключи ограничивать проверенными значениями. Следите за сообщениями об исчерпании зоны в журнале ошибок.
- Проверка ключа. Если для каждого запроса вы ходите в базу, чтобы проверить ключ, то под нагрузкой сама авторизация становится узким местом, особенно на потоке мусора. Грубый IP-лимит перед ней как раз и нужен, чтобы мусор не доходил до дорогой проверки. Результат проверки разумно кэшировать на короткое время.
- Дополнительные хопы. Внешняя очередь или отдельный сервис счётчиков добавляют задержку и новую точку отказа. Если лимитер недоступен, нужно заранее решить, пропускать запросы (fail open) или блокировать (fail closed), и этот выбор для разных маршрутов может различаться.
- Несколько серверов. Зона nginx живёт на одном сервере. Если API работает на нескольких VPS, у каждого будут свои независимые счётчики, и суммарный лимит клиента окажется кратным числу узлов. Распределённый лимит требует отдельного решения, например общего хранилища счётчиков, и это отдельная архитектурная задача со своими компромиссами.
Замеряйте накладные расходы так же, как нагрузку: прогоните стенд с включённым и выключенным лимитом и сравните загрузку процессора nginx и задержку лёгких запросов. Если разница в пределах шума, контроль дешёвый; если нет, ищите, какой слой её создаёт.
Обходной endpoint и другие щели
Самая частая причина, по которой лимит «не работает», не в настройке зон, а в том, что нашёлся маршрут, на который он не распространяется. Проверьте несколько типичных мест.
- Альтернативные пути к той же операции. Старая версия
/v0/reports, зеркальный маршрут для мобильного клиента, тот же обработчик под другим префиксом. Лимит стоит вешать на класс операции, а не на отдельный URL, либо проверять, что каждый путь к тяжёлому обработчику действительно закрыт. - Различия в записи пути. Двойные слэши, регистр, параметры в строке запроса, завершающий слэш. Убедитесь, что
locationперехватывает все варианты, которые доходят до приложения. - Пакетные и GraphQL-подобные эндпоинты. Один HTTP-запрос может содержать десятки операций. Счётчик видит один запрос, а нагрузка равна десятку. Здесь лимит надо считать на стороне приложения по числу вложенных операций.
- Прямой доступ к бэкенду. Если приложение слушает публичный порт или доступно в обход прокси, лимит nginx бессмыслен. Бэкенд должен принимать соединения только от прокси.
- Другие протоколы. Веб-сокеты и потоковые ответы живут долго, и частотный лимит их почти не ограничивает; нужен предел числа одновременных соединений.
- Внешние вызовы и SQL. Rate limit не ограничивает стоимость запроса к базе и число обращений к сторонним сервисам. Это задача для таймаутов запросов, ограничений на выборку и собственных бюджетов в коде.
Полезная привычка: пройтись по списку всех публичных маршрутов и для каждого записать, к какому классу стоимости он относится и какие лимиты на него действуют. Маршрут, у которого эта графа пустая, и есть кандидат на обход.
Граница между защитой от злоупотребления и защитой от DDoS
Всё сказанное — про справедливое распределение ресурса между легитимными клиентами и ограждение от случайных или недобросовестных перегрузок. Это не полноценная защита от распределённых атак. Если на сервер приходит поток от тысяч адресов, который забивает канал или сетевой стек, nginx с его зонами получает этот трафик уже на свой процесс, и расход ресурсов на отказ всё равно присутствует. Против такого нужны меры на других уровнях: фильтрация на сетевом уровне, услуги провайдера, отдельные средства защиты перед сервером. Аккуратный rate limit внутри VPS эту задачу не решает, и обещать обратное клиентам не стоит. Зато он хорошо закрывает повседневные сценарии: сломанный скрипт интегратора, жадный парсер, ночной экспорт, который не рассчитал аппетит.
Где всё это жить: ресурсы VPS под такую схему
Для публичного API на VPS нужен root-доступ, чтобы настраивать nginx, зоны, системные лимиты и пул обработчиков. На VPS MAATRIX есть root-доступ, NVMe-диски и процессоры AMD EPYC, а локации в публичном предложении указаны UK, US, RU и FR. Для репетиции лимитов удобно поднять отдельный стенд, а для боевого API выбирать конфигурацию по результатам замера: линейка тарифов от Ba (1 vCPU, 2 ГБ RAM, 40 ГБ NVMe) до Djed (16 vCPU, 32 ГБ, 320 ГБ) позволяет расти по мере появления данных, но сами характеристики не равны обещанию числа клиентов. Если вы разделяете лёгкие и тяжёлые обработчики по пулам, число vCPU напрямую определяет, сколько воркеров вы можете выделить каждому классу. Точное наличие образов и операционных систем проверяется в форме заказа на странице аренды VPS.
Как выглядит удачный результат
Хороший лимит замечают лишь по тому, чего не происходит. Пока один арендатор запускает пачку больших отчётов, они выстраиваются в очередь с предельным числом одновременных исполнений, лишние получают понятный 429 с рекомендацией повторить позже, а клиент с правильной логикой ждёт и повторяет с разбросом. В это же время виджет за тем же NAT и другие пользователи продолжают получать карточки за те же миллисекунды, что и в тихий час. Именно по этому признаку и стоит принимать настройку на стенде: график задержки лёгких запросов во время тяжёлой нагрузки остаётся почти плоским. Большой отчёт занимает своё место в очереди и не отнимает у остальных быстрые обычные запросы.