paint-brush
Cómo implementar la especificación de clave de idempotencia en Apache APISIXpor@nfrankel
411 lecturas
411 lecturas

Cómo implementar la especificación de clave de idempotencia en Apache APISIX

por Nicolas Fränkel14m2024/04/11
Read on Terminal Reader

Demasiado Largo; Para Leer

En esta publicación, mostré una implementación simple de la especificación del encabezado Idempotency-Key en Apache APISIX a través de un complemento. En esta etapa, tiene margen de mejora: pruebas automatizadas, la capacidad de configurar Redis por ruta, configurar el dominio/ruta para que sea parte de la solicitud, configurar un clúster de Redis en lugar de una sola instancia, usar otro K/ Tienda V, etc.
featured image - Cómo implementar la especificación de clave de idempotencia en Apache APISIX
Nicolas Fränkel HackerNoon profile picture

La semana pasada, escribí un análisis de la especificación IETF Idempotency-Key . La especificación tiene como objetivo evitar solicitudes duplicadas. En definitiva, la idea es que el cliente envíe una clave única junto con la solicitud:


  • Si el servidor no conoce la clave, procede como de costumbre y luego almacena la respuesta.


  • Si el servidor conoce la clave, cortocircuita cualquier procesamiento posterior y devuelve inmediatamente la respuesta almacenada.


Esta publicación muestra cómo implementarlo con Apache APISIX .

Descripción general

Antes de comenzar a codificar, debemos definir un par de cosas. Apache APISIX ofrece una arquitectura basada en complementos. Por lo tanto, codificaremos la lógica anterior en un complemento.


Apache APISIX se basa en OpenResty, que a su vez se basa en nginx. Cada componente define fases, que se asignan más o menos a través de los componentes. Para obtener más información sobre las fases, consulte esta publicación anterior .


Finalmente decidiremos una prioridad. La prioridad define el orden en el que APISIX ejecuta los complementos dentro de una fase . Me decidí por 1500 , ya que todos los complementos de autenticación tienen una prioridad en el rango 2000 y más, pero quiero devolver la respuesta almacenada en caché lo antes posible.


La especificación requiere que almacenemos datos. APISIX ofrece muchas abstracciones, pero el almacenamiento no es una de ellas. Necesitamos acceso a través de la clave de idempotencia para que parezca un almacén de valores-clave.


Elegí Redis arbitrariamente porque está bastante extendido y el cliente ya forma parte de la distribución APISIX. Tenga en cuenta que Redis simple no ofrece almacenamiento JSON; por lo tanto, uso la imagen de Docker redis-stack .


La infraestructura local es la siguiente:


 services: apisix: image: apache/apisix:3.9.0-debian volumes: - ./apisix/config.yml:/usr/local/apisix/conf/config.yaml:ro - ./apisix/apisix.yml:/usr/local/apisix/conf/apisix.yaml:ro #1 - ./plugin/src:/opt/apisix/plugins:ro #2 ports: - "9080:9080" redis: image: redis/redis-stack:7.2.0-v9 ports: - "8001:8001" #3
  1. Configuración de ruta estática
  2. Camino a nuestro futuro complemento
  3. Puerto de Redis Insights (GUI). No es necesario per se , pero es muy útil durante el desarrollo para la depuración.


La configuración de APISIX es la siguiente:

 deployment: role: data_plane role_data_plane: config_provider: yaml #1 apisix: extra_lua_path: /opt/?.lua #2 plugins: - idempotency # priority: 1500 #3 plugin_attr: #4 idempotency: host: redis #5
  1. Configurar APISIX para la configuración de rutas estáticas
  2. Configurar la ubicación de nuestro complemento
  3. Los complementos personalizados deben declararse explícitamente. El comentario de prioridad no es obligatorio pero es una buena práctica y mejora la mantenibilidad.
  4. Configuración de complementos común en todas las rutas
  5. Vea abajo


Finalmente, declaramos nuestra ruta única:

 routes: - uri: /* plugins: idempotency: ~ #1 upstream: nodes: "httpbin.org:80": 1 #2 #END #3
  1. Declara el complemento que vamos a crear.
  2. httpbin es un flujo ascendente útil ya que podemos probar diferentes URI y métodos.
  3. ¡Obligatorio para la configuración de rutas estáticas!


Con esta infraestructura implementada, podemos comenzar la implementación.

Diseño del complemento

Los fundamentos de un complemento Apache APISIX son bastante básicos:


 local plugin_name = "idempotency" local _M = { version = 1.0, priority = 1500, schema = {}, name = plugin_name, } return _M


El siguiente paso es la configuración, por ejemplo, el host y el puerto de Redis. Para empezar, ofreceremos una única configuración de Redis en todas las rutas. Esa es la idea detrás de la sección plugin_attr en el archivo config.yaml : configuración común. Desarrollemos nuestro complemento:


 local core = require("apisix.core") local plugin = require("apisix.plugin") local attr_schema = { --1 type = "object", properties = { host = { type = "string", description = "Redis host", default = "localhost", }, port = { type = "integer", description = "Redis port", default = 6379, }, }, } function _M.init() local attr = plugin.plugin_attr(plugin_name) or {} local ok, err = core.schema.check(attr_schema, attr) --2 if not ok then core.log.error("Failed to check the plugin_attr[", plugin_name, "]", ": ", err) return false, err end end
  1. Definir la forma de la configuración.


  2. Compruebe que la configuración sea válida.


Debido a que definí valores predeterminados en el complemento, solo puedo anular el host para redis para que se ejecute dentro de mi infraestructura Docker Compose y usar el puerto predeterminado.


A continuación, necesito crear el cliente Redis. Tenga en cuenta que la plataforma me impide conectarme en cualquier fase después de la sección de reescritura/acceso. Por lo tanto, lo crearé en el método init() y lo guardaré hasta el final.


 local redis_new = require("resty.redis").new --1 function _M.init() -- ... redis = redis_new() --2 redis:set_timeout(1000) local ok, err = redis:connect(attr.host, attr.port) if not ok then core.log.error("Failed to connect to Redis: ", err) return false, err end end
  1. Haga referencia a la new función del módulo OpenResty Redis.


  2. Llámelo para obtener una instancia.


El cliente Redis ahora está disponible en la variable redis durante el resto del ciclo de ejecución del complemento.

Implementación de la ruta nominal

En mi vida anterior de ingeniero de software, generalmente implementaba primero la ruta nominal. Luego, hice el código más sólido administrando los casos de error individualmente. De esta manera, si tuviera que publicar en algún momento, seguiría entregando valores comerciales, con advertencias. Abordaré este miniproyecto de la misma manera.


El pseudoalgoritmo en la ruta nominal se parece al siguiente:


 DO extract idempotency key from request DO look up value from Redis IF value doesn't exist DO set key in Redis with empty value ELSE RETURN cached response DO forward to upstream DO store response in Redis RETURN response


Necesitamos asignar la lógica a la fase que mencioné anteriormente. Hay dos fases disponibles antes del upstream, reescritura y acceso ; tres después, header_filter , body_filter y log . La fase de acceso parecía obvia para el trabajo antes, pero necesitaba decidir entre las otras tres. Elegí al azar body_filter , pero estoy más que dispuesto a escuchar argumentos sensatos para otras fases.


Tenga en cuenta que eliminé los registros para que el código sea más legible. Los registros de errores e informativos son necesarios para facilitar la depuración de problemas de producción.


 function _M.access(conf, ctx) local idempotency_key = core.request.header(ctx, "Idempotency-Key") --1 local redis_key = "idempotency#" .. idempotency_key --2 local resp, err = redis:hgetall(redis_key) --3 if not resp then return end if next(resp) == nil then --4 local resp, err = redis:hset(redis_key, "request", true ) --4 if not resp then return end else local data = normalize_hgetall_result(resp) --5 local response = core.json.decode(data["response"]) --6 local body = response["body"] --7 local status_code = response["status"] --7 local headers = response["headers"] for k, v in pairs(headers) do --7 core.response.set_header(k, v) end return core.response.exit(status_code, body) --8 end end
  1. Extraiga la clave de idempotencia de la solicitud.
  2. Prefije la clave para evitar posibles colisiones.
  3. Obtenga el conjunto de datos almacenado en Redis bajo la clave de idempotencia.
  4. Si no se encuentra la clave, guárdela con una marca booleana.
  5. Transforme los datos en una tabla Lua mediante una función de utilidad personalizada.
  6. La respuesta se almacena en formato JSON para tener en cuenta los encabezados.
  7. Reconstruir la respuesta.
  8. Devolver la respuesta reconstruida al cliente. Tenga en cuenta la declaración return : APISIX omite las fases posteriores del ciclo de vida.


 function _M.body_filter(conf, ctx) local idempotency_key = core.request.header(ctx, "Idempotency-Key") --1 local redis_key = "idempotency#" .. idempotency_key if core.response then local response = { --2 status = ngx.status, body = core.response.hold_body_chunk(ctx, true), headers = ngx.resp.get_headers() } local redis_key = "idempotency#" .. redis_key local resp, err = red:set(redis_key, "response", core.json.encode(response)) --3 if not resp then return end end end
  1. Extraiga la clave de idempotencia de la solicitud.


  2. Organizar los diferentes elementos de una respuesta en una tabla Lua.


  3. Almacene la respuesta codificada en JSON en un conjunto de Redis


Las pruebas revelan que funciona como se esperaba.


Intentar:

 curl -i -X POST -H 'Idempotency-Key: A' localhost:9080/response-headers\?freeform=hello curl -i -H 'Idempotency-Key: B' localhost:9080/status/250 curl -i -H 'Idempotency-Key: C' -H 'foo: bar' localhost:9080/status/250


Además, intente reutilizar una clave de idempotencia que no coincida, por ejemplo , A para la tercera solicitud. Como todavía no hemos implementado ninguna gestión de errores, obtendrá la respuesta en caché para otra solicitud. Es hora de mejorar nuestro juego.

Implementación de rutas de error

La especificación define varias rutas de error:


  • Falta la clave de idempotencia.


  • La clave de idempotencia ya está utilizada.


  • Hay una solicitud pendiente para esta clave de idempotencia


Implementémoslos uno por uno. Primero, verifiquemos que la solicitud tenga una clave de idempotencia. Tenga en cuenta que podemos configurar el complemento por ruta, por lo que si la ruta incluye el complemento, podemos concluir que es obligatorio.


 function _M.access(conf, ctx) local idempotency_key = core.request.header(ctx, "Idempotency-Key") if not idempotency_key then return core.response.exit(400, "This operation is idempotent and it requires correct usage of Idempotency Key") end -- ...


Simplemente devuelva el 400 correspondiente si falta la clave. Ese fue fácil.


Verificar la reutilización de una clave existente para una solicitud diferente es un poco más complicado. Primero debemos almacenar la solicitud, o más precisamente, la huella digital de lo que constituye una solicitud. Dos solicitudes son iguales si tienen: el mismo método, la misma ruta, el mismo cuerpo y los mismos encabezados. Dependiendo de su situación, el dominio (y el puerto) pueden o no ser parte de ellos. Para mi implementación simple, lo dejaré fuera.


Hay varios problemas a resolver. En primer lugar, no encontré una API existente para aplicar hash al objeto core.request como ocurre en otros lenguajes con los que estoy más familiarizado, por ejemplo , Object.hash() de Java. Decidí codificar el objeto en JSON y aplicar hash a la cadena. Sin embargo, el core.request existente tiene subelementos que no se pueden convertir a JSON. Tuve que extraer las partes mencionadas anteriormente y convertir la tabla.


 local function hash_request(request, ctx) local request = { --1 method = core.request.get_method(), uri = ctx.var.request_uri, headers = core.request.headers(), body = core.request.get_body() } local json = core.json.stably_encode(request) --2 return ngx.encode_base64(json) --3 end
  1. Cree una tabla con solo las partes relevantes.


  2. La biblioteca cjson produce JSON cuyos miembros pueden ordenarse de manera diferente en varias llamadas. Por lo tanto, da como resultado diferentes hashes. core.json.stably_encode soluciona ese problema.


  3. Ha golpeado.


Luego, en lugar de almacenar un valor booleano al recibir la solicitud, almacenamos el hash resultante.


 local hash = hash_request(core.request, ctx) if next(resp) == nil then core.log.warn("No key found in Redis for Idempotency-Key, set it: ", redis_key) local resp, err = redis:hset(redis_key, "request", hash) if not resp then core.log.error("Failed to set data in Redis: ", err) return end then -- ...


Leemos el hash almacenado bajo la clave de idempotencia en la otra rama. Si no coinciden, salimos con el código de error correspondiente:


 local data = normalize_hgetall_result(resp) local stored_hash = data["request"] if hash ~= stored_hash then return core.response.exit(422, "This operation is idempotent and it requires correct usage of Idempotency Key. Idempotency Key MUST not be reused across different payloads of this operation.") end


La gestión final de errores se produce justo después. Imagine el siguiente escenario:


  1. Una solicitud viene con la clave de idempotencia X.


  2. El complemento toma las huellas digitales y almacena el hash en Redis.


  3. APISIX reenvía la solicitud al nivel ascendente.


  4. Una solicitud duplicada viene con la misma clave de idempotencia, X.


  5. El complemento lee los datos de Redis y no encuentra ninguna respuesta almacenada en caché.


El upstream no terminó de procesar la solicitud; por lo tanto, la primera solicitud aún no ha llegado a la fase body_filter .


Agregamos el siguiente código al fragmento anterior:


 if not data["response"] then return core.response.exit(409, " request with the same Idempotency-Key for the same operation is being processed or is outstanding.") end


Eso es todo.

Conclusión

En esta publicación, mostré una implementación simple de la especificación del encabezado Idempotency-Key en Apache APISIX a través de un complemento. En esta etapa, tiene margen de mejora: pruebas automatizadas, la capacidad de configurar Redis por ruta, configurar el dominio/ruta para que sea parte de la solicitud, configurar un clúster de Redis en lugar de una sola instancia, usar otro K/ Tienda V, etc.


Sin embargo, implementa la especificación y tiene el potencial de evolucionar hacia una implementación de mayor nivel de producción.


El código fuente completo de esta publicación se puede encontrar en GitHub .


Para llegar más lejos:



Publicado originalmente en A Java Geek el 7 de abril de 2024