😸
SQLite + Litestream で作る低コストなNext.jsアプリのCloudRunデプロイ
はじめに
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 /app/next.config.* ./
COPY /app/.next .next
COPY /app/public public
COPY /app/node_modules node_modules
COPY /app/package.json package.json
COPY /app/package-lock.json package-lock.json
COPY /app/data.db data.db
COPY /app/litestream.yml /etc/litestream.yml
COPY /usr/local/bin/litestream /usr/local/bin/litestream
COPY /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 にバケットを作成した状態で以下の手順を実施します。
- コンテナをデプロイ
- サービス
- リポジトリから継続的にデプロイする
- CLOUD BUILD の設定で、GitHubリポジトリを選択
- ビルドタイプは Dockerfile. ソースの場所
/Dockerfile
- 認証 - 未認証の呼び出しを許可
- 課金 - リクエストベース
- インスタンスの最小数:0, インスタンスの最大数: 1
- 作成
結果
デプロイ後、以下のように、データベースに保存したコメントが表示されます。
Discussion