この記事を書くに至った経緯と目的
個人開発やまだ利益の出ていないアプリって、予算の制約から選べるDBが限られてしまうことがあるよね。
具体的には Amazon DynamoDB や Cloud FireStore のような 「アクセス回数や通信量、保存サイズなどで金額が決まるDB(インスタンス起動時間ではなく)」 を選ぶことが多いと思うのだけど、これらのDBはNoSQLである以上、文字通り、SQLを使えないという難点がある。
だけどNoSQLのノウハウはちょっと一癖あるから、できることなら通常のRDBMSのようにSQLを使いたい... って人が結構いるんじゃないかなと思っていて、この記事はそういった人たちに、SQLを使える低予算DBの選択肢として、SQLite
+ Litestream
+ Google Cloud Run
を紹介することを目的としているよ。
筆者プロフィール
Kenpal株式会社 でITエンジニアとして色々いじってる faable01 です。ものづくりが好きで、学生時代から創作仲間と小説を書いたりして楽しんでいたのですが、当時はその後自分がIT技術者になるとはつゆ程も思っていませんでした。紆余曲折あり、20代の半ばを過ぎた頃に初めてこの業界と出会った形です。
直近では個人として、9/10からオンライン開催されている技術書典13に出展し、Nuxt3 + TailwindCSS + AWS CDK
に関する初学者向けの書籍を出したりしました。趣味は「技術記事を口語で書くこと」です。
それから、業務日報SaaS 「RevisNote」 を運営しています。リッチテキストでの日報と、短文SNS感のある分報を書けるのが特徴で、組織に所属する人数での従量課金制です。アカウント開設後すぐ使えて、無料プランもあるから、気軽にお試しください。
用語紹介
SQLiteってなに?
SQLite は、スマホアプリ開発ならお馴染みの、単一ファイルで管理できる軽量DBだよ。
軽量DBとはいえその性能や安定性は中々のもので、一工夫すれば結構な数のアクセスや同時実行にも耐えうる。IoTやスマホアプリ専門のDBと思ってる人も多いかもしれないけど、その実、ウェブサイトやエンタープライズデータのキャッシュなどのユースケースでも活躍できるよ。
Litestreamってなに?
Litestream は、SQLiteのレプリケーションを実現してくれるツールだよ。
SQLiteは元々レプリケーションに難点を抱えるデータベースで、そのために本番運用を避けられてきたという経緯があるよ。その状況を打破すべく、Litestreamは既存手法より簡単にSQLiteをレプリケーションできるよう開発されたみたいだね。
クラウドサービスとの相性もよくて、AWSやAzure、GCPのストレージサービス上に簡単にSQLiteをレプリケーション出来るようになってるよ。
Google Cloud Runってなに?
Google Cloud Run は、GCPのコンテナ実行サービスだよ。
料金の安さに定評があって、リクエストが来たらCPUを割り当てるプラン を選べば、CPU割り当てのない時間には課金されないという性質を持つよ。
だから利用者が少ないうちでも安心して使えるし、逆に利用者が増えてきたなら、その時は必要に応じて簡単にスケールアップできるようになっているよ。
本題 : SQLite + Litestream + CloudRun で低予算DBを用意する
見出しの通り、SQLite
+ Litestream
+ Google Cloud Run
の構成なら、低予算でもSQLを捨てない(NoSQLを使わずに済む)DBを用意できるよ。
Google Cloud Run
ならリクエストのない時(正確にはCPUの割り当てがない時)は課金されないようにできるから、この特性を活かして次のように構成するよ。
-
Cloud Run
にLitestream
をメインプロセス、SQLiteをDBとして利用する任意のバックエンドアプリケーション
をサブプロセスとして動かすコンテナを放り込む。 -
Litestream
には、Google Cloud Storage
にSQLiteをレプリケーションさせる。 - リクエストが来てコンテナが起動するときには、上記でレプリケーションされた
Google Cloud Storage
上のSQLiteのバックアップデータから、コンテナのサブプロセス(バックエンドアプリケーション)が使うSQLiteのデータベースファイルを復元させる。
つまるところ、「リクエストが途絶えてコンテナが停止する度に失われるはずの コンテナ内のSQLite
を、Litestreamで永続化させる」 ことで、低コストで運用可能なバックエンドを実現させるという話なんだ。
前置きはここまでにして、早速実装の方法を説明していくよ。
構成例 : Next.js + SQLite + Litestream + Cloud Run
解説に先立って、主要なファイルをどこにどう置くかの俯瞰図を示しておくよ。
.
└── next
├── Dockerfile
├── data.db
├── lib
│ └── prisma.ts
├── litestream.yml
├── next.config.js
├── package.json
├── pages
│ ├── _app.tsx
│ ├── api
│ │ └── ranking.ts
│ └── index.tsx
├── prisma
│ └── seed.ts
├── run.sh
├── schema.prisma
├── styles
│ └── globals.css
├── tailwind.config.js
├── tsconfig.json
└── yarn.lock
それから、ここで紹介する構成例の完成版コードは下記に置いてあるから、必要な人は参考にしてね。
手順1 : ローカル環境で Next.js + SQLite を構成する
まずは Next.js
アプリケーションを初期構築するよ。なお好みの問題から、ついでにCSSフレームワークとして TailwindCSS
を導入しておくよ。
# ---- Next.js (with typescript) 導入 ----
# https://nextjs.org/docs/api-reference/create-next-app
npx create-next-app --ts next # nextディレクトリに作成
cd next
# SWR導入
yarn add swr
# 個人的な好みから ohmyfetch (軽量で便利なfetchライブラリ) 導入
yarn add ohmyfetch
# ---- TailwindCSS 導入 ----
# https://tailwindcss.com/docs/guides/nextjs
yarn add -D tailwindcss postcss autoprefixer
yarn -s run tailwindcss init -p
# ---- Prisma 導入 ----
yarn add @prisma/client
yarn add -D prisma
# PrismaでDBをシードするために
yarn add -D ts-node
# ここまで出来たらVSCodeを開いて...(好きなエディタがあれば読み替えてね)
code .
以下、nextディレクトリをルートディレクトリとみなした相対パスで記述。
/** @type {import('tailwindcss').Config} */
module.exports = {
content: [
"./pages/**/*.{js,ts,jsx,tsx}",
"./components/**/*.{js,ts,jsx,tsx}",
],
theme: {
extend: {},
},
plugins: [],
}
@tailwind base;
@tailwind components;
@tailwind utilities;
+ "prisma": {
+ "seed": "ts-node --compiler-options {\"module\":\"CommonJS\"} prisma/seed.ts"
+ },
"scripts": {
- "dev": "next dev",
+ "dev": "DATABASE_URL=file:./data.db next dev",
"build": "next build",
- "start": "next start",
+ "start": "DATABASE_URL=file:./data.db next start",
"lint": "next lint",
+ "prisma": "DATABASE_URL=file:./data.db prisma"
},
generator client {
provider = "prisma-client-js"
previewFeatures = ["referentialIntegrity"]
}
datasource db {
provider = "sqlite"
url = env("DATABASE_URL")
referentialIntegrity = "prisma"
}
/// 都道府県
model todouhuken {
id Int @id
name String
ranking ranking[]
}
/// 都道府県ごとの任意名のランキング
model ranking {
id String @id @default(uuid())
name String
todouhukenId Int
rank Int
todouhuken todouhuken @relation(fields: [todouhukenId], references: [id])
@@unique([name, todouhukenId])
}
schema.prismaを書いたら、一旦下記を実行。
yarn prisma format
yarn prisma generate
yarn prisma generate
を実行したら、schema.prismaに従った型が生成されるので、これを使って残りの実装を進める。
初期データの投入処理(シード)を書いて...
import { PrismaClient, Prisma } from '@prisma/client';
const prisma = new PrismaClient();
const todouhukenCreateInput: Prisma.todouhukenCreateInput[] = [
{ id: 40, name: '福岡' },
{ id: 41, name: '佐賀' },
{ id: 42, name: '長崎' },
{ id: 43, name: '熊本' },
{ id: 44, name: '大分' },
{ id: 45, name: '宮崎' },
{ id: 46, name: '鹿児島' },
{ id: 47, name: '沖縄' },
];
const main = async () => {
console.log("初期データの投入を開始します。");
await prisma.$transaction(
todouhukenCreateInput.map(input => prisma.todouhuken.upsert({
where: { id: input.id },
update: {},
create: input,
}))
);
console.log("初期データの投入が完了しました。");
};
main().catch((e) => {
console.error(e)
process.exit(1)
}).finally(async () => {
await prisma.$disconnect()
});
Prismaクライアントに warn(prisma-client) There are already 10 instances of Prisma Client actively running.
と怒られないための対策コードを書いて...
import { PrismaClient } from '@prisma/client'
declare global {
var prisma: PrismaClient | undefined
}
export const prisma = global.prisma
|| new PrismaClient(
{log: ["query", "error", "info", "warn"]}
);
if (process.env.NODE_ENV !== 'production') {
global.prisma = prisma
}
DB操作を担うAPIを書いて...
import type { NextApiRequest, NextApiResponse } from 'next'
import { prisma } from '../../lib/prisma'
type Data = {
result?: unknown;
error?: string;
};
interface Request extends NextApiRequest {
body: {
name?: string;
rank?: number;
todouhukenId?: number;
};
};
export default async function handler(
req: Request,
res: NextApiResponse<Data>
) {
switch (req.method) {
case 'GET':
// GETなら全件取得
const getResult = await prisma.ranking.findMany({
select: {
name: true,
todouhuken: {
select: {
name: true,
}
},
rank: true,
}
});
res.status(200).json({ result: getResult });
return;
case 'POST':
// POSTならランキング名と都道府県IDに基づいて更新
const { name, rank, todouhukenId } = req.body;
if (!name || !rank || !todouhukenId) {
res.status(400).json({ error: 'Bad Request' });
return;
}
const postResult = await prisma.ranking.upsert({
where: {
name_todouhukenId: {name, todouhukenId}
},
update: {name, rank},
create: {name, rank, todouhuken: {
connect: {id: todouhukenId}
}},
});
res.status(200).json({ result: postResult });
return;
case 'DELETE':
// DELETEならランキング全削除
const deleteResult = await prisma.ranking.deleteMany();
res.status(200).json({ result: deleteResult });
return;
default:
res.status(405).end();
return;
}
}
簡単なindexページを書いて...
import type { NextPage } from "next";
import { $fetch } from "ohmyfetch";
import useSWR from "swr";
// 配列要素内のランダムな要素を取得する関数
const getRandom = <T extends Object>(array: T[]): T => {
return array[Math.floor(Math.random() * array.length)];
};
const Page: NextPage = () => {
const {data, error, mutate} = useSWR("/api/ranking", $fetch);
const randomPost = async () => {
await $fetch("/api/ranking", {
method: "POST",
body: {
name: getRandom(["月毎ランキング", "週間ランキング", "日間ランキング"]),
rank: getRandom([1, 2, 3, 4, 5, 6, 7, 8]),
todouhukenId: getRandom([40, 41, 42, 43, 44, 45, 46, 47]),
},
});
mutate();
};
const deleteAll = async () => {
await $fetch("/api/ranking", {
method: "DELETE",
});
mutate();
}
return (
<div className="text-gray-800 container p-4 grid grid-cols-1 gap-4">
<h2 className="text-3xl">
Next.js + SQLite + Litestream + Cloud Run
</h2>
<pre className={`${error ? "text-red-800" : null} bg-gray-100 p-4 rounded`}>
{JSON.stringify(error ?? data, null, 2)}
</pre>
<button onClick={randomPost} className="bg-blue-500 hover:bg-blue-700 text-white font-bold py-2 px-4 rounded">
random post
</button>
<button onClick={deleteAll} className="bg-red-500 hover:bg-red-700 text-white font-bold py-2 px-4 rounded">
delete all
</button>
</div>
);
};
export default Page;
ここまで来たら、一旦ローカルでSQLiteをマイグレーションして動かしてみる。
yarn prisma migrate dev --name init
yarn dev
localhost:3000 で、期待通り Next.js + SQLite を動かせたら、次の手順に進むよ。
手順2 : Cloud Storage に Litestream がレプリケートするためのバケットを用意する
Google Cloud Storage
に好きな名前のバケットを用意してね。
今回は仮に litestream_with_next_on_cloud_run_sample220915
という名前の
バケットを作成したものとして、話を進めるよ。
手順3 : Next.js + Litestream on Cloud Run 用に Dockerfile を作る
ポイントは次の通り。
- コンテナ起動時に、レプリケーション先の
Cloud Storage
から SQLite を復元する - メインプロセスが Litestream によるレプリケーション、サブプロセスが Next.js となるよう実行コマンドを記述する
- litestream.yml にDBのパスとレプリカ先のパスを書く
- litestream.yml に
Cloud Storage
のパスを書くときは、gs://
ではなくgcs://
で書く (gsでは解釈してくれない)
さっそく記述していくよ。
FROM node:16-alpine AS builder
RUN apk add --no-cache libc6-compat
RUN apk add sqlite-dev
ADD https://github.com/benbjohnson/litestream/releases/download/v0.3.8/litestream-v0.3.8-linux-amd64-static.tar.gz /tmp/litestream.tar.gz
RUN tar -C /usr/local/bin -xzf /tmp/litestream.tar.gz
WORKDIR /app
ENV NEXT_TELEMETRY_DISABLED 1
COPY . .
RUN yarn install --frozen-lockfile
RUN yarn build
FROM node:16-alpine as runner
ENV NODE_ENV production
ENV NEXT_TELEMETRY_DISABLED 1
COPY --from=builder /app/next.config.js next.config.js
COPY --from=builder /app/.next .next
COPY --from=builder /app/public public
COPY --from=builder /app/node_modules node_modules
COPY --from=builder /app/package.json package.json
COPY --from=builder /app/yarn.lock yarn.lock
COPY --from=builder /app/data.db data.db
COPY --from=builder /app/litestream.yml /etc/litestream.yml
COPY --from=builder /usr/local/bin/litestream /usr/local/bin/litestream
COPY --from=builder /app/run.sh /run.sh
CMD ["sh", "run.sh"]
#!/bin/sh
set -e
# コンテナ起動時に持っているSQLiteのデータベースファイルは、
# 後続処理でリストアに成功したら削除したいので、リネームしておく
if [ -f ./data.db ]; then
mv ./data.db ./data.db.bk
fi
# Cloud Storage からリストア
litestream restore -if-replica-exists -config /etc/litestream.yml ./data.db
if [ -f ./data.db ]; then
# リストアに成功したら、リネームしていたファイルを削除
echo "---- Restored from Cloud Storage ----"
rm ./data.db.bk
else
# 初回起動時にはレプリカが未作成であり、リストアに失敗するので、
# その場合には、冒頭でリネームしたdbファイルを元の名前に戻す
echo "---- Failed to restore from Cloud Storage ----"
mv ./data.db.bk ./data.db
fi
# メインプロセスに、litestreamによるレプリケーション、
# サブプロセスに Next.js アプリケーションを走らせる
exec litestream replicate -exec "yarn start" -config /etc/litestream.yml
dbs:
- path: ./data.db
replicas:
- url: gcs://litestream_with_next_on_cloud_run_sample220915/replicas
こんな感じだね。
ちなみに最後の litestream.yml
で dbs.replicas.url
として定義したURLは、
gcs://バケット名/レプリカを配置するパス
という形式で書いているよ。
手順4 : Cloud Run にデプロイする
Google Cloud Run
には GitHub リポジトリと連携して自動デプロイできる機能があるから、それを使うよ。
ざっくり説明すると、こんな手順でできるよ。
- ブラウザからGCPのマネジメントコンソールを開く
- Cloud Run のページから
サービスの作成
をクリック -
ソース リポジトリから新しいリビジョンを継続的にデプロイする
にチェックを入れて、その下に表示されるCLOUD BUILD の設定
をクリックする - リポジトリを選んで
Build Type
にDockerfile
を選んでパスを指定する -
保存
をクリック - あとは、インスタンスの最小数を
0
、インスタンスの最大数を1
にして、未認証の呼び出しを許可
にチェックを入れたら画面左下の作成
をクリック - しばらく待ってデプロイに成功したら、完了!
動作確認
作成された Cloud Run
のサービスにはそれぞれ、https://xxxxxx-xxxxxx-an.a.run.app
のような形式のエンドポイントが用意されるから、デプロイに完了したらアクセスして動かしてみるよ。
上記エンドポイントのURLは、Cloud Run の サービスの詳細
ページの上らへんに書いてあるから、そこから確認してね。
無事に Next.js アプリケーションが表示されて、一通りのDB操作を試してレコードをいくつか追加してあげたら、しばらくアクセスしないで放っておくよ。
リクエストがある程度の時間来なければ、Cloud Run で起動したコンテナが停止するはずだから、それまで待つ。
10分ほど待って、そろそろコンテナ停止したかな...? と思ったら、再びアクセスしてみて。
その際に、さっきの操作で追加したレコードが残っていたら、無事に Litestream による SQLite のレプリケーションとリストアは成功だね。
ちなみに、レプリケーション先として選んだ Cloud Storage
のバケット内には、次のキャプチャのようにレプリカが作成されてるはずだからね。
これで、低予算でもSQLを捨てずに開発できるよ。
まとめ
-
Litestream
を使えばAmazon S3
やGoogle Cloud Storage
などのストレージサービスに、SQLiteを簡単にレプリケーションできるよ - CPUが割り当てられている時だけ課金される
Google Cloud Run
と組み合わせれば、低予算SQLありDBを用意できるよ - 記事で解説した Next.js + SQLite + Litestream + CloudRun 構成のサンプルコードは下記にまとめてあるから、必要があれば見てね
余談
今回解説した構成そのままでは、スケールアップに難があるよ。
だからアクセス者数が増えて、予算にも余裕が出てきたら他のDBに乗り換えるのが妥当だと思う。
でもSQLiteも優れたDBだから、工夫すればもっとその可能性を引き出すことができるかもしれないよ。
腕に自信のある人はやってみてね。
おまけ
SQLiteについて詳しく知りたい人には、下記のドキュメントがとっても詳しく紹介してくれていておすすめだよ。