74
57

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

SQLite + Litestream + CloudRun で「個人開発並みの予算でもSQLを捨てない」バックエンド構築(Next.jsを例にして)

Last updated at Posted at 2022-09-16

この記事を書くに至った経緯と目的

個人開発やまだ利益の出ていないアプリって、予算の制約から選べるDBが限られてしまうことがあるよね。

具体的には Amazon DynamoDBCloud 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の割り当てがない時)は課金されないようにできるから、この特性を活かして次のように構成するよ。

  1. Cloud RunLitestream をメインプロセス、SQLiteをDBとして利用する任意のバックエンドアプリケーション をサブプロセスとして動かすコンテナを放り込む。
  2. Litestream には、Google Cloud Storage にSQLiteをレプリケーションさせる。
  3. リクエストが来てコンテナが起動するときには、上記でレプリケーションされた 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ディレクトリをルートディレクトリとみなした相対パスで記述。

tailwind.config.js
/** @type {import('tailwindcss').Config} */
module.exports = {
  content: [
    "./pages/**/*.{js,ts,jsx,tsx}",
    "./components/**/*.{js,ts,jsx,tsx}",
  ],
  theme: {
    extend: {},
  },
  plugins: [],
}
styles/globals.css
@tailwind base;
@tailwind components;
@tailwind utilities;
package.json
+ "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"
  },
schema.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に従った型が生成されるので、これを使って残りの実装を進める。

初期データの投入処理(シード)を書いて...

prisma/seed.ts
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. と怒られないための対策コードを書いて...

lib/prisma.ts
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を書いて...

pages/api/ranking.ts
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ページを書いて...

pages/index.tsx
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 を動かせたら、次の手順に進むよ。

for_qiita.gif

手順2 : Cloud Storage に Litestream がレプリケートするためのバケットを用意する

Google Cloud Storage に好きな名前のバケットを用意してね。

今回は仮に litestream_with_next_on_cloud_run_sample220915 という名前の
バケットを作成したものとして、話を進めるよ。

スクリーンショット 2022-09-15 12.26.48.png

手順3 : Next.js + Litestream on Cloud Run 用に Dockerfile を作る

ポイントは次の通り。

  1. コンテナ起動時に、レプリケーション先の Cloud Storage から SQLite を復元する
  2. メインプロセスが Litestream によるレプリケーション、サブプロセスが Next.js となるよう実行コマンドを記述する
  3. litestream.yml にDBのパスとレプリカ先のパスを書く
  4. litestream.yml に Cloud Storage のパスを書くときは、 gs:// ではなく gcs:// で書く (gsでは解釈してくれない)

さっそく記述していくよ。

Dockerfile
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"]
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
litestream.yml
dbs:
  - path: ./data.db
    replicas:
      - url: gcs://litestream_with_next_on_cloud_run_sample220915/replicas

こんな感じだね。

ちなみに最後の litestream.ymldbs.replicas.url として定義したURLは、
gcs://バケット名/レプリカを配置するパス という形式で書いているよ。

手順4 : Cloud Run にデプロイする

Google Cloud Run には GitHub リポジトリと連携して自動デプロイできる機能があるから、それを使うよ。

スクリーンショット 2022-09-15 12.31.03.png

ざっくり説明すると、こんな手順でできるよ。

  1. ブラウザからGCPのマネジメントコンソールを開く
  2. Cloud Run のページから サービスの作成 をクリック
  3. ソース リポジトリから新しいリビジョンを継続的にデプロイする にチェックを入れて、その下に表示される CLOUD BUILD の設定 をクリックする
  4. リポジトリを選んで Build TypeDockerfile を選んでパスを指定する
  5. 保存 をクリック
  6. あとは、インスタンスの最小数を 0 、インスタンスの最大数を 1 にして、 未認証の呼び出しを許可 にチェックを入れたら画面左下の 作成 をクリック
  7. しばらく待ってデプロイに成功したら、完了!

スクリーンショット 2022-09-15 22.10.56.png

動作確認

作成された Cloud Run のサービスにはそれぞれ、https://xxxxxx-xxxxxx-an.a.run.app のような形式のエンドポイントが用意されるから、デプロイに完了したらアクセスして動かしてみるよ。

上記エンドポイントのURLは、Cloud Run の サービスの詳細 ページの上らへんに書いてあるから、そこから確認してね。

スクリーンショット 2022-09-15 22.12.32.png

無事に Next.js アプリケーションが表示されて、一通りのDB操作を試してレコードをいくつか追加してあげたら、しばらくアクセスしないで放っておくよ。

リクエストがある程度の時間来なければ、Cloud Run で起動したコンテナが停止するはずだから、それまで待つ。

10分ほど待って、そろそろコンテナ停止したかな...? と思ったら、再びアクセスしてみて。

その際に、さっきの操作で追加したレコードが残っていたら、無事に Litestream による SQLite のレプリケーションとリストアは成功だね。

ちなみに、レプリケーション先として選んだ Cloud Storage のバケット内には、次のキャプチャのようにレプリカが作成されてるはずだからね。

スクリーンショット 2022-09-15 21.06.47.png

これで、低予算でもSQLを捨てずに開発できるよ。

まとめ

  • Litestream を使えば Amazon S3Google Cloud Storage などのストレージサービスに、SQLiteを簡単にレプリケーションできるよ
  • CPUが割り当てられている時だけ課金される Google Cloud Run と組み合わせれば、低予算SQLありDBを用意できるよ
  • 記事で解説した Next.js + SQLite + Litestream + CloudRun 構成のサンプルコードは下記にまとめてあるから、必要があれば見てね

余談

今回解説した構成そのままでは、スケールアップに難があるよ。

だからアクセス者数が増えて、予算にも余裕が出てきたら他のDBに乗り換えるのが妥当だと思う。

でもSQLiteも優れたDBだから、工夫すればもっとその可能性を引き出すことができるかもしれないよ。

腕に自信のある人はやってみてね。

おまけ

SQLiteについて詳しく知りたい人には、下記のドキュメントがとっても詳しく紹介してくれていておすすめだよ。

74
57
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
74
57

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?