Динамическое ограничение скорости (Dynamic Rate-Limiting) в OpenResty

API7.ai

January 6, 2023

OpenResty (NGINX + Lua)

В предыдущей статье я познакомил вас с алгоритмами leaky bucket и token bucket, которые часто используются для обработки всплесков трафика. Также мы узнали, как проводить ограничение скорости запросов с использованием конфигурации NGINX. Однако использование конфигурации NGINX находится на уровне удобства, и до реальной полезности еще далеко.

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

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

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

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

В OpenResty мы рекомендуем использовать lua-resty-limit-traffic для ограничения трафика. Он включает limit-req (ограничение скорости запросов), limit-count (ограничение количества запросов) и limit-conn (ограничение количества одновременных соединений); а также предоставляет limit.traffic для объединения этих трех методов.

Ограничение скорости запросов

Давайте начнем с limit-req, который использует алгоритм "протекающего ведра" для ограничения скорости запросов.

В предыдущем разделе мы кратко рассмотрели ключевой код реализации алгоритма "протекающего ведра" в этой библиотеке, а теперь узнаем, как использовать эту библиотеку. Сначала рассмотрим следующий пример кода.

resty --shdict='my_limit_req_store 100m' -e 'local limit_req = require "resty.limit.req" local lim, err = limit_req.new("my_limit_req_store", 200, 100) local delay, err = lim:incoming("key", true) if not delay then if err == "rejected" then return ngx.exit(503) end return ngx.exit(500) end if delay >= 0.001 then ngx.sleep(delay) end'

Мы знаем, что lua-resty-limit-traffic использует shared dict для хранения и подсчета ключей, поэтому нам нужно объявить пространство 100m для my_limit_req_store, прежде чем мы сможем использовать limit-req. Это аналогично для limit-conn и limit-count, которые также требуют отдельного пространства shared dict для различения.

limit_req.new("my_limit_req_store", 200, 100)

Эта строка кода является одной из самых важных. Она означает, что используется shared dict с именем my_limit_req_store для хранения статистики, а скорость в секунду установлена на 200, так что если она превышает 200, но меньше 300 (это значение рассчитывается как 200 + 100), запрос будет поставлен в очередь, а если превышает 300, он будет отклонен.

После настройки нам нужно обработать запрос от клиента. lim: incoming("key", true) делает это. incoming имеет два параметра, которые нам нужно подробно рассмотреть.

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

local province = get_ province(ngx.var.binary_remote_addr) local channel = ngx.req.get_headers()["channel"] local key = province .. channel lim:incoming(key, true)

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

Давайте рассмотрим второй параметр функции incoming, это логическое значение. По умолчанию оно равно false, что означает, что запрос не будет записан в shared dict для статистики; это просто тест. Если оно установлено в true, это будет иметь реальный эффект. Поэтому в большинстве случаев вам нужно явно установить его в true.

Вы можете задаться вопросом, зачем нужен этот параметр. Рассмотрим сценарий, где вы настраиваете два разных экземпляра limit-req с разными ключами, один ключ — это имя хоста, а другой — IP-адрес клиента. Затем, когда обрабатывается запрос клиента, методы incoming этих двух экземпляров вызываются по порядку, как показано в следующем псевдокоде.

local limiter_one, err = limit_req.new("my_limit_req_store", 200, 100) local limiter_two, err = limit_req.new("my_limit_req_store", 20, 10) limiter_one :incoming(ngx.var.host, true) limiter_two:incoming(ngx.var.binary_remote_addr, true)

Если запрос пользователя проходит проверку порога limiter_one, но отклоняется проверкой limiter_two, то вызов функции limiter_one:incoming должен считаться тестовым, и нам не нужно его учитывать.

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

for i = 1, n do local lim = limiters[i] local delay, err = lim:incoming(keys[i], i == n) if not delay then return nil, err end end

Вот для чего нужен второй аргумент функции incoming. Этот код является основным кодом модуля limit.traffic, который используется для объединения нескольких ограничителей скорости.

Ограничение количества запросов

Давайте рассмотрим limit.count, библиотеку, которая ограничивает количество запросов. Она работает как GitHub API Rate Limiting, ограничивая количество запросов пользователя в фиксированном временном окне. Как обычно, начнем с примера кода.

local limit_count = require "resty.limit.count" local lim, err = limit_count.new("my_limit_count_store", 5000, 3600) local key = ngx.req.get_headers()["Authorization"] local delay, remaining = lim:incoming(key, true)

Вы можете видеть, что limit.count и limit.req используются аналогично. Сначала мы определяем shared dict в nginx.conf.

lua_shared_dict my_limit_count_store 100m;

Затем создаем объект ограничителя с помощью new, и, наконец, используем функцию incoming для определения и обработки.

Однако разница в том, что второе возвращаемое значение функции incoming в limit-count представляет оставшиеся вызовы, и мы можем добавить поля в заголовок ответа, чтобы лучше информировать клиента.

ngx.header["X-RateLimit-Limit"] = "5000" ngx.header["X-RateLimit-Remaining"] = remaining

Ограничение количества одновременных соединений

limit.conn — это библиотека для ограничения количества одновременных соединений. Она отличается от двух ранее упомянутых библиотек тем, что имеет специальный API leaving, который я кратко опишу здесь.

Ограничение скорости запросов и количества запросов, как упоминалось выше, может быть выполнено непосредственно на этапе access. В отличие от ограничения количества одновременных соединений, которое требует не только определения превышения порога на этапе access, но и вызова API leaving на этапе log.

log_by_lua_block { local latency = tonumber(ngx.var.request_time) - ctx.limit_conn_delay local key = ctx.limit_conn_key local conn, err = lim:leaving(key, latency) }

Однако основной код этого API довольно прост, это следующая строка кода, которая уменьшает количество соединений на единицу. Если не очищать на этапе log, количество соединений будет продолжать расти и скоро достигнет порога одновременных соединений.

local conn, err = dict:incr(key, -1)

Комбинация ограничителей скорости

На этом введение каждого из этих трех методов завершено. Наконец, давайте посмотрим, как объединить limit.rate, limit.conn и limit.count. Здесь нам нужно использовать функцию combine в limit.traffic.

local lim1, err = limit_req.new("my_req_store", 300, 200) local lim2, err = limit_req.new("my_req_store", 200, 100) local lim3, err = limit_conn.new("my_conn_store", 1000, 1000, 0.5) local limiters = {lim1, lim2, lim3} local host = ngx.var.host local client = ngx.var.binary_remote_addr local keys = {host, client, client} local delay, err = limit_traffic.combine(limiters, keys, states)

Этот код должен быть легко понятен с учетом полученных знаний. Основной код функции combine, который мы уже упоминали в анализе limit.rate выше, в основном реализован с помощью функций drill и uncommit. Такая комбинация позволяет вам устанавливать разные пороги и ключи для нескольких ограничителей, чтобы достичь более сложных бизнес-требований.

Заключение

limit.traffic поддерживает не только три ограничителя скорости, упомянутые сегодня, но и любой ограничитель скорости, имеющий API incoming и uncommit, может управляться функцией combine из limit.traffic.

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