Re-Architektur: Wechsel von Redis zu SQLite
(wafris.org)- Wafris ersetzt im Middleware-Client v2 für Rails den benutzereigenen Redis-Speicher aus v1 durch einen SQLite-basierten Speicher, um sowohl die Komplexität der Bereitstellung als auch die Latenz bei der Request-Auswertung zu senken
- Die ursprüngliche Entscheidung für Redis war von der Trägheit des Rails-Ökosystems mit Heroku und Sidekiq geprägt, in der Praxis mussten Nutzer aber zunehmend auch die Rolle des Redis-Administrators übernehmen
- Der Lesepfad, der alle eingehenden HTTP-Requests mit Regeln vergleicht, ist der zentrale Engpass; Schreibvorgänge fürs Reporting können langsamer, gebündelt oder asynchron verarbeitet werden
- Bei Tests der schlechtesten IP-Bereichsabfrage mit einem Datensatz von 1,2 Millionen Bereichen auf einem lokalen MacBook Air M2 zeigte SQLite gegenüber lokalem Redis eine etwa 3-fach höhere Performance; Netzwerklatenz war dabei nicht eingerechnet
- v2 nutzt eine Synchronisationsstruktur, bei der nach einer bestimmten Zeit oder Anzahl von Requests auf neue Regeln geprüft und dann die gesamte neue SQLite-DB heruntergeladen wird; erfolgreiche Installationen stiegen dadurch um etwa das Dreifache
Bereitstellungs-Reibung in Redis-basiertem v1
- Wafris ist ein Open-Source-Unternehmen für Web Application Firewalls und bietet einen Middleware-Client für Rails-Anwendungen an
- Der v1-Client benötigte einen benutzereigenen Redis-Speicher, der zusammen mit der Anwendung bereitgestellt wurde
- Für die anfängliche Wahl von Redis spielten die einfache Anbindung auf Heroku, der Komfort des Remote-Zugriffs und erfolgreiche Beispiele wie Sidekiq eine Rolle
- Tatsächliche Nutzerumgebungen waren vielfältiger, und viele Nutzer hatten Schwierigkeiten, Probleme bei Redis-Bereitstellung und -Konfiguration zu debuggen
- Auch auf RailsWorld 2023 war die Stimmung gegenüber der Annahme negativ, dass neben einer Rails-Anwendung selbstverständlich ein Redis-Server nötig sei
Der Kern des Geschwindigkeitsproblems ist die Netzwerklatenz
- Redis ist im Vergleich zu traditionellen RDBMS schnell, verlangt als separate Datenbank aber trotzdem Verbindungs-, Speicher- und Prozessverwaltung
- In Cloud-Umgebungen beeinflusst Netzwerklatenz direkt die Performance der Request-Verarbeitung
- Wafris muss alle eingehenden HTTP-Requests einer Anwendung mit gespeicherten Regeln vergleichen
- Selbst wenn der v1-Client maximal optimiert war, konnte eine langsame Netzwerkumgebung der Anwendung die gesamte Antwort verlangsamen
- Rails-Apps wurden auch nicht immer als einheitlicher „majestic monolith“ bereitgestellt
- Apps, die über mehrere Zonen verteilt bereitgestellt werden
- Apps, die auf mehrere Server mit überlappenden Zuständigkeiten aufgeteilt sind
- Apps, die nur teilweise Rails nutzen und zusammen mit anderen Sprachen oder Frameworks bereitgestellt werden
- In solchen Betriebsumgebungen wurde die Reibung durch Redis noch größer
Trennung von Lesen und Schreiben in der Wafris-Request-Verarbeitung
- Wafris wird als Rails-Middleware installiert; nach dem Setzen von Regeln wie „IP 1.2.3.4 blockieren“ werden eingehende Requests mit diesen Regeln verglichen
- Der vereinfachte Verarbeitungsablauf besteht aus zwei Schritten
- HTTP-Request mit den Regeln vergleichen und bei Treffer 403, sonst 200 zurückgeben
- Das Ergebnis wie Blockieren, Erlauben oder Durchlassen melden
- Der erste Schritt, das Lesen der Regeln, ist deutlich wichtiger als das Schreiben fürs Reporting
- Requests müssen sequenziell verarbeitet werden
- Wenn das Filtering nicht funktioniert, können bösartige Requests durchkommen
- Die Request-Auswertung beeinflusst die vom Nutzer wahrgenommene Website-Performance
- Reporting-Schreibvorgänge können langsamer, im Batch oder asynchron erfolgen
Warum SQLite gewählt wurde
- Der wichtigste Engpass bei Wafris war Netzwerk-I/O, und die Aussage aus der SQLite-Dokumentation „SQLite competes with
fopen(), not with client/server databases“ beeinflusste die Entscheidung - Unter der Annahme, dass allein das Eliminieren der Netzwerk-Roundtrips schneller sein könnte als die Redis-basierte Struktur, wurden SQLite und Redis benchmarked
- Als Referenzen dienten
- Aaron Francis’ „High Performance SQLite“: https://highperformancesqlite.com/
- Stephen Margheims SQLite on Rails - the how and why of optimal performance
- Oldmoe: https://oldmoe.blog/
Benchmark-Umfang und absichtliche Einschränkungen
- Der Benchmark war kein allgemeiner Datenbank-Performancevergleich, sondern ein bewusst verzerrter Test für den Hot Path von Wafris und dessen schlimmste Query
- Die schlimmste Query war eine Abfrage auf einer „lexical decimal“-Datenstruktur, die IP-Bereiche und Kategorien zuordnet
- Ein einfaches Beispiel ist ein IP-zu-Land-Mapping, das prüft, ob eine IP-Adresse in einem Bereich zwischen zwei Adressen liegt, und dann das Land zurückgibt
- Diese Struktur umfasst Millionen von Zeilen, und bei IPv6 ist jeder Eintrag groß
- Bereichsabfragen wurden vorab berechnet und in beide Speicher geschrieben
- eine Tabelle in SQLite
- ein sorted set in Redis
- Im pathologischen Fall muss jeder eingehende HTTP-Request die Request-IP mit folgenden Bereichen vergleichen
- benutzerdefinierte Allow-Bereiche
- benutzerdefinierte Block-Bereiche
- GeoIP-Bereiche
- IP-Reputationsbereiche
- Dieser Query-Typ war wichtig genug, dass keine anderen Queries oder Funktionen portiert wurden und nur dieser eine Typ getestet wurde
Testmethode und Ergebnisse
- Getestet wurde auf einem lokalen MacBook Air M2 mit per Homebrew installiertem Redis und einer lokalen SQLite-DB
- Das Protokoll war wie folgt
- Verwendung des bestehenden Bereichs-Datensatzes mit 1,2 Millionen Einträgen
- Ausführung mehrerer IP-Sets in derselben Reihenfolge auf SQLite und Redis
- Pro Multiplikator wurde der Test 5-mal ausgeführt und der Durchschnitt verwendet
- Für den konkreten Use Case von Wafris zeigte SQLite gegenüber lokalem Redis eine etwa 3-fach höhere Performance
- Dieses Ergebnis gilt vor Berücksichtigung der Netzwerklatenz
- Der Test spiegelte absichtlich ein einfaches Setup und reale Nutzungsmängel wider und lässt sich nur schwer zu einem allgemeinen Datenbankvergleich verallgemeinern
Betriebsunterschiede, die im Diagramm nicht sichtbar sind
- Selbst wenn SQLite im Benchmark deutlich schlechter als Redis abgeschnitten hätte, ging Wafris davon aus, dass es in realen Umgebungen wegen der Netzwerklatenz zu Redis im selben Datacenter oder derselben Region trotzdem schneller sein könnte
- Auch wenn ein Redis-Server robust mit Clustering oder Sharding aufgebaut ist, bleiben Einschränkungen wie Netzwerkbandbreite, Verbindungszahl und Latenz zwischen Regionen bestehen
- Da SQLite lokal auf jeder Compute-Instanz vorhanden ist, verschwinden für diesen Wafris-Anwendungsfall die Kosten horizontaler Skalierung nahezu
- Auch das Onboarding ist mit SQLite einfacher
- Nutzer können ein gem zur Web-App hinzufügen und es ausführen, ohne überhaupt zu wissen, dass SQLite verwendet wird
- Für Redis gäbe es ebenfalls viel Spielraum für zusätzliche Optimierung, aber Wafris konnte Nutzer kaum konsistent davon überzeugen, selbst grundlegende Einstellungen wie Cache-Eviction-Policies zu ändern
Erforderliche Strukturänderungen nach dem Wechsel zu SQLite
- Der Update-Ablauf von Redis-basiertem v1 war einfach
- Der Nutzer aktualisiert Regeln im Wafris Hub
- Der Wafris Hub aktualisiert die Regeln im Redis-Speicher des Nutzers
- Mit SQLite konnte der Wafris Hub die SQLite-Datenbank nicht direkt auf den Webserver „pushen“
- Einige Anbieter von SQLite as a service ermöglichen etwas Ähnliches, passten für Wafris aber wegen Kosten-, Performance- und Sicherheitsaspekten nicht
- Einzelne Nutzer müssten dies selbst bereitstellen
- Ports müssten geöffnet werden
- Eingehende Verbindungen müssten erlaubt sein
- Der Update-Ablauf des SQLite-basierten v2 sieht so aus
- Der Nutzer aktualisiert Regeln im Wafris Hub
- Der Client prüft in zeitlichen Abständen oder nach einer bestimmten Anzahl von Requests auf aktualisierte Regeln
- Wenn sich die Regeln geändert haben, lädt der Client eine komplett neue SQLite-Datenbank herunter
- Diese Struktur reduzierte die Verantwortung der Nutzer für Installation und Konfiguration deutlich, und erfolgreiche Installationen des v2-Clients stiegen um etwa das Dreifache
SQLite-Struktur in verteilten Deployments
- Wenn Rails-Apps in Cloud-Umgebungen wie AWS, Heroku oder Fly mit Auto-Scaling betrieben werden, steigt oft die Zahl der Compute-Instanzen, nicht aber die der Datenbanken
- Wenn Requests von 100 req/s auf 10.000 req/s steigen, fahren Dynos, Machines oder EC2-Instanzen hoch, aber der Datenbank-Engpass kann bestehen bleiben
- Nach Beobachtung von Wafris sind Hauptursachen für Ausfälle von Rails-Apps oft nicht echte DDoS-Angriffe, sondern Credential Stuffing oder schlechter Bot-Traffic
- Solcher Traffic löst Auto-Scaling aus
- Danach können Datenbankverbindungen erschöpft werden und die App zusammenbrechen
- Wenn die SQLite-DB auf jede Compute-Instanz synchronisiert wird, bleiben alle Aufrufe auf neuen Instanzen lokal
Der Schreibpfad wird aus dem Client entfernt
- Die vorherigen Tests berücksichtigten keine Schreiboperationen, und Wafris geht auch nicht davon aus, dass SQLite für alle Rollen geeignet ist
- Wafris setzt SQLite gezielt für eine leseorientierte Rolle ein und entwirft den Schreibpfad separat neu
- Der Reporting-Pfad in v2 wurde wie folgt geändert
- Reporting über eine asynchrone Verbindung zum Wafris Hub
- Batch-Übertragung von Reporting-Daten
- vollständige Entfernung von Datenbank-Schreibvorgängen aus dem Client
- Diese Struktur passt möglicherweise nicht zu den meisten anderen Services, entspricht aber dem Ziel von Wafris nach einfacher Bereitstellung und einem schnellen Client
- Die SQLite-basierte v2-Architektur hat bereits mehreren Websites geholfen, Angriffe zu überstehen und online zu bleiben, und zugleich den Support-Aufwand und die Reibung für Nutzer reduziert
1 Kommentare
Kommentare auf Hacker News
Das Modell, bei dem jeder Anwendungsserver eine Kopie der SQLite-Datenbankdatei hat und diese in regelmäßigen Abständen komplett austauscht, ist wirklich interessant.
Hier wird es für Regeln einer Web Application Firewall verwendet, aber es dürfte auch gut zu Feature-Flag-Konfigurationen passen. Feature Flags können pro Request dutzendfach geprüft werden, und oft braucht man Abfragen wie „der Nutzer gehört zu Gruppe A und die IP liegt in Land B“, was lokales SQLite gut bewältigen kann. Bei Feature Flags ist es in der Regel akzeptabel, wenn Updates erst einige Sekunden oder sogar später propagiert werden.
Allerdings habe ich für diesen Zweck noch keine Verwendung von SQLite gesehen; üblicherweise nutzt man DBM-artige Datenbanken wie LMDB oder Kyoto Cabinet.
Anfangs holte ein Cron-Job auf jedem Server die Daten des jeweiligen Dienstes ab; nach dem Umzug zu Kubernetes ließ man über ein DaemonSet und etwas Ähnliches wie Host-Tagging/Taints verschiedene Daten auf die jeweiligen Hosts herunterladen und stellte anschließend sicher, dass Dienste, die diese Daten nutzen, nur auf den entsprechenden Hosts liefen. In Ruby hieß das „hammerspace“: https://github.com/airbnb/hammerspace
So zahlt man innerhalb eines Requests die teuren Abfragekosten nicht mehr als einmal, und es besteht auch nicht das Risiko, dass ein Flag während der Request-Verarbeitung aktualisiert wird und die Werte dadurch inkonsistent werden.
Darauf lassen sich verschiedenste Verteilmechanismen setzen, etwa ein Gossip-Netzwerk oder ein explizites baumförmiges Cache-/Distributionsnetz. Jeder mit einer neueren Version kann Updates liefern.
Die aktuelle DB-Version kann in das Anwendungsartefakt eingebettet werden, nach dem Start der App aber effizient aktualisiert werden. Bei Feature Flags oder Live-Konfigurationen kann man statt auf Code-Level-Defaults auf einen aktuellen Zustand zurückfallen.
Jeder Client kann den globalen Zähler wie ein ETAG mitsenden und nur die Änderungen bekommen; auch eine erneute Verbindung nach einem kurzen Netzwerkausfall wird dadurch einfach und günstig.
Wenn man einige Minuten Historie vorhalten kann, lässt sich der Zähler nach Art des W3C-Baggage-Headers an Microservice-Aufrufe anhängen, sodass mehrere Dienste Flags/Konfiguration/Daten auf Basis derselben Versionsnummer auswerten können. Selbst ohne zeitbasierte Auswertungsfunktion ist es für nachträgliches Debugging enorm hilfreich, die Generationsnummer in Logs zu erfassen.
Neben der Netzwerklatenz habe ich bei Redis beobachtet, dass die Latenz für Reads/Writes tendenziell nahezu linear mit der Anzahl der abgefragten Keys steigt; die Diagramme im Artikel sehen ähnlich aus.
Als wir in einer monolithischen App Postgres und Redis je nach Zweck zusammen nutzten, funktionierte das ziemlich gut, aber es war viel zu leicht, neue Features in den gemeinsam genutzten Redis-Cluster zu schieben. Redis ist Single-Threaded, daher kann ein rücksichtsloses Feature, das mehr als 100.000 Keys massenhaft abfragt, auch andere Features ausbremsen.
Deshalb schlug ich als Leitlinie vor, Redis dann zu verwenden, wenn man jeweils einen einzelnen Key oder eine kleine, feste Anzahl von Keys liest und schreibt, etwa bei Locks oder Rate Limiting für populäre Endpoints, wenn viele zufällige Features Redis nutzen.
In diesem Fall würde Redis bei einfachen Single-Key-Abfragen (IP-Adresse) glänzen, scheint aber für komplexere Reads wie die Darstellung von Range Queries nicht gut zu passen. Ich verstehe nicht im Detail, warum SQLite im Vergleich zu lokalem Redis so gut abschneidet, daher fand ich das unerwartet und interessant.
Redis betrachtet man am besten als Cache mit reichhaltigeren Basisoperationen. Richtig eingesetzt ist es schnell und zuverlässig.
Wenn man jedoch anfängt, Dinge in Redis zu legen, die nicht gut in das primäre RDBMS passen, entstehen bald Job-Queues, verschiedene Arten von Locks usw.; dann ist es letztlich nur eine Frage der Zeit, bis die Performance über eine Klippe fällt oder es aus anderen Gründen zusammenbricht. Die Wiederherstellung wird normalerweise unsauber und endet leicht damit, dass man einen gewissen Datenverlust akzeptieren muss.
Diese Entwicklung passiert leicht schrittweise, daher braucht es Disziplin, um sie zu vermeiden. Die Performance von SQLite liegt nicht nur daran, dass Netzwerk-Overhead vermieden wird, sondern auch daran, dass viele die Kosten für Serialisierung/Deserialisierung unterschätzen. Selbst wenn das Redis-Protokoll ziemlich minimal ist, summieren sich die Kosten; bei SQLite läuft vieles letztlich auf Speicherkopien innerhalb des Prozesses hinaus.
Vor ein paar Wochen habe ich bei einem internen Hackathon bei Neon einen kleinen Node.js-Server gebaut, der Redis’ Wire Protocol RESP in Postgres-Abfragen übersetzt.
Das war ein sehr spaßiges Hack-Projekt: https://github.com/btholt/redis-to-postgres
Das klingt nach einem speziellen Use Case, für den serverseitiges SQLite ziemlich gut passt, weil die Datenbank read-only ist und keine Replikation braucht.
Eine andere Alternative wäre, statische Dateien in den Speicher zu laden, aber in diesem Fall scheint die Datenmenge zu groß zu sein, um sie dauerhaft im Speicher zu halten, sodass SQLite eine gute Alternative ist.
Der Unterschied ist, dass SQLite sehr robust, performant und außergewöhnlich gut getestet ist.
Mich macht die Stelle neugierig, dass es auf der RailsWorld 2023 gegenüber Redis eine Stimmung gegeben habe, als hätte man „Blut gerochen“
In meiner Karriere habe ich nur eine produktive Rails-App gebaut, und diese App nutzte Redis, daher kenne ich die aktuellen Strömungen nicht gut. Ich frage mich, ob sich das Ökosystem aus geschäftlicher Sicht gerade von Redis abwendet, ob es an der Lizenzänderung liegt oder ob es eher eine Situation nahe an YAGNI ist
Damals haben wir es zusammen mit Rescue vor allem zum Planen asynchroner Jobs wie Indexierung und Transcoding genutzt, und zu der Zeit wirkte es wie ein ziemlich gutes Tool
Auch in der Rails-Community-Umfrage 2024 taucht Redis weiterhin als der in Apps am häufigsten genutzte Datenspeicher auf
Allerdings nutzen viele Apps Redis zwar, tatsächlich aber nur für Dinge wie Sidekiq, und nicht für Echtzeit-Leaderboards oder Vector-DB-Funktionen. Dadurch ist das tatsächliche Nutzungsmuster etwas unscharf
Heute hat ein Rails-Setup mit einem gewissen Traffic oft Frontend-Server, eine SQL-DB, einen Key-Value-Store (Redis oder Memcached) und dazu einen Cache-Store, der auf diesen Key-Value-Store zeigt. Allerdings unterscheiden sich typische Key-Value-Nutzungen wie das Vorhalten von API-Quoten oder Rate Limits ziemlich stark von Cache-Nutzungsmustern, was lästig ist
Da die Disk-Performance inzwischen schnell genug ist und auch SQL schneller geworden ist, gibt es eine Bewegung dahin, den Key-Value-Store zu entfernen und traditionelle Key-Value-Zwecke in die SQL-DB zu verlagern, während das Cache-Backend auf Disk liegt. Neue NVMe-Disks sind fast so schnell wie RAM, aber deutlich günstiger, sodass man mehr cachen kann
Aber im Zeitalter von Kubernetes und Redis-as-a-service liegt dieser „Shared Memory“ auf einer anderen VM in einem anderen Rack. An dem Punkt hat man Ressourceneffizienz ohnehin schon aufgegeben, also ist es meiner Ansicht nach besser, einfach Dateien aus S3 zu lesen und dorthin zu schreiben
Zu der Stelle „Redis ist im Vergleich zu klassischen RDBMS schnell, aber es ist immer noch eine Datenbank, bei der man Verbindungen, Speicher, Prozesse usw. verwalten muss, wodurch der Stack fragiler wird“: Sobald man anfängt, Transaktionen jenseits von Spielzeugniveau zu behandeln, brauchen meiner Ansicht nach alle Datenbanken, ob relational oder nicht relational, ungefähr ein ähnliches Maß an Verwaltung und Wartung
Auch der Teil mit „schnell“ ist etwas interessant. Wenn man sich nicht um Joins kümmern muss, sind auch Zeileneinfügungen und Abfragen ziemlich schnell
Wenn man versteht, dass direkt von der lokalen Disk gelesen wird, verlieren Diskussionen über Joins oder Transaktionen stark an Bedeutung. Das ist für sich genommen bereits ein um mehrere Größenordnungen schnellerer Pfad
Jedes Mal, wenn ich in der Dokumentation lese, wann man VACUUM ausführen sollte, bin ich verwirrt. Als ich zuletzt eine SQLite-basierte Anwendung deployt habe, habe ich es einfach mit einem Counter gelöst und nach vielen Schreibvorgängen VACUUM ausgeführt
Wenn eine Datenbank als eigenständiger Serverprozess betrieben werden muss, wird es zwangsläufig Unterschiede beim Verwaltungs- und Wartungsaufwand geben. Im Extremfall fällt es schwer zu glauben, dass es wirklich keinen Unterschied macht, SQLite oder Oracle zu verwenden
Vielleicht ist Redka interessant, eine Neuimplementierung von Redis mit SQLite in Go: https://github.com/nalgeon/redka
Es ist definitiv ein tolles Projekt, aber damit gibt es kaum einen Anreiz zum Wechseln
Ich möchte prüfen, ob ich die Redis-Nutzung richtig verstanden habe
In v1 lagen WAF und Redis auf demselben Server; wenn ein Client im Admin-Panel eine neue Regel festlegte, wurde diese Regel in das Redis auf demselben Server wie das Admin-Panel geschrieben, und dank des internen Synchronisierungsmechanismus von Redis wurden die Regeln auf die lokalen Redis-Instanzen neben den WAFs weltweit aktualisiert. Ich frage mich, ob danach bei einer neuen Anfrage an irgendeinen WAF der WAF die Anfrage/IP anhand der aktualisierten Redis-Regeln prüfte
In v2 wurde der Redis-Cluster entfernt, alle WAF-Server haben eine SQLite-DB, und es wurde ein eigener Synchronisierungsmechanismus gebaut, der neue Regeln vom Admin-Panel an jeden WAF+SQLite-Server verteilt; bei einer neuen Anfrage prüft der WAF dann schnell anhand der aktualisierten SQLite-Regeln. Ich frage mich, ob diese Struktur gemeint ist
Der beste Satz ist: „SQLite konkurriert nicht mit Client/Server-Datenbanken. SQLite konkurriert mit fopen()“