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 запросов в минуту. Как показывают логи, наши умозаключения верны и за лимиты мы пока что не вываливаемся.