- OpenRun ist eine Bereitstellungsplattform für Web-Apps für interne Tools. Statt statische Dateien, App-Code und Konfigurationsdateien im Dateisystem abzulegen, speichert es sie in SQLite und verwaltet den Deployment-Zustand datenbankzentriert.
- App-Updates, bei denen mehrere Dateien gemeinsam geändert werden, werden als eine Transaktion verarbeitet. Das zentrale Ziel ist, zu verhindern, dass während eines Versionswechsels fehlerhafte Webseiten ausgeliefert werden.
- Durch die Verwendung des SHA256-Hashs vor der Komprimierung als Primärschlüssel wird die doppelte Speicherung identischer Dateien zwischen App-Versionen sowie zwischen Staging-, Preview- und Production-Apps reduziert.
- Die Speicherung in SQLite vereinfacht Rollbacks, Backups, das Speichern von Hashes für ETags und die Speicherung mit Brotli-Komprimierung. Bei Bedarf lassen sich auch GZip- oder unkomprimierte Daten über zusätzliche Spalten mitverwalten.
- Derzeit läuft OpenRun auf einem Single Node. Für Multi-Node-Unterstützung ist geplant, eine gemeinsam genutzte Postgres-Datenbank zusammen mit einem lokalen SQLite-Dateicache zu verwenden, um Latenzen zu reduzieren.
Wie OpenRun Dateien speichert
- OpenRun ist eine Open-Source-Deployment-Plattform für code-first interne Tools und stellt Web-Apps im GitOps-Stil auf einem einzelnen Node oder in einem Kubernetes-Cluster bereit.
- Statt statische Inhalte wie ein typischer Webserver im Dateisystem abzulegen, speichert OpenRun App-Daten wie statische Dateien, App-Code und Konfigurationsdateien in SQLite.
- Da App-Metadaten dynamisch erzeugt werden, ist die Speicherung in einer Datenbank naheliegend. Werden auch Dateien in derselben Speicherschicht verwaltet, lässt sich der Deployment-Zustand leichter gemeinsam kontrollieren.
- Beim Erstellen und Aktualisieren von Apps werden Dateien aus GitHub oder von einer lokalen Festplatte in die SQLite-Datenbank hochgeladen.
- Nur im Entwicklungsmodus wird das lokale Dateisystem verwendet.
Warum SQLite gewählt wurde
- Der größte Vorteil sind transaktionale Updates.
- Änderungen an mehreren Dateien können in einer einzigen Transaktion gebündelt verarbeitet werden.
- Dank Isolation wird während eines Updates keine defekte Web-App ausgeliefert.
- Wenn ein Deployment fehlschlägt, kann auf Ebene einer Datenbanktransaktion ein Rollback durchgeführt werden.
- Auch wenn mehrere Apps gleichzeitig aktualisiert werden, lassen sie sich auf einmal zurücksetzen.
- Das ist einfacher, als im Dateisystem geänderte Dateien zu finden und aufzuräumen.
- OpenRun versioniert alle Updates automatisch; Dateidaten werden in einer Tabelle mit folgendem Schema gespeichert:
CREATE TABLE files (sha text, compression_type text, content blob, create_time datetime, PRIMARY KEY(sha));
- Da der SHA256-Hash des Inhalts vor der Komprimierung als Primärschlüssel verwendet wird, wird identischer Dateiinhalt über mehrere Versionen hinweg nur einmal gespeichert.
- Jede Production-App hat eine Staging-App und kann mehrere Preview-Apps haben, wodurch Dateiduplikate entstehen können.
- Die SQLite-basierte Speicherung verhindert, dass Dateien mit identischem Inhalt auch app-übergreifend doppelt gespeichert werden.
Backup, Caching und Komprimierung
- Der gesamte Systemzustand, Metadaten und Dateien können mit SQLite-Backup-Tools wie Litestream gesichert werden.
- Wenn der für den ETag-Header des Browser-Cachings benötigte Inhalts-SHA beim Datei-Upload einmal gespeichert wird, muss er später nicht erneut berechnet werden.
- Dateiinhalte werden in der SQLite-Tabelle in Brotli-komprimierter Form gespeichert.
- Beim Datenbankansatz können durch Hinzufügen von Spalten zur Tabelle
files auch GZip-komprimierte oder unkomprimierte Daten gespeichert werden.
Performance und Multi-Node-Pläne
- Der SQLite-Datenbankansatz bietet in OpenRun gute Performance.
- Da es keine gleichwertige dateisystembasierte Implementierung gibt, wurden keine direkten Vergleichsbenchmarks durchgeführt.
- Laut den Benchmarks des SQLite-Teams kann SQLite bei manchen Workloads eine bessere Performance erzielen als die direkte Nutzung des Dateisystems.
- OpenRun läuft derzeit auf einem Single Node.
- Wenn künftig Multi-Node-Unterstützung hinzukommt, ist geplant, für die Speicherung von Metadaten und Dateidaten statt einer lokalen SQLite-Datenbank eine gemeinsam genutzte Postgres-Datenbank zu verwenden.
- Dieser Ansatz kann Latenzprobleme verursachen.
- Um Zugriffslatenzen auf Postgres zu vermeiden, ist geplant, eine lokale SQLite-Datenbank als Dateicache zu verwenden.
Warum der Dateisystemansatz häufiger ist
- Einer der Gründe, warum die meisten Webserver das Dateisystem verwenden, ist Bequemlichkeit.
- Mit bestehenden Dateisystem-Tools wie rsync oder tar lassen sich Dateien kopieren und aktualisieren.
- Ein weiterer Grund ist historisch bedingt.
- Dateisysteme wurden schon genutzt, bevor es gute relationale In-Process-Datenbanken gab.
- Wer eine Datenbank als Dateispeicher verwenden will, braucht eine API-Schnittstelle für Datei-Uploads; das ist nicht immer praktikabel.
1 Kommentare
Meinungen auf Hacker News
Ich habe vor ein paar Jahren mit dieser Idee experimentiert; teils war sie von dem Artikel „35% Faster Than The Filesystem“ inspiriert: https://www.sqlite.org/fasterthanfs.html
Meine damaligen Notizen stehen hier: https://simonwillison.net/2020/Jul/30/fun-binary-data-and-sq...
Ich habe https://datasette.io/plugins/datasette-media gebaut, ein Plugin, das statische Dateien aus SQLite in Datasette ausliefert, und es funktioniert gut; ehrlich gesagt habe ich es seitdem aber nicht viel genutzt.
Ein verwandtes Konzept ist das Ausliefern von Karten-Tiles aus SQLite; https://datasette.io/plugins/datasette-tiles übernimmt das. Das MBTiles-Format stellte sich als SQLite-Datenbank heraus, die voller PNGs steckt.
Wenn man mit SQLite zum Ausliefern von Dateien experimentieren möchte, kann für die anfängliche Befüllung der Datenbank das CLI-Tool „sqlite-utils insert-files“ nützlich sein: https://sqlite-utils.datasette.io/en/stable/cli.html#inserti...
Der Content-Hash muss nur einmal beim Datei-Upload erzeugt werden; man braucht keinen Build-Schritt, der ihn bei jedem Neustart des Webservers neu erstellt oder die tatsächlichen Dateinamen ändert. Das lässt sich zwar auch dynamisch auf Dateien im Dateisystem anwenden (siehe die embedFS-Implementierung in https://github.com/benbjohnson/hashfs), aber eine Datenbank macht es etwas einfacher.
requests-cache speichert Anfragen, wenn ich mich richtig erinnere, in SQLite mit
(date, URI)als Cache-Key: https://github.com/requests-cache/requests-cache/blob/main/r...Suche nach pyfilesystem SQLite: https://www.google.com/search?q=pyfilesystem+sqlite
Suche nach sendfile mmap SQLite: https://www.google.com/search?q=sendfile+mmap+sqlite
https://github.com/adamobeng/wddbfs ist ein „webdavfs-Provider, mit dem man Inhalte einer sqlite-Datenbank lesen kann“.
Es dürfte auch eine gute Möglichkeit geben, ein Dateisystem zu implementieren, indem man Unix-Dateiberechtigungen und xattrs, also erweiterte Dateiattribut-Berechtigungen, auf SQLite aufsetzt.
Wäre SQLite etwa schneller oder bequemer als ngx_http_memcached_module.c? Ich frage mich auch, ob SQLite ACLs auf Zellebene hat.
Beim Lesen statischer Dateien muss man die Datei bei jeder Anfrage öffnen, lesen und schließen; selbst wenn die Dateisystem-Schicht die Dateiinhalte gecacht hat, entstehen dadurch mehr Kontextwechsel. Wenn man das schnell machen will, ist es sinnvoller, ein Caching-Frontend davorzusetzen, statt alles in eine Datenbank zu verlagern. Das ist schneller als SQLite und zudem einfacher zu warten und zu debuggen.
Das gilt auch für Dateisysteme, die vollständig im Userspace laufen. FUSE ist ausgenommen, weil die Aufrufe durch den Kernel gehen.
Die Aussage, dass „transaktionale Updates“ der Hauptvorteil seien, hat ihre Grenzen. Ob der Server SQLite oder ein Dateisystem verwendet: Für sich genommen verhindert das keine während eines Updates kaputte Webapp.
Jede Seite im Browser ist ein Baum von Ressourcen, die über separate HTTP-Requests geholt werden, und damit kein Ziel eines serverseitigen Transaktions-/atomaren Update-Systems. Selbst wenn auf dem Server alle Ressourcen transaktional ausgetauscht werden, kann der Browser eine Mischung aus alten und neuen Ressourcen sehen.
Die übliche Lösung ist, alle Unterressourcen einer Seite (JavaScript-Bundles, Stylesheets, Medien usw.) unter Namen (URLs) mit Content-Hash oder Version abzulegen. Wenn das Root-HTML-Dokument Version X lädt, müssen auch alle Unterressourcen die entsprechende Version X laden.
Außerdem muss man beim Update von X auf Y die Unterressourcen von X noch eine Weile weiter ausliefern, nachdem man begonnen hat, Y-Seiten zu serven. Wenn man sie nicht beibehält, bis man sicher sein kann, dass vernünftigerweise kein Browser mehr eine X-Seite lädt, kann die X-Seite kaputtgehen.
Deshalb ist es eher falsch, Root-HTML und Unterressourcen in ein einziges atomar ausgetauschtes Bundle packen zu wollen. Denn damit entfernt man frühere Unterressourcen, obwohl sie noch referenziert werden können.
Je nach Fall möchte man manche Unterressourcen, etwa Mediendateien, vielleicht getrennt vom HTML-Dokument versionieren. Wenn man sie aktualisieren will, ohne den Cache für App-Strukturelemente wie JavaScript-Blöcke oder Stylesheets komplett zu invalidieren, muss auch das Page-Build-System das eventuell berücksichtigen.
Als das in einem großen Unternehmen ausprobiert wurde (das damals einen erheblichen Teil des Webs gesehen hat), blieb die Mehrheit der Nutzer (über 80 %) etwa 2–3 Tage in der Webapp. Das war vermutlich dadurch verzerrt, dass Leute Tabs über das Wochenende offen ließen.
Der 95-%-Punkt lag bei etwa 2 Wochen, und 100 % bei ungefähr 600 Tagen. Es gab also Nutzer, die einen Tab fast 2 Jahre lang offen hielten.
Wenn man 100 % anpeilt, muss man ziemlich lange warten. Diese Zahlen sind alle aus dem Gedächtnis, und ich arbeite nicht mehr bei diesem Unternehmen.
Das Szenario, dass ein Nutzer lange auf einer Seite bleibt und dann kaputte Links erhält, ist eher ein SPA-Problem.
Ich stimme im Großen und Ganzen zu, aber transaktionale Updates verhindern nur eine bestimmte Kategorie updatebezogener Probleme. Auch andere Probleme auf App-Ebene können zu einer kaputten Erfahrung führen.
Es ist möglich, alte Versionen statischer Inhalte, die per Content-Hash referenziert werden, weiter auszuliefern, aber in Clace ist das derzeit nicht implementiert.
Der Kerntrick besteht darin, Änderungen, die nicht HTML betreffen, vor HTML-Änderungen hochzuladen, damit keine Dateien referenziert werden, bevor sie existieren. Wenn man die App maximal kompliziert machen möchte, kann man beim Upload eine Tiefensuche anwenden. Wenn einem die geistige Gesundheit wichtig ist, sollte man das Problem aber entschärfen und in der App Asset-First-Uploads wählen.
Als ich 2011/2012 bei einem kleinen Spieleentwickler arbeitete, empfahl ich, alle Assets unter 100 KB in eine sqlite3-DB zu verschieben, „pak-Dateien“ zu erzeugen und die Offsets dieser Dateien in der sqlite3-DB zu speichern.
Diese Entscheidung war von Richard Hipps Postmortem-Vortrag beeinflusst, in dem er rückblickend sagte, es wäre besser gewesen, BLOBs wie Inodes zu behandeln, sie also an weiter hinten liegende Offsets in der Datenbank zu setzen und die BLOBs an die Datei anzuhängen.
Das Laden der Assets war unglaublich schnell. Da es ein Mobile Game war, gab es nur sehr wenige Assets, die nicht in der DB waren. Es ist auch interessant zu sehen, dass später mehr Leute diesen Ansatz übernommen haben.
Ein weiterer leicht zu übersehender Vorteil ist, dass man neben dem Content praktisch unbegrenzt Metadaten ablegen und damit „ähnliche“ Dateien per Datenbankabfrage finden kann.
Wir haben sehr viele Metadaten in die DB gepackt; die endgültige pak-Datei war, glaube ich, etwa 200 MB groß, die Datenbank etwa 20 MB. Noch einmal: Es war ein Mobile Game.
Das Schlimmste auf Client-Seite war ein doppelter innerer Join, den wir wegen serverseitiger Komplexität nicht reduzieren konnten. Es war frustrierend, dass wir die Serverimplementierung nicht selbst machen konnten; die andere Seite, mit der wir arbeiteten, war sehr schlecht in Softwareentwicklung und änderte Builds, ohne die komplette Backend-Spezifikation mitzuteilen, sodass sie plötzlich brachen.
Für Game-Replays verwendeten wir ebenfalls eine separate sqlite3-Datenbank; nach dem Ende einer Partie konnte man das gesamte Spiel abspielen und sehen, was die jeweiligen Gegner getan hatten. Auch für automatisierte Tests war das hervorragend.
Auch beim lix-Änderungskontrollsystem gehen wir dazu über, Dateien in SQLite abzulegen, statt mit dem Dateisystem und git zu arbeiten. Dieser Artikel behandelt die Probleme, die wir hatten: https://opral.substack.com/i/150054233/breaking-git-compatib...
Probleme wie Dateisperren und Nebenläufigkeit löst SQLite für uns.
Mit SQLite kann man Dateien per SQL abfragen, statt plattformspezifische Dateisystem-APIs zu verwenden.
SQL-Abfragen lassen sich mit Kysely https://kysely.dev/ auch ohne ORM typsicher schreiben.
Man muss allerdings beachten, dass SQLite-Datenbanken nicht kleiner werden, wenn man kein VACUUM ausführt. Im Grunde ist das ein Vorgang, bei dem Daten in eine separate Datei kopiert und das Original gelöscht wird.
Das ist etwas, das man manuell zu einem Zeitpunkt tun muss, der innerhalb der Anwendung sinnvoll ist; wenn man binäre Daten schreibt und löscht, sollte man also auf die Festplattennutzung achten.
Interessanterweise funktioniert der statische Site-Generator CMS, den ich gebaut habe, genau umgekehrt zu dem hier beschriebenen Ansatz.
Während der Entwicklung/Aktualisierung einer Website sind alle Seiten und Beiträge Einträge in einer SQLite-Datenbank und werden über ein Web-Interface bearbeitet, das eine editierbare Version der Website zeigt.
Anschließend wird die Website als statische Seiten ins Dateisystem gedumpt und direkt deployt, oder als ZIP heruntergeladen und an anderer Stelle hochgeladen, einschließlich vollständig statischer Hosting-Dienste.
Laut „Appropriate Uses For SQLite“ von SQLite https://www.sqlite.org/whentouse.html hängt der Web-Traffic, den SQLite bewältigen kann, davon ab, wie intensiv die Site die Datenbank nutzt.
Im Allgemeinen sollten Sites mit weniger als 100K Hits pro Tag gut mit SQLite funktionieren. 100K/Tag ist eine konservative Schätzung, keine harte Obergrenze. Es gibt Fälle, in denen SQLite auch das Zehnfache dieses Traffics bewältigt hat.
Die SQLite-Website (https://www.sqlite.org/) verwendet natürlich ebenfalls SQLite und verarbeitete Stand 2015 etwa 400K~500K HTTP-Requests pro Tag, von denen 15–20 % dynamische Seiten waren, die die Datenbank berührten. Dynamische Inhalte verwenden etwa 200 SQL-Statements pro Webseite.
Diese Konfiguration läuft auf einer einzelnen VM, die den physischen Server mit 23 anderen VMs teilt, und hält dennoch die Load Average die meiste Zeit unter 0,1. Referenz: https://news.ycombinator.com/item?id=33975635
Bei einer leseorientierten Workload wie dem Ausliefern statischer Dateien kann SQLite deutlich mehr bewältigen. Wenn Content-Caching-Header gesetzt sind, cached der Browser die Inhalte, sodass Server-Requests nur noch für neue Clients nötig werden.
In den meisten Anwendungsfällen dürfte SQLite wohl nicht zum Bottleneck werden.
Die Idee, statische Inhalte mit SQLite auszuliefern, nur auf Basis der Seite „35% Faster Than The Filesystem“ von 2017, wirkt freundlich gesagt unausgereift.
Moderne Webserver wie Nginx verwenden optimale Strategien für den Umgang mit statischen Dateien. Das beginnt bei sendfile, reicht bis zu io_uring- und splice-Operationen und läuft in gut designten Thread-Pools auf der jeweils passenden Grundlage aus epoll, kqueue oder eventport.
Das Beste, was SQLite von Haus aus bieten kann, ist dagegen im Wesentlichen Unterstützung für Memory-Mapped I/O (https://www.sqlite.org/mmap.html).
Dieser Ansatz kann für Single-Client-Dienste wie lokal gehostete Webapps gut passen (siehe auch https://github.com/electron/asar). Bei großen Websites versucht man damit aber, wie andere Kommentare sagen, ein Problem zu lösen, das nicht existiert.
Ich mache viel High-Performance-Scientific-Computing, und besonders beim parallelen Zugriff auf Daten war eine schreibgeschützte SQLite-Datenbank auf einer RAM-Disk oft die flexibelste und schnellste Methode.
Es fühlt sich extrem hacky an, ist aber einfacher einzurichten und schneller als alles andere, was ich bisher gefunden habe.
Ein Freund aus der Astronomie hat einmal gemeint, dass sich viele Leute in der Wissenschaft mit Datenbanken vertraut machen sollten. Sonst stecken sie am Ende unbewusst enorm viel Aufwand in den Bau einer miserablen eigenen Datenbank.
Der Grund, warum dieser Ansatz nicht verbreiteter ist: Dateisysteme sind sehr gut im Umgang mit Dateien.
Wenn man atomare Updates braucht, kann man in ein neues Verzeichnis auschecken und den symbolischen Link umbiegen.
Ich habe mehrere Varianten gesehen, bei denen eine Datenbank wie ein Dateisystem verwendet wurde. Das hat gute Seiten, kann aber zum Albtraum werden, wenn etwas schiefgeht.
Dann könnte man etwas wie btrfs verwenden, um Deduplizierung auf Dateisystemebene zu bekommen.
Das Argument, dass sich bei App-Updates viele Dateien ändern können und man mit einer Datenbank alle Änderungen transaktional atomar behandeln könne, um das Ausliefern kaputter Webseiten während eines Versionswechsels zu verhindern, hat ein Problem.
Der Grund ist, dass SQLite-Dateien für serialisierbare Isolation bei Lesezugriffen während Schreibvorgängen gesperrt werden. Daraus folgt eher, dass man die Datenbankoperationen an einer Offline-Datei ausführt und dann die bestehende Datei in Produktion gegen die neue austauscht.
Das läuft letztlich darauf hinaus, eine tar-Datei zu verwenden oder ein separates Verzeichnis zu nutzen, das durch neue Inhalte ersetzt wird.
Statische Dateien lassen sich viel einfacher statisch ausliefern. Man muss sie nicht aus einem Programm ausliefern, das Live-SQLite-Verbindungen verwaltet und versucht, seltsame „Update-Concurrency“-Magie zu erreichen. Dieses Problem lässt sich ganz ohne Schwierigkeit lösen.
Ein CMS über eine SQLite-Datenbank zu verwalten ist in Ordnung, aber wenn die Inhalte statisch sind und live ausgeliefert werden, sollte man besser statische Dateien verwenden.