1 puntos por GN⁺ 2024-09-26 | 1 comentarios | Compartir por WhatsApp
  • 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
    1. Comparar la solicitud HTTP contra las reglas y responder 403 si coincide, o 200 si no
    2. 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

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
  • En casos patológicos, cada solicitud HTTP entrante debe comparar la IP de la solicitud contra los siguientes rangos
    1. Rangos permitidos definidos por el usuario
    2. Rangos bloqueados definidos por el usuario
    3. Rangos GeoIP
    4. 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
    1. Usar un dataset de rangos existente con 1,2 millones de elementos
    2. Ejecutar varios conjuntos de IP en SQLite y Redis en el mismo orden
    3. 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
    1. El usuario actualiza reglas en Wafris Hub
    2. 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
    1. El usuario actualiza reglas en Wafris Hub
    2. El cliente revisa reglas actualizadas en intervalos definidos por tiempo o por cantidad de solicitudes
    3. 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

 
GN⁺ 2024-09-26
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.

    • Este enfoque también es un modelo que se usa en CDN. Se actualiza un archivo global de configuración que contiene los certificados de todos los clientes, reglas de ruteo HTTP, etc., en una estructura B-tree de un solo archivo, y luego ese “bundle” se distribuye con frecuencia a todas las ubicaciones edge.
      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.
    • En Airbnb también distribuíamos con este modelo traducciones, feature flags, configuración, índices de búsqueda, etc. En lugar de SQLite usábamos Sparkey, un formato de archivo clave-valor creado por Spotify.
      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
    • El problema de consultar varias veces por solicitud los valores de feature flags se resuelve trayendo el valor del flag una sola vez y guardándolo en el objeto de la solicitud.
      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.
    • Después de trabajar mucho tiempo con feature flags durante migraciones largas, terminé convencido de que el primitivo realmente útil es algo “como SQLite, pero con un contador de versión global que aumenta con cada cambio y permite traer o aplicar solo los cambios entre números de versión”.
      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.
    • SQLite para distribución es genial. Esta idea parece inspirada en cierta medida por el proyecto Datasette, y más adelante todavía creo que podría hacerse algo en reportes y exploración de datos.
  • 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.

    • Redis muchas veces termina siendo una trampa porque es fácil malentender sus pros y contras.
      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.

    • Espero que el artículo lo haya transmitido, pero esta es una gran solución para un caso de uso específico, no un reemplazo 1:1 de Redis o Postgres.
    • Si uno empieza con la idea de cargar un archivo estático en memoria y va agregando solo las funciones necesarias para que funcione en casos de uso generales, al final termina con una base de datos relacional como SQLite.
      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

    • Es algo cercano a YAGNI. La razón principal por la que Redis se volvió tan usado en la comunidad Rails fueron los trabajos asíncronos, y la herramienta que la mayoría tomó fue Sidekiq
      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
    • Lo veo puramente como un tema de simplicidad
      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
    • Redis antes era útil como una memoria compartida muy inteligente, con funciones integradas como expiración. Con clustering, también se podía compartir entre varias máquinas
      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
    • Como los SSD de hoy son rápidos, más gente debería usar RocksDB. Permite correr un almacén clave-valor mucho más grande sobre SSD
  • 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

    • La razón por la que SQLite es excepcionalmente rápido en un entorno de servidor es que no necesita llamadas de red al hacer consultas o traer datos
      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
    • SQLite tiene una operación VACUUM que parece una especie de recolección de basura
      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
    • No estoy de acuerdo con que “todas las bases de datos requieran más o menos el mismo nivel de administración y mantenimiento”
      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

    • Me pareció lo bastante interesante como para considerar migrar, pero ver la parte de “según los benchmarks, Redka es varias veces más lento que Redis” me hizo dudar
      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()