Limitación dinámica de tasa (Dynamic Rate-Limiting) en OpenResty

API7.ai

January 6, 2023

OpenResty (NGINX + Lua)

En el artículo anterior, te presenté los algoritmos de leaky bucket y token bucket, que son comunes para manejar tráfico en ráfagas. Además, aprendimos cómo realizar la limitación de tasa de solicitudes utilizando la configuración de NGINX. Sin embargo, usar la configuración de NGINX solo está al nivel de usabilidad y todavía queda un largo camino para ser realmente útil.

El primer problema es que la clave de limitación de tasa está limitada a un rango de variables de NGINX y no se puede configurar de manera flexible. Por ejemplo, no hay forma de establecer diferentes umbrales de límite de velocidad para diferentes provincias y diferentes canales de clientes, lo cual es un requisito común con NGINX.

Otro problema más grande es que la tasa no se puede ajustar dinámicamente, y cada cambio requiere recargar el servicio de NGINX. Como resultado, limitar la velocidad en función de diferentes períodos solo se puede implementar de manera torpe a través de scripts externos.

Es importante entender que la tecnología sirve al negocio, y al mismo tiempo, el negocio impulsa la tecnología. En el momento del nacimiento de NGINX, había poca necesidad de ajustar la configuración dinámicamente; se trataba más de necesidades como el proxy inverso, el balanceo de carga, el bajo uso de memoria y otras similares que impulsaron el crecimiento de NGINX. En términos de arquitectura e implementación tecnológica, nadie podría haber predicho la explosión masiva de la demanda de control dinámico y granular en escenarios como el Internet móvil, IoT y microservicios.

El uso de scripts Lua en OpenResty compensa la falta de NGINX en este ámbito, convirtiéndolo en un complemento efectivo. Es por eso que OpenResty es tan ampliamente utilizado como reemplazo de NGINX. En los próximos artículos, continuaré presentándote más escenarios y ejemplos dinámicos en OpenResty. Comencemos viendo cómo usar OpenResty para implementar la limitación de tasa dinámica.

En OpenResty, recomendamos usar lua-resty-limit-traffic para limitar el tráfico. Incluye limit-req (limitar la tasa de solicitudes), limit-count (limitar el número de solicitudes) y limit-conn (limitar las conexiones concurrentes); y proporciona limit.traffic para combinar estos tres métodos.

Limitar la tasa de solicitudes

Comencemos con limit-req, que utiliza un algoritmo de cubo con fugas para limitar la tasa de solicitudes.

En la sección anterior, presentamos brevemente el código clave de implementación del algoritmo de cubo con fugas en esta biblioteca resty, y ahora aprenderemos cómo usar esta biblioteca. Primero, veamos el siguiente código de ejemplo.

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 lua-resty-limit-traffic utiliza un shared dict para almacenar y contar claves, por lo que necesitamos declarar el espacio de 100m para my_limit_req_store antes de poder usar limit-req. Esto es similar para limit-conn y limit-count, que también necesitan espacios de shared dict separados para distinguirse.

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

La línea de código anterior es una de las más críticas. Significa que se utiliza un shared dict llamado my_limit_req_store para almacenar estadísticas, y la tasa por segundo se establece en 200, de modo que si supera 200 pero es menor que 300 (este valor se calcula a partir de 200 + 100), se pondrá en cola, y si supera 300, se rechazará.

Una vez configurado, debemos procesar la solicitud del cliente. lim: incoming("key", true) está aquí para hacer esto. incoming tiene dos parámetros, que necesitamos leer en detalle.

El primer parámetro, la clave especificada por el usuario para la limitación de tasa, es una constante de cadena en el ejemplo anterior, lo que significa que la limitación de tasa debe ser uniforme para todos los clientes. Si deseas limitar la tasa según diferentes provincias y canales, es muy simple usar ambas informaciones como clave, y el siguiente es el pseudocódigo para lograr este 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)

Por supuesto, también puedes personalizar el significado de la clave y las condiciones para llamar a incoming, de modo que puedas obtener un efecto de limitación de tasa muy flexible.

Veamos el segundo parámetro de la función incoming, que es un valor booleano. El valor predeterminado es false, lo que significa que la solicitud no se registrará en el shared dict para estadísticas; es solo un ejercicio. Si se establece en true, tendrá un efecto real. Por lo tanto, en la mayoría de los casos, necesitarás establecerlo explícitamente en true.

Puedes preguntarte por qué existe este parámetro. Considera un escenario en el que configuras dos instancias diferentes de limit-req con diferentes claves, una clave siendo el nombre del host y la otra clave siendo la dirección IP del cliente. Luego, cuando se procesa una solicitud de cliente, los métodos incoming de estas dos instancias se llaman en orden, como lo indica el siguiente pseudocó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)

Si la solicitud del usuario pasa la detección de umbral de limiter_one pero es rechazada por la detección de limiter_two, entonces la llamada a la función limiter_one:incoming debería considerarse un ejercicio y no necesitamos contarla.

En este caso, la lógica del código anterior no es lo suficientemente rigurosa. Necesitamos hacer un recorrido previo de todos los limitadores para que si se activa un umbral de limitador que puede rechazar la solicitud del cliente, pueda devolver directamente.

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

Esto es de lo que trata el segundo argumento de la función incoming. Este código es el código central del módulo limit.traffic, que se utiliza para combinar múltiples limitadores de tasa.

Limitar el número de solicitudes

Echemos un vistazo a limit.count, una biblioteca que limita el número de solicitudes. Funciona como la Limitación de Tasa de la API de GitHub, que limita el número de solicitudes de usuario en una ventana de tiempo fija. Como siempre, comencemos con un código de ejemplo.

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)

Puedes ver que limit.count y limit.req se usan de manera similar. Comenzamos definiendo un shared dict en nginx.conf.

lua_shared_dict my_limit_count_store 100m;

Luego new un objeto limitador, y finalmente usamos la función incoming para determinar y procesarlo.

Sin embargo, la diferencia es que el segundo valor de retorno de la función incoming en limit-count representa las llamadas restantes, y podemos agregar campos al encabezado de respuesta en consecuencia para dar una mejor indicación al cliente.

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

Limitar el número de conexiones concurrentes

limit.conn es una biblioteca para limitar el número de conexiones concurrentes. Se diferencia de las dos bibliotecas mencionadas anteriormente en que tiene una API especial leaving, que describiré brevemente aquí.

Limitar la tasa de solicitudes y el número de solicitudes, como se mencionó anteriormente, se puede hacer directamente en la fase access. A diferencia de limitar el número de conexiones concurrentes, que requiere no solo determinar si se supera el umbral en la fase access sino también llamar a la API leaving en la fase 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)
}

Sin embargo, el código central de esta API es bastante simple, que es la siguiente línea de código que disminuye el número de conexiones en uno. Si no limpias en la fase log, el número de conexiones seguirá aumentando y pronto alcanzará el umbral de concurrencia.

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

Combinación de limitadores de tasa

Este es el final de la introducción de cada uno de estos tres métodos. Finalmente, veamos cómo combinar limit.rate, limit.conn y limit.count. Aquí necesitamos usar la función combine en 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 debería ser fácil de entender con el conocimiento que acabas de adquirir. El código central de la función combine, que ya mencionamos en el análisis de limit.rate anterior, se implementa principalmente con la ayuda de la función drill y la función uncommit. Esta combinación te permite establecer diferentes umbrales y claves para múltiples limitadores para lograr requisitos comerciales más complejos.

Resumen

No solo limit.traffic admite los tres limitadores de tasa mencionados hoy, sino que siempre que el limitador de tasa tenga las API incoming y uncommit, puede ser gestionado por la función combine de limit.traffic.

Finalmente, te dejaré una pregunta de tarea. ¿Puedes escribir un ejemplo que combine los limitadores de tasa de token y cubo que presentamos antes? Siéntete libre de escribir tu respuesta en la sección de comentarios para discutir conmigo, y también te invito a compartir este artículo con tus colegas y amigos para aprender y comunicarnos juntos.