🐙

LiteFS入門

2022/07/28に公開

LiteFSとは

https://github.com/superfly/litefs

LiteFSはLitestreamの可用性に関する課題を解決するために同作者によって新しく作られたソフトウェア。

Live Read Replication の実験的な機能ではノード間のHTTP通信でリードレプレカを同期してプライマリで書き込んだデータをrestoreを通さずにレプリカから参照することができるようになる予定だった。

この時書き込みクエリをプライマリに振り分けるのはアプリケーションの責務になる。例:

https://github.com/benbjohnson/litestream-read-replica-example/blob/dd2c9e50bac387ef3fbe6e3941cabbf590a2937b/main.go#L124

ただそもそも複数台でLitestreamを利用する用途の為にノード間のLive Replicationを実装したとしても、デプロイやフェイルオーバーでノードの入れ替わりが発生する時に、無停止でプライマリを別のノードに切り替えることも考慮したりと、当初のLitestreamのスコープになかった新しい問題も出てくる。

なので「サーバー内のsqlite3ファイルをS3に同期君」を作ったのに複数のプロセスがサーバー間で動作する複雑なものになってきたから、目的をLiteFSとLitestreamに分けるという決定をBenはしたのだと思われる。

As some of you may know, I've been working on a next generation tool for running SQLite on a distributed platform and, after some discussion, I've decided to keep Litestream as a pure disaster recovery tool and not include live replication. It's going to be difficult to maintain it long term—especially when when the new tooling will work better for this use case.
https://github.com/benbjohnson/litestream/issues/8#issuecomment-1173214316

なので単一のLitestreamのみで使うことや、LiteFSで運用しているSQLiteデータベースにLitestreamを加えるパターンも考慮してLitestreamはこのまま維持するのだと思う。(追記:Litestreamと互換性はないので併用する計画はないみたいだ。LiteFS側にS3同期機能が付くらしい。https://github.com/superfly/litefs/issues/18 )

KUOKAさんがVitessを例に出していて確かに……と思った(https://twitter.com/mumoshu/status/1552064218731790336)。SQLite分散環境ということでrqliteやdqliteの名前を上げている人も居た。

環境

git clone https://github.com/superfly/litefs

チェックアウトするとDockerfileがあるのでコンテナ環境でlitefsが起動できるのかと思いきや、これはたぶんバイナリをGitHub Releasesにアップロードする時のもので、システム内にfuse3が有効になっていないと使えない。

なので現時点ではLinux環境が必要になると思う。ビルドにgoの1.18系が要求されて、自分はUbuntu 22.04の公式パッケージからインストールして使った。

lsb_release  -a
No LSB modules are available.
Distributor ID: Ubuntu
Description:    Ubuntu 22.04 LTS
Release:        22.04
Codename:       jammy

依存ライブラリを入れる

apt install golang-go sqlite fuse3 libfuse-dev consul
go version
go version go1.18.1 linux/amd64

ビルド

go build -ldflags "-s -w -X 'main.Version=latest' -extldflags '-static'" -tags osusergo,netgo,sqlite_omit_load_extension -o ./litefs ./cmd/litefs

consul起動

RaftのLeaderノード選出にconsulを使っているので別途起動しておく。

# consul agent -dev

litefs起動

./cmd/litefs/etc 以下にyamlファイルやホスト名解決などの開発用の設定などあるので利用させてもらう。

litefs.yml
mount-dir: "/path/to/mnt"

FUSEのマウント先を mkdir -p "/path/to/mnt" で作っておく。

litefs.yml
consul:
  # Required. The base URL of the Consul server.
  url: "http://localhost:8500"

Consulのホスト名もここに指定されてる。

# ./litefs -config ./cmd/litefs/etc/litefs.yml

initializing consul: key=http://localhost:8500 url=litefs/primary advertise-url=http://localhost:20202
LiteFS mounted to: /path/to/mnt
http server listening on: http://localhost:20202
primary lease acquired, advertising as http://localhost:20202

起動できた。

レプリケーション実行

複数のマシンを用意するのがめんどうだったのでlitefsを複数起動して、かつsqliteファイルの配置場所を別々にしてみる。

# mkdir /path/to/mnt{2,3}
# cp cmd/litefs/etc/litefs{,2}.yml
# cp cmd/litefs/etc/litefs{,3}.yml
# ls cmd/litefs/etc/
hostname  hosts  litefs2.yml  litefs3.yml  litefs.yml  resolv.conf
mount-dir: "/path/to/mnt2"

http:
  addr: ":20203"

consul:
  url: "http://localhost:8500"
  advertise-url: "http://localhost:20203"
  
---

mount-dir: "/path/to/mnt3"

http:
  addr: ":20204"

consul:
  url: "http://localhost:8500"
  advertise-url: "http://localhost:20204"
./litefs -config cmd/litefs/etc/litefs2.yml
./litefs -config cmd/litefs/etc/litefs3.yml

アプリケーションから読み書き

simple.go を参考にこんな感じにREAD or WRITE * mnt,mnt2,mnt3 の振り分けを作ってみる。

package main

import (
	"database/sql"
	"fmt"
	_ "github.com/mattn/go-sqlite3"
	"log"
)

func main() {
	Write("/path/to/mnt/simple.db")
	Read("/path/to/mnt/simple.db")
	Read("/path/to/mnt2/simple.db")
	Read("/path/to/mnt3/simple.db")
}

func Read(path string) {
	db, err := sql.Open("sqlite3", path)
	if err != nil {
		log.Fatal(err)
	}
	defer db.Close()

	rows, err := db.Query("select id, name from foo")
	if err != nil {
		log.Fatal(err)
	}
	defer rows.Close()
	for rows.Next() {
		var id int
		var name string
		err = rows.Scan(&id, &name)
		if err != nil {
			log.Fatal(err)
		}
		fmt.Println(path, id, name)
	}
	err = rows.Err()
	if err != nil {
		log.Fatal(err)
	}
}

func Write(path string) {
	db, err := sql.Open("sqlite3", path)
	if err != nil {
		log.Fatal(err)
	}
	defer db.Close()

	sqlStmt := `
	create table if not exists foo (id integer not null primary key, name text);
	delete from foo;
	`
	_, err = db.Exec(sqlStmt)
	if err != nil {
		log.Printf("%q: %s\n", err, sqlStmt)
		return
	}

	_, err = db.Exec("insert into foo(id, name) values(1, strftime('%Y-%m-%d %H-%M-%f','now')), (2, strftime('%Y-%m-%d %H-%M-%f','now')), (3, strftime('%Y-%m-%d %H-%M-%f','now'))")
	if err != nil {
		log.Fatal(err)
	}
}
# Write("/path/to/mnt/simple.db")+ Read("/path/to/mnt/simple.db")
# go run simple.go
/path/to/mnt/simple.db 1 2022-07-28 10-13-22.971
/path/to/mnt/simple.db 2 2022-07-28 10-13-22.971
/path/to/mnt/simple.db 3 2022-07-28 10-13-22.971

primaryにINSERTしてSELECTはできたので次はmnt2のREADをしてみる

# Write("/path/to/mnt/simple.db")+ Read("/path/to/mnt/simple.db") + Read("/path/to/mnt2/simple.db")
# go run simple.go 
/path/to/mnt/simple.db 1 2022-07-28 10-18-14.342
/path/to/mnt/simple.db 2 2022-07-28 10-18-14.342
/path/to/mnt/simple.db 3 2022-07-28 10-18-14.342
/path/to/mnt2/simple.db 1 2022-07-28 10-18-14.342
/path/to/mnt2/simple.db 2 2022-07-28 10-18-14.342
/path/to/mnt2/simple.db 3 2022-07-28 10-18-14.342

Writeで書き込んだ内容が即時同期されていることが確認できた。

全部いってみる。

# Read All
# go run simple.go 
/path/to/mnt/simple.db 1 2022-07-28 10-18-14.342
/path/to/mnt/simple.db 2 2022-07-28 10-18-14.342
/path/to/mnt/simple.db 3 2022-07-28 10-18-14.342
/path/to/mnt2/simple.db 1 2022-07-28 10-18-14.342
/path/to/mnt2/simple.db 2 2022-07-28 10-18-14.342
/path/to/mnt2/simple.db 3 2022-07-28 10-18-14.342
/path/to/mnt3/simple.db 1 2022-07-28 10-18-14.342
/path/to/mnt3/simple.db 2 2022-07-28 10-18-14.342
/path/to/mnt3/simple.db 3 2022-07-28 10-18-14.342

sha1sumが同一なことを確認

# sha1sum /path/to/mnt*/simple.db
8a7a04e69cf75bfc531020452a584a4f83fca9b1  /path/to/mnt2/simple.db
8a7a04e69cf75bfc531020452a584a4f83fca9b1  /path/to/mnt3/simple.db
8a7a04e69cf75bfc531020452a584a4f83fca9b1  /path/to/mnt/simple.db

primaryのmnt(:20202)を殺してみる

# mnt
^Csignal received, litefs shutting down
stream connected
stream disconnected
exiting primary, destroying lease
stream disconnected

# mnt2
cannot acquire lease or find primary, retrying: no primary
existing primary found (http://localhost:20204), connecting as replica

# mnt3
cannot acquire lease or find primary, retrying: no primary
primary lease acquired, advertising as http://localhost:20204
stream connected

mnt3(:20204) にprimaryが変った。

そして Read("/path/to/mnt/simple.db") はtableが見付からなくなった。

# Read("/path/to/mnt/simple.db")
# go run simple.go 
2022/07/28 18:26:36 no such table: foo
exit status 1
# Read("/path/to/mnt2/simple.db") + Read("/path/to/mnt3/simple.db")
# go run simple.go 
/path/to/mnt2/simple.db 1 2022-07-28 10-18-14.342
/path/to/mnt2/simple.db 2 2022-07-28 10-18-14.342
/path/to/mnt2/simple.db 3 2022-07-28 10-18-14.342
/path/to/mnt3/simple.db 1 2022-07-28 10-18-14.342
/path/to/mnt3/simple.db 2 2022-07-28 10-18-14.342
/path/to/mnt3/simple.db 3 2022-07-28 10-18-14.342

mnt2と3は生きてる。

mnt3がprimaryらしいのでmn3に書き込んでみる。

# Write("/path/to/mnt3/simple.db") + Read("/path/to/mnt3/simple.db")
# go run simple.go 
/path/to/mnt3/simple.db 1 2022-07-28 10-29-21.040
/path/to/mnt3/simple.db 2 2022-07-28 10-29-21.040
/path/to/mnt3/simple.db 3 2022-07-28 10-29-21.040

書き込めた。

mnt1番氏を復活させてみる。

# Read All
# go run simple.go 
/path/to/mnt/simple.db 1 2022-07-28 10-29-21.040
/path/to/mnt/simple.db 2 2022-07-28 10-29-21.040
/path/to/mnt/simple.db 3 2022-07-28 10-29-21.040
/path/to/mnt2/simple.db 1 2022-07-28 10-29-21.040
/path/to/mnt2/simple.db 2 2022-07-28 10-29-21.040
/path/to/mnt2/simple.db 3 2022-07-28 10-29-21.040
/path/to/mnt3/simple.db 1 2022-07-28 10-29-21.040
/path/to/mnt3/simple.db 2 2022-07-28 10-29-21.040
/path/to/mnt3/simple.db 3 2022-07-28 10-29-21.040

不在時に書き込まれた 10-29-21.040 も同期されている。

今度はprimaryではなくなったmnt1に書き込んでみる。

# Write("/path/to/mnt/simple.db") + Read All
# go run simple.go 
2022/07/28 18:32:23 "unable to open database file: no such file or directory": 
        create table if not exists foo (id integer not null primary key, name text);
        delete from foo;

/path/to/mnt/simple.db 1 2022-07-28 10-29-21.040
/path/to/mnt/simple.db 2 2022-07-28 10-29-21.040
/path/to/mnt/simple.db 3 2022-07-28 10-29-21.040
/path/to/mnt2/simple.db 1 2022-07-28 10-29-21.040
/path/to/mnt2/simple.db 2 2022-07-28 10-29-21.040
/path/to/mnt2/simple.db 3 2022-07-28 10-29-21.040
/path/to/mnt3/simple.db 1 2022-07-28 10-29-21.040
/path/to/mnt3/simple.db 2 2022-07-28 10-29-21.040
/path/to/mnt3/simple.db 3 2022-07-28 10-29-21.040

クエリ実行時エラーになった。

まとめ

  • 今回のような簡単なケースではすべて期待どうりに動いた。素晴しい
  • 同一プロセスから別々のsqliteにWriteしてReadはしてみたがトランザクションや並列処理などは試してない
  • ただ実際の利用では1つのsqliteを読み書きすることになるんだと思う
  • 動的に変わるprimaryを特定して書き込みクエリを振り分けるのはどう実装するのか不明

Discussion