😸

SQLite + Litestream で作る低コストなNext.jsアプリのCloudRunデプロイ

2025/01/01に公開

はじめに

Litestream は、SQLite データベースファイルを Amazon S3 や Google Cloud Storage などのオブジェクトストレージにリアルタイムでレプリケートできるツールです。

Next.js の API 経由でデータベースを使いたいのですが、RDS などのデータベースを使うとコストが高く付くので、より安価に利用するために litestream を選びました。

プロジェクトを作成

npx create-next-app@latest

package.json の修正

以下のように package.json を修正します。

package.json
{
  "name": "sample-project",
  "version": "0.1.0",
  "private": true,
  "scripts": {
    "dev": "DATABASE_URL=file:./data.db next dev",
    "build": "next build",
    "start": "DATABASE_URL=file:./data.db next start -p $PORT",
    "lint": "next lint",
    "prisma": "DATABASE_URL=file:./data.db prisma",
    "clean": "rimraf .next/cache/fetch-cache",
    "clean-dev": "run-s clean dev",
    "clean-build": "run-s clean build",
    "clean-start": "run-s clean build start"
  },
  "dependencies": {
    "react": "^19.0.0",
    "react-dom": "^19.0.0",
    "next": "15.1.3",
    "@prisma/client": "^6.1.0"
  },
  "devDependencies": {
    "typescript": "^5",
    "@types/node": "^20",
    "@types/react": "^19",
    "@types/react-dom": "^19",
    "postcss": "^8",
    "tailwindcss": "^3.4.1",
    "eslint": "^9",
    "eslint-config-next": "15.1.3",
    "@eslint/eslintrc": "^3",
    "prettier": "^3.4.2",
    "prisma": "^6.1.0",
    "rimraf": "^6.0.1",
    "tsx": "^4.19.2"
  },
  "prisma": {
    "seed": "tsx prisma/seed/index.ts"
  }
}

必要なライブラリをインストールします。

npm i

next.config.ts を修正

next.config.ts
import type { NextConfig } from "next";

const nextConfig: NextConfig = {
  /* config options here */
  server: {
    port: 8080,
  },
};

export default nextConfig;

litestream.yml を作成

プロジェクトのルートディレクトリに litestream.yml を作ります。

touch litestream.yml
litestream.yml
dbs:
  - path: ./data.db
    replicas:
      - url: gcs://cloud-storage-bucket-name/replicas

cloud-storage-bucket-name の部分は、CloudStorage に作成したバケット名です。

Dockerfile を作成

touch Dockerfile
Dockerfile
# Builder stage
FROM node:22-alpine AS builder

RUN echo "=== Starting build process ==="

RUN apk add --no-cache libc6-compat sqlite-dev && echo "=== Base packages installed ==="

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 && echo "=== Litestream installed ===" && ls -la /usr/local/bin/litestream

WORKDIR /app

ENV NEXT_TELEMETRY_DISABLED=1
ENV DATABASE_URL=file:/app/data.db

COPY . .
RUN echo "=== Files copied to /app ===" && ls -la /app && echo "=== Content of run.sh ===" && cat run.sh

RUN npm install --frozen-lockfile && echo "=== NPM dependencies installed ==="

RUN echo "=== Starting Prisma operations ===" && npx prisma generate && echo "=== Prisma client generated ===" && npx prisma migrate dev --name init && echo "=== Database migrations completed ===" && npx prisma db seed && echo "=== Database seeding completed ==="

RUN echo "=== Checking database file ===" && ls -al /app/data.db && ls -al /app/data.db

RUN npm run build && echo "=== Application built successfully ==="

# Runner stage
FROM node:22-alpine AS runner

RUN echo "=== Starting production image setup ==="

ENV NODE_ENV=production
ENV NEXT_TELEMETRY_DISABLED=1
ENV PORT=8080
ENV DATABASE_URL=file:/app/data.db

WORKDIR /app

COPY --from=builder /app/next.config.* ./
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/package-lock.json package-lock.json
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

RUN echo "=== Checking copied files ===" && ls -la /app && echo "=== Checking run.sh ===" && ls -la /run.sh && echo "=== Content of run.sh ===" && cat /run.sh

RUN chmod +x /run.sh && echo "=== Permissions set for run.sh ===" && ls -la /run.sh

EXPOSE 8080

CMD ["sh", "-x", "/run.sh"]

run.sh を作成

touch run.sh
run.sh
#!/bin/sh
set -e

# ログ: スクリプトの開始
echo "Starting run.sh script..."

# 既存のデータベースファイルをバックアップ
if [ -f ./data.db ]; then
  echo "Backing up existing data.db..."
  mv ./data.db ./data.db.bk
fi

# Cloud Storage からデータベースを復元
echo "Restoring data.db from Cloud Storage..."
litestream restore -if-replica-exists -config /etc/litestream.yml ./data.db

# 復元が成功したか確認
if [ -f ./data.db ]; then
  echo "---- Restored from Cloud Storage ----"
  # バックアップファイルを削除
  if [ -f ./data.db.bk ]; then
    echo "Removing backup file..."
    rm ./data.db.bk
  fi
else
  echo "---- Failed to restore from Cloud Storage ----"
  # 復元に失敗した場合、バックアップファイルを元に戻す
  if [ -f ./data.db.bk ]; then
    echo "Restoring from backup file..."
    mv ./data.db.bk ./data.db
  else
    echo "No backup file found. Starting with a fresh database."
  fi
fi

# Litestream でレプリケーションを開始し、Next.js を起動
echo "Starting Litestream replication and Next.js application on port $PORT..."

# Next.js アプリケーションを起動(環境変数PORTを使用)
PORT="${PORT:-8080}"
exec litestream replicate -exec "npx next start -p $PORT" -config /etc/litestream.yml

.gitignore に data.db を追記

echo data.db >> .gitignore

prisma の準備

mkdir -p prisma/{migrations,seed}
touch prisma/schema.prisma

schema.prisma:

schema.prisma
generator client {
  provider = "prisma-client-js"
}

datasource db {
  provider = "sqlite"
  url      = env("DATABASE_URL")
}

model Comment {
  id        String   @id @default(cuid())
  createdAt DateTime @default(now())
  content   String
  author    String?
  slug      String
}

fixtureファイルを用意します。

prisma/seed/comment/fixture.json

fixture.json
[
  {
    "id": "1",
    "createdAt": "2025-01-01T00:00:00Z",
    "content": "This is a test comment",
    "author": "John Doe",
    "slug": "test-blog-1"
  },
  {
    "id": "2",
    "createdAt": "2025-01-02T00:00:00Z",
    "content": "Another test comment",
    "author": "Jane Doe",
    "slug": "test-blog-2"
  }
]

prisma/seed/comment/index.ts

index.ts
import fixture from './fixture.json';
import type { PrismaPromise, Comment } from '@prisma/client';
import { prisma } from '@/lib/prisma';

export const comment = async (): Promise<PrismaPromise<Comment>[]> => {
  await prisma.comment.deleteMany()
  const res: PrismaPromise<Comment>[] = [];
  fixture.forEach((data) => {
    const row = prisma.comment.create({ data });
    res.push(row);
  });
  return res;
};

prisma/seed/index.ts

import { PrismaClient } from '@prisma/client';
import { comment } from './comment/index';


export const prisma = new PrismaClient();

const main = async () => {
  console.log(`Start seeding ...`);
  const commentData = await comment();
  await prisma.$transaction([...commentData]);
  console.log(`Seeding finished.`);
};

main()
  .catch((e) => {
    console.error(e);
    process.exit(1);
  })
  .finally(async () => {
    await prisma.$disconnect();
  });

src/lib/prisma/index.ts

import { PrismaClient, Prisma } from "@prisma/client";

const prismaClientSingleton = () => {
  return new PrismaClient();
};

type PrismaClientSingleton = ReturnType<typeof prismaClientSingleton>;

const globalForPrisma = globalThis as unknown as {
  prisma: PrismaClientSingleton | undefined;
};

export const prisma = globalForPrisma.prisma ?? prismaClientSingleton();

if (process.env.NODE_ENV !== "production") globalForPrisma.prisma = prisma;

export { Prisma };

Next.js でデータベースからデータを取得して表示する

src/app/page.tsx を修正します。

import Image from "next/image";
import { prisma } from "@/lib/prisma";

export default async function Home() {
  // Prisma を使用して Comment モデルからデータを取得
  const comments = await prisma.comment.findMany({
    orderBy: { createdAt: "desc" },
  });

  return (
    <div className="grid grid-rows-[20px_1fr_20px] items-center justify-items-center min-h-screen p-8 pb-20 gap-16 sm:p-20 font-[family-name:var(--font-geist-sans)]">
      <main className="flex flex-col gap-8 row-start-2 items-center sm:items-start">
        <Image
          className="dark:invert"
          src="/next.svg"
          alt="Next.js logo"
          width={180}
          height={38}
          priority
        />
        <ol className="list-inside list-decimal text-sm text-center sm:text-left font-[family-name:var(--font-geist-mono)]">
          <li className="mb-2">
            Get started by editing{" "}
            <code className="bg-black/[.05] dark:bg-white/[.06] px-1 py-0.5 rounded font-semibold">
              src/app/page.tsx
            </code>
            .
          </li>
          <li>Save and see your changes instantly.</li>
        </ol>

        {/* コメントリストの表示 */}
        <div className="w-full max-w-2xl">
          <h2 className="text-xl font-bold mb-4">Comments</h2>
          {comments.length > 0 ? (
            <ul className="space-y-4">
              {comments.map((comment) => (
                <li
                  key={comment.id}
                  className="border p-4 rounded-lg shadow-md bg-gray-50 dark:bg-gray-800"
                >
                  <p className="text-sm text-gray-500">
                    {comment.author || "Anonymous"} -{" "}
                    {new Date(comment.createdAt).toLocaleDateString()}
                  </p>
                  <p className="text-md text-gray-700 dark:text-gray-300">
                    {comment.content}
                  </p>
                  <p className="text-xs text-gray-400">Slug: {comment.slug}</p>
                </li>
              ))}
            </ul>
          ) : (
            <p className="text-gray-500">No comments yet.</p>
          )}
        </div>

        <div className="flex gap-4 items-center flex-col sm:flex-row">
          <a
            className="rounded-full border border-solid border-transparent transition-colors flex items-center justify-center bg-foreground text-background gap-2 hover:bg-[#383838] dark:hover:bg-[#ccc] text-sm sm:text-base h-10 sm:h-12 px-4 sm:px-5"
            href="https://vercel.com/new?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
            target="_blank"
            rel="noopener noreferrer"
          >
            <Image
              className="dark:invert"
              src="/vercel.svg"
              alt="Vercel logomark"
              width={20}
              height={20}
            />
            Deploy now
          </a>
          <a
            className="rounded-full border border-solid border-black/[.08] dark:border-white/[.145] transition-colors flex items-center justify-center hover:bg-[#f2f2f2] dark:hover:bg-[#1a1a1a] hover:border-transparent text-sm sm:text-base h-10 sm:h-12 px-4 sm:px-5 sm:min-w-44"
            href="https://nextjs.org/docs?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
            target="_blank"
            rel="noopener noreferrer"
          >
            Read our docs
          </a>
        </div>
      </main>
      <footer className="row-start-3 flex gap-6 flex-wrap items-center justify-center">
        <a
          className="flex items-center gap-2 hover:underline hover:underline-offset-4"
          href="https://nextjs.org/learn?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
          target="_blank"
          rel="noopener noreferrer"
        >
          <Image
            aria-hidden
            src="/file.svg"
            alt="File icon"
            width={16}
            height={16}
          />
          Learn
        </a>
        <a
          className="flex items-center gap-2 hover:underline hover:underline-offset-4"
          href="https://vercel.com/templates?framework=next.js&utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
          target="_blank"
          rel="noopener noreferrer"
        >
          <Image
            aria-hidden
            src="/window.svg"
            alt="Window icon"
            width={16}
            height={16}
          />
          Examples
        </a>
        <a
          className="flex items-center gap-2 hover:underline hover:underline-offset-4"
          href="https://nextjs.org?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
          target="_blank"
          rel="noopener noreferrer"
        >
          <Image
            aria-hidden
            src="/globe.svg"
            alt="Globe icon"
            width={16}
            height={16}
          />
          Go to nextjs.org →
        </a>
      </footer>
    </div>
  );
}

Cloud Run

あらかじめ Cloud Storage にバケットを作成した状態で以下の手順を実施します。

  1. コンテナをデプロイ
  2. サービス
  3. リポジトリから継続的にデプロイする
  4. CLOUD BUILD の設定で、GitHubリポジトリを選択
  5. ビルドタイプは Dockerfile. ソースの場所 /Dockerfile
  6. 認証 - 未認証の呼び出しを許可
  7. 課金 - リクエストベース
  8. インスタンスの最小数:0, インスタンスの最大数: 1
  9. 作成

結果

デプロイ後、以下のように、データベースに保存したコメントが表示されます。

Discussion