Manejo del tráfico en la Capa 4 e Implementación de un Servidor Memcached con OpenResty
API7.ai
November 10, 2022
En algunos artículos anteriores, introdujimos algunas APIs de Lua para manejar solicitudes, todas relacionadas con la Capa 7. Además, OpenResty proporciona el módulo stream-lua-nginx-module
para manejar tráfico de la Capa 4. Este módulo ofrece instrucciones y APIs que son básicamente las mismas que las del lua-nginx-module
.
Hoy hablaremos sobre cómo implementar un servidor Memcached con OpenResty, lo cual requiere solo unas 100 líneas de código. En este pequeño ejercicio práctico, utilizaremos mucho de lo que hemos aprendido anteriormente y también incorporaremos algunos contenidos de los capítulos de pruebas y optimización de rendimiento que veremos más adelante.
Debemos tener claro que el objetivo de este artículo no es entender cada línea de código, sino comprender la visión completa de cómo OpenResty desarrolla un proyecto desde cero, desde la perspectiva de los requisitos, pruebas, desarrollo, etc.
Requisitos originales y soluciones técnicas
Sabemos que el tráfico HTTPS se está convirtiendo en el estándar, pero algunos navegadores antiguos no admiten session tickets
, por lo que necesitamos almacenar el ID de sesión en el servidor. Si el espacio de almacenamiento local es insuficiente, necesitamos un clúster para almacenar los datos, y como los datos pueden descartarse, Memcached es más adecuado.
En este punto, introducir Memcached debería ser la solución más directa. Sin embargo, en este artículo, elegiremos usar OpenResty para construir una solución desde cero por las siguientes razones:
- Primero, introducir Memcached directamente agregaría un proceso adicional, aumentando los costos de implementación y mantenimiento.
- Segundo, el requisito es lo suficientemente simple, ya que solo requiere operaciones de
get
yset
, y admite la expiración. - Tercero, OpenResty tiene un módulo
stream
, que puede implementar rápidamente este requisito.
Dado que queremos implementar un servidor Memcached, primero debemos entender su protocolo. El protocolo de Memcached puede soportar TCP y UDP. Aquí usaremos TCP. A continuación se muestra el protocolo específico para los comandos get
y set
.
Get
Obtener valor con clave
Comando Telnet: get <key>*\r\n
Ejemplo:
get key
VALUE key 0 4 data END
Set
Guardar clave-valor en memcached
Comando Telnet:set <key> <flags> <exptime> <bytes> [noreply]\r\n<value>\r\n
Ejemplo:
set key 0 900 4 data
STORED
También necesitamos saber cómo se manejan los "errores" en el protocolo de Memcached, además de get
y set
. El manejo de errores es muy importante para los programas del lado del servidor, y necesitamos escribir programas que manejen no solo solicitudes normales, sino también excepciones. Por ejemplo, en un escenario como el siguiente:
- Memcached envía una solicitud que no es
get
oset
, ¿cómo la manejo? - ¿Qué tipo de retroalimentación doy al cliente de Memcached cuando hay un error en el servidor?
Además, queremos escribir una aplicación cliente compatible con Memcached. De esta manera, los usuarios no tendrán que distinguir entre la versión oficial de Memcached y la implementación de OpenResty.
La siguiente figura de la documentación de Memcached describe lo que se debe devolver en caso de error y el formato exacto, que puedes usar como referencia.
Ahora, definamos la solución técnica. Sabemos que el shared dict
de OpenResty se puede usar entre workers
y que almacenar datos en un shared dict
es muy similar a almacenarlos en Memcached. Ambos admiten operaciones de get
y set
, y los datos se pierden cuando se reinicia el proceso. Por lo tanto, es apropiado usar un shared dict
para emular Memcached, ya que sus principios y comportamientos son los mismos.
Desarrollo guiado por pruebas
El siguiente paso es comenzar a trabajar en ello. Sin embargo, basándonos en la idea del desarrollo guiado por pruebas, construyamos el caso de prueba más simple antes de escribir el código específico. En lugar de usar el marco test::nginx
, que es notoriamente difícil de comenzar, iniciemos con una prueba manual usando 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 prueba utiliza la biblioteca cliente lua-rety-memcached
para iniciar operaciones de connect
y set
, y asume que el servidor Memcached está escuchando en el puerto 11212
en la máquina local.
Parece que debería funcionar bien. Puedes ejecutar este código en tu máquina y, como era de esperar, devolverá un error como failed to set dog: closed
, ya que el servicio no está iniciado en este punto.
En este punto, tu solución técnica es clara: usar el módulo stream
para recibir y enviar datos, y usar el shared dict
para almacenarlos.
La métrica para medir la finalización del requisito es clara: ejecutar el código anterior e imprimir el valor real de dog
.
Construyendo el marco
Entonces, ¿qué estás esperando? ¡Comienza a escribir código!
Mi hábito es construir primero un marco de código mínimamente ejecutable y luego llenarlo gradualmente. La ventaja de esto es que puedes establecer muchos pequeños objetivos durante el proceso de codificación, y los casos de prueba te darán retroalimentación positiva cuando alcances un pequeño objetivo.
Comencemos configurando el archivo de configuración de NGINX, ya que stream
y shared dict
deben estar preconfigurados en él. Aquí está el archivo de configuración que he configurado.
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 puedes ver, hay varias piezas clave de información en este archivo de configuración.
- Primero, el código se ejecuta en el contexto
stream
de NGINX, no en el contextoHTTP
, y está escuchando en el puerto11212
. - Segundo, el nombre del
shared dict
esmemcached
, y el tamaño es100M
, que no se puede cambiar en tiempo de ejecución. - Además, el código se encuentra en el directorio
lib/resty/memcached
, el nombre del archivo esserver.lua
, y la función de entrada esrun()
, que puedes encontrar enlua_package_path
ycontent_by_lua_block
.
A continuación, es hora de construir el marco del código. Puedes intentarlo tú mismo, y luego veamos juntos mi marco de código.
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 fragmento de código implementa la lógica principal de la función de entrada run()
. Aunque no he hecho ningún manejo de excepciones y las dependencias parse_args
, get
y set
son todas funciones vacías, este marco ya expresa completamente la lógica del servidor Memcached.
Llenando el código
A continuación, implementemos estas funciones vacías en el orden en que se ejecuta el código.
Primero, podemos analizar los parámetros del comando Memcached según la documentación del protocolo de 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
Mi consejo es implementar primero una versión de la manera más intuitiva, sin pensar en ninguna optimización de rendimiento. Después de todo, la finalización siempre es más importante que la perfección, y la optimización incremental basada en la finalización es la única manera de acercarse a la perfección.
A continuación, implementemos la función get
. Puede consultar múltiples claves a la vez, por lo que uso un bucle for
en el siguiente código.
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) -- un segundo de tiempo de espera
local bytes, err = tcpsock:send(reply)
end
Aquí solo hay una línea de código central: local value, flags = mc_shdict:get(key)
, es decir, consultar los datos del shared dict
; el resto del código sigue el protocolo de Memcached para concatenar la cadena y finalmente enviarla al cliente.
Finalmente, veamos la función set
. Convierte los parámetros recibidos al formato de la API del shared dict
, almacena los datos y, en caso de errores, los maneja según el protocolo de 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) -- un segundo de tiempo de espera
local bytes, err = tcpsock:send(reply)
end
Además, puedes usar casos de prueba para verificar y depurar con ngx.log
mientras llenas las funciones anteriores. Desafortunadamente, estamos usando ngx.say
y ngx.log
para depurar, ya que no hay un depurador con puntos de interrupción en OpenResty, lo cual es una era primitiva que espera más exploración.
Resumen
Este proyecto práctico está llegando a su fin, y finalmente, me gustaría dejar una pregunta: ¿Podrías tomar el código de implementación del servidor Memcached anterior, ejecutarlo completamente y pasar el caso de prueba?
La pregunta de hoy probablemente requerirá mucho esfuerzo, pero esta sigue siendo una versión primitiva. No hay manejo de errores, optimización de rendimiento ni pruebas automatizadas, lo cual se mejorará más adelante.
Si tienes alguna duda sobre la explicación de hoy o tu práctica, no dudes en dejar un comentario y discutirlo con nosotros. También puedes compartir este artículo con tus colegas y amigos para que podamos practicar y progresar juntos.