infra
March 7

Nginx Rate Limiting. Как не быть назойливым

Обычно программист озабочен производительностью собственных серверов и как сделать так, чтобы его не заDDoSили собственные пользователи. Мы же столкнулись с обратной проблемой — как не попасть под бан от DDoS-защиты внешнего API?

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

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

Самое простое решение - это, конечно, пул прокси-серверов, но мы считаем это серым паттерном, так как хотели бы выстраивать полноценное B2B-взаимодействие, а не поднимать черный флаг. Но пока бизнес переговаривает переговоры, нам надо продолжать работать.

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

В очередь, с*кины дети, в очередь!

У службы техподдержки удалось выяснить, что внешний сервис готов без проблем получать 1000 запросов в минуту, и именно это значение мы берем для себя как пороговое.

В качестве способа контроля и ограничения трафика был выбран reverse proxy через nginx с модулем ngx_http_limit_req_module. Мотивацией именно к такому решению стало то, что с внешним сервисом взаимодействуют много наших стендов в разных инфраструктурах, а потому вариант с встраиванием счетчиков на базе redis, postgresql или ещё чего в сам продукт нам не подошел.

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

Это дает несколько профитов:

  • логи — вы всегда будете знать, сколько запросов вы шлете, коды ответа, длительность запросов и прочее;
  • возможность быстрой смены URL — если внешний сервис сменил адрес для получения запросов, то вы сможете быстро и просто перенаправить все ваши стенды в новое место. Или организовать балансировку, если внешний сервис предоставляет несколько контуров для взаимодействия;
  • различные возможности по корректировке запросов — добавление заголовков (в том числе авторизационных), rate limiting, ну или более сложные фишки, если вы - искушенный администратор nginx.

Вернемся к нашей задаче, а именно rate limiting’у. Казалось бы, уже есть статьи по теме (русский, английский), да и официальная документация вполне себе подробная. Но если бы все было так просто, то не было бы и этого текста.

То ли лыжи не едут …

К проблеме мы подошли академично и начали с RTFM, согласно которому наша задача решалась вполне тривиально:

http {
  limit_req_zone "external_srv1" zone=external_srv1:10m rate=1000r/m;
  
  server {
    location / {
      limit_req zone=external_srv1;
    }
  }
}

Вообще в документации написано limit_req_zone $binary_remote_addr, но так как нам надо лимитировать все запросы, имя мы выдаем статичное.

Запускаем и видим довольно неприятную картину — если отправить 5 запросов одновременно, то nginx примет только 1-2 из них, а остальные будут отклонены с 503 ошибкой.

У документации есть ответ и на этот вопрос — надо добавить параметр burst, который разрешит принимать «всплески» запросов:

location / {
  limit_req zone=external_srv1 burst=100;
}

Казалось бы, все хорошо, если попробовать отправить уже не 5, а 100 запросов одновременно, ошибок 503 не будет. Но появляется другая проблема — nginx запросы то принимает, но отправляет их не сразу, а с некоторой задержкой.

Документация дает ответ и на этот вопрос — параметр nodelay

location / {
  limit_req zone=external_srv1 burst=100 nodelay;
}

В этом случае 100 запросов будут обработаны без каких-либо задержек в отправке, а начиная с 101 начнут отклоняться с ошибкой 503. Казалось бы, то что нужно! Выставляем burst=2000, чтобы переварить все возможные наши всплески и радуемся тому, что мы проходим ниже локаторов DDoS-защиты.

… то ли я не умею кататься на лыжах

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

Анализ логов nginx показал, что мы действительно в какие-то моменты шлем больше 1000 запросов в условную минуту. Но мы же все настроили, как так?

Тут мы осознали первую нашу ошибку — параметр burst был воспринят нами, как некая очередь, куда попадают входящие запросы и постепенно отправляются, пока не будет достигнут лимит 1000 запросов в минуту. Затем запросы дождутся следующей минуты, и отправка продолжится. Это в корне неверное суждение.
Возможно, смутила довольно популярная картинка из официальной статьи о rate limiting.

Осознав, что burst функционала очереди не добавляет и отправляет все запросы разом внутри заданного числа, снижаем его ниже нашего порога, ожидая что 700 уйдут мгновенно, а оставшиеся 300 как-нибудь сами распределятся по остатку выделенной нам DDoS-защитой минуте:

location / {
  limit_req zone=external_srv1 burst=700 nodelay;
}

Господин nodelay нам больше не друг

В целом банов больше не было, но система мониторинга стала паниковать и сообщать о большом количестве ошибок 503. Что собственно не удивительно, ведь «съев» разрешенный всплеск до 700 запросов, остальные обрабатывались по стандартной схеме, с учетом запрошенной скорости и начинали генерировать 503.

Очередной подход к документации приводит нас к параметру delay, который в противовес nodelay как раз таки начинает задерживать, но делает это в рамках так называемого Two-Stage Rate Limiting (картинка выше собственно его и иллюстрировала).

Радуемся и стараемся сделать так, чтобы 800 запросов обрабатывались мгновенно, а остальные копились в очередь из 700 и, соблюдая скорость в 1 запрос в 60 мс, отправлялись. По нашему разумению на тот момент, в Two Stage Rate Limiting это должно было выглядеть вот так:

location / {
  limit_req zone=external_srv1 burst=1500 delay=800;
}

Познание дзена

Ну вы поняли — новый бан.

Вдумчивое изучение логов показало, что nginx в целом действует как ему сказано: 800 запросов отправляет без задержек и соблюдает ограничение 1000r/m. Вот только делает он это почти независимо!

Суть его работы заключается в том, что когда вы указываете в конфигурации rate=1000r/m это не значит, что nginx будет засекать минуту и считать внутри неё запросы. На самом деле nginx делит запрошенное значение на количество миллисекунд в указанном периоде и получает частоту отправки запросов. В нашем случае 60*1000 / 1000 = 60 мс.

Таким образом, отправив 800 запросов, nginx продолжает слать запросы со скоростью 1 запрос в 60 мс. То есть, если 800 запросов пришли секунд за 10, то до конца минуты nginx с радостью обслужит ещё порядка 830 запросов (50 000 мс / 60 мс).

Причем если он не получает в какие-нибудь из 60 мс запроса, то будет постепенно накапливать счетчик burst\delay и пропускать всплески, равные накопленному. То есть, пропустив 800 запросов мгновенно, через 30 секунд тишины nginx позволит отправить ещё 500 запросов мгновенно.

Ну или после 800 мгновенных запросов поставит в очередь 500 и плавно их отправит в течение все тех же 30 секунд.

Итого

Вооруженные каким-никаким осознанием, что же все-таки происходит, мы смогли составить конфиг, который пусть и не идеально, но смог удовлетворить наши требования: с одной стороны хоть как-то выполняет заданные лимиты, а с другой не отбивает собственные запросы и складывает лишнее в очередь:

http {
  limit_req_zone "external_srv1" zone=external_srv1:10m rate=1000r/m;
  
  server {
    location / {
      limit_req zone=external_srv1 burst=1000 delay=500;
    }
  }
}

В такой конфигурации nginx будет отправлять 500 запросов без задержек, а следующие 500 положит в очередь и будет с зазором в 60мс отправлять. В теории максимально возможное количество запросов при такой схеме - 1500, но это при условии очень равномерной нагрузки. Но так как мы сами запросы отправляем некоторыми пачками, получается что те объемы, что в теории могут вылезти за границу, будут отсечены ошибкой 503, и на выходе будет +- 1000 запросов в минуту. Как показывают логи, наши умозаключения верны и за лимиты мы пока что не вываливаемся.