No último Hackaton da Globo.com, nós implementamos uma biblioteca Lua bastante simples para prover um sistema distribuído (baseado em Redis) para medição de taxas no Nginx. Mas antes de explicarmos o que fizemos, vamos começar entendendo o problema que um sistema de throttling tenta resolver e algumas implementações possíveis.
Suponha que acabamos de construir uma API, mas alguns usuários estão fazendo requisições numa taxa acima do que consideramos razoável. Como lidamos com isso?
O Nginx tem um mecanismo nativo para throttling de acessos que é bem simples de se utilizar:
events { worker_connections 1024; } error_log stderr; http { limit_req_zone $binary_remote_addr zone=mylimit:10m rate=1r/m; server { listen 8080; location /api0 { default_type 'text/plain'; limit_req zone=mylimit; content_by_lua_block { ngx.say("hello world") } } } }
Com essa configuração, estamos criando uma zona chamada mylimit que limita o usuário (ou, na verdade, seu respectivo IP) a, no máximo, 1 request por minuto.
Para ver isso na prática, salve essa configuração num arquivo chamado nginx.conf
e rode o comando abaixo:
docker run --rm -p 8080:8080 \ -v $(pwd)/nginx.conf:/usr/local/openresty/nginx/conf/nginx.conf \ openresty/openresty:alpine
Podemos usar o curl para validar se está tudo funcionando conforme o desejado:
Como você pode ver, ocorreu tudo bem no 1º request (feito logo no segundo 1 do minuto 50). Mas os 2 requests seguintes falharam, pois estamos restringidos pela diretiva limit_req, que configuramos para 1 request por minuto.
Já o quarto e último request foi bem-sucedido, pois o fizemos 1 minuto após o primeiro (observe o minuto 51).
Essa abordagem possui alguns problemas. Num deles, um usuário poderia usar múltiplas VM’s para burlar a limitação de IP (já que cada VM possui um IP próprio).
Além disso, existe uma outra boa razão pra não usarmos o IP do usuário. Muitos usuários distintos podem estar utilizando o mesmo IP, num mecanismo chamado NAT. Portanto, poderíamos limitar muitos usuários legítimos se seguíssemos com essa abordagem.
Vamos então usar um token da nossa API para fazer a limitação:
events { worker_connections 1024; } error_log stderr; http { limit_req_zone $arg_token zone=mylimit:10m rate=1r/m; server { listen 8080; location /api0 { default_type 'text/plain'; limit_req zone=mylimit; content_by_lua_block { ngx.say("hello world") } } } }
Com isso, um usuário não pode mais utilizar múltiplos IPs para burlar nosso mecanismo, pois estamos utilizando seu token de identificação como chave pro limit_req_zone.
Refazendo os testes com o curl, temos:
À medida que nossa API vai ficando mais importante e muitos usuários começam a pagar por ela, precisamos escalá-la colocando um load balancer na frente de 2 instâncias da nossa API. A gente consegue fazer isso usando o próprio Nginx, com uma configuração como a mostrada abaixo:
events { worker_connections 1024; } error_log stderr; http { upstream app { server nginx1:8080; server nginx2:8080; } server { listen 8080; location /api0 { proxy_pass http://app; } } }
Para simular o funcionamento nesse cenário, precisaremos de múltiplos containers. Vamos, então, usar o docker-compose com 3 serviços: 1 para o load balancer, e 2 para as instâncias da API.
version: '3' services: nginxlb: image: openresty/openresty:alpine volumes: - "./lbnginx.conf:/usr/local/openresty/nginx/conf/nginx.conf" ports: - "8080:8080" nginx1: image: openresty/openresty:alpine volumes: - "./nginx.conf:/usr/local/openresty/nginx/conf/nginx.conf" ports: - "8080" nginx2: image: openresty/openresty:alpine volumes: - "./nginx.conf:/usr/local/openresty/nginx/conf/nginx.conf" ports: - "8080"
Rode o comando docker-compose up
e, num outro terminal, faremos os requests usando o curl.
As requisições para localhost:8080 irão para o load balancer.
Estranho, não? Nosso sistema de limitação não está funcionando (ao menos não como deveria). A primeira requisição resultou em 200 conforme o esperado, mas a requisição seguinte também deu 200.
O que acontece é que o load balancer precisa encaminhar as requisições pra uma das duas instâncias da nossa API, e o algoritmo que ele está usando aqui pra fazer essa escolha é o round-robin: pra cada request ele escolhe a próxima instância da lista. E a diretiva de Nginx limit_req
armazena seus contadores na memória da própria instância.
Pra contornar isso, devemos armazenar esses contadores num banco de dados compartilhado. Uma boa escolha aqui seria o Redis, porque é em memória e é muito rápido e eficiente.
Chegamos agora no ponto de implementar o sistema de contagem/medição de taxas. Como faremos isso?
Podemos resolver esse problema usando um histograma para obter a média, um algoritmo como o Leaky Bucket ou um esquema de janela deslizante como este proposto pela Cloudfare.
Para implementar o algoritmo de janela deslizante, precisamos armazenar 2 contadores: um para o último minuto e outro para o minuto atual. Com isso, calculamos a taxa atual fatorando os dois contadores como se as requisições estivessem numa frequência constante.
last_counter * ((60 – current_second) / 60) + current_counter
Vamos ver um exemplo na prática. Digamos que nosso sistema de throttling permite 10 requisições por minuto, que no minuto anterior tivemos 6 requisições, que no minuto atual tivemos 1 requisição e que estamos no segundo 10. O cálculo da taxa é feito da seguinte forma:
6 * ((60 – 10) / 60) + 1
A conta acima resulta em 6, que está abaixo do nosso limite de 10. Portanto, as requisições desse usuário não serão limitadas ainda.
-- redis_client is an instance of a redis_client -- key is the limit parameter, in this case ngx.var.arg_token redis_rate.measure = function(redis_client, key) local current_time = math.floor(ngx.now()) local current_minute = math.floor(current_time / 60) % 60 local past_minute = current_minute - 1 local current_key = key .. current_minute local past_key = key .. past_minute local resp, err = redis_client:get(past_key) local last_counter = tonumber(resp) resp, err = redis_client:incr(current_key) local current_counter = tonumber(resp) - 1 resp, err = redis_client:expire(current_key, 2 * 60) local current_rate = last_counter * ((60 - (current_time % 60)) / 60) + current_counter return current_rate, nil end return redis_rate
Para armazenar esses contadores no Redis, nós usamos 3 operações O(1):
- GET: para recuperar o valor do contador do último minuto;
- INCR: para incrementar o valor do contador atual e incrementar seu valor em 1;
- EXPIRE: para setar um tempo de expiração para o contador atual, dado que ele não será mais utilizado depois de 2 minutos.
Nós optamos por não utilizar o comando MULTI mesmo sabendo que, em função da concorrência de acessos, alguns usuários podem ter seu acesso liberado erroneamente.
Uma das razões pra isso é o fato de usarmos um driver Lua que não oferece suporte ao comando MULTI mas utilizamos pipeline e hash tag para que toda a conversa com o redis fosse feita em uma única requisição TCP.
Agora chegamos ao ponto de integrarmos o nosso algortimo ao Nginx:
http { server { listen 8080; location /lua_content { default_type 'text/plain'; content_by_lua_block { local redis_client = redis_cluster:new(config) local rate, err = redis_rate.measure(redis_client, ngx.var.arg_token) if err then ngx.log(ngx.ERR, "err: ", err) ngx.exit(ngx.HTTP_INTERNAL_SERVER_ERROR) end if rate > 10 then ngx.exit(ngx.HTTP_FORBIDDEN) end ngx.say(rate) } } } }
A configuração acima é relativamente simples: ela utiliza um token como chave para os contadores e, se a taxa de acessos para esse token estiver acima de 10 por minuto, o Nginx responderá um 403.
Soluções simples normalmente são elegantes e podem ser bastante escaláveis.
O código-fonte da biblioteca Lua e um exemplo completo e funcional de uso estão disponíveis no Github. Você pode rodar ele na sua própria máquina sem grandes esforços.
Parabéns pelo artigo! 👏🏼
CurtirCurtir