- OpenRunは内部ツール向けのWebアプリ配布プラットフォームで、静的ファイル・アプリコード・設定ファイルをファイルシステムではなく SQLite に保存し、配布状態をデータベース中心で管理している
- 複数のファイルが一緒に変わるアプリ更新を1つの トランザクション として処理し、バージョン切り替え中に壊れたWebページが配信される状況を防ぐことが主な目的である
- 圧縮前の SHA256ハッシュ を主キーとして使い、アプリのバージョン間、staging・preview・productionアプリ間での重複ファイル保存を減らしている
- SQLiteで保存する方式は、ロールバック、バックアップ、ETag用ハッシュ保存、Brotli圧縮 の保存をシンプルにし、必要であればGZip・非圧縮データもカラム追加で一緒に扱える
- 現在は シングルノード で動作しており、マルチノード対応時には共有PostgresとローカルSQLiteファイルキャッシュを併用してレイテンシを減らす方向を計画している
OpenRunのファイル保存方式
- OpenRun は、コード優先(code-first)の内部ツール向けオープンソース配布プラットフォームで、単一ノードまたはKubernetesクラスターにGitOps方式でWebアプリを配布する
- 一般的なWebサーバーのように静的コンテンツをファイルシステムに置く代わりに、OpenRunは静的ファイル・アプリコード・設定ファイルといったアプリデータを SQLite に保存する
- アプリのメタデータが動的に生成されるためデータベース保存は自然であり、ファイルまで同じ保存レイヤーで扱えば配布状態をまとめて管理しやすい
- アプリ作成と更新時に、ファイルはGitHubまたはローカルディスクからSQLiteデータベースへアップロードされる
- 開発モード でのみローカルファイルシステムを使用する
SQLiteを選んだ理由
- 最大の利点は トランザクション更新 である
- 複数ファイルの変更を1つのトランザクションにまとめて処理できる
- 分離性により、更新途中の壊れたWebアプリが配信されない
- 配布エラーが起きた場合は、データベースのトランザクション単位で ロールバック できる
- 複数アプリが同時に更新される場合でもまとめて戻せる
- ファイルシステム上の変更ファイルを探して整理する方法よりシンプルである
- OpenRunはすべての更新を自動的に バージョン管理 しており、ファイルデータは次のスキーマのテーブルに保存される
CREATE TABLE files (sha text, compression_type text, content blob, create_time datetime, PRIMARY KEY(sha));
- 圧縮前コンテンツの SHA256ハッシュ を主キーとして使うため、同じファイル内容は複数バージョンにまたがっても1回だけ保存される
- productionアプリごとに staging app があり、複数の preview apps を持てるため、ファイル重複が発生しうる
- SQLiteベースの保存では、アプリ間でも同じ内容のファイルを重複保存しないようにできる
バックアップ、キャッシュ、圧縮処理
- システム全体の状態、メタデータ、ファイルを、SQLiteバックアップツールである Litestream などでバックアップできる
- ブラウザーキャッシュ用 ETag ヘッダーに必要なコンテンツSHAを、ファイルアップロード時に一度保存しておけば、後から再計算する必要がない
- ファイル内容はSQLiteテーブルに Brotli 圧縮形式で保存される
- データベース方式では、
files テーブルにカラムを追加してGZip圧縮データや非圧縮データも保存できる
パフォーマンスとマルチノード計画
- OpenRunにおけるSQLiteデータベース方式は良好な性能を提供している
- 同等のファイルシステム実装がないため、直接比較のベンチマークは実施していない
- SQLiteチームの ベンチマーク によれば、一部のワークロードではSQLiteがファイルシステムを直接使うより優れた性能を出せる
- OpenRunは現在 シングルノード で動作している
- 今後マルチノード対応が追加されると、メタデータとファイルデータの保存にはローカル SQLite の代わりに共有 Postgres データベースを使う計画である
- この方式ではレイテンシの問題が生じる可能性がある
- Postgresアクセスのレイテンシを避けるため、ローカルSQLiteデータベースをファイルキャッシュとして使う予定である
ファイルシステム方式のほうが一般的な理由
- ほとんどのWebサーバーがファイルシステムを使う理由の1つは 利便性 である
- rsyncやtarのような既存のファイルシステムツールでファイルをコピーして更新できる
- もう1つの理由は歴史的背景である
- 優れたインプロセスのリレーショナルデータベースが存在する以前から、ファイルシステムは使われてきた
- データベースをファイル保存先として使うには、ファイルアップロード用の APIインターフェース が必要であり、常に実現可能な方法とは限らない
1件のコメント
Hacker Newsの意見
数年前にこのアイデアを試してみたことがあり、一部は「35% Faster Than The Filesystem」という記事に触発されたものだった: https://www.sqlite.org/fasterthanfs.html
当時のメモはこちら: https://simonwillison.net/2020/Jul/30/fun-binary-data-and-sq...
DatasetteでSQLiteから静的ファイルを配信するプラグインとしてhttps://datasette.io/plugins/datasette-mediaを作り、うまく動くが、正直なところ作ってからあまり使ってはいない
関連する概念としてSQLiteから地図タイルを配信する方法もあり、https://datasette.io/plugins/datasette-tilesがそれを担う。MBTiles形式は、実はPNGが大量に入ったSQLiteデータベースだった
ファイル配信用SQLiteを試すなら、データベースの初期構成には「sqlite-utils insert-files」CLIツールが役立つかもしれない: https://sqlite-utils.datasette.io/en/stable/cli.html#inserti...
コンテンツハッシュはファイルのアップロード時に一度作ればよく、Webサーバーが再起動するたびに作ったり、実際のファイル名を変更するビルドステップを置いたりする必要がない。ファイルシステム上のファイルにも動的に適用できるが(https://github.com/benbjohnson/hashfsのembedFS実装を参照)、データベースのほうが少し簡単にしてくれる
requests-cacheは、記憶が正しければSQLiteに
(date, URI)でリクエストをキャッシュする: https://github.com/requests-cache/requests-cache/blob/main/r...pyfilesystem SQLite検索: https://www.google.com/search?q=pyfilesystem+sqlite
sendfile mmap SQLite検索: https://www.google.com/search?q=sendfile+mmap+sqlite
https://github.com/adamobeng/wddbfsは「SQLiteデータベースの内容を読めるwebdavfsプロバイダー」である
Unixのファイル権限やxattrs拡張ファイル属性の権限をSQLite上に載せてファイルシステムを実装する、よい方法もありそうだ
SQLiteは、たとえばngx_http_memcached_module.cより速い、あるいは扱いやすいのだろうか?SQLiteにセル単位ACLもあるのか気になる
静的ファイルを読む場合は、リクエストごとにファイルを開き、読み、閉じる必要があるので、ファイルシステム層がファイル内容をキャッシュしていたとしてもコンテキストスイッチが増える。これを高速化したいなら、すべてをデータベースに変えるのではなく、キャッシュ用フロントエンドを付けるのが適切だ。SQLiteより速く、保守やトラブルシューティングも容易である
完全にユーザー空間で動くファイルシステムも含まれる。FUSEは呼び出しがカーネルを経由するため除外する
「トランザクション更新」が主な利点だという主張には限界がある。サーバーがSQLiteを使うにせよファイルシステムを使うにせよ、それだけでは更新中に壊れたWebアプリを防げない。
ブラウザの各ページは、個別のHTTPリクエストで取得されるリソースのツリーなので、サーバー側のトランザクション/アトミック更新システムの対象ではない。サーバーですべてのリソースをトランザクションで差し替えても、ブラウザは古いリソースと新しいリソースが混在した組み合わせを見る可能性がある。
通常の解決策は、ページのすべてのサブリソース(JavaScriptバンドル、スタイルシート、メディアなど)を、コンテンツハッシュやバージョンを含む名前(URL)にしておくことだ。ルートのHTMLドキュメントがバージョンXをロードするなら、すべてのサブリソースも対応するバージョンXをロードしなければならない。
また、XからYへ更新するときも、Yのページを配信し始めた後しばらくは、Xのサブリソースを提供し続ける必要がある。まだXのページを読み込み中のブラウザが合理的に存在しないと確信できるまで保持しておかないと、Xのページが壊れる可能性がある。
そのため、ルートHTMLとサブリソースを1つのアトミックに差し替えられるバンドルに入れたいと考えるのは、むしろ良くない。以前のサブリソースがまだ参照される可能性があるのに削除してしまうからだ。
場合によっては、メディアファイルのような一部のサブリソースをHTMLドキュメントとは別にバージョン管理したいこともある。JavaScriptの塊やスタイルシートのようなアプリ構造要素のキャッシュをすべて無効化せずに更新するには、ページのビルドシステムでもその点を考慮する必要があるかもしれない。
ある大企業でこれを実験したとき(当時のWebのかなりの部分を見ていた)、ユーザーの大半(80%超)はWebアプリに約2〜3日滞在していた。週末の間タブを開きっぱなしにする人がいるため、偏りがあった可能性は高い。
95%地点は約2週間で、100%は約600日だった。ほぼ2年間タブを開きっぱなしにしていたユーザーがいたということだ。
100%を目指すなら、かなり長く待つ必要がある。これらの数値はすべて記憶に頼ったもので、今はもうその会社には勤めていない。
ユーザーが1つのページに長くとどまった後、壊れたリンクを受け取るシナリオは、SPA側の問題に近い。
おおむね同意するが、トランザクション更新が防いでくれるのは、更新に関連する問題の一部のカテゴリだけだ。アプリレベルの別の問題でも壊れた体験は起こり得る。
コンテンツハッシュで参照される静的コンテンツの以前のバージョンを配信し続けることは可能だが、Claceには現在実装されていない。
要点は、HTMLの変更より先にHTML以外の変更をアップロードして、存在する前のファイルが参照されないようにすることだ。アプリをできるだけ複雑にしたいなら、アップロードに深さ優先探索を適用できる。ただしメンタルヘルスを重視するなら、問題を緩和してアプリではアセット優先アップロードを選ぶほうがよい。
2011/2012年に小さなゲーム開発会社で働いていたとき、私の提案で100KB未満のアセットをすべてsqlite3 DBに移し、「pakファイル」を作ったうえで、それらのファイルのオフセットをsqlite3 DB内に保存した。
この選択は、Richard Hippの事後分析の発表で、振り返ればBLOBをinodeのように扱い、データベース内のより後ろのオフセットに置いて、BLOBはファイルに追記する形にすればよかったと語っていたことに影響を受けたものだった。
アセットの読み込みは非常に速かった。モバイルゲームだったので、DBにないアセットはごく一部だけだった。その後、この方式を採用する人が増えていくのを見るのも興味深い。
もう1つ見落とされがちな利点は、コンテンツの横にほぼ無制限にメタデータを付けられるため、「似た」ファイルをデータベースクエリで探せることだ。
DBには大量のメタデータを入れており、最終的なpakファイルは200MB、データベースは20MBほどだったと思う。繰り返すが、モバイルゲームだった。
クライアント側で最悪だったのは、サーバー側の複雑さのために減らせなかった二重の内部結合が1つあったことだ。サーバー実装をこちらでできなかったのはもどかしかった。一緒に仕事をした相手がソフトウェア開発を非常に苦手としていて、バックエンド仕様全体を知らせずに変更し、ビルドが突然壊れることがあった。
ゲームのリプレイにも別のsqlite3データベースを使い、試合終了後にゲーム全体を再生して、各対戦相手が何をしたかを見られるようにしていた。自動テストにも非常に役立った。
lix変更管理システムでも、ファイルシステムとgitを扱う代わりに、ファイルをSQLiteに入れる方式へ進むことになった。この記事は私たちが直面した問題を扱っている: https://opral.substack.com/i/150054233/breaking-git-compatib...
ファイルロックや並行性のような問題はSQLiteが解決してくれる。
SQLiteを使えば、プラットフォームごとのファイルシステムAPIの代わりにSQLでファイルをクエリできる。
SQLクエリはKysely https://kysely.dev/を使えば、ORMなしでも型安全に書ける。
ただし、SQLiteデータベースはvacuumしないと小さくならない点に注意が必要だ。基本的にはデータを別ファイルにコピーし、元のファイルを削除する作業になる。
アプリケーション内で妥当なタイミングに手動で行う必要がある作業なので、バイナリデータを書いたり消したりする用途では、ディスク使用量に注意しなければならない。
興味深いことに、私が作った静的サイトジェネレーターCMSは、ここでの方式と正反対に動作する。
Webサイトを開発/更新している間、すべてのページと記事はSQLiteデータベースのエントリであり、編集可能なWebサイトのバージョンを表示するWebインターフェースで操作する。
その後、Webサイトを静的ページとしてファイルシステムにダンプして直接デプロイするか、zipとしてダウンロードし、完全静的ホスティングサービスを含む別の場所にアップロードしてデプロイする。
SQLite の “Appropriate Uses For SQLite” https://www.sqlite.org/whentouse.html によると、SQLite が処理できる Web トラフィックは、そのサイトがデータベースをどれだけ頻繁に使うかに左右される
一般に、1 日 100K ヒット未満のサイトなら SQLite で問題なく動作するはず。100K/日は保守的な推定値であり、厳密な上限ではない。SQLite はその 10 倍のトラフィックを処理した事例もある
SQLite の Web サイト(https://www.sqlite.org/)も当然 SQLite を使っており、2015 年時点で 1 日あたり約 400K〜500K 件の HTTP リクエストを処理し、そのうち 15〜20% はデータベースに触れる動的ページだった。動的コンテンツは Web ページあたり約 200 個の SQL 文を使っている
この構成は、物理サーバーを他の 23 個の VM と共有する単一 VM 上で動作しながらも、ほとんどの時間でロードアベレージを 0.1 未満に保っている。参考: https://news.ycombinator.com/item?id=33975635
静的ファイル配信のような読み取り中心のワークロードなら、SQLite ははるかに多くを処理できる。コンテンツのキャッシュヘッダーが設定されていればブラウザがコンテンツをキャッシュするので、サーバーへのリクエストは新規クライアントに対してだけ必要になる
ほとんどの用途で SQLite がボトルネックになるとは思えない
2017 年の “35% Faster Than The Filesystem” ページだけを根拠に、SQLite で静的コンテンツを配信しようというアイデアは、控えめに言っても練り不足に見える
Nginx のような現代的な Web サーバーは、静的ファイル処理に最適な戦略を使っている。sendfile から始まり、io_uring や splice 操作にまで及び、epoll、kqueue、eventport のうち必要に合った基盤の上で、よく設計されたスレッドプール内で動作する
一方、SQLite が標準で提供できる最善は、メモリマップ I/O サポート(https://www.sqlite.org/mmap.html)程度だ
この方式は、ローカルホストの Web アプリのような単一クライアントサービスにはよく合うかもしれない(https://github.com/electron/asar も参照)。しかし大規模な Web サイトでは、他のコメントが言うように、存在しない問題を解こうとしているようなものだ
高性能科学計算をよくやっているが、特に並列にデータへアクセスする場合、最も柔軟で高速な方式がラムディスク上の読み取り専用 SQLite データベースであることが多かった
かなりハックっぽく感じるが、これまで見つけたどの方法よりも設定が簡単で速い
天文学分野の友人が、科学分野の多くの人はデータベースに慣れるべきだと言っているのを見たことがある。そうしないと結局、自分でも気づかないうちに膨大な労力をかけて、ひどい自前データベースを作ることになる、ということだ
なぜこのアプローチがもっと一般的でないのかというと、ファイルシステムはファイル処理に優れているからだ
アトミックな更新が必要なら、新しいディレクトリにチェックアウトしてシンボリックリンクを切り替えればいい
データベースをファイルシステムのように使ういくつものバージョンを見てきたが、良い面もある一方で、何か起きたときには悪夢のような面もある
そうすれば btrfs のようなものを使って、ファイルシステム層で重複排除もできる
アプリ更新時には多くのファイルが変わり得るので、データベースを使えばすべての変更をトランザクションでアトミックに処理でき、バージョン変更中に壊れた Web ページを配信するのを防げる、という主張には問題がある
SQLite ファイルはシリアライズ可能分離を実現するため、書き込み中の読み取りに対してロックされるからだ。そうなると、データベース作業はオフラインのファイルで行い、本番環境の既存ファイルと新しいファイルを入れ替えるほうがよい、という結論になる
これは結局、tar ファイルを使うか、新しいコンテンツに差し替えるための別ディレクトリを使え、という話に近い
静的ファイルは静的に配信するほうがずっと簡単だ。リアルタイムの SQLite 接続を管理し、奇妙な「更新の同時実行性」マジックを実現しようとするプログラムから配信する必要はない。この問題はまったく難しくなく解決できる
CMS を SQLite データベースで管理するのはよいが、コンテンツが静的でリアルタイムに配信するなら、静的ファイルを使うほうがよい