Manejo del tráfico en la Capa 4 e Implementación de un Servidor Memcached con OpenResty

API7.ai

November 10, 2022

OpenResty (NGINX + Lua)

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 y set, 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 o set, ¿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.

formato de error

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 contexto HTTP, y está escuchando en el puerto 11212.
  • Segundo, el nombre del shared dict es memcached, y el tamaño es 100M, 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 es server.lua, y la función de entrada es run(), que puedes encontrar en lua_package_path y content_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.

Share article link