claude run で永続化した sqlite を使う

激安でアプリを作る際のの選択肢を増やしたいと考えていた時、そういえばできるはずと思いだした。
claude run で永続化した sqlite を使う方法を確認しておきたい。

作ったのはこちら。
Octo8080X/gcp-cloud-run-sqlite

参考

実装

ローカルからLitestreamを使ってGCSにsqliteを永続化する

ローカルにDockerでLitestreamを立ち上げて、GCSにsqliteを永続化する。

ひとまず、アプリ本体を動かす最小構成

docker-compose.yaml
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
services:
app:
build:
context: .
dockerfile: dockerfile
image: deno-app:latest
container_name: deno-app
ports:
- "8000:8000"
volumes:
- ./:/app
- deno-cache:/deno-dir
command: deno task start

volumes:
deno-cache:

dockerfile
1
2
3
4
5
6
7
8
9
10
11
12
FROM denoland/deno:latest

WORKDIR /app

# Keep cache in a stable path so compose can mount it.
ENV DENO_DIR=/deno-dir

COPY . .

EXPOSE 8000

CMD ["deno", "task", "start"]
main.ts
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
function fetch(req: Request): Response {
const url = new URL(req.url);

if (url.pathname === "/api") {
return Response.json({
message: "Hello, world!",
time: new Date().toISOString(),
});
}

return new Response("<h1>Welcome to Deno!</h1>", {
headers: { "content-type": "text/html" },
});
}

export default {fetch};

これで、以下コマンドでサーバー起動する

1
2
$ docker comose build
$ docker compose up

litestreamを入れる(local)

まず、localでlitestreamを入れるにあたり以下のように直す。

docker-compose.yaml
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
services:
app:
build:
context: .
dockerfile: dockerfile
image: deno-app:latest
container_name: deno-app
ports:
- "8000:8000"
volumes:
- ./:/app
- deno-cache:/deno-dir
command: /run.sh

volumes:
deno-cache:
dockerfile
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
FROM litestream/litestream:0.5.10 AS litestream_bin

FROM denoland/deno:latest

WORKDIR /app

# Keep cache in a stable path so compose can mount it.
ENV DENO_DIR=/deno-dir

COPY . .
RUN deno cache main.ts

EXPOSE 8000

# Litestream
COPY --from=litestream_bin /usr/local/bin/litestream /usr/local/bin/litestream
COPY litestream.yml /etc/litestream.yml
COPY run.sh /run.sh
RUN chmod +x /run.sh && mkdir -p /app/data /tmp/litestream
ENV DATABASE_PATH=/app/data/app.db

CMD ["/run.sh"]
run.sh
1
2
3
4
5
6
7
8
9
10
#!/bin/sh
set -eu

DB_PATH="${DATABASE_PATH:-/app/data/app.db}"
mkdir -p "$(dirname "$DB_PATH")" /tmp/litestream

# Create database file if it does not exist yet.
[ -f "$DB_PATH" ] || : > "$DB_PATH"

exec litestream replicate -config /etc/litestream.yml -exec "deno task start"
litestream.yml
1
2
3
4
5
dbs:
- path: ${DATABASE_PATH}
replicas:
- type: file
path: /tmp/litestream/app.db
main.ts
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
import { Database } from "@db/sqlite";

// データべースの初期化とテストデータ設定
const db = new Database(Deno.env.get("DATABASE_PATH") || "/app/data/app.db");
db.exec("PRAGMA journal_mode=WAL;");

// create todos table ,id, title, completed
db.exec(`CREATE TABLE IF NOT EXISTS todos (
id INTEGER PRIMARY KEY AUTOINCREMENT,
title TEXT NOT NULL,
completed INTEGER NOT NULL DEFAULT 0
)`);

if(db.prepare("SELECT * FROM todos").all().length == 0){
console.log("Todos table already has data, skipping seeding.");
db.exec("insert into todos (title) values('test')");
}


function handler(req: Request): Response {
const url = new URL(req.url);

if (url.pathname === "/api") {
return Response.json(db.prepare("SELECT * FROM todos").all());
}

return new Response(JSON.stringify(db.prepare("SELECT * FROM todos").all()), {
headers: { "content-type": "text/html" },
});
}

export { handler };
export default { fetch: handler };

ここまで用意できたら実行する。

1
2
$ docker compose build
$ docker compose up

これでテストデータに入れた結果がjsonで返ってくることが確認できる。

また、data/app.db が作成されており、litestreamを経由してアクセスできることが確認できる。

litestreamを入れる(google cloud storage)

続けて、litestreamを使い、sqliteファイルをgoogle cloud storageに永続化する。

cloud storageのバケットを作成し、サービスアカウントを作成、鍵を発行しjsonファイルをダウンロードする。
保管先は./tmp以下とする

これを踏まえ認証情報と接続先を書き換える。

docker-compose.yaml
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
services:
app:
build:
context: .
dockerfile: dockerfile
image: deno-app:latest
container_name: deno-app
ports:
- "8000:8000"
environment:
GOOGLE_APPLICATION_CREDENTIALS: /app/tmp/hogehoge.json
volumes:
- ./:/app
- deno-cache:/deno-dir
command: /run.sh

volumes:
deno-cache:
litestream.yml
1
2
3
4
dbs:
- path: ${DATABASE_PATH}
replicas:
- url: gs://[バケット名]/litestream/app.db

これで起動する。

1
2
$ docker compose build
$ docker compose up

すると、作成したバケットに書き込みが確認できるようになる。
ただし、ローカルのsqliteの時と異なり、.dbファイルではなく.ltxファイルのみ作成の様子が見える。

アプリを改修して、アクセスのたびにデータを増やすなどすると、バケット内でのファイルの増え具合を見ることができた。

アプリ本体をcloud runにデプロイする

cloud run への標準的な導入設定をすればOK。

で起動すると、先の書き込みの内容が反映されていませんでした。
litestreamの設定でレプリカ処理はしたものの、復元処理がされていなかったようです。
run.shに復元処理を追記します。

run.sh
1
2
3
4
5
6
7
8
9
10
11
12
13
#!/bin/sh
set -eu

DB_PATH="${DATABASE_PATH:-/app/data/app.db}"
mkdir -p "$(dirname "$DB_PATH")" /tmp/litestream

# Try to restore from replica on startup. If no replica exists, continue.
litestream restore -config /etc/litestream.yml -if-db-not-exists -if-replica-exists "$DB_PATH"

# Create an empty database if restore did not materialize one.
[ -f "$DB_PATH" ] || : > "$DB_PATH"

exec litestream replicate -config /etc/litestream.yml -exec "deno task start"

さらに、どうせ接続できたのでローカルでの起動と、並行させると妙な挙動をします。
調べてみると、書き込みするClientを複数置くことはできないものとのこと。

sqlite界隈でいうとtrusoは、readは分散しているが、writeは1つのエンドポイントに集約されていた。
このことが思い出された。

アプリ本体をcloud runにデプロイする(インスタンス数N)

先の通り、Claude Run で動かす際にインスタンス数を1にしておくとこれは担保できる。
他の方法としてLBを立てて、httpメソッドで振り分けして、Postを処理するインスタンスグループは、1台制限。
Getを処理するインスタンスグループは、複数台で逐次同期。
これであれば担保できると想定した。
実際には他のメソッドもあるが、シンプルのため今回はこうしたい。

run.sh
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
#!/bin/sh
set -eu

DB_PATH="${DATABASE_PATH:-/app/data/app.db}"
MODE="${LITESTREAM_MODE:-replicate}"
mkdir -p "$(dirname "$DB_PATH")" /tmp/litestream

restore_latest() {
litestream restore -config /etc/litestream.yml -if-replica-exists -o "${DB_PATH}.replica" "$DB_PATH" || true

if [ -f "${DB_PATH}.replica" ]; then
rm -f "$DB_PATH" "${DB_PATH}-wal" "${DB_PATH}-shm"
mv -f "${DB_PATH}.replica" "$DB_PATH"
fi
}

restore_latest

[ -f "$DB_PATH" ] || : > "$DB_PATH"

# READONLY では、replica から復元を繰り返して接続させる
if [ "$MODE" = "read_only_sync" ]; then
export APP_READ_ONLY=true
(
while true; do
litestream restore -config /etc/litestream.yml -if-replica-exists -o "${DB_PATH}.next" "$DB_PATH" || true
if [ -f "${DB_PATH}.next" ]; then
rm -f "${DB_PATH}-wal" "${DB_PATH}-shm"
mv -f "${DB_PATH}.next" "$DB_PATH"
fi
done
) &
exec deno task start
fi

exec litestream replicate -config /etc/litestream.yml -exec "deno task start"

と、このようして高頻度で同期をさせる。
さらにアプリ側も読む動作については、そのタイミングで接続のOpen/Closeを繰り返すようにする。

main.ts
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
import { Database } from "@db/sqlite";
import {Hono} from "hono";

const isReadOnly = Deno.env.get("APP_READ_ONLY") === "true";

// データべースの初期化とテストデータ設定
const db = new Database(
Deno.env.get("DATABASE_PATH") || "/app/data/app.db",
isReadOnly ? { readonly: true } : undefined,
);

if (!isReadOnly) {
db.exec("PRAGMA journal_mode=WAL;");

// create todos table ,id, title, completed
db.exec(`CREATE TABLE IF NOT EXISTS todos (
id INTEGER PRIMARY KEY AUTOINCREMENT,
title TEXT NOT NULL,
completed INTEGER NOT NULL DEFAULT 0
)`);

if (db.prepare("SELECT * FROM todos").all().length === 0) {
console.log("Todos table is empty, seeding initial row.");
db.exec("insert into todos (title) values('test')");
}
}


const app = new Hono();

function fetchTodos() {
if (isReadOnly) {
const reader = new Database(
Deno.env.get("DATABASE_PATH") || "/app/data/app.db",
{ readonly: true },
);
try {
return reader.prepare("SELECT * FROM todos order by id DESC").all();
} finally {
reader.close();
}
}

return db.prepare("SELECT * FROM todos order by id DESC").all();
}

// シンプルに一覧のHTMLを返す
app.get("/", (c) => {
const todos = fetchTodos();
return c.html(`
<h1>TODO List</h1>
<form method="POST" action="/todo">
<input type="text" name="title" placeholder="New TODO" required />
<button type="submit">Add</button>
</form>
<ul>
${todos.map(todo => `<li>${todo.id}: ${todo.title}</li>`).join("")}
</ul>
`);
});

// POST /todo で新しいTODOを追加して/にリダイレクトする
app.post("/todo", async(c) => {
if (isReadOnly) {
return c.text("read-only mode", 403);
}

const body = await c.req.parseBody();
const title = body.title;
if (typeof title !== "string" || title.length === 0) {
return c.text("title is required", 400);
}

db.prepare("INSERT INTO todos (title) VALUES (?)").run(title);
return c.redirect("/");
})

export default { fetch: app.fetch };

とこのようしてデプロイすると、破綻することなく、複数台でのアクセスが可能となる。
しかし、litestreamのレプリカの同期は、デフォルトで1秒の制限があり、試すと3~4秒程度のタイムラグが発生する。

sync-interval How often to push frames to replica 1s

litestream - Global Replica Defaults

このようになると、リアルタイム性を担保すべきAPIは、Writeにルーティングし、そうでもないものはReadにルーティングするなどの工夫がいる。
ただし、一覧と書き込み口が同じ掲示板だとかなり苦しい可能性がある。

一応試す。

main.ts(抜粋)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
app.post("/todo", async(c) => {
if (isReadOnly) {
return c.text("read-only mode", 403);
}

const body = await c.req.parseBody();
const title = body.title;
if (typeof title !== "string" || title.length === 0) {
return c.text("title is required", 400);
}

db.prepare("INSERT INTO todos (title) VALUES (?)").run(title);
return c.redirect("/?realtime=1"); // リアルタイム性を担保するために、リダイレクト先にパラメータを付与
})
terraform(url map部分抜粋)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
resource "google_compute_url_map" "method_router" {
name = "${var.lb_name}-url-map"
default_service = google_compute_backend_service.get_backend.id

host_rule {
hosts = ["*"]
path_matcher = "allpaths"
}

path_matcher {
name = "allpaths"
default_service = google_compute_backend_service.get_backend.id

route_rules {
priority = 10
service = google_compute_backend_service.post_backend.id

match_rules {
prefix_match = "/"

header_matches {
header_name = ":method"
exact_match = "POST"
}
}
}

route_rules {
priority = 15
service = google_compute_backend_service.post_backend.id

match_rules {
prefix_match = "/"

header_matches {
header_name = ":method"
exact_match = "GET"
}

query_parameter_matches {
name = "realtime"
exact_match = "1"
}
}
}

route_rules {
priority = 20
service = google_compute_backend_service.get_backend.id

match_rules {
prefix_match = "/"

header_matches {
header_name = ":method"
exact_match = "GET"
}
}
}
}

depends_on = [google_project_service.compute_api]
}

と、このように用意してデプロイすると以下のように挙動する。

クエリが付いていない場合、表示が遅延していることがわかる。
本当にリアルタイム性を担保しなければならないのでなければ許容されるのではなかろうか?

liteFS

liteFS というlitestreamの作者が作った、よりリアルタイム同期性に振ったものがある。
が、現在もベータであったり、1年くらい更新が無かったりとしており、今回は見送りとする。


ということで、claude run で永続化した sqlite を使う方法を確認してみた。

昨今、様々なサービスがフリー利用枠の消滅しているが、この方法であればかなり抑えられるのではなかろうか?

では。