Litestream を動かしてみた (ベンチマークも少し)

# はじめに

GW 中 Twitter で話題になっていた [Litestream](https://litestream.io/) という SQLite のレプリケーションツールについて調べてみました。

Litestream は以下のような特徴があります。

* OSS
* セットアップが容易
* WAL ファイル単位でのレプリケーション
  * 指定した時刻の内容のデータベースにリカバリ可能
* レプリケーション先に Amazon S3 を使用可能
  * S3 互換のオブジェクトストレージも使用可能
  * ファイルシステムや SFTP も使用可能

# 環境

今回は以下の環境を使用しました。

* DBサーバー
  * OS: CentOS 7
  * SQLite3 : 標準の yum リポジトリからインストール
  * Litestream : 2022/5/17 時点の main ブランチ ([benbjohnson/litestream@98673c6](https://github.com/benbjohnson/litestream/commit/98673c67851c1f009e21ec6c21198bda76bf23e6))
* レプリケーション先
  * [MinIO](https://min.io/) - S3 互換のオブジェクトストレージ
    * DB サーバーとは別のホストにセットアップ

※ Litestream を使って、古くから運用している複数の Trac プロジェクトの DB (SQLite) に一括してクエリを投げるツールを作りたかったので、かなりレガシーな構成を使ってしまいました。

# インストールメモなど
## MinIO

公式サイトのインストール手順 ([Linux 向け](https://min.io/download#/linux)) に従い、以下の手順でインストール & 実行しました。

```console
$ wget https://dl.min.io/server/minio/release/linux-amd64/minio
$ cat << EOF > run.sh
> #!/bin/sh
>
> MINIO_ROOT_USER=admin
> MINIO_ROOT_PASSWORD=password
> ./minio server ../data --console-address ":9001"
> EOF

$ chmod u+x minio run.sh
$ ./run.sh
```

この方法で起動するとコンソールにストレージ (API) と管理用 Web 画面の URL/認証情報が表示されます。

管理画面で bucket を作成しておきます。

## Litestream のビルド

本稿記述時の最新の安定板は v0.3.8 ですが、v0.4.0 が beta2 まで来ていたので main ブランチの最新をソースからビルドしてみました。

1. `git clone https://github.com/benbjohnson/litestream.git`
2. go1.18.2.linux-amd64.tar.gz を取得して `/opt/go1.18.2` に展開	
3. `/opt/go1.18.2/bin` に PATH を通して `go install ./cmd/litestream/` を実施!	<br /> → CentOS7 の git が古くてビルドエラー!!
4. git を 2 系に更新	<br />see [【2021年確認済み】centos7系にgit2系をインストールする（依存関係エラー解消版） \| きなこもちエクステンド！](https://kinacomochi.com/entry/centos7_git2)	
5. 再度 `go install ./cmd/litestream/` <br /> → `~/go/bin/litestream` がビルドされた
	
※ `yum groupinstall "Development Tools"` してる環境だったので上記の手順でビルドができました。
開発環境未導入の場合は他にも必要なパッケージがあるかもしれません。

# Hello, world

チュートリアルの内容を参考に、別ホストに立てたオブジェクトストレージ (MinIO) へのレプリケーションとリストアを動かしてみます。

チュートリアルの後でベンチマーク取得や PITR を試すことも考慮して、少し真面目に環境整備しつつ試してみました。

## ディレクトリ構成

以下のような構成で実行環境を作ります。

```
path/to/work
├─conf      litestream 設定ファイル配置先
├─db        SQLite データベースファイル格納先
├─restore   リストア作業用
└─script    起動/データ生成などのスクリプト置き場
```

## データベースファイルの作成

[Getting Started](https://litestream.io/getting-started/) に記載されているサンプルのデータベースファイルを作成します。
作業中何度もデータベースを再構築することが予想されるので、db ディレクトリに以下の Makefile を用意して、db ディレクトリ内の *.sql ファイルから *.db ファイルを作成可能にしました。

```makefile
.SUFFIXES:
.SUFFIXES: .sql .db

SQLS=$(shell ls *.sql)
DBS=$(SQLS:%.sql=%.db)

.sql.db:
        @cat $< | sqlite3 `basename $< .sql`.db
        @ls -l `basename $< .sql`.db

all:    $(DBS)

clean:
        @rm -vrf *.db *.db-shm *.db-wal *.db-litestream
```

サンプルデータベース構築用の .sql ファイルを作成後 `make` を実行します。

```console
$ vi helloworld.sql
$ cat helloworld.sql
CREATE TABLE fruits (name TEXT, color TEXT);

INSERT INTO fruits (name, color) VALUES ('apple', 'red');
INSERT INTO fruits (name, color) VALUES ('banana', 'yellow');

$ make
-rw-r--r-- 1 lab lab 2048  5月 25 13:29 helloworld.db
```

## 設定ファイルの用意

レプリケーション対象のデータベースファイルやレプリケーション先の指定は litestream の起動引数で与えることもできますが、設定内容の差分が取りやすいよう `-config` オプションで設定ファイルを指定して起動する方法を使ってみます。

今回のようにシンプルな方法で MinIO を起動した場合はレプリケーション先定義を `url: s3://～` で行うのではなく、以下のように type, endpoint 等を個別に設定した方がよいようです。

```yml
# $ cat conf/helloworld.conf
dbs:
  - path: /home/lab/work/db/helloworld.db
    replicas:
      - type: s3
        endpoint: http://xxx.xxx.xxx.xxx:9000
        bucket: test-bucket
        path: helloworld.db
        forcePathStyle: true
        access-key-id: minioadmin
        secret-access-key: minioadmin
```

## 起動 ～ DB 更新 ～ リストア 

litestream を起動します。
```console
$ ~/go/bin/litestream replicate -config ./conf/helloworld.conf
```

データベースを更新します。
```console
$ sqlite3 db/helloworld.db
sqlite> INSERT INTO fruits (name, color) VALUES ('grape', 'purple');
sqlite> .quit
```

リストアします。`-config` オプション指定時はデータベースファイルが未指定でも動作することを期待しましたが、今回使用したバージョンでは引数エラーになりました。
```console
$ ~/go/bin/litestream restore -o restore/restore_test.db -config ./conf/helloworld.conf
database path or replica URL required

$ ~/go/bin/litestream restore -o restore/restore_test.db -config ./conf/helloworld.conf db/helloworld.db
2022/05/25 13:41:56.403709 restoring snapshot 646d4479d1fa4d35/0000000000000000 to restore/restore_test.db.tmp
2022/05/25 13:41:56.452500 applied wal 646d4479d1fa4d35/0000000000000000 elapsed=10.050914ms
2022/05/25 13:41:56.458167 applied wal 646d4479d1fa4d35/0000000000000001 elapsed=5.653181ms
2022/05/25 13:41:56.458175 renaming database from temporary location

$ sqlite3 restore/restore_test.db 'SELECT * FROM fruits'
apple|red
banana|yellow
grape|purple
```

# 動かして分かったこと
## `journal_mode=WAL` の設定は自動で行なわれる

公式サイトの [Tips & Caveats \- Litestream](https://litestream.io/tips/#wal-journal-mode) にはレプリケーションを行うためには WAL を使用するモードで SQLite を実行する必要があると記載されています。

今回使用した環境ではデータベースファイルを新規作成すると WAL を使用しないモード (`jorunal_mode=DELETE`) になっていたのですが、Hello, world 実行後のデータベースファイルを確認すると WAL を使用するモードに変更されていました。

litestream が DB ファイルを開いた時に [モードを変更してくれる](https://github.com/benbjohnson/litestream/blob/main/db.go#L547) ようです。

## Litestream が作成するテーブル・ファイル

レプリケーションを実行すると、対象データベースに `_litestream` で始まるふたつのテーブルが作成されてました。

```console
$ sqlite3 db/helloworld.db '.schema'
CREATE TABLE fruits (name TEXT, color TEXT);
CREATE TABLE _litestream_seq (id INTEGER PRIMARY KEY, seq INTEGER);
CREATE TABLE _litestream_lock (id INTEGER);
```

また、データベースファイルと同じディレクトリに以下のファイル/ディレクトリが作成されます。
レプリケーションを実行したデータベースを再構築する場合、データベースファイルに加えてこれらのファイル/ディレクトリも削除する必要があります。

```console
db/helloworld.db-shm
db/helloworld.db-wal
helloworld.db-litestream/
```

## レプリケーションデータの構造・管理

レプリケーション先の MinIO の bucket 内は以下の構造になっていました。

```
test-bucket/                    : bucket 名
└─ helloworld.db                : conf ファイルの replicas/path に指定した文字列 
    └─generations/
        ├─0a40f6ba782b4810/
        │  ├─snapshots/
        │  │      0000000000000000.snapshot.lz4
        │  │
        │  └─wal/
        │      ├─0000000000000000/
        │      │      0000000000000000.wal.lz4
        │      │      0000000000001080.wal.lz4
        │      │
        │      └─0000000000000001/
        │              0000000000000000.wal.lz4
        │
        └─73384c2e8925edf1/
```

* litestream 起動時 generations 以下にデータ保存ディレクトリが作成される。(ディレクトリ名はランダムな16桁文字列で構成される。)
   * 起動時以外の作成タイミングは未調査
* generation ディレクトリ以下には snapshot (データベース全体のバックアップ) と WAL データが保存されている
* litestream 起動時、および 24 時間に 1 回 snapshot が取得される (デフォルト設定の場合)
* 次回の snapshot 取得までは WAL ファイルの差分が取得される
* snapshot の取得周期やバックアップデータの保持に関するパラメータは以下の通り。
  * snapshot-interval=5m, retention=1h 設定して連続動作させたが 1h 以上過去のバックアップデータが残存した
  * retention-check-interval > retention の場合は確認処理が走らない? (未調査)

| パラメータ名              | 機能                         | デフォルト値
|---------------------------|------------------------------|--------------
| snapshot-interval         | snapshot の取得周期          | 24h
| retention                 | バックアップデータの保持期間 | 24h
| retention-check-interval  | 保持期間切れの確認周期       | 1h


詳細は [Snapshots & generations](https://litestream.io/how-it-works/#snapshots--generations) や [Litestream入門](https://blog.freedom-man.com/litestream) を参照ください。


# レプリケーション中のオーバーヘッドはどの程度?

[SQLite のパフォーマンスチューニング、または DBIx::Sunny 0.16 の話](https://blog.nomadscafe.jp/2012/06/sqlite-dbixsunny-016.html) で公開されているベンチマークコードを参考に、litestream のオーバーヘッドを測定してみます。

上記のベンチマークでは

* (1) `journal_mode=DELETE` の場合
* `jorunal_mode=WAL` の場合で
   * (2) `synchronous=FULL`
   * (3) `synchronous=NORMAL`
   * (4) `synchronous=OFF`

の 4 パターンで計測を行なっていました。(`synchronous` については [SQLite のノウハウ](https://site-builder.wiki/posts/891) を参照)

[Tips & Caveats](https://litestream.io/tips/#synchronous-pragma) で synchronous は NORMAL に設定することを検討する言及があったので、本稿では以下の2パターンでレプリケーション有無でのパフォーマンスを計測することにしました。

1. `jorunal_mode=WAL && synchronous=FULL`
2. `jorunal_mode=WAL && synchronous=NORMAL`

## ベンチマークスクリプトの修正

参考にさせて頂いたエントリで使用されていたベンチマークスクリプトに以下の修正を加えたものを使用しました。

```diff_perl
6d5
< use File::Temp qw/tempfile/;
9,10c8,9
< for my $n ( 1..4 ) {
<     (undef ,my $db) = tempfile(OPEN => 0);
---
> for my $n ( 1..2 ) {
>     my $db = "db/case${n}.db";
28,30c27,28
<             $self->stash->{dbh}->do('PRAGMA journal_mode = WAL') if $n > 1;
<             $self->stash->{dbh}->do('PRAGMA synchronous = NORMAL') if $n == 3;
<             $self->stash->{dbh}->do('PRAGMA synchronous = OFF') if $n > 3;
---
>             $self->stash->{dbh}->do('PRAGMA journal_mode = WAL');
>             $self->stash->{dbh}->do('PRAGMA synchronous = NORMAL') if $n == 2;
45c43
<         concurrency => 4,
---
>         concurrency => 2,
```

## 計測結果

上記のスクリプトを使用した処理速度 (正確には 1 秒あたりの INSERT レコード数) の測定結果は以下の通りです。データのディスクへの反映を同期的に行なう FULL の場合は 15% 程度の性能劣化が見られましたが、NORMAL では有意な性能劣化は見られませんでした。

| Litestream  | FULL    | NORMAL   |
|:-----------:|--------:|---------:|
| なし        | 336.273 | 7276.586 |
| あり        | 286.998 | 7291.277 |
|             | 85.3%   | 100%     |

この結果だけで性能は評価できませんが、本格的な検証をする際は NORMAL に設定しておいた方がよいことは間違いなさそうです。
※ `synchronous=NORMAL` の設定は永続化されないため、SQLite データベースファイルへ接続した際に毎回実施する必要がある点に注意が必要です。


# まとめ

チュートリアルの内容を中心に、オブジェクトストレージへのレプリケーションとリストア (全体と時刻指定 (PITR)) を試してみて特徴を把握することができました。

今回はコンソールから起動していましたが [Infrastructure guides](https://litestream.io/guides/) には systemd 配下のサービスとして起動する方法や Docker/k8s での起動方法、Windows サービスとしての起動方法も説明されていており、様々な場面で運用できそうです。
(コンテナ構成については [LiteStream をサイドカー構成にしたデータベース永続化](https://zenn.dev/mattn/articles/fef682a8b204ac) が非常に参考になります。)

今後は [リードレプリカの実装が予定されてる](https://fly.io/blog/all-in-on-sqlite-litestream/#enter-litestrea) ようなので、使えるようになったら試してみたいと思います。
( [Use fsnotify #369](https://github.com/benbjohnson/litestream/pull/369) がマージされたら Windows でのビルドと実行も試してみたい。)

# 参考リンク

* [Litestream \- Streaming SQLite Replication](https://litestream.io/)		
  * 公式サイト
* [GitHub \- benbjohnson/litestream: Streaming replication for SQLite\.](https://github.com/benbjohnson/litestream)	
* [Litestream入門](https://blog.freedom-man.com/litestream)
* [LiteStream をサイドカー構成にしたデータベース永続化](https://zenn.dev/mattn/articles/fef682a8b204ac)		
* [SQLite のノウハウ](https://site-builder.wiki/posts/891)
  * SQLIte の journal_mode と synchronous 設定について	
* [SQLite のパフォーマンスチューニング、または DBIx::Sunny 0.16 の話](https://blog.nomadscafe.jp/2012/06/sqlite-dbixsunny-016.html)	
  * journal_mode と synchronous の効果を測定するベンチマーク (とテストコード) が記載されている。