Обеспечение надёжности высоконагруженного приложения. Паттерн Rate Limiting

Крайне часто сбои в системе возникают из-за некорректного или непредсказуемого поведения одного из компонентов самой системы или её потребителей. Например, если какой-то из потребителей отправляет количество запросов, превышающее изначальные договорённости и возможности системы. Бывает, что сбой происходит только в одном экземпляре приложения, но это оказывает воздействие на всех его потребителей, хотя остальные экземпляры работают нормально.
Для решения этих и других потенциальных проблем есть ряд общепринятых паттернов надёжности. С некоторыми из них вы уже успели познакомиться в прошлых спринтах, а именно с Retry и Circuit Breaker. Однако существует ещё несколько и в этом уроке вы узнаете о паттерне Rate limiting, а в следующем разберём паттерны Bulkhead и Transactional outbox.

Rate Limiting

✏️ Rate limiting — популярный шаблон для обеспечения надёжности в распределённых и нагруженных системах. Его задача — осуществлять контроль количества запросов, то есть нагрузку, с которой пользователи или другие системы обращаются к ресурсу (API, сервису или БД). Он играет важную роль в защите системных ресурсов и обеспечении равномерного использования ресурсов всеми пользователями
.
Этот паттерн решает целый ряд задач:
  • Оптимизация производительности
    Rate limiting ограничивает скорость входящих запросов и предотвращает чрезмерное потребление ресурсов, а это, в свою очередь, помогает поддерживать нужное время отклика.
  • Обеспечение стабильности
    В ситуациях, когда ресурсов системы уже становится недостаточно для обработки запросов, системы подвержены не только снижению производительности, но и сбоям. За счёт ограничения скорости входящих запросов система избегает перегрузки.
  • Контроль расходов на инфраструктуру
    Чем больше объём запросов, тем больше требуется ресурсов для их обработки, что может повлечь за собой дополнительные расходы. Паттерн помогает защититься от перерасхода средств, предотвращая чрезмерное использование сервиса.
  • Контроль количества исходящих запросов
    В дополнение к предыдущему пункту Rate limiting важен для сервисов, которые выполняют исходящие запросы, особенно тех, что используют платные сторонние API. Многие сторонние службы взимают плату за каждый вызов своего API (например, сервисы получения прогноза погоды, сервисы генерации контента (Midjourney) и т. д.).
  • Смягчение DoS-атак
    Контроль количества запросов может выступать дополнительным защитным механизмом от DoS-атак. Однако не стоит использовать Rate limiter как единственное средство защиты от DoS: часто алгоритмы контроля на Rate limiter легко обойти (например, за счёт большего количества IP-адресов), с которых проводится атака. Поэтому рекомендуется использовать и специализированные средства защиты, которые работают по аналогичным алгоритмам, но направлены именно на борьбу с DoS.
  • Справедливое распределение ресурсов для потребителей
    Rate limiting позволяет избегать ситуаций, при которых один из клиентов отправляет чрезмерное количество запросов и тем самым «сжирает» много ресурсов приложения. В итоге это может привести к деградации производительности для других клиентов, отправляющих запросы.
  • Защита от нецелевого использования
    Паттерн защищает системы от злоупотреблений или нецелевого использования. Например, если сервис предполагается только для использования обычными пользователями, то Rate limiter может блокировать ботов за счёт контроля количества запросов.

Когда применять?

Применение Rate limiting может сыграть решающую роль для вашей системы во множестве кейсов.
  1. Лимитирование запросов от пользователей. Представьте банковское приложение, которое позволяет осуществлять переводы денежных средств другим лицам, проводить конвертации валют и многое другое. Чтобы предотвратить мошеннические действия, лимитирование может осуществляться в разрезе пользователей. Оно ограничивает количество переводов или конвертаций, которые пользователь может сделать за минуту.
  2. Лимитирование запросов на уровне API. Rate limiting часто реализуют на уровне API, особенно публичных, для контроля скорости запросов от клиентов, обеспечивая справедливый доступ к ресурсам и предотвращая злоупотребления. Например, мессенджеры, такие как Telegram, ограничивают количество запросов API в секунду в разрезе технической учётной записи, чтобы предотвратить спам и обеспечить стабильность системы.
  3. Лимитирование запросов к БД. Также применяется для предотвращения чрезмерной нагрузки на сервер базы данных и поддержания её производительности.
  4. Ограничение количества логинов. Контроль количества попыток входа применяется, например, для предотвращения атак методом подбора пароля, ограничивая количество попыток входа на пользователя или IP-адрес.
Теперь, когда вы представляете выгоду использования Rate limiting, поговорим о принципе работы этого паттерна.

Принцип работы Rate limiter

В системе создаётся компонент, Rate limiter, который встаёт между клиентом и ресурсом, для которого нужно ограничить нагрузку. То есть весь трафик проходит через Rate limiter, а он в свою очередь выполняет контроль.
Паттерн может быть реализован с использованием разных стратегий, которые мы разберём позже. Но общая логика очень проста: когда скорость запросов превышает порог, который определён в Rate limiter, запросы задерживаются или блокируются.
Например, пользователь не сможет отправлять более двух запросов в секунду или создавать более десяти учётных записей в день с одного и того же IP-адреса.

Базовые понятия Rate limiter

Большинство реализаций ограничения скорости объединяет три основных понятия: лимит, окно и идентификатор.
  • Лимит определяет максимальное значение для количества запросов или действий в течение назначенного промежутка времени. Например, можно разрешить пользователю отправлять не более двух запросов в секунду.
  • Окно — это период времени, в течение которого вступает в действие лимит. Это может быть любой промежуток времени, будь то секунда, минута, час или даже больше. Однако надо отметить, что чем более длительными периодами вы оперируете, тем больше сложностей может возникнуть при реализации из-за проблем долгосрочного хранения лимитов. К этой проблеме вернёмся позже.
  • Идентификатор — это уникальный атрибут, который позволяет отличать потребителей ресурса, перед которым стоит Rate limiter. Это может быть учётная запись, идентификатор пользователя или IP-адрес или что-то другое. Важно, чтобы он давал максимально точно идентифицировать потребителя, не затронув других потребителей.
    Например, IP-адреса в качестве идентификатора может создать сложности, когда несколько пользователей по какой-то причине имеют одинаковый публичный IP-адрес, а количество запросов превышает только один из них. Поэтому к выбору идентификатора надо подходить ответственно, учитывая конкретные условия вашего приложения.
    Идентификатор лимита может быть составной (например, тип операции + идентификатор пользователя).
Определение нужных значений осуществляется исходя из нескольких показателей, которые во многом зависят от того, решение каких проблем для вас приоритетно:
  • Возможности приложения. В первую очередь при определении лимитов стоит учитывать возможности вашего приложения. Предположим, вы предоставляете публичный API. Зная пропускную способность вашего приложения, а также количество максимальных одновременных систем — потребителей у приложения, вы можете определить оптимальные параметры.
    Например, максимальное количество одновременных потребителей API равно 100, а максимальная пропускная способность равна 300. Отталкиваясь от этих параметров, вы можете установить лимит запросов, равный трём запросам в секунду или меньше, что защитит приложение от избыточной нагрузки.
    Аналогичный подход можно использовать в случаях лимитирования запросов от пользователей, которые взаимодействуют с приложением через UI.
  • Соглашения с потребителями. Не всегда удаётся применить подход, описанный в предыдущем пункте. На определение лимитов могут также влиять требования или договорённости бизнеса.
Например, в случае с тем же публичным API потребителям в условиях подписки могут гарантироваться SLA, включая доступное им количество запросов в секунду. Тогда при конфигурации необходимо учитывать договорённости и SLA, прописанные в соглашении с потребителями.
  • Другие аспекты. Также на определение значений лимита может влиять множество других факторов. Например, требования безопасности в примере с ограничением количества логинов и многое другое.
Главное — помнить, что первостепенная задача паттерна Rate limiting — это предотвратить отказ или сбои в вашем приложении. И выставление лимитов должно всегда учитывать реальные возможности приложения.

Типовые способы обработки превышения лимита

При реализации паттерна важно определить то, как Rate limiter будет реагировать на превышение лимита. Обычно выделяют три типа — блокировку, троттлинг и приоритизацию.
  1. Блокировка является наиболее простой реализацией: в случае превышения Rate limiter просто не пропускает запрос. Обычно это сопровождается ответным сообщением об ошибке. Для HTTP-взаимодействий в таких ситуациях принято использовать 429 код — Too many requests (слишком много запросов).
  2. Троттлинг же предполагает задержку или замедление запросов, которые выходят за пределы лимита. После завершения текущего окна запросы могут быть направлены на ресурс, если не истекло время ответа.
    Например, приложение онлайн-газеты может не отправлять запросы на обновление главной страницы чаще, чем раз в пять секунд. То есть только по истечении пяти секунд запрос будет направлен на сервер. Альтернативно запросы в такой стратегии могут направляться на более медленный ресурс или ресурс, который предоставляет услуги или контент более низкого качества. Например, сервис потокового видео может передавать видео в низком разрешении для снижения объёмов данных, передаваемых по сети.
  3. При использовании стратегии приоритизации запросы не блокируются и не откладываются. Но этим запросам назначается более низкий приоритет. Это гарантирует, что пользователи, соблюдающие лимиты, получат качественный сервис.
    Например, в CDN запросы от пользователей, которые превысили свои лимиты, могут обрабатываться в последнюю очередь, в то время как запросы от обычных пользователей имеют приоритет. В то же время, если в данный момент ресурс не сильно загружен, пользователи, превысившие лимит, могут этого даже не заметить.

Алгоритмы лимитирования запросов

При реализации Rate limiter могут быть применены различные алгоритмы. О каждом расскажем по порядку.

1. Счётчик фиксированного окна

Алгоритм счётчика фиксированного окна делит временную шкалу на временные окна фиксированного размера и назначает счётчик для каждого окна. Каждый запрос увеличивает счётчик на некоторое значение. Как только счётчик достигает порогового значения, последующие запросы блокируются до начала нового временного окна.
Например, мы можем установить окно размером в одну секунду и установить лимит в три запроса в секунду. Соответственно, после получения трёх запросов в пределах одной секунды последующие запросы, поступающие в том же временном окне, блокируются.
Такой алгоритм просто реализовать, но у него есть проблема: невозможно контролировать лимит запросов, которые происходят на границах окон, и это позволяет пройти избыточным запросам.
Например, окно установлено в одну минуту, а лимит запросов — в пять запросов в минуту. Представим, что были сделано пять запросов в период между 2:00:30 и 2:01:00, а потом ещё пять в период 2:01:00 и 2:01:30. Получается, за минутный интервал было совершено на самом деле десять запросов. Это вдвое больше разрешённых запросов.
Такой подход может оказаться недостаточно совершенен при больших всплесках нагрузки и когда лимиты исчисляются сотнями или тысячами запросов за короткие промежутки времени.

2. Журнал скользящего окна, или алгоритм счётчика фиксированного окна с определяемым пользователем началом

Этот алгоритм устраняет проблему с алгоритмом счётчика фиксированного окна, так как отслеживает временные метки отдельных запросов в пользователей и сохраняет их в журнал.
Окна начинаются в момент, когда пользователь совершил первый запрос, и с каждым новым запросом журнал проверяется на наличие запросов в пределах окна. По мере устаревания из-за завершения временного окна метки удаляются из журнала и заменяются новыми. Для хранения журнала обычно используется кеш, например Redis.
Этот алгоритм обеспечивает более равномерное распределение запросов. В любом окне запросы не превысят лимит. Однако он потребляет много памяти, так как ведётся журнал всех временных меток запросов в разрезе каждого пользователя даже для отклонённых запросов.

3. Счётчик скользящего окна

Представляет собой гибрид, который объединяет предыдущие два алгоритма. Вместо ведения журнала временных меток запросов он вычисляет взвешенный счётчик для предыдущего временного окна. Когда поступает новый запрос, счётчик корректируется на основе веса и запрос разрешается, если общая сумма ниже лимита.
Например, окно установлено в одну минуту, а лимит равен семи запросам. Представим, что потребитель осуществил пять запросов за прошлую минуту, то есть он израсходовал примерно 70 процентов своего лимита. Поэтому в следующую минуту в первые 30% от этого временного окна для проверки используется такой алгоритм:
  1. Считается количество разрешённых запросов в предыдущем фиксированном окне.
  2. Считается количество разрешённых запросов в текущем фиксированном окне.
  3. Определяется вес запросов предыдущего окна, который пропорционален пересечению этого окна с плавающим окном, завершающимся в текущий момент.
  4. Суммируются взвешенные запросы из (3) с невзвешенными запросами из (2).
Используя этот алгоритм, получаем 3 + 5 × 0,7% = 6,5 запроса. Результат может округляться как в большую, так и в меньшую сторону. Это зависит от конкретных задач, которые пытается решить команда.
Такой подход используется в различных готовых решениях (например, Upstash или Cloudflare). Он более эффективен, чем предыдущие, потому что:
  • позволяет сглаживать пиковые нагрузки за счёт использования количества запросов из предыдущего окна при расчёте,
  • снижает требования к хранению и обработке временных меток по сравнению с алгоритмом счётчика фиксированного окна с определяемым пользователем началом.
Но ситуации, при которых алгоритм будет пропускать всплески нагрузки, всё ещё могут возникать. Так как он основан на предположении, что запросы в предыдущем окне были распределены равномерно.
Однако команда Cloudflare, одного из промышленных решений Rate limiter, проводила эксперименты, которые показали, что только 0,003% запросов ошибочно блокируются или разрешаются.

4. Бакет токенов

Этот алгоритм прост, понятен и широко используется крупными технологическими компаниями, такими как Amazon, Stripe и другие, для ограничения запросов к своим API.
Разберём, как он работает. Токены представляют собой разрешённое количество запросов, которые хранятся в бакете, или, по-другому, ведре. Бакет изначально заполнен токенами, при этом с течением времени они добавляются в бакет с фиксированной скоростью. Когда поступает запрос, Rate limiter забирает токен из ведра. Если в бакете есть токены, то запрос пропускается, если нет — блокируется.
Главным преимуществом данного алгоритма является возможность ограничения резких скачков нагрузки. При этом он хорошо показывает себя и при средних нагрузках.
Существует также альтернативный алгоритм — дырявое ведро. Он работает по обратному принципу «наполнения» ведра поступающими запросами и периодического их «сливания» из него.

Где расположить Rate limiter?

Теперь, когда в общих чертах ясно, что такое Rate limiter и как он работает, пришло время ответить на вопрос, где он должен быть расположен. И первую очередь — определить, устанавливать его на клиенте или на сервере.
Для принятия решения нужно учитывать несколько аспектов. Для наглядности прописали их в таблице.
В большинстве ситуаций реализация на стороне сервера будет более подходящей. Но в отдельных случаях стоит использовать ограничитель запросов на стороне клиента (например, при вызове внешних платных API с целью контроля расходов).
Как вы поняли из таблицы, при реализации Rate limiter на стороне сервера необходимо определить, на каком слое он будет расположен:
  1. Уровень приложения Ограничение скорости на уровне приложения включает реализацию логики ограничения скорости в самом коде приложения. В этом случае Rate limiter можно реализовать самостоятельно или с использованием библиотеки. При этом надо учитывать, что контроль будет осуществляться каждым отдельным экземпляром приложения. В случае микросервисной архитектуры это значит, что каждый сервис, а точнее каждый развёрнутый его экземпляр, будет осуществлять лимитирование запросов. Такая реализация может оказаться избыточной в части потребления ресурсов, так как контроль будет происходить уже достаточно поздно и приводить к потреблению ресурсов самого приложения. Но, наоборот, будет полезна для ситуаций, когда требуется настройка на уровне каждого отдельного приложения или сервиса. Или когда по какой-то причине нет возможности реализовать лимитирование на слоях выше, в балансировке или API-шлюзе.
  2. Уровень балансировщика или шлюза API Ограничение скорости на этом уровне зачастую является оптимальным решением, так как позволяет избежать влияния на прикладные компоненты приложения. Как правило, Rate limiter осуществляется через настройку правил в случае использования готовых решений, например NGINX. Многие современные балансировщики и шлюзы API позволяют осуществить гибкую настройку в разрезе конкретных ресурсов, например для конкретного REST-ресурса. Лимиты применяются к входящим запросам до их пересылки в нижестоящие компоненты. Отлично подходит как для управления доступом к общедоступным API, так и для защиты внутренних API от чрезмерного трафика. Однако такая реализация не сможет защитить в ситуациях, когда трафик можно направлять в обход — напрямую в приложение.
  3. Уровень Service mesh Лимитирование запросов с использованием Service mesh стоит вынести отдельно. Такая реализация закрывает минусы предыдущих пунктов, так как Service Mesh контролирует весь поступающий в приложение трафик, но при этом не дублирует функциональность на каждый отдельный экземпляр. Но пока сам Service mesh мало распространён, он подходит далеко не всем.
  4. Уровень базы данных Такая реализация полезна для защиты базы данных от чрезмерной нагрузки. Rate limiter предотвращает снижение производительности и обеспечивает справедливое распределение ресурсов между несколькими приложениями или их экземплярами. Важно понимать, что необязательно ограничиваться реализацией только на одном слое: их можно комбинировать. Всё зависит от конкретной архитектуры приложения и локальных требований к надёжности приложения. Тем не менее надо всегда помнить, что каждый дополнительный элемент, через который проходит трафик, добавляет расход ресурсов и увеличивает время отклика.

Применение Rate limiter на примере NGINX

Как было описано выше, одной из самых распространённых и эффективных реализаций является лимитирование запросов на уровне балансировщика или шлюза API. Давайте коротко посмотрим, как его можно применить в популярном инструменте NGINX.
Для изучения общей нотации конфигурации стоит ознакомиться с документацией проекта.
Лимитирование запросов в NGINX на основе счётчика фиксированного окна настраивается с помощью двух основных директив: limit_req_zone и limit_req, как в этом примере:

JSX

limit_req_zone $binary_remote_addr zone=mylimit:10m rate=10r/s;
server {

    location /login/ {

        limit_req zone=mylimit;
        proxy_pass http://my_upstream;

    }

} 
Директива limit_req_zone определяет параметры ограничения скорости, в то время как limit_req включает ограничение скорости для конкретного ресурса, в примере для всех запросов к /login/. Обычно limit_req_zone определяется в блоке http, чтобы была возможность её переиспользовать для разных ресурсов.
Она содержит три параметра:
  1. Key — определяет ключ для лимитирования. В примере используется переменная NGINX $binary_remote_addr, которая содержит двоичное представление IP-адреса клиента.
  2. Zone — определяет зону общей памяти, где хранится состояние для каждого IP-адреса и того, как часто он обращался к URL-адресу с ограничением по запросам. Определение состоит из двух частей:
    • имя зоны, идентифицированного ключевым словом zone=,
    • размер, который отражается после двоеточия. В один мегабайт помещается информация о состоянии примерно для 16 000 IP-адресов.
  3. Скорость — устанавливает максимальную частоту запросов. В этом примере частота не может превышать 10 запросов в секунду.
Для кастомизации кода ошибки, которую NGINX будет возвращать в случае превышения лимита запросов, можно использовать директиву limit_req_status. По умолчанию NGINX вернёт 503 ошибку. Если хотите её поменять (например, на 444), добавьте следующую строчку в конфигурацию limit_req_status 444;.

JSX

limit_req_zone $binary_remote_addr zone=mylimit:10m rate=10r/s;
server {

    location /login/ {

        limit_req zone=mylimit;
        limit_req_status 444;

        proxy_pass http://my_upstream;

    }

} 
NGINX предлагает широкий спектр возможностей для конфигурации лимитирования запросов. В случае необходимости его использования рекомендуем ознакомиться с официальной документацией.
Пример. Вспомним сервис «ОнлиСхемы»: команда решила использовать Rate limiting в качестве инструмента борьбы с самописными ботами пользователей, которые осуществляют миграцию данных из ушедшего Miro в их сервис.
Хотя сервис «ОнлиСхемы» предлагает решение для этого, пользователи, желающие сэкономить, написали собственное. С помощью Rate limiter команда ограничила количество запросов на создание объектов в разрезе учётной записи клиента до одного запроса в три секунды. Ограничение команда установила на уровне балансировщика, так как не выявила сценариев, при которых запросы могут пройти в обход него до приложения.
Такое решение сделало работу ботов неэффективной и долгой. А компания заметила рост популярности услуги по миграции данных из Miro.

Задание 1

Выберите из списка все проблемы, которые помогает решить Rate limiter.
Применение Rate Limiter на стороне сервиса-провайдера поможет ограничить потребление ресурсов за счёт контроля количества входящих запросов.
Rate limiter можно установить на стороне приложения-клиента для контроля количества исходящих запросов, чтобы контролировать расходы на платные API.
Подсчёт лимитов для такого ресурса, как журнал, логично вести в разрезе IP-адресов, чтобы не влиять на доступ к ресурсу других пользователей. Поэтому в случае с атакой, описанной выше Rate limiter, скорее всего, окажется бессилен, так как устройств умного дома, а значит, и IP-адресов, с которых будет вестись атака, большое количество.
Rate limiting может быть использован как средство защиты от нецелевого использования за счёт ограничения количества отправляемых сообщений в день.
Для решения этой проблемы, скорее, подойдёт паттерн Circuit Breaker. Ограничив количество запросов к этому API, не получится решить описанную проблему.

Задание 2

Приложение периодически сталкивается с проблемой чрезмерного количества запросов от некоторых пользователей. Команда решает настроить контроль запросов на отправку сообщений из веб-приложения в разрезе каждого пользователя. На какой стороне требуется реализовать Rate limiter в этой ситуации?
В большинстве случаев сервер является оптимальным решением. Такая реализация даёт возможность гибкой настройки для контроля, в том числе в разрезе пользователей или их IP-адресов.
Управление лимитов на стороне клиента в данном случае не является оптимальным решением. Контроль запросов на стороне клиента не позволяет управлять нагрузкой на сервис, так как количество клиентов может вырасти. К тому же пользователи веб-приложения теоретически имеют возможность манипулировать лимитами на своей стороне.

Задание 3

Приложение большую часть времени работает под средней нагрузкой, однако периодически сталкивается со всплесками нагрузки, в разы превышающую среднюю, что приводит к сбоям в приложении. Какие алгоритмы ограничения запросов подойдут в такой ситуации?
Алгоритм прост в реализации, но всё же позволяет пройти избыточным запросам, которые происходят на границах окон.
Один из допустимых вариантов ограничения запросов: в любом окне запросы не превысят лимит. Но стоит помнить, что такой алгоритм потребляет много памяти, так как ведётся журнал всех временных меток запросов в разрезе каждого пользователя.
Главным преимуществом данного алгоритма является возможность ограничения резких скачков нагрузки, так как не зависит от окна времени.
Этот алгоритм позволяет сглаживать пиковые нагрузки, рассчитывая запросы из предыдущего окна, но всё ещё может пропускать всплески нагрузки.

Что почитать про Rate Limiter

Advanced Rate Limiting & Brute Force Protection | Cloudfare
Best practices | Cloudfare Web Application Firewall (WAF)
nginx documentation

Итоги

  • Rate limiter — это метод управления количеством запросов, которые может отправить пользователь или клиент к серверу в определённый период времени. Он применяется для защиты серверов от перегрузки, предотвращения DoS-атак и обеспечения справедливого распределения ресурсов между пользователями. Это особенно важно для публичных API и веб-сервисов, где большое количество запросов может привести к ухудшению производительности.
  • Существует несколько алгоритмов для реализации Rate limiter. Счётчик фиксированного окна фиксирует количество запросов за заданный интервал времени, сбрасывая счётчик по истечении этого времени. Журнал скользящего окна фиксирует временные метки каждого запроса и учитывает только те, что попадают в определённый интервал. Счётчик скользящего окна комбинирует подходы фиксированного окна и журнала, позволяя более гибко отслеживать запросы. Бакет токенов использует токены, которые заполняются в бакете с определённой скоростью, а пользователи тратят токены при каждом запросе.
  • Rate limiter можно реализовать на уровнях приложения, балансировщика или API-шлюза, Service mesh и базы данных. На уровне приложения гибкая настройка возможна, но может увеличить потребление ресурсов. Балансировщики позволяют управлять трафиком без влияния на приложение, а Service mesh контролирует весь трафик без дублирования. Реализация паттерна на уровне базы данных защищает от чрезмерной нагрузки. Необязательно ограничиваться реализацией только на одном слое: их можно комбинировать, но такой подход увеличивает расходы и время отклика.
  • В NGINX Rate limiter можно реализовать с помощью модуля limit_req. Этот модуль позволяет ограничивать количество запросов на основе определённых ключей, таких как IP-адреса. Например, можно настроить лимиты на 10 запросов в секунду с использованием директивы limit_req_zone. Это эффективно управляет трафиком и улучшает стабильность работы веб-сервера.