Limitação Dinâmica de Taxa no OpenResty

API7.ai

January 6, 2023

OpenResty (NGINX + Lua)

No artigo anterior, apresentei os algoritmos de leaky bucket e token bucket, que são comuns para lidar com tráfego em rajadas. Além disso, aprendemos como realizar a limitação de taxa de solicitações usando a configuração do NGINX. No entanto, usar a configuração do NGINX está apenas no nível de usabilidade e ainda há um longo caminho para ser útil.

O primeiro problema é que a chave de limitação de taxa é limitada a um intervalo de variáveis do NGINX e não pode ser definida de forma flexível. Por exemplo, não há como definir diferentes limites de velocidade para diferentes províncias e diferentes canais de clientes, o que é uma necessidade comum com o NGINX.

Outro problema maior é que a taxa não pode ser ajustada dinamicamente, e cada alteração requer a recarga do serviço NGINX. Como resultado, limitar a velocidade com base em diferentes períodos só pode ser implementado de forma precária por meio de scripts externos.

É importante entender que a tecnologia serve ao negócio e, ao mesmo tempo, o negócio impulsiona a tecnologia. No momento do nascimento do NGINX, havia pouca necessidade de ajustar a configuração dinamicamente; era mais sobre proxy reverso, balanceamento de carga, baixo uso de memória e outras necessidades semelhantes que impulsionaram o crescimento do NGINX. Em termos de arquitetura e implementação de tecnologia, ninguém poderia ter previsto a enorme explosão de demanda por controle dinâmico e granular em cenários como a Internet móvel, IoT e microsserviços.

O uso de scripts Lua pelo OpenResty compensa a falta do NGINX nessa área, tornando-o um complemento eficaz. É por isso que o OpenResty é tão amplamente usado como substituto do NGINX. Nos próximos artigos, continuarei apresentando mais cenários e exemplos dinâmicos no OpenResty. Vamos começar vendo como usar o OpenResty para implementar a limitação de taxa dinâmica.

No OpenResty, recomendamos o uso de lua-resty-limit-traffic para limitar o tráfego. Ele inclui limit-req (limitar a taxa de solicitações), limit-count (limitar o número de solicitações) e limit-conn (limitar conexões simultâneas); e fornece limit.traffic para agregar esses três métodos.

Limitar a taxa de solicitações

Vamos começar olhando para o limit-req, que usa um algoritmo de leaky bucket para limitar a taxa de solicitações.

Na seção anterior, apresentamos brevemente o código de implementação chave do algoritmo de leaky bucket nesta biblioteca resty, e agora aprenderemos como usar essa biblioteca. Primeiro, vamos ver o seguinte código de exemplo.

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'

Sabemos que o lua-resty-limit-traffic usa um shared dict para armazenar e contar chaves, então precisamos declarar o espaço de 100m para my_limit_req_store antes de podermos usar o limit-req. Isso é semelhante para limit-conn e limit-count, que precisam de espaços separados de shared dict para serem distinguidos.

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

A linha de código acima é uma das mais críticas. Ela significa que um shared dict chamado my_limit_req_store é usado para armazenar estatísticas, e a taxa por segundo é definida como 200, de modo que, se exceder 200 mas for menor que 300 (este valor é calculado a partir de 200 + 100), será enfileirado, e se exceder 300, será rejeitado.

Após a configuração, temos que processar a solicitação do cliente. lim: incoming("key", true) está aqui para fazer isso. incoming tem dois parâmetros, que precisamos ler em detalhes.

O primeiro parâmetro, a chave especificada pelo usuário para a limitação de taxa, é uma constante de string no exemplo acima, o que significa que a limitação de taxa deve ser uniforme para todos os clientes. Se você quiser limitar a taxa de acordo com diferentes províncias e canais, é muito simples usar ambas as informações como a chave, e o seguinte é o pseudo-código para alcançar esse requisito.

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

Claro, você também pode personalizar o significado da chave e as condições para chamar incoming, para que possa obter um efeito muito flexível de limitação de taxa.

Vamos olhar para o segundo parâmetro da função incoming, e é um valor booleano. O padrão é false, o que significa que a solicitação não será registrada no shared dict para estatísticas; é apenas um exercício. Se for definido como true, terá um efeito real. Portanto, na maioria dos casos, você precisará defini-lo explicitamente como true.

Você pode se perguntar por que esse parâmetro existe. Considere um cenário em que você configura duas instâncias diferentes de limit-req com chaves diferentes, uma chave sendo o nome do host e a outra sendo o endereço IP do cliente. Então, quando uma solicitação do cliente é processada, os métodos incoming dessas duas instâncias são chamados em ordem, conforme indicado pelo seguinte pseudo-código.

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)

Se a solicitação do usuário passar pela detecção de limite de limiter_one mas for rejeitada pela detecção de limiter_two, então a chamada da função limiter_one:incoming deve ser considerada um exercício e não precisamos contá-la.

Nesse caso, a lógica do código acima não é rigorosa o suficiente. Precisamos fazer um exercício de todos os limitadores antecipadamente, para que, se um limite de limitador for acionado que possa rejeitar a solicitação do cliente, ele possa retornar diretamente.

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

É sobre isso que se trata o segundo argumento da função incoming. Este código é o código central do módulo limit.traffic, que é usado para combinar vários limitadores de taxa.

Limitar o número de solicitações

Vamos dar uma olhada no limit.count, uma biblioteca que limita o número de solicitações. Ela funciona como o GitHub API Rate Limiting, que limita o número de solicitações do usuário em uma janela de tempo fixa. Como de costume, vamos começar com um código de exemplo.

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)

Você pode ver que limit.count e limit.req são usados de forma semelhante. Começamos definindo um shared dict no nginx.conf.

lua_shared_dict my_limit_count_store 100m;

Então new um objeto limitador, e finalmente usamos a função incoming para determinar e processar.

No entanto, a diferença é que o segundo valor de retorno da função incoming em limit-count representa as chamadas restantes, e podemos adicionar campos ao cabeçalho de resposta para dar uma indicação melhor ao cliente.

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

Limitar o número de conexões simultâneas

limit.conn é uma biblioteca para limitar o número de conexões simultâneas. Ela difere das duas bibliotecas mencionadas anteriormente por ter uma API especial leaving, que descreverei brevemente aqui.

Limitar a taxa de solicitação e o número de solicitações, como mencionado acima, pode ser feito diretamente na fase de access. Diferente de limitar o número de conexões simultâneas, que requer não apenas determinar se o limite foi excedido na fase de access, mas também chamar a API leaving na fase de 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)
}

No entanto, o código central dessa API é bastante simples, que é a seguinte linha de código que decrementa o número de conexões em um. Se você não limpar na fase de log, o número de conexões continuará subindo e logo atingirá o limite de simultaneidade.

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

Combinação de limitadores de taxa

Este é o fim da introdução de cada um desses três métodos. Finalmente, vamos ver como combinar limit.rate, limit.conn e limit.count. Aqui precisamos usar a função combine em 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)

Este código deve ser fácil de entender com o conhecimento que você acabou de adquirir. O código central da função combine, que já mencionamos na análise de limit.rate acima, é principalmente implementado com a ajuda da função drill e da função uncommit. Essa combinação permite que você defina diferentes limites e chaves para vários limitadores para atender a requisitos de negócios mais complexos.

Resumo

Não apenas o limit.traffic suporta os três limitadores de taxa mencionados hoje, mas desde que o limitador de taxa tenha as APIs incoming e uncommit, ele pode ser gerenciado pela função combine do limit.traffic.

Finalmente, vou deixar uma tarefa para você. Você pode escrever um exemplo que combine os limitadores de taxa de token e bucket que apresentamos antes? Sinta-se à vontade para escrever sua resposta na seção de comentários para discutir comigo, e você também é bem-vindo a compartilhar este artigo com seus colegas e amigos para aprender e se comunicar juntos.