Rearquitectura: migración de Redis a SQLite
(wafris.org)- Wafris reemplazó en la v2 el almacenamiento Redis propiedad del usuario del cliente middleware para Rails v1 por un almacenamiento basado en SQLite, buscando reducir tanto la dificultad de despliegue como la latencia en la evaluación de solicitudes
- La elección inicial de Redis estuvo influida por la inercia del ecosistema Rails, con Heroku y Sidekiq, pero en operación real creció el problema de que los usuarios también tuvieran que asumir el rol de administradores de Redis
- La ruta de lectura, que compara cada solicitud HTTP entrante contra las reglas, es el cuello de botella principal; las escrituras para reportes pueden procesarse más lento o separarse en lotes y de forma asíncrona
- En una MacBook Air M2 local, al probar la peor consulta de rangos de IP con un dataset de 1,2 millones de rangos, SQLite mostró un rendimiento aproximadamente 3 veces más rápido que Redis local, sin incluir latencia de red
- La v2 usa una estructura de sincronización que revisa reglas nuevas según un intervalo de tiempo o cantidad de solicitudes, y luego descarga una nueva base de datos SQLite completa; las instalaciones exitosas aumentaron aproximadamente 3 veces
Fricción de despliegue expuesta en la v1 basada en Redis
- Wafris es una empresa de firewall de aplicaciones web open source que ofrece un cliente middleware para aplicaciones Rails
- El cliente v1 requería un almacenamiento Redis propiedad del usuario desplegado junto con la aplicación
- La elección inicial de Redis estuvo influida por el entorno en el que era fácil agregar Redis en Heroku, la comodidad del acceso remoto y casos de éxito como Sidekiq
- Los entornos reales de los usuarios eran más variados, y muchos tuvieron dificultades para depurar problemas de despliegue y configuración de Redis
- En RailsWorld 2023 también había un ambiente negativo frente a la suposición de que necesariamente se necesitaba un servidor Redis junto a una aplicación Rails
El núcleo del problema de velocidad es la latencia de red
- Redis es rápido comparado con los RDBMS tradicionales, pero al ser una base de datos separada exige gestión de conexiones, memoria y procesos
- En entornos cloud, la latencia de red afecta directamente el rendimiento del procesamiento de solicitudes
- Wafris debe comparar cada solicitud HTTP que entra a la aplicación contra las reglas almacenadas
- Aunque el cliente v1 se hiciera lo más rápido posible, si la red donde estaba desplegada la aplicación era lenta, la respuesta total podía volverse lenta
- Las apps Rails tampoco siempre se despliegan en una forma única de “majestic monolith”
- Apps desplegadas en varias zonas
- Apps divididas en varios servidores con responsabilidades superpuestas
- Apps donde solo una parte es Rails y se despliegan junto con otros lenguajes o frameworks
- En estos entornos operativos, la fricción por usar Redis aumenta aún más
Separación de lectura y escritura en el procesamiento de solicitudes de Wafris
- Wafris se instala como middleware de Rails, configura reglas como “bloquear IP 1.2.3.4” y luego compara las solicitudes entrantes contra esas reglas
- El flujo simplificado de procesamiento tiene dos pasos
- Comparar la solicitud HTTP contra las reglas y responder 403 si coincide, o 200 si no
- Reportar el resultado del procesamiento, como bloqueada, permitida o pasada
- El primer paso, la lectura de reglas, es mucho más importante que la escritura de reportes
- Las solicitudes deben procesarse de forma secuencial
- Si el filtrado no funciona, pueden pasar solicitudes maliciosas
- La evaluación de solicitudes afecta el rendimiento del sitio percibido por el usuario
- La escritura de reportes puede procesarse más lento, en lotes y de forma asíncrona
Por qué eligieron SQLite
- El principal cuello de botella de Wafris era el I/O de red, y en la decisión influyó la frase de la documentación de SQLite: “SQLite no compite con bases de datos cliente/servidor. SQLite compite con fopen()”
- Bajo la hipótesis de que solo eliminar los viajes de ida y vuelta por la red podía hacerlo más rápido que la arquitectura basada en Redis, compararon SQLite y Redis en benchmarks
- Los materiales de referencia fueron los siguientes
- “High Performance SQLite” de Aaron Francis: https://highperformancesqlite.com/
- SQLite on Rails - the how and why of optimal performance de Stephen Margheim
- Oldmoe: https://oldmoe.blog/
Alcance del benchmark y límites intencionales
- El benchmark no fue una comparación universal de rendimiento de bases de datos, sino una prueba sesgada enfocada en el hot path de Wafris y en su peor consulta
- La peor consulta era una búsqueda sobre una estructura de datos “lexical decimal” que mapea rangos de IP y categorías
- Como ejemplo simple, existe un mapeo IP → país que verifica si una dirección IP está dentro de un rango entre dos direcciones y devuelve el país
- Esta estructura tiene millones de filas, y en IPv6 cada elemento es grande
- Las búsquedas por rango se precalcularon y luego se escribieron en los dos almacenamientos
- Tabla de SQLite
- sorted set de Redis
- En casos patológicos, cada solicitud HTTP entrante debe comparar la IP de la solicitud contra los siguientes rangos
- Rangos permitidos definidos por el usuario
- Rangos bloqueados definidos por el usuario
- Rangos GeoIP
- Rangos de reputación de IP
- Como este tipo de consulta era lo suficientemente importante, probaron solo este tipo sin portar otras consultas ni funcionalidades
Método de prueba y resultados
- Las pruebas usaron Redis instalado con Homebrew en una MacBook Air M2 local y una base de datos SQLite local
- El protocolo fue el siguiente
- Usar un dataset de rangos existente con 1,2 millones de elementos
- Ejecutar varios conjuntos de IP en SQLite y Redis en el mismo orden
- Ejecutar la prueba 5 veces para cada múltiplo y usar el promedio
- En el caso de uso específico de Wafris, SQLite mostró un rendimiento aproximadamente 3 veces más rápido que Redis local
- Este resultado es una cifra antes de considerar la latencia de red
- La prueba reflejó intencionalmente una configuración simple y las imperfecciones del uso real, y es difícil generalizarla como una comparación universal de bases de datos
Diferencias operativas que no aparecen en el gráfico
- Incluso si el rendimiento de SQLite hubiera sido notablemente peor que Redis en el benchmark, consideraron que en entornos reales podría ser más rápido debido a la latencia de red hacia Redis dentro del mismo datacenter o región
- Aunque el servidor Redis esté configurado de forma robusta con clúster o sharding, siguen existiendo límites como ancho de banda de red, cantidad de conexiones y latencia entre regiones
- Como SQLite existe localmente en cada instancia de cómputo, en este caso de uso de Wafris el costo de escalamiento horizontal prácticamente desaparece
- El onboarding también es más simple con SQLite
- El usuario puede agregar la gem a su app web y ejecutarla, incluso sin saber que se usa SQLite
- Redis también tiene muchas oportunidades de optimización adicionales, pero a Wafris le resultó difícil convencer de forma consistente a los usuarios incluso de cambiar configuraciones básicas como la política de expulsión de caché
Cambios estructurales necesarios tras migrar a SQLite
- El flujo de actualización de la v1 basada en Redis era simple
- El usuario actualiza reglas en Wafris Hub
- Wafris Hub actualiza las reglas en el almacenamiento Redis del usuario
- Con SQLite, Wafris Hub no podía “empujar” directamente la base de datos SQLite al servidor web
- Algunos proveedores de SQLite as a service permiten métodos similares, pero no encajaban para Wafris por consideraciones de costo, rendimiento y seguridad
- Cada usuario tendría que desplegarlo
- Tendría que abrir puertos
- Tendría que permitir conexiones entrantes
- El flujo de actualización de la v2 basada en SQLite es el siguiente
- El usuario actualiza reglas en Wafris Hub
- El cliente revisa reglas actualizadas en intervalos definidos por tiempo o por cantidad de solicitudes
- Si las reglas cambiaron, el cliente descarga una base de datos SQLite completamente nueva
- Esta estructura redujo de forma importante la responsabilidad del usuario sobre instalación y configuración, y las instalaciones exitosas del cliente v2 aumentaron aproximadamente 3 veces
Estructura de SQLite en despliegues distribuidos
- Cuando una app Rails escala automáticamente en entornos cloud como AWS, Heroku o Fly, suelen aumentar las instancias de cómputo, pero no necesariamente aumenta la base de datos junto con ellas
- Si las solicitudes aumentan de 100 req/s a 10.000 req/s, se levantan dynos, machines o instancias EC2, pero el cuello de botella de la base de datos puede permanecer igual
- Las principales causas de fallas en apps Rails que observó Wafris solían ser credential stuffing o tráfico de bots maliciosos, más que DDoS reales
- Ese tráfico dispara el autoescalado
- Luego se agotan las conexiones a la base de datos y la app puede caer
- Al sincronizar la DB SQLite en cada instancia de cómputo, todas las llamadas de una nueva instancia pueden mantenerse locales
La ruta de escritura se elimina del cliente
- Las pruebas anteriores no consideraron operaciones de escritura, y tampoco ven a SQLite como adecuado para todos los roles
- Wafris usa SQLite ajustado a un rol centrado en lectura, y rediseñó por separado la ruta de escritura
- La ruta de reportes de la v2 cambió así
- Reportar mediante una conexión asíncrona a Wafris Hub
- Enviar los datos de reporte por lotes
- Eliminar por completo las escrituras en base de datos desde el cliente
- Esta estructura puede no encajar con la mayoría de los demás servicios, pero sí con el objetivo que quieren los usuarios de Wafris: despliegue fácil y un cliente rápido
- La arquitectura v2 basada en SQLite ya ayudó a varios sitios a resistir ataques y mantenerse en línea, y también redujo el trabajo de soporte de Wafris y las molestias para los usuarios
1 comentarios
Opiniones en Hacker News
Es realmente interesante el modelo en el que cada servidor de aplicación tiene una copia del archivo de base de datos SQLite y la reemplaza completa periódicamente.
Aquí lo usan para reglas de firewall de aplicaciones web, pero creo que también encajaría bien con la configuración de feature flags. Los feature flags pueden revisarse decenas de veces por solicitud y muchas veces requieren consultas como “el usuario pertenece al grupo A y la IP está en el país B”, algo que SQLite local puede manejar bien. En general, para feature flags es aceptable que las actualizaciones se propaguen con unos segundos de demora, o incluso más.
Dicho eso, todavía no he visto casos en los que se use SQLite para este propósito; normalmente se usan bases de datos estilo DBM como LMDB o Kyoto Cabinet.
Al principio, una tarea Cron en cada servidor traía los datos del servicio correspondiente; después de migrar a Kubernetes, hacíamos que se descargaran varios datos en cada host usando DaemonSet y algo parecido a etiquetado/taints de hosts, y luego garantizábamos que los servicios que usaban esos datos solo se ejecutaran en esos hosts. En Ruby a eso le llamábamos “hammerspace”: https://github.com/airbnb/hammerspace
Así no se paga más de una vez el costo de una consulta cara dentro de una misma solicitud, y tampoco existe el riesgo de que un flag se actualice durante el procesamiento y deje valores inconsistentes.
Encima de eso se pueden montar distintos mecanismos de distribución, como una red de gossip o una red explícita de cachés/distribución en forma de árbol. Cualquiera que tenga una versión más nueva puede entregar actualizaciones.
Se puede incluir la versión actual de la DB en el artefacto de la aplicación y, una vez que la app esté en ejecución, actualizarla eficientemente. Para feature flags o configuración en vivo, se puede hacer fallback al estado reciente en lugar de a valores por defecto a nivel de código.
Cualquier cliente puede enviar el contador global como si fuera un ETAG y recibir solo los cambios; reconectarse después de un corte breve de red también se vuelve simple y barato.
Si se puede conservar un historial de unos minutos, se puede adjuntar el contador a las llamadas entre microservicios con algo como el header W3C Baggage, de modo que varios servicios evalúen flags/configuración/datos contra el mismo número de versión. Incluso si no hay una función de evaluación basada en tiempo, registrar el número de generación en los logs ayuda muchísimo para depuración posterior.
Además de la latencia de red, en Redis vi que la latencia de lectura/escritura tiende a crecer casi linealmente con la cantidad de claves consultadas, y en las gráficas del artículo parece verse algo parecido.
En una app monolítica, cuando usábamos Postgres y Redis juntos según el caso de uso, funcionaba bastante bien, pero era demasiado fácil meter una nueva funcionalidad en el clúster Redis compartido. Como Redis es single-threaded, una sola funcionalidad poco considerada que haga consultas masivas de más de 100 mil claves puede volver lentas también a otras funcionalidades.
Por eso propuse como guía que Redis es especialmente bueno cuando se leen y escriben una clave por vez, o una cantidad pequeña y fija de claves, por ejemplo para locks o rate limiting en endpoints populares cuando varias funcionalidades aleatorias usan Redis.
En este caso, Redis brillaría con una consulta simple de una sola clave (dirección IP), pero no parece encajar bien con lecturas más complejas, como expresar consultas por rangos. No entiendo a fondo por qué SQLite funciona tan bien en comparación con un Redis local, así que me resultó inesperado e interesante.
Lo mejor es verlo como una caché con primitivas más ricas. Si se usa adecuadamente, es rápido y confiable.
Pero cuando se empiezan a meter en Redis cosas que no encajan bien en el RDBMS principal, pronto aparecen colas de trabajo, distintos tipos de locks, etc., y al final es cuestión de tiempo hasta que el rendimiento caiga por un precipicio o se rompa por alguna otra razón. El proceso de recuperación suele ensuciarse y es fácil terminar aceptando cierta pérdida de datos.
Como esta deriva ocurre fácilmente de a poco, evitarla requiere disciplina. El rendimiento de SQLite no se debe solo a evitar el overhead de red, sino también a que mucha gente subestima el costo de serialización/deserialización. Aunque el protocolo de Redis sea bastante minimalista, el costo se acumula, mientras que en SQLite muchas operaciones terminan reduciéndose a copias de memoria dentro del proceso.
Hace unas semanas, en un hackatón interno de Neon, hice un pequeño servidor Node.js que convierte RESP, el protocolo wire de Redis, en consultas Postgres.
Fue un proyecto de hacking muy divertido: https://github.com/btholt/redis-to-postgres
Suena como un caso de uso especial en el que SQLite del lado del servidor, de solo lectura y sin necesidad de replicación, encaja bastante bien.
Otra alternativa sería cargar un archivo estático en memoria, pero en este caso parece que hay demasiados datos para mantenerlos siempre en memoria, así que SQLite resulta una buena opción.
La diferencia es que SQLite es muy robusto, tiene buen rendimiento y está excepcionalmente bien probado.
Me da curiosidad la parte sobre que en RailsWorld 2023 había un ambiente de que “olían sangre” respecto de Redis
En mi carrera solo trabajé con una app Rails en producción, y esa app usaba Redis, así que no conozco bien las tendencias actuales. Me pregunto si el ecosistema le está dando la espalda a Redis desde una perspectiva de negocio, si es por el cambio de licencia, o si es más bien una situación cercana a YAGNI
En ese momento lo usábamos principalmente junto con Rescue para programar trabajos asíncronos como indexación y transcodificación, y entonces parecía una herramienta bastante buena
Incluso en la encuesta de la comunidad Rails de 2024, Redis sigue apareciendo como el almacén de datos más usado en las apps
Pero aunque muchas apps usan Redis, en la práctica a menudo lo usan solo para cosas como Sidekiq, y no para funcionalidades como leaderboards en tiempo real o bases de datos vectoriales, así que el patrón real de uso queda un poco difuso
Hoy, en una configuración Rails con cierto nivel de tráfico, suele haber servidores frontend, una base de datos SQL, un almacén clave-valor (Redis o Memcached) y un almacén de caché que apunta a ese almacén clave-valor. Pero el uso típico de clave-valor para mantener cuotas de API o límites de velocidad y los patrones de uso de caché son bastante distintos, lo que lo vuelve engorroso
Como el rendimiento de disco ya es suficientemente rápido y el rendimiento de SQL también mejoró, hay un movimiento para eliminar el almacén clave-valor y dividir las cosas: los usos tradicionales de clave-valor pasan a la base SQL, y el backend de caché pasa al disco. Los discos NVMe nuevos son casi tan rápidos como la RAM, pero mucho más baratos, así que permiten cachear más
Pero en la era de Kubernetes y Redis-as-a-service, esa “memoria compartida” termina estando en otra VM, en otro rack. Llegado ese punto, ya renunciaste a la eficiencia de recursos, así que me parece mejor simplemente leer y escribir archivos en S3
Sobre la frase “Redis es rápido en comparación con los RDBMS tradicionales, pero sigue siendo una base de datos para la que hay que gestionar conexiones, memoria, procesos, etc., lo que vuelve más frágil el stack”, creo que cuando empiezas a manejar transacciones más allá del nivel de juguete, todas las bases de datos, relacionales o no relacionales, requieren más o menos un nivel parecido de administración y mantenimiento
La parte de “rápido” también es algo interesante. Si no te preocupan los joins, insertar y consultar filas también es bastante rápido
Si entiendes que lee directamente desde el disco local, hablar de joins o transacciones pierde mucha relevancia. Ya de por sí está en una ruta varios órdenes de magnitud más rápida
Cada vez que leo la documentación sobre cuándo hay que ejecutar VACUUM, me confundo. La última vez que desplegué una aplicación basada en SQLite, simplemente puse un contador y ejecutaba VACUUM después de muchas escrituras
Si la base de datos exige operar un proceso de servidor independiente, inevitablemente habrá diferencias en el esfuerzo de administración y mantenimiento. Llevándolo al extremo, cuesta creer que usar SQLite y usar Oracle realmente no tenga diferencias
Quizá te interese Redka, una reimplementación de Redis en Go con SQLite: https://github.com/nalgeon/redka
Es un proyecto genial, sí, pero con eso casi no hay incentivo para migrar
Quiero confirmar si entiendo bien cómo se usaba Redis
En v1, ¿el WAF y Redis estaban en el mismo servidor, y cuando el cliente configuraba una regla nueva desde el panel de administración, esa regla entraba al Redis del mismo servidor que el panel de administración, y gracias al mecanismo interno de sincronización de Redis se actualizaban los Redis locales junto a los WAF de todo el mundo? Luego, cuando una nueva solicitud llegaba a algún WAF, ¿el WAF validaba la solicitud/IP contra las reglas actualizadas en Redis? Quiero confirmar si ese era el flujo
En v2, ¿se eliminó el clúster de Redis, cada servidor WAF tiene una base SQLite, se creó un mecanismo de sincronización separado para distribuir las reglas nuevas desde el panel de administración a cada servidor WAF+SQLite, y cuando llega una nueva solicitud el WAF la valida rápidamente contra las reglas actualizadas en SQLite?
La mejor frase es: “SQLite no compite con las bases de datos cliente/servidor. SQLite compite con fopen()”