Réarchitecture : passer de Redis à SQLite
(wafris.org)- Wafris remplace, dans la v2 de son client middleware Rails, le stockage Redis appartenant à l’utilisateur de la v1 par un stockage basé sur SQLite, afin de réduire à la fois la difficulté de déploiement et la latence d’évaluation des requêtes
- Le choix initial de Redis avait été influencé par l’inertie de l’écosystème Rails, avec Heroku et Sidekiq, mais en production le problème s’est amplifié : les utilisateurs devaient aussi endosser le rôle d’administrateur Redis
- Le principal goulot d’étranglement est le chemin de lecture, qui compare toutes les requêtes HTTP entrantes aux règles ; les écritures destinées au reporting peuvent être traitées plus lentement, en batch ou de façon asynchrone
- Lors d’un test local sur un MacBook Air M2 avec un jeu de données de 1,2 million de plages, pour la pire requête de plage IP, SQLite s’est montré environ 3 fois plus performant que Redis local, sans inclure la latence réseau
- La v2 utilise une structure de synchronisation qui vérifie les nouvelles règles après un certain temps ou un certain nombre de requêtes, puis télécharge l’intégralité de la nouvelle base SQLite ; le nombre d’installations réussies a augmenté d’environ 3 fois
Les frictions de déploiement révélées par la v1 basée sur Redis
- Wafris est une entreprise de pare-feu applicatif web open source qui fournit un client middleware pour les applications Rails
- Le client v1 nécessitait un stockage Redis appartenant à l’utilisateur, déployé avec l’application
- Le choix initial de Redis a été influencé par l’environnement où Redis pouvait être facilement ajouté sur Heroku, par la commodité de l’accès distant et par des réussites comme Sidekiq
- Les environnements réels des utilisateurs étaient plus variés, et beaucoup d’utilisateurs ont eu du mal à déboguer des problèmes de déploiement et de configuration de Redis
- Lors de RailsWorld 2023, l’idée qu’un serveur Redis soit évidemment nécessaire à côté d’une application Rails a aussi suscité une réaction négative
Le cœur du problème de vitesse est la latence réseau
- Redis est rapide par rapport aux SGBDR traditionnels, mais comme il s’agit d’une base de données séparée, il impose la gestion des connexions, de la mémoire et des processus
- Dans les environnements cloud, la latence réseau affecte directement les performances de traitement des requêtes
- Wafris doit comparer chaque requête HTTP entrant dans l’application aux règles stockées
- Même en rendant le client v1 aussi rapide que possible, si le réseau où l’application est déployée est lent, la réponse globale pouvait être ralentie
- Les apps Rails ne sont pas non plus toujours déployées sous la forme d’un unique « majestic monolith »
- Des apps déployées sur plusieurs zones
- Des apps réparties sur plusieurs serveurs aux responsabilités qui se chevauchent
- Des apps dont seule une partie est en Rails, déployées avec d’autres langages ou frameworks
- Dans ces environnements d’exploitation, les frictions liées à Redis augmentent encore
Séparer lectures et écritures dans le traitement des requêtes Wafris
- Wafris s’installe comme middleware Rails et compare les requêtes entrantes à des règles configurées, par exemple « bloquer l’IP 1.2.3.4 »
- Le flux de traitement simplifié comporte deux étapes
- Comparer la requête HTTP aux règles et renvoyer 403 en cas de correspondance, sinon 200
- Rapporter le résultat du traitement, comme bloqué, autorisé ou passé
- La première étape, la lecture des règles, est beaucoup plus importante que l’écriture de reporting
- Les requêtes doivent être traitées séquentiellement
- Si le filtrage ne fonctionne pas, de mauvaises requêtes peuvent passer
- L’évaluation des requêtes a un impact sur les performances du site perçues par l’utilisateur
- Les écritures de reporting peuvent être traitées plus lentement, en batch ou de manière asynchrone
Pourquoi choisir SQLite
- Le principal goulot d’étranglement de Wafris était l’I/O réseau, et une phrase de la documentation SQLite a influencé le choix : « SQLite ne concurrence pas les bases de données client/serveur. SQLite concurrence fopen() »
- En partant de l’hypothèse que la suppression des allers-retours réseau pourrait à elle seule rendre l’architecture plus rapide que celle basée sur Redis, Wafris a benchmarké SQLite et Redis
- Les ressources de référence sont les suivantes
- « High Performance SQLite » d’Aaron Francis : https://highperformancesqlite.com/
- SQLite on Rails - the how and why of optimal performance de Stephen Margheim
- Oldmoe : https://oldmoe.blog/
Périmètre du benchmark et limites volontaires
- Le benchmark n’était pas une comparaison générale des performances de bases de données, mais un test biaisé ciblant le hot path de Wafris et sa pire requête
- La pire requête était une recherche dans une structure de données « lexical decimal » qui associe des plages IP à des catégories
- Un exemple simple est un mapping IP → pays, qui vérifie si une adresse IP se trouve entre deux adresses et renvoie un pays
- Cette structure compte des millions de lignes, et en IPv6 chaque élément est volumineux
- Les recherches de plages sont précalculées puis enregistrées dans les deux stockages
- Une table SQLite
- Un sorted set Redis
- Dans un cas pathologique, chaque requête HTTP entrante doit comparer l’IP de la requête aux plages suivantes
- Plages d’autorisation définies par l’utilisateur
- Plages de blocage définies par l’utilisateur
- Plages GeoIP
- Plages de réputation IP
- Comme ce type de requête était suffisamment important, Wafris n’a testé que celui-ci, sans porter d’autres requêtes ni fonctionnalités
Méthode de test et résultats
- Les tests ont été effectués sur un MacBook Air M2 local, avec Redis installé via Homebrew et une base SQLite locale
- Le protocole était le suivant
- Utiliser un jeu de données existant de 1,2 million d’entrées de plages
- Exécuter plusieurs ensembles d’IP, dans le même ordre, sur SQLite et Redis
- Exécuter le test 5 fois pour chaque multiplicateur et utiliser la moyenne
- Dans le cas d’usage spécifique de Wafris, SQLite s’est montré environ 3 fois plus performant que Redis local
- Ce résultat est mesuré avant prise en compte de la latence réseau
- Le test reflétait volontairement une configuration simple et les défauts d’un usage réel ; il est difficile de le généraliser comme comparaison universelle de bases de données
Les différences opérationnelles que le graphique ne montre pas
- Même si les performances de SQLite avaient été nettement pires que celles de Redis dans le benchmark, Wafris estimait qu’il pourrait être plus rapide en environnement réel en raison de la latence réseau jusqu’à Redis situé dans le même datacenter ou la même région
- Même lorsqu’un serveur Redis est configuré de manière robuste en cluster ou en sharding, des limites subsistent, comme la bande passante réseau, le nombre de connexions et la latence inter-régions
- SQLite étant présent localement sur chaque instance de calcul, dans ce cas d’usage de Wafris le coût du scaling horizontal disparaît presque
- L’onboarding est aussi plus simple côté SQLite
- L’utilisateur peut ajouter la gem à son application web et l’exécuter sans même savoir que SQLite est utilisé
- Redis offre encore de nombreuses possibilités d’optimisation, mais Wafris avait du mal à convaincre systématiquement les utilisateurs de modifier même des réglages de base, comme la politique d’éviction du cache
Changements d’architecture nécessaires après le passage à SQLite
- Le flux de mise à jour de la v1 basée sur Redis était simple
- L’utilisateur met à jour les règles dans Wafris Hub
- Wafris Hub met à jour les règles dans le stockage Redis de l’utilisateur
- Avec SQLite, Wafris Hub ne pouvait pas « pousser » directement la base de données SQLite vers le serveur web
- Certains fournisseurs de SQLite as a service permettent une approche similaire, mais elle ne convenait pas à Wafris pour des raisons de coût, de performance et de sécurité
- Chaque utilisateur devrait le déployer
- Il faudrait ouvrir des ports
- Il faudrait autoriser des connexions entrantes
- Le flux de mise à jour de la v2 basée sur SQLite est le suivant
- L’utilisateur met à jour les règles dans Wafris Hub
- Le client vérifie à intervalle régulier, selon le temps écoulé ou le nombre de requêtes, si les règles ont été mises à jour
- Si les règles ont changé, le client télécharge une toute nouvelle base de données SQLite
- Cette structure a fortement réduit la responsabilité d’installation et de configuration côté utilisateur, et les installations réussies du client v2 ont augmenté d’environ 3 fois
Architecture SQLite dans les déploiements distribués
- Dans des environnements cloud comme AWS, Heroku ou Fly, lorsque les apps Rails autoscalent, le nombre d’instances de calcul augmente, mais la base de données ne suit souvent pas
- Si le trafic passe de 100 req/s à 10 000 req/s, des dynos, machines ou instances EC2 démarrent, mais le goulot d’étranglement de la base de données peut rester inchangé
- D’après ce qu’a observé Wafris, les principales causes de pannes d’apps Rails étaient souvent le credential stuffing ou le trafic de mauvais bots, davantage que de véritables DDoS
- Ce trafic déclenche l’autoscaling
- Ensuite, les connexions à la base de données s’épuisent et l’app peut tomber
- En synchronisant la DB SQLite sur chaque instance de calcul, tous les appels des nouvelles instances peuvent rester locaux
Le chemin d’écriture est supprimé du client
- Les tests précédents ne prenaient pas en compte les écritures, et Wafris ne considère pas non plus que SQLite convienne à tous les rôles
- Wafris utilise SQLite pour un rôle centré sur la lecture et a reconçu séparément le chemin d’écriture
- Le chemin de reporting de la v2 change ainsi
- Reporting via une connexion asynchrone à Wafris Hub
- Envoi des données de reporting par batch
- Suppression complète des écritures en base de données côté client
- Cette architecture ne conviendra peut-être pas à la plupart des autres services, mais elle répond aux objectifs recherchés par les utilisateurs de Wafris : un déploiement facile et un client rapide
- L’architecture v2 basée sur SQLite a déjà aidé plusieurs sites à résister à des attaques et à rester en ligne, tout en réduisant le travail de support de Wafris et les désagréments pour les utilisateurs
1 commentaires
Avis de Hacker News
Le modèle où chaque serveur d’application possède une copie du fichier de base de données SQLite et le remplace intégralement à intervalles réguliers est vraiment intéressant.
Ici, il est utilisé pour des règles de pare-feu applicatif web, mais il semble aussi très adapté à la configuration des feature flags. Les feature flags peuvent être vérifiés des dizaines de fois par requête, et nécessitent souvent des requêtes du type « l’utilisateur appartient au groupe A et son IP se trouve dans le pays B », ce que SQLite local peut très bien gérer. Pour les feature flags, il est généralement acceptable que les mises à jour se propagent avec quelques secondes de retard, voire davantage.
Cela dit, je n’ai pas encore vu SQLite utilisé pour cet usage ; en général, on utilise plutôt des bases de données de style DBM comme LMDB ou Kyoto Cabinet.
Au départ, une tâche Cron sur chaque serveur récupérait les données du service concerné ; après le passage à Kubernetes, ils ont utilisé un DaemonSet et un mécanisme proche du marquage d’hôtes/taints pour télécharger plusieurs jeux de données sur chaque hôte, puis garantir que les services utilisant ces données ne s’exécutent que sur ces hôtes. En Ruby, cela s’appelait « hammerspace » : https://github.com/airbnb/hammerspace
Ainsi, on évite de payer deux fois ou plus le coût d’une consultation chère pour une même requête, et il n’y a pas non plus de risque qu’un flag soit mis à jour pendant le traitement de la requête et produise des valeurs incohérentes.
On peut ajouter par-dessus divers mécanismes de distribution, comme un réseau gossip ou un réseau explicite de caches/diffusion en arbre. Toute entité disposant d’une version plus récente peut fournir une mise à jour.
On peut inclure la version actuelle de la base dans l’artefact applicatif, tout en permettant à l’application de se mettre à jour efficacement après son démarrage. Pour les feature flags ou la configuration live, on peut faire un fallback vers un état récent plutôt que vers des valeurs par défaut au niveau du code.
N’importe quel client peut envoyer le compteur global comme un ETAG et ne recevoir que les changements ; la reconnexion après une brève coupure réseau devient aussi simple et peu coûteuse.
Si l’on peut conserver quelques minutes d’historique, on peut attacher le compteur aux appels de microservices, par exemple via un en-tête W3C Baggage, afin que plusieurs services évaluent les flags/la configuration/les données par rapport au même numéro de version. Même sans fonctionnalité d’évaluation temporelle, journaliser le numéro de génération aide beaucoup au débogage a posteriori.
En dehors de la latence réseau, j’ai constaté qu’avec Redis, la latence de lecture/écriture tendait à être presque linéairement proportionnelle au nombre de clés consultées, et les graphiques de l’article semblent montrer quelque chose de similaire.
Quand nous utilisions Postgres et Redis chacun pour des usages précis dans une application monolithique, cela fonctionnait plutôt bien, mais il était trop facile de pousser une nouvelle fonctionnalité dans le cluster Redis partagé. Redis est monothread, donc une fonctionnalité peu considerate qui lit en masse plus de 100 000 clés peut ralentir aussi les autres fonctionnalités.
J’avais donc proposé comme règle d’utiliser Redis surtout lorsqu’on lit et écrit une seule clé à la fois, ou un petit nombre fixe de clés, par exemple pour un verrou ou du rate limiting sur un endpoint très fréquenté, lorsque plusieurs fonctionnalités aléatoires utilisent Redis.
Dans ce cas, Redis brillerait pour une simple consultation sur une seule clé (adresse IP), mais semble moins adapté à des lectures plus complexes, comme l’expression de requêtes par plages. Je ne sais pas en détail pourquoi SQLite fonctionne aussi bien par rapport à Redis local, donc c’était inattendu et intéressant.
Il vaut mieux voir Redis comme un cache doté d’opérations de base plus riches. Bien utilisé, il est rapide et fiable.
Mais dès qu’on commence à mettre dans Redis des choses qui ne rentrent pas bien dans le SGBDR principal, on se retrouve vite avec des files de tâches, plusieurs types de verrous, etc., et ce n’est plus qu’une question de temps avant que les performances franchissent une falaise ou que le système s’effondre pour une autre raison. Le rétablissement devient généralement désordonné et se termine souvent par l’acceptation d’une certaine perte de données.
Ce genre de dérive se produit facilement par petites étapes, donc l’éviter demande de la discipline. Les performances de SQLite ne viennent pas seulement du fait d’éviter l’overhead réseau : beaucoup de gens sous-estiment aussi le coût de sérialisation/désérialisation. Même si le protocole Redis est assez minimaliste, les coûts s’accumulent, tandis qu’avec SQLite beaucoup d’opérations se résument à des copies mémoire dans le processus.
Il y a quelques semaines, lors d’un hackathon interne chez Neon, j’ai créé un petit serveur Node.js qui transforme RESP, le protocole filaire de Redis, en requêtes Postgres.
C’était un hack très amusant : https://github.com/btholt/redis-to-postgres
Cela ressemble à un cas d’usage particulier où SQLite côté serveur convient plutôt bien, puisque la base de données est en lecture seule et ne nécessite pas de réplication.
Une autre option serait de charger des fichiers statiques en mémoire, mais dans ce cas il semble y avoir trop de données pour les garder en mémoire en permanence, ce qui fait de SQLite une bonne alternative.
La différence est que SQLite est très robuste, performant et exceptionnellement bien testé.
Je suis curieux du passage disant qu’à RailsWorld 2023, il y avait une ambiance où l’on « sentait le sang » autour de Redis
Dans ma carrière, je n’ai travaillé que sur une seule app Rails en production, et cette app utilisait Redis, donc je ne connais pas bien les tendances actuelles. Je me demande si l’écosystème est en train de tourner le dos à Redis d’un point de vue business, si c’est dû au changement de licence, ou si c’est plutôt une situation proche de YAGNI
À l’époque, on l’utilisait surtout avec Rescue pour planifier des tâches asynchrones comme l’indexation et le transcodage, et ça semblait être un outil plutôt correct
Dans l’enquête 2024 de la communauté Rails, Redis reste aussi le datastore le plus utilisé dans les apps
Cela dit, même si beaucoup d’apps utilisent Redis, dans les faits c’est souvent uniquement pour des usages comme Sidekiq, et pas pour des classements temps réel ou des fonctionnalités de base de données vectorielle ; les usages réels sont donc un peu flous
Aujourd’hui, une configuration Rails avec un certain volume de trafic comporte souvent des serveurs front-end, une base SQL, un stockage clé-valeur (Redis ou Memcached), puis un stockage de cache pointant vers ce stockage clé-valeur. Or les usages clé-valeur classiques, comme maintenir des quotas d’API ou des limites de débit, et les patterns d’utilisation du cache sont assez différents, ce qui devient pénible
Les performances disque sont devenues suffisamment rapides et SQL s’est aussi amélioré, si bien qu’il existe un mouvement visant à supprimer le stockage clé-valeur, à confier les usages clé-valeur traditionnels à la base SQL, et à mettre le backend de cache sur disque. Les nouveaux disques NVMe sont presque aussi rapides que la RAM tout en étant beaucoup moins chers, ce qui permet de mettre davantage de choses en cache
Mais à l’ère de Kubernetes et de Redis-as-a-service, cette « mémoire partagée » se retrouve sur une autre VM, dans une autre baie. À ce stade, on a déjà renoncé à l’efficacité des ressources ; à mon avis, autant lire et écrire des fichiers dans S3
À propos du passage disant que « Redis est rapide par rapport aux SGBDR traditionnels, mais reste une base de données pour laquelle il faut gérer connexions, mémoire, processus, etc., ce qui rend la stack plus fragile », je pense qu’à partir du moment où l’on commence à traiter des transactions au-delà du niveau jouet, toutes les bases de données, relationnelles ou non, demandent à peu près le même niveau d’administration et de maintenance
La partie « rapide » est aussi assez intéressante. Si l’on ne se préoccupe pas des jointures, les insertions et les lectures de lignes sont aussi plutôt rapides
Une fois qu’on comprend qu’il lit directement depuis le disque local, les discussions sur les jointures ou les transactions perdent beaucoup de leur importance. C’est déjà, en soi, un chemin plusieurs ordres de grandeur plus rapide
Chaque fois que je lis la documentation pour savoir quand lancer VACUUM, je m’y perds. La dernière fois que j’ai déployé une application basée sur SQLite, j’ai simplement mis en place un compteur et lancé VACUUM après un grand nombre d’écritures
Pour une base de données nécessitant d’exécuter un processus serveur indépendant, il y aura forcément des différences d’effort d’administration et de maintenance. À l’extrême, il est difficile de croire qu’utiliser SQLite ou Oracle ne fait vraiment aucune différence
Redka, une réimplémentation de Redis en Go avec SQLite, pourrait vous intéresser : https://github.com/nalgeon/redka
C’est clairement un beau projet, mais à ce niveau-là, il y a très peu d’incitation à migrer
Je voudrais vérifier si je comprends bien la manière dont Redis était utilisé
Dans la v1, le WAF et Redis étaient-ils sur le même serveur, et lorsqu’un client définissait une nouvelle règle dans le panneau d’administration, cette règle était-elle ajoutée à Redis sur le même serveur que le panneau d’administration, puis propagée vers les Redis locaux situés à côté des WAF dans le monde entier grâce au mécanisme de synchronisation interne de Redis ? Ensuite, lorsqu’une nouvelle requête arrivait sur un WAF quelconque, le WAF validait-il la requête/l’IP à l’aide des règles Redis mises à jour ?
Dans la v2, le cluster Redis a-t-il été supprimé, chaque serveur WAF disposant de sa propre base SQLite, avec un mécanisme de synchronisation distinct créé pour diffuser les nouvelles règles depuis le panneau d’administration vers chaque serveur WAF+SQLite, puis, lorsqu’une nouvelle requête arrive, le WAF la valide rapidement à l’aide des règles SQLite mises à jour ?
La meilleure phrase est : « SQLite ne concurrence pas les bases de données client/serveur. SQLite concurrence fopen() »