Litestream の書き込み可能な VFS
Litestream の作者 Ben Johnson が、SQLite を S3 互換のオブジェクトストレージに同期する Unix 流のツールと、それが Sprites の中核部品になった経緯を語る。今回の新要素は書き込み可能な VFS。
日本語
コピー

*Image by:
I'm Ben Johnson, and I work on Litestream at Fly.io. Litestream is the missing backup and restore system for SQLite. It's free, open-source software, and it ought to run anywhere. Read more here.
Litestream について書くたびに、その説明は少しずつ研ぎ澄まれていく。今回はこうだ。Litestream は SQLite データベースを S3 互換のオブジェクトストレージと同期し続ける、Unix 流のツールである。これがあれば、壊滅的なデータ損失を心配せずに SQLite の速さと手軽さを手に入れられる。アプリケーション側は Litestream の存在を知る必要すらない。バックグラウンドのツールとして動かしておくだけでいい。
この2週間は慌ただしかった。
先日 Sprites をローンチした。Sprites が何なのかまだ知らない人は、見てきてほしい。これまでに出した中で最もクールなもののひとつだ。ここで売り込む時間は取らない。ともかく、Sprites は大きな話で、Litestream はその重要な構成要素になっている。これは私にとっても大きな話だ。
Sprites は Litestream に直接依存している。重要な点が2つある。
ひとつ目は、グローバルな Sprites オーケストレーターの中核に Litestream SQLite があることだ。主力製品である Fly Machines は中央集約型の Postgres クラスタに依存しているが、Sprites は違う。Elixir で書かれたこのオーケストレーターは、S3 互換のオブジェクトストレージ上で直接動く。Sprites にサインアップした組織はそれぞれ自分の SQLite データベースを持ち、それを Litestream が同期し続ける。
この設計は面白い。「大量の SQLite データベース」というパターンを使っている。このパターンは過小評価されている。スケールする。そして Fly.io が成長するにつれ、あの Postgres クラスタを健全に保つことは大きなエンジニアリング上の課題になってきた。
ただ、Litestream の話としては、このオーケストレーターについて言うことはあまりないので、ここまでにしておく。Sprites が Litestream を使う2つ目の方法のほうがずっと面白い。
Litestream は、各 Sprite が動作するディスクストレージスタックに直接組み込まれている。
Sprite の起動時間は1秒未満で、各 Sprite は起動時に 100GB の永続ストレージを備える。その裏側はかなり厄介なエンジニアリングだ。これが可能なのは、Sprite ストレージのルートが S3 互換のオブジェクトストレージだからだ。そして高速化のために、使用中のストレージブロックを記録するデータベースを維持し、マウントした NVMe をリードスルーキャッシュとして使っている。これを担うシステムが JuiceFS で、そのデータベース——ここでは「ブロックマップ」と呼ぶことにする——は、(ご想像のとおり)BoltDB をベースに書き直されたメタデータストアだ。
冗談です!もちろん Litestream SQLite です。
Sprite のストレージは気難しい
Sprite のすべては、素早く起動するために設計されています。
Sprite の土台にある Fly Machine が再起動すると、オブジェクトストレージからブロックマップを再構築する必要があるかもしれません。ブロックマップは大きくはありませんが、小さくもありません。最悪の場合、数十メガバイト程度になります。
問題は、これが Sprite の再起動の最中に起きるということです。別の言い方をすれば、受信した Web リクエストに応答している最中かもしれません。つまり、そのリクエストに間に合うよう、十分短い時間で終わらせなければなりません。時間の予算はかなり厳しいです。
さらに高速化するために、私たちは Litestream VFS を統合して起動時間を改善しています。VFS は動的ライブラリで、アプリに読み込むだけで使えます。読み込むと、こんなことができるようになります:
sqlite> .open file:///my.db?vfs=litestream
sqlite> PRAGMA litestream_time = '5 minutes ago';
sqlite> SELECT * FROM sandwich_ratings ORDER BY RANDOM() LIMIT 3 ;
22|Veggie Delight|New York|4
30|Meatball|Los Angeles|5
168|Chicken Shawarma Wrap|Detroit|5
Litestream VFS を使うと、オブジェクトストレージ上の blob に対して直接ポイントインタイムの SQLite クエリを実行でき、データベースのダウンロードが終わる前にクエリに答えられます。
これは優れていますが、完璧ではありません。問題が 2 つあります:
- 読み取りはできても書き込みができない。ユーザーは Sprite のディスクにデータを書き込みます。ストレージスタックは即座に書き込める必要があります。
- コールドスタート時はデータベース全体をダウンロードするしかなく、そんなときにオブジェクトストレージ上で直接クエリを走らせられるのはまさに救いですが、定常状態では十分に速くありません。
どちらも面白い問題です。以下が、私たちがこれを解決した最初のバージョンです。
書き込み可能な VFS
最初に取り組んだのは、VFS がオプションで読み書きをサポートできるようにすることでした。この機能はかなり微妙です。面白いのですが、見た目ほど汎用的ではありません。まず仕組みを説明し、それからなぜこういう設計なのかを説明します。 読むときは、ここで話しているのは VFS そのものだけだという点を思い出してください。当然ながら、通常の方法で Litestream を使う普通の SQLite データベースは書き込み可能です。
VFS の仕組みはこうです。オブジェクトストレージ上のデータベースの各ページについて (file,offset, size) インデックスを維持します。インデックスのデータ自体は LTX ファイルに置かれるので、VFS の起動時に素早く再構築でき、クエリもほとんどがキャッシュに乗ります。先ほど sandwich_ratings をクエリしたとき、私たちの VFS ライブラリが SQLite の読み取りメソッドを横取りし、インデックスで必要なページを探し、取得してキャッシュしました。
読み取りパスは問題ありません。書き込みはずっと厄介です。
読み取り専用モードでは、Litestream がバックグラウンドでポーリングするので、リモートの書き込み側が新しく作った LTX ファイルを検出できます。これは便利なシナリオを支えます。テストを走らせたり、本番環境では速さを保たなければならないデータベースに対して遅い分析クエリを投げたりする場合です。
書き込みモードでは複数の書き込み側を許しません。複数書き込み側の分散 SQLite データベースは Lament Configuration であり、私たちはその苦痛に満ちた風景を探索したいとは思いません。なので書き込みモードの VFS はポーリングを切ります。書き込み側は 1 つだけと仮定し、監視すべき追加のバックアップもないとみなします。
次はバッファリングです。書き込みはまずローカルの一時バッファ(「書き込みバッファ」)に落ちます。およそ 1 秒に 1 回(またはクリーンなシャットダウン時に)、書き込みバッファをオブジェクトストレージに同期します。その同期が起きるまで、VFS 経由で書き込まれたデータは真に永続化されたとは言えません。
ほとんどのストレージのブロックマップはこれよりずっと小さいですが、それでもです。
ここで、私たちが支えようとしているシナリオを思い出してください。ある Sprite がコールドスタートしていて、そのストレージスタックは起動から数ミリ秒以内に書き込みを処理できる必要があり、しかも手元にその 10MB のブロックマップの完全なコピーはありません。書き込み可能な VFS モードは、これを可能にしました。
肝心なのは、私たちがこれを、このシナリオがもともと持つ永続性の要件の範囲内でしか支えていないことです。Sprite 上のすべてのストレージはこの「最終的に永続化される」という性質を共有しているので、VFS 書き込みのこの取り決めはここでは筋が通ります。あなたのアプリに持ってくると、おそらく筋は通りません。でも、何らかの理由でちょうど合うなら、使ってください。Litestream VFS で書き込みを有効にするには、環境変数 LITESTREAM_WRITE_ENABLED を "true" に設定するだけです。

ハイドレーション
Sprite のストレージスタックは VFS モードの SQLite を使います。私たちの最初の VFS 設計では、データの大部分は S3 上にありました。繰り返しますが、コールドスタートでは問題ありません。定常状態ではあまり良くありません。
これを解決するために、私たちは dm-clone のようなシステムから一手を借りました。バックグラウンドハイドレーションです。ハイドレーションの設計は、リモートでクエリに応答しながら、別のループでデータベース全体を引き落とすというものです。VFS の起動時に LITESTREAM_HYDRATION_PATH 環境変数を設定すると、そのファイルへデータをハイドレートします。
ハイドレーションは LTX 圧縮を利用し、各ページの最新バージョンだけを書き込みます。読み取りはハイドレーションにブロックされません。読み取りリクエストにはオブジェクトストレージから直接応答し、ハイドレーションファイルが準備できてからそちらに切り替えます。

ハイドレーションファイルとは何か?それはあなたのデータベースの完全なコピーです。litestream restore を一度走らせて得られるものとまったく同じです。
この設計は Sprites のような頻繁に再起動する環境を対象にしているので、私たちはデータベースを一時ファイルに書きます。起動のたびに、完全な復元をしない限り、データベースが最新の状態を使っていると確信できません。なので VFS を終了するときは、ハイドレーションファイルをそのまま捨てます。この挙動は今のところ VFS にハードコードされています。この機能は Sprites のニーズを満たしますが、同じく、あなたのアプリが望むものとは限りません。
まとめると
この記事は、私たちがオープンソースプロジェクト Litestream に対して行った 2 つの大きめの動きについてです。ただ、これらの機能の適用範囲は狭く、私たちのストレージスタックが直面している問題に似たシナリオを対象にしています。あなたにとって役に立つと思うなら嬉しいですし、ぜひ教えてほしいです。
普通の読み書き負荷なら、この仕組みは要らない。Litestream は VFS なしでも問題なく動く。アプリ側の変更も不要で、sidecar としてアプリと一緒に走らせればいい。この構成の狙いは、書き込みに効率よく追従することに尽きる。書き込みが起きた時点で完全なデータベースが手元にあるなら、それは簡単な話だ。
ただ、これは Litestream が比較的複雑で要求の厳しい問題領域でどう使われるかを示す、価値のある事例だと思っている。Sprites はとてもクールで、Sprite 上のディスク書き込みがすべて Litestream を通るというのは、なかなか気持ちがいい。