¿Cómo es Apache APISIX rápido?
June 12, 2023
"Alta velocidad", "latencia mínima" y "rendimiento máximo" son términos que a menudo se utilizan para caracterizar Apache APISIX. Incluso cuando alguien me pregunta sobre APISIX, mi respuesta siempre incluye "puerta de enlace API nativa de la nube de alto rendimiento".
Los puntos de referencia de rendimiento (frente a Kong, Envoy) confirman que estas características son realmente precisas (pruébalo tú mismo).
Pruebas ejecutadas durante 10 rondas con 5000 rutas únicas en Standard D8s v3 (8 vCPUs, 32 GiB de memoria).
Pero, ¿cómo logra APISIX esto?
Para responder a esa pregunta, debemos analizar tres cosas: etcd, tablas hash y árboles radix.
En este artículo, veremos el funcionamiento interno de APISIX y descubriremos qué son estas cosas y cómo trabajan juntas para mantener a APISIX en su máximo rendimiento mientras maneja un tráfico significativo.
etcd como Centro de Configuración
APISIX utiliza etcd para almacenar y sincronizar configuraciones.
etcd está diseñado para funcionar como un almacén de clave-valor para configuraciones de sistemas distribuidos a gran escala. APISIX está diseñado para ser distribuido y altamente escalable desde el principio, y el uso de etcd en lugar de bases de datos tradicionales facilita esto.
Otra característica indispensable para las puertas de enlace API es ser altamente disponible, evitando tiempos de inactividad y pérdida de datos. Esto se puede lograr eficientemente implementando múltiples instancias de etcd para garantizar una arquitectura nativa de la nube tolerante a fallos.
APISIX puede leer/escribir configuraciones desde/hacia etcd con una latencia mínima. Los cambios en los archivos de configuración se notifican al instante, lo que permite a APISIX monitorear solo las actualizaciones de etcd en lugar de sondear una base de datos con frecuencia, lo que puede agregar sobrecarga de rendimiento.
Este gráfico resume cómo etcd se compara con otras bases de datos.
Tablas Hash para Direcciones IP
Las listas de permitidos/denegados basadas en direcciones IP son un caso de uso común para las puertas de enlace API.
Para lograr un alto rendimiento, APISIX almacena la lista de direcciones IP en una tabla hash y la utiliza para la coincidencia (O(1)) en lugar de iterar a través de la lista (O(N)).
A medida que aumenta el número de direcciones IP en la lista, el impacto en el rendimiento de usar tablas hash para el almacenamiento y la coincidencia se vuelve evidente.
Internamente, APISIX utiliza la biblioteca lua-resty-ipmatcher para implementar esta funcionalidad. El siguiente ejemplo muestra cómo se utiliza la biblioteca:
local ipmatcher = require("resty.ipmatcher")
local ip = ipmatcher.new({
"162.168.46.72",
"17.172.224.47",
"216.58.32.170",
})
ngx.say(ip:match("17.172.224.47")) -- true
ngx.say(ip:match("176.24.76.126")) -- false
La biblioteca utiliza tablas Lua, que son tablas hash. Las direcciones IP se hashean y se almacenan como índices en una tabla, y para buscar una dirección IP dada, solo tienes que indexar la tabla y verificar si es nil o no.
Para buscar una dirección IP, primero calcula el hash (índice) y verifica su valor. Si no está vacío, tenemos una coincidencia. Esto se hace en tiempo constante O(1).
Árboles Radix para Enrutamiento
Por favor, perdóname por engañarte con una lección de estructuras de datos. Pero escúchame; aquí es donde se pone interesante.
Un área clave donde APISIX optimiza el rendimiento es en la coincidencia de rutas.
APISIX coincide una ruta con una solicitud a partir de su URI, métodos HTTP, host y otra información (ver enrutador). Y esto debe ser eficiente.
Si has leído la sección anterior, una respuesta obvia sería usar un algoritmo hash. Pero la coincidencia de rutas es complicada porque múltiples solicitudes pueden coincidir con la misma ruta.
Por ejemplo, si tenemos una ruta /api/*
, entonces tanto /api/create
como /api/destroy
deben coincidir con la ruta. Pero esto no es posible con un algoritmo hash.
Las expresiones regulares pueden ser una solución alternativa. Las rutas se pueden configurar en una expresión regular, y puede coincidir con múltiples solicitudes sin necesidad de codificar cada solicitud.
Si tomamos nuestro ejemplo anterior, podemos usar la expresión regular /api/[A-Za-z0-9]+
para coincidir con /api/create
y /api/destroy
. Expresiones regulares más complejas podrían coincidir con rutas más complejas.
¡Pero las expresiones regulares son lentas! Y sabemos que APISIX es rápido. En su lugar, APISIX utiliza árboles radix, que son árboles de prefijos comprimidos (trie) que funcionan muy bien para búsquedas rápidas.
Veamos un ejemplo simple. Supongamos que tenemos las siguientes palabras:
- romane
- romanus
- romulus
- rubens
- ruber
- rubicon
- rubicundus
Un árbol de prefijos lo almacenaría así:
El recorrido resaltado muestra la palabra "rubens".
Un árbol radix optimiza un árbol de prefijos fusionando nodos hijos si un nodo solo tiene un nodo hijo. Nuestro ejemplo de trie se vería así como un árbol radix:
El recorrido resaltado aún muestra la palabra "rubens". ¡Pero el árbol se ve mucho más pequeño!
Cuando creas rutas en APISIX, APISIX las almacena en estos árboles.
APISIX puede funcionar perfectamente porque el tiempo que tarda en coincidir una ruta solo depende de la longitud del URI en la solicitud y es independiente del número de rutas (O(K), K es la longitud de la clave/URI).
Por lo tanto, APISIX será tan rápido como lo es al coincidir 10 rutas cuando comienzas y 5000 rutas cuando escalas.
Este ejemplo crudo muestra cómo APISIX puede almacenar y coincidir rutas usando árboles radix:
El recorrido resaltado muestra la ruta /user/*
donde el *
representa un prefijo. Por lo tanto, un URI como /user/navendu
coincidirá con esta ruta. El código de ejemplo a continuación debería aclarar más estas ideas.
APISIX utiliza la biblioteca lua-resty-radixtree, que envuelve rax, una implementación de árbol radix en C. Esto mejora el rendimiento en comparación con implementar la biblioteca en Lua puro.
El siguiente ejemplo muestra cómo se utiliza la biblioteca:
local radix = require("resty.radixtree")
local rx = radix.new({
{
paths = { "/api/*action" },
metadata = { "metadata /api/action" }
},
{
paths = { "/user/:name" },
metadata = { "metadata /user/name" },
methods = { "GET" },
},
{
paths = { "/admin/:name" },
metadata = { "metadata /admin/name" },
methods = { "GET", "POST", "PUT" },
filter_fun = function(vars, opts)
return vars["arg_access"] == "admin"
end
}
})
local opts = {
matched = {}
}
-- coincide con la primera ruta
ngx.say(rx:match("/api/create", opts)) -- metadata /api/action
ngx.say("action: ", opts.matched.action) -- action: create
ngx.say(rx:match("/api/destroy", opts)) -- metadata /api/action
ngx.say("action: ", opts.matched.action) -- action: destroy
local opts = {
method = "GET",
matched = {}
}
-- coincide con la segunda ruta
ngx.say(rx:match("/user/bobur", opts)) -- metadata /user/name
ngx.say("name: ", opts.matched.name) -- name: bobur
local opts = {
method = "POST",
var = ngx.var,
matched = {}
}
-- coincide con la tercera ruta
-- el valor para `arg_access` se obtiene de `ngx.var`
ngx.say(rx:match("/admin/nicolas", opts)) -- metadata /admin/name
ngx.say("admin name: ", opts.matched.name) -- admin name: nicolas
La capacidad de gestionar un gran número de rutas de manera eficiente ha convertido a APISIX en la puerta de enlace API elegida para muchos proyectos a gran escala.
Mira bajo el Capó
Solo puedo explicar tanto sobre el funcionamiento interno de APISIX en un artículo.
Pero la mejor parte es que las bibliotecas mencionadas aquí y Apache APISIX son completamente de código abierto, lo que significa que puedes mirar bajo el capó y modificar las cosas tú mismo.
Y si puedes mejorar APISIX para obtener ese último bit de rendimiento, puedes contribuir con los cambios al proyecto y permitir que todos se beneficien de tu trabajo.