Handling Layer 4 of traffic and Implementing Memcached Server by OpenResty
API7.ai
November 10, 2022
Em alguns artigos anteriores, introduzimos algumas APIs Lua para lidar com solicitações, todas relacionadas à Camada 7. Além disso, o OpenResty fornece o módulo stream-lua-nginx-module
para lidar com o tráfego da Camada 4. Ele oferece instruções e APIs que são basicamente as mesmas do lua-nginx-module
.
Hoje, vamos falar sobre a implementação de um servidor Memcached com o OpenResty, que requer apenas cerca de 100 linhas de código. Neste pequeno exercício prático, usaremos muito do que aprendemos anteriormente e também introduziremos alguns conteúdos dos capítulos de teste e otimização de desempenho mais adiante.
E devemos estar cientes de que o objetivo deste artigo não é entender as funções de cada linha de código, mas compreender a visão completa de como o OpenResty desenvolve um projeto do zero, desde a perspectiva de requisitos, testes, desenvolvimento, etc.
Requisitos originais e soluções técnicas
Sabemos que o tráfego HTTPS está se tornando predominante, mas alguns navegadores mais antigos não suportam session tickets
, então precisamos armazenar o ID da sessão no lado do servidor. Se o espaço de armazenamento local for insuficiente, precisamos de um cluster para armazenamento, e os dados podem ser descartados, então o Memcached é mais adequado.
Neste ponto, introduzir o Memcached deveria ser a solução mais direta. No entanto, neste artigo, escolheremos usar o OpenResty para construir uma solução personalizada pelos seguintes motivos.
- Primeiro, introduzir o Memcached diretamente adicionaria um processo extra, aumentando os custos de implantação e manutenção.
- Segundo, o requisito é simples o suficiente, exigindo apenas operações de
get
eset
, e suporte a expiração. - Terceiro, o OpenResty possui um módulo
stream
, que pode implementar rapidamente esse requisito.
Como queremos implementar um servidor Memcached, precisamos primeiro entender seu protocolo. O protocolo Memcached pode suportar TCP e UDP. Aqui usaremos TCP. Abaixo está o protocolo específico para os comandos get
e set
.
Get
obter valor com chave
Comando Telnet: get <key>*\r\n
Exemplo:
get key
VALUE key 0 4 data END
Set
Salvar chave-valor no memcached
Comando Telnet:set <key> <flags> <exptime> <bytes> [noreply]\r\n<value>\r\n
Exemplo:
set key 0 900 4 data
STORED
Também precisamos saber como o "tratamento de erros" do protocolo Memcached é implementado, além de get
e set
. O "tratamento de erros" é muito importante para programas do lado do servidor, e precisamos escrever programas que lidem não apenas com solicitações normais, mas também com exceções. Por exemplo, em um cenário como o seguinte:
- O Memcached envia uma solicitação diferente de
get
ouset
, como devo lidar com isso? - Que tipo de feedback devo dar ao cliente Memcached quando há um erro no lado do servidor?
Além disso, queremos escrever um aplicativo cliente compatível com Memcached. Dessa forma, os usuários não precisam distinguir entre a versão oficial do Memcached e a implementação do OpenResty.
A figura abaixo da documentação do Memcached descreve o que deve ser retornado em caso de erro e o formato exato, que você pode usar como referência.
Agora, vamos definir a solução técnica. Sabemos que o shared dict
do OpenResty pode ser usado entre workers
e que colocar dados em um shared dict
é muito semelhante a colocá-los no Memcached. Ambos suportam operações de get
e set
, e os dados são perdidos quando o processo é reiniciado. Portanto, é apropriado usar um shared dict
para emular o Memcached, pois seus princípios e comportamentos são os mesmos.
Desenvolvimento Orientado a Testes
O próximo passo é começar a trabalhar nisso. No entanto, com base na ideia de desenvolvimento orientado a testes, vamos construir o caso de teste mais simples antes de escrever o código específico. Em vez de usar o framework test::nginx
, que é notoriamente difícil de começar, vamos começar com um teste manual usando o resty
.
$ resty -e 'local memcached = require "resty.memcached"
local memc, err = memcached:new()
memc:set_timeout(1000) -- 1 segundo
local ok, err = memc:connect("127.0.0.1", 11212)
local ok, err = memc:set("dog", 32)
if not ok then
ngx.say("failed to set dog: ", err)
return
end
local res, flags, err = memc:get("dog")
ngx.say("dog: ", res)'
Este código de teste usa a biblioteca cliente lua-rety-memcached
para iniciar operações de connect
e set
e assume que o servidor Memcached está escutando na porta 11212
na máquina local.
Parece que deveria funcionar bem. Você pode executar este código em sua máquina e, sem surpresa, ele retornará um erro como failed to set dog: closed
, já que o serviço não foi iniciado neste momento.
Neste ponto, sua solução técnica está clara: use o módulo stream
para receber e enviar dados e use o shared dict
para armazená-los.
A métrica para medir a conclusão do requisito é clara: execute o código acima e imprima o valor real de dog
.
Construindo o framework
Então, o que você está esperando? Comece a escrever o código!
Meu hábito é construir primeiro um framework de código mínimo executável e, em seguida, preencher gradualmente o código. A vantagem disso é que você pode definir muitas pequenas metas durante o processo de codificação, e os casos de teste lhe darão feedback positivo quando você atingir uma pequena meta.
Vamos começar configurando o arquivo de configuração do NGINX, já que stream
e shared dict
devem ser pré-configurados nele. Aqui está o arquivo de configuração que eu configurei.
stream {
lua_shared_dict memcached 100m;
lua_package_path 'lib/?.lua;;';
server {
listen 11212;
content_by_lua_block {
local m = require("resty.memcached.server")
m.run()
}
}
}
Como você pode ver, várias informações-chave estão neste arquivo de configuração.
- Primeiro, o código é executado no contexto
stream
do NGINX, não no contextoHTTP
, e está escutando na porta11212
. - Segundo, o nome do
shared dict
émemcached
, e o tamanho é100M
, que não pode ser alterado em tempo de execução. - Além disso, o código está localizado no diretório
lib/resty/memcached
, o nome do arquivo éserver.lua
, e a função de entrada érun()
, que você pode encontrar emlua_package_path
econtent_by_lua_block
.
Em seguida, é hora de construir o framework do código. Você pode tentar por conta própria, e então vamos ver meu framework de código juntos.
local new_tab = require "table.new"
local str_sub = string.sub
local re_find = ngx.re.find
local mc_shdict = ngx.shared.memcached
local _M = { _VERSION = '0.01' }
local function parse_args(s, start)
end
function _M.get(tcpsock, keys)
end
function _M.set(tcpsock, res)
end
function _M.run()
local tcpsock = assert(ngx.req.socket(true))
while true do
tcpsock:settimeout(60000) -- 60 segundos
local data, err = tcpsock:receive("*l")
local command, args
if data then
local from, to, err = re_find(data, [[(\S+)]], "jo")
if from then
command = str_sub(data, from, to)
args = parse_args(data, to + 1)
end
end
if args then
local args_len = #args
if command == 'get' and args_len > 0 then
_M.get(tcpsock, args)
elseif command == "set" and args_len == 4 then
_M.set(tcpsock, args)
end
end
end
end
return _M
Este trecho de código implementa a lógica principal da função de entrada run()
. Embora eu não tenha feito nenhum tratamento de exceções e as dependências parse_args
, get
e set
sejam todas funções vazias, este framework já expressa completamente a lógica do servidor Memcached.
Preenchendo o código
Em seguida, vamos implementar essas funções vazias na ordem em que o código é executado.
Primeiro, podemos analisar os parâmetros do comando Memcached de acordo com a documentação do protocolo Memcached.
local function parse_args(s, start)
local arr = {}
while true do
local from, to = re_find(s, [[\S+]], "jo", {pos = start})
if not from then
break
end
table.insert(arr, str_sub(s, from, to))
start = to + 1
end
return arr
end
Meu conselho é implementar primeiro uma versão mais intuitiva, sem pensar em nenhuma otimização de desempenho. Afinal, a conclusão é sempre mais importante que a perfeição, e a otimização incremental com base na conclusão é a única maneira de se aproximar da perfeição.
Em seguida, vamos implementar a função get
. Ela pode consultar várias chaves de uma vez, então uso um loop for
no código a seguir.
function _M.get(tcpsock, keys)
local reply = ""
for i = 1, #keys do
local key = keys[i]
local value, flags = mc_shdict:get(key)
if value then
local flags = flags or 0
reply = reply .. "VALUE" .. key .. " " .. flags .. " " .. #value .. "\r\n" .. value .. "\r\n"
end
end
reply = reply .. "END\r\n"
tcpsock:settimeout(1000) -- timeout de um segundo
local bytes, err = tcpsock:send(reply)
end
Há apenas uma linha de código central aqui: local value, flags = mc_shdict:get(key)
, ou seja, consultar os dados do shared dict
; quanto ao resto do código, ele segue o protocolo Memcached para montar a string e finalmente enviá-la ao cliente.
Por fim, vamos ver a função set
. Ela converte os parâmetros recebidos no formato da API do shared dict
, armazena os dados e, em caso de erros, os trata de acordo com o protocolo do Memcached.
function _M.set(tcpsock, res)
local reply = ""
local key = res[1]
local flags = res[2]
local exptime = res[3]
local bytes = res[4]
local value, err = tcpsock:receive(tonumber(bytes) + 2)
if str_sub(value, -2, -1) == "\r\n" then
local succ, err, forcible = mc_shdict:set(key, str_sub(value, 1, bytes), exptime, flags)
if succ then
reply = reply .. “STORED\r\n"
else
reply = reply .. "SERVER_ERROR " .. err .. “\r\n”
end
else
reply = reply .. "ERROR\r\n"
end
tcpsock:settimeout(1000) -- timeout de um segundo
local bytes, err = tcpsock:send(reply)
end
Além disso, você pode usar casos de teste para verificar e depurar com ngx.log
enquanto preenche as funções acima. Infelizmente, estamos usando ngx.say
e ngx.log
para depurar, já que não há um depurador com pontos de interrupção no OpenResty, que ainda está em uma era rudimentar esperando por mais exploração.
Resumo
Este projeto prático está terminando agora e, finalmente, gostaria de deixar uma pergunta: Você poderia pegar o código de implementação do servidor Memcached acima, executá-lo completamente e passar no caso de teste?
A pergunta de hoje provavelmente exigirá muito esforço, mas esta ainda é uma versão primitiva. Não há tratamento de erros, otimização de desempenho e testes automatizados, que serão melhorados posteriormente.
Se você tiver alguma dúvida sobre a explicação de hoje ou sua prática, sinta-se à vontade para deixar um comentário e discutir conosco. Você também pode compartilhar este artigo com seus colegas e amigos para que possamos praticar e progredir juntos.