Kamal 2でNext.js + DatabaseをVPSにデプロイする

2024/10/10に公開

Kamalシリーズ

Kamalについては他にも記事を書いていますので、ご覧ください

はじめに

先日、Kamal 2 を使い、インフラに詳しくない人でもNext.jsを296円のVPSにデプロイできるよう、説明してみるという記事を公開しました。今回はそれをパワーアップして、Next.js + Database (SQLite3)を安価なVPSにデプロイしましたので紹介します。

こんな人におすすめ

  1. Next.jsでリレーショナルデータベース(Postgres, MySQL, SQLite等)を含めたフルスタック開発を勉強したい – バックエンドはSQLを知っておくのは大事!ってXでみんな言っていた!
  2. フリーで使えるリレーショナルデータベースを探している。クラウド型データベースの無料プランという選択肢もあるが、制限が厳しかったり、そもそも無料枠がなくなったりするのが不安
  3. 個人開発でリレーショナルデータベースを使った複数のプロダクトを安価に公開したい
  4. 商用サイトを運用しているが、Vercel, AWSなどのクラウド費用が高いので、代替案として、信頼性が高くかつ安価なデプロイ先・デプロイ方法を検討したい

なお本記事は、これからデータベースの勉強をしたり、これからフルスタック開発を目指す人を主な対象としていますので、なるべく細かく解説していきたいと思います。

今回やること

  1. Kamal 2 を使い、インフラに詳しくない人でもNext.jsを296円のVPSにデプロイできるよう、説明してみるをベースにする
  2. Next.jsにPrismaをインストールして、SQLiteと繋げる
  3. Kamal 2用のDockerfileとデプロイスクリプトを修正して、SQLiteのストレージが永続化するようにする

完成したコードはGitHubで公開しています

なおKamal 2のドキュメントとしては、公式サイトに加えて、Kamal Handbookが大変参考になります。有料ですが、データベースをAWS S3にバックアップする方法も紹介されていますので、お勧めです。

参考までに、SQLiteはiPhone, Androidのスマートフォンを含め、テレビや車載AVシステムでも使用されている、世界で最も広く使われているデータベースと言われています。またWebサーバの世界でも、ここ数年で急速に注目されるようになっています。今回はシンプルさに着目して入門用のDBとして紹介しますが、これだけではなく、今後はPostgresなどのDBをKamal 2でデプロイする記事も書いていきたいと思います。

インフラ構成

  1. 前回の記事と同様、Kamal 2を使って、VPS上のDockerコンテナの中でNext.jsのアプリを実行します
  2. SQLite3の特徴は、データベースを単一のファイルとして保存することです。この保存場所には注意する必要があります。Next.jsアプリを実行するコンテナは、コード変更をデプロイするたびに丸ごと入れ替わります。したがってデータベースをこのコンテナに保存してしまうと、デプロイのたびに内容が消えてしまいます。これではデータベースとして都合があるいので、デプロイしても消えない、別の場所に保管する必要があります
  3. 上記の問題に対処するため、Dockerのvolume機能を使って、SQLite3のデータベースを保管します

.gitignoreファイルの作成

本来Gitで管理したくないファイルを誤ってコミットしてしまわないために、先に.gitignoreに次のものを追加してください。出来上がったファイルはGitHubで確認できます。

.gitignore
.env
/storage/*
!/storage/.keep

Prismaのインストール

前回のコードから出発します。これはCreate Next Appで作成されたアプリをDocker化してhealthcheckエンドポイントを作成したものです。これにまずデータベースを管理するためのORM – Prismaをインストールします。

基本的には公式ガイドのQuickstartに沿った内容になりますが、用途や説明のしやすさを考慮して何箇所か変更しています。

Prisma CLIのインストール

プロジェクトのフォルダから、下記のコマンドを実行してください。これによってPrismaがインストールされます

npm install prisma --save-dev

SQLiteのセットアップ

プロジェクトのフォルダから、下記のコマンドを実行してください。これによってPrismaの設定ファイルである/prisma/schema.prismaファイルが作成されます。SQLite用に設定済みのものになります。

npx prisma init --datasource-provider sqlite

.envファイルの編集

上記のステップでは2つのファイルが作成されたはずです

  1. .env
  2. schema.prisma

.envには下記の記述があります。これはデータベースがどこにあるかを表しています。PostgresやMySQLの場合はネットワークにあるデータベースサーバのアドレスだったりしますが、SQLiteの場合はもっとシンプルで、単にデータベースファイルの場所を意味しています。初期設定では"file:./dev.db"と書かれていますので/prisma/dev.rbにデータベースファイルが作られるわけです。

DATABASE_URL="file:./dev.db"

Docker化する時、この場所は都合が悪いです(Next.jsアプリのコンテナと別管理しにくく、永続化がしにくい)。そこで下記のように書き換えます。これによってデータベースは/prisma/dev.rbではなく/storage/dev.dbに保存されるようになります。

DATABASE_URL="file:../storage/dev.db"

なお.env.gitignoreに登録していますので、Gitにコミットされません。GitHubに私が公開しているコードにもありませんのでご注意ください。

データモデルを作成

prisma/schema.prismaファイルに下記の内容を追加してください。ここでは解説しませんが、データベースにどのようなデータを保存するかを記述したものです。

prisma/schema.prisma
model User {
  id    Int     @id @default(autoincrement())
  email String  @unique
  name  String?
  posts Post[]
}

model Post {
  id        Int     @id @default(autoincrement())
  title     String
  content   String?
  published Boolean @default(false)
  author    User    @relation(fields: [authorId], references: [id])
  authorId  Int
}

ローカルマシンに開発用のデータベースを作成

プロジェクトのフォルダから、下記のコマンドを実行してください。これによってデータベース作成用のSQLコマンド(migration.sql)が作成され、かつそれに沿って、ローカルマシンに開発用のデータベースが作成されます(マイグレーションの実行)。

npx prisma migrate dev --name init

この時、いくつかのファイルが/prisma/migrationsに作成され、場合によってはpackage.json, package-lock.jsonも更新されると思います。

その他に/storage/dev.dbファイルが作成されるはずです。これがSQLiteのデータベースファイルになります。データベース内のデータはGit管理したくないので、先に用意した.gitignoreファイルによって、これがGitにコミットされないようになっているはずです。

/storage/フォルダがGitに登録されるようにする

/storage/フォルダがGitに登録されるようにします。/storage/フォルダの中のデータベースファイル(dev.rb)はGitに登録したくないのですが、空のフォルダだけはGitに登録します。

Gitは一般に空フォルダを登録しませんので、/storage/.keepという空のファイルを作ってください。こうすることで/storage/フォルダがGitに登録されるようになります。

動作確認

動作確認のために/script.mjsというファイルを作成し、下記の内容を書き込んでください。(公式ドキュメントはTypeScriptで記述されていますが、ここでは少し変更して、簡単のためにJavaScriptで記述します)

/script.mjs
import { PrismaClient } from '@prisma/client'

const prisma = new PrismaClient()

async function main() {
  const user = await prisma.user.create({
    data: {
      name: 'Alice',
      email: 'alice@prisma.io',
    },
  })
  console.log(user)
}

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

上記ファイルを保存後、ターミナルから下記のコマンドを実行すると{ id: 1, email: 'alice@prisma.io', name: 'Alice' }の結果が返ってくるはずです。

npx node script.mjs

なお、2回以上実行するとUnique constraint failed on the fields: (email)というエラーが出るかと思います。これは同じemailアドレスを2つ以上作れないというエラーですので、その場合は下記のようにしてください。

  1. /storage/dev.dbのファイルを削除します
  2. ターミナルでnpx prisma migrate dev --name initを実行します
  3. 再度npx node script.mjsを実行するとうまくいくはずです

以上がうまくいけば、開発環境でのPrismaのセットアップは終了です!

Next.jsアプリがDBを使うようにする

上記でPrismaのセットアップは完了したのですが、肝心のNext.jsアプリにはデータベースにアクセスするコードがありません。そこを用意します。

なおここではPrismaの使い方の解説はしませんので、そのままコードをコピーペーストしていただく感じになります。

データベースにデータを入れる

コマンドラインからデータを入れるための準備をします。/prisma/seed.mjsのファイルを作成し、下記の内容を入れてください。

/prisma/seed.mjs
import { PrismaClient } from '@prisma/client'
const prisma = new PrismaClient()
async function main() {
  const alice = await prisma.user.upsert({
    where: { email: 'alice@prisma.io' },
    update: {},
    create: {
      email: 'alice@prisma.io',
      name: 'Alice',
      posts: {
        create: {
          title: 'Check out Prisma with Next.js',
          content: 'https://www.prisma.io/nextjs',
          published: true,
        },
      },
    },
  })
  const bob = await prisma.user.upsert({
    where: { email: 'bob@prisma.io' },
    update: {},
    create: {
      email: 'bob@prisma.io',
      name: 'Bob',
      posts: {
        create: [
          {
            title: 'Follow Prisma on Twitter',
            content: 'https://twitter.com/prisma',
            published: true,
          },
          {
            title: 'Follow Nexus on Twitter',
            content: 'https://twitter.com/nexusgql',
            published: true,
          },
        ],
      },
    },
  })
  console.log({ alice, bob })
}
main()
  .then(async () => {
    await prisma.$disconnect()
  })
  .catch(async (e) => {
    console.error(e)
    await prisma.$disconnect()
    process.exit(1)
  })

また/package.jsonファイルに下記の内容を追加してください。出来上がった例はGitHubにあります

/package.json
  "prisma": {
    "seed": "node prisma/seed.mjs"
  },

最後にコマンドラインから下記のコマンドを実行します。"The seed command has been executed"と表示されれば成功です!

npx prisma db seed

データをウェブページに表示する

下記のファイルを作成してください。Next.jsがPrismaを介してSQLiteデータベースからUserを取得して、結果をtableタグで表示するものになります。

/app/users/page.jsのファイルを作成し、下記の内容をコピーペーストしてください。(ここでも簡単のためにTypeScriptではなくJavaScriptを使います)

/app/users/page.js
import { PrismaClient } from '@prisma/client'
const prisma = new PrismaClient()

export const dynamic = 'force-dynamic'
export const revalidate = 0

export default async function UsersIndexPage() {
  const users = await prisma.user.findMany()

  return (
    <>
    <h1>Users Index</h1>
    <table>
      <thead>
      <tr>
        <th>Name</th>
        <th>Email</th>
      </tr>
      </thead>
      <tbody>
      {users.map((user, i) => (
        <tr key={i}>
          <td>{user.name}</td>
          <td>{user.email}</td>
        </tr>
      ))}
      </tbody>
    </table>
    </>
  )
}

次にブラウザでhttp://localhost:3000/usersにアクセスしてください(Next.jsの開発用サーバを起動していない場合は、先にnpm run devをお忘れなく)。下記の画面が表示されて、データベースの内容が反映されていれば出来上がりです!

注意点

上のコードの下記の部分は実はとても大切です。これを設定しないと、Docker Imageをビルドできない可能性がありますので注意してください。原因については後ほど解説します

export const dynamic = 'force-dynamic'

Kamal 2でデプロイする準備

先の記事でも解説した通り、Kamalを使ったデプロイ手順は以下のステップがあります。

  1. Docker Imageをbuildする
  2. ImageをDocker Hubにアップロードする
  3. Kamalは各サーバにDeployの指示を出す
  4. 各サーバがDocker ImageをDockerHubにリクエストする
  5. 各サーバがDocker ImageをDockerHubからダウンロードする
  6. 各サーバがDocker Imageを実行する
  7. healthcheckが順調なら公開する

今回はSQLiteデータベースおよびPrismaの導入に伴って、1.のDocker Imageのbuild、および6.のDocker Imageを実行する、の箇所に変更を加えます。それぞれDockerfileファイルの変更、およびconfig/deploy.ymlファイルの変更になります。それ以外の箇所は変更がありません。

Dockerfileの変更

Dockerfileは下記のように変更してください。出来上がったDockerfileはGitHubに公開しています

  1. Next.jsからデータベースにアクセスできるようにする(Prisma Clientファイルを作成する)コマンドがRUN npx prisma generateです[1]
  2. Prismaの設定等が保存されている/prismaフォルダを、Docker ImageにコピーするコマンドがCOPY --from=builder /app/prisma ./prismaです
  3. SQLiteのデータベースファイルを保管する/storage/フォルダを、Docker ImageにコピーするコマンドがCOPY --from=builder /app/storage ./storageです
  4. データベースを準備するにはマイグレーション[2]が必要です。そのためにCMDの行を書き換え、npx prisma migrate deploy(マイグレーション)を実行してからnode server.js(Next.jsサーバ起動)を実行するようにします

なお上記の他に、権限管理を簡略化しています(nodejsグループやnext.jsユーザを使わないように変更)。通常はここまで権限管理を厳密化する必要はなく、一方で設定を複雑化してしまうためです

Dockerfile
...

# Rebuild the source code only when needed
FROM base AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .

+ RUN npx prisma generate

# Next.js collects completely anonymous telemetry data about general usage.
# Learn more here: https://nextjs.org/telemetry
# Uncomment the following line in case you want to disable telemetry during the build.
# ENV NEXT_TELEMETRY_DISABLED=1

RUN \
  if [ -f yarn.lock ]; then yarn run build; \
  elif [ -f package-lock.json ]; then npm run build; \
  elif [ -f pnpm-lock.yaml ]; then corepack enable pnpm && pnpm run build; \
  else echo "Lockfile not found." && exit 1; \
  fi

# Production image, copy all the files and run next
FROM base AS runner
WORKDIR /app

ENV NODE_ENV=production
# Uncomment the following line in case you want to disable telemetry during runtime.
# ENV NEXT_TELEMETRY_DISABLED=1

- RUN addgroup --system --gid 1001 nodejs
- RUN adduser --system --uid 1001 nextjs

COPY --from=builder /app/public ./public
+ COPY --from=builder /app/prisma ./prisma
+ COPY --from=builder /app/storage ./storage

# Set the correct permission for prerender cache
RUN mkdir .next
- RUN chown nextjs:nodejs .next

# Automatically leverage output traces to reduce image size
# https://nextjs.org/docs/advanced-features/output-file-tracing
- COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
- COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
+ COPY --from=builder /app/.next/standalone ./
+ COPY --from=builder /app/.next/static ./.next/static

- USER nextjs

EXPOSE 3000

ENV PORT=3000

# server.js is created by next build from the standalone output
# https://nextjs.org/docs/pages/api-reference/next-config-js/output
ENV HOSTNAME="0.0.0.0"
- CMD ["node", "server.js"]
+ CMD ["sh", "-c", "npx prisma migrate deploy && node server.js"]

Kamal設定ファイルの変更 (config/deploy.yml)

config/deploy.ymlを下記のように変更します。完成例をGitHubに公開しています。ただし前の記事で解説している通り、個別のカスタマイズが必要ですので、ご注意ください。下記の内容については、個別のカスタマイズは必要ないので、そのまま入力してください。

  1. 環境変数DATABASE_URLをセットしています。この結果、SQLiteのデータベースファイルは/storage/production.dbに保存されるようになります[3]
  2. kamal_next_storageという名前のDocker volumeを作成し、/storage/の内容(つまりSQLiteのデータベース)をここに保管します。Docker volumeは新規デプロイをしても内容が更新されませんので、データベースの内容が永続化されます
config/deploy.yml
...
# Inject ENV variables into containers (secrets come from .kamal/secrets).
#
- # env:
- #   clear:
- #     DB_HOST: 192.168.0.2
+ env:
+   clear:
+     DATABASE_URL: "file:../storage/production.db"
#    DB_HOST: 192.168.0.2
#   secret:
#     - RAILS_MASTER_KEY

# Aliases are triggered with "bin/kamal <alias>". You can overwrite arguments on invocation:
# "bin/kamal logs -r job" will tail logs from the first server in the job section.
#
# aliases:
#   shell: app exec --interactive --reuse "bash"

# Use a different ssh user than root
#
# ssh:
#   user: app

# Use a persistent storage volume.
#
- # volumes:
- #   - "app_storage:/app/storage"
+ volumes:
+   - "kamal_next_storage:/app/storage"
...

デプロイ

コードをGitにコミットしたのち、コマンドラインからkamal deployを実行してください。やり方は前回の記事と同じです。もしデプロイが初めてで、セットアップがまだの場合はkamal setupを実行してください。

デプロイが成功したらブラウザで[ホスト名]/usersにアクセスしてください。ローカルのデータベースで試したときと異なり、Userが一人も表示されないはずです。これはデータベースにまだデータが入っていないためです。

リモートから本番データベースにデータを入れる操作は次のステップです。

リモートで/prisma/seed.mjsを実行し、本番データベースにデータを入れる: リモートでのコマンド実行

最初にローカル環境で試したときは、ローカルマシンのコマンドラインでnpx prisma db seedを実行しました。

今回は本番環境のコマンドラインにアクセスし、同じコマンドを実行します。

  1. kamal app exec -i --reuse shを実行して、本番環境のコマンドラインにアクセスします。このコマンドの詳細についてはkamal app exec --helpで確認できます
  2. 本番環境のコマンドラインからnpx prisma db seedを実行します

私が実行した時の出力は下記のようになりました。

最後にウェブページに反映されていることを確認します。ブラウザで[ホスト名]/usersにアクセスし、ローカルのデータベースで試したときと同じようになっていれば成功です!

これでフルスタック開発環境が出来上がりました!

なおコマンドラインのkamalにはたくさんのコマンドやオプションがあり、主なサーバ管理はここからできます。kamal rollbackで以前のバージョンに戻るのはもちろんのことkamal auditなどで、いつ、誰がデプロイをしたかなども全て確認できます。ぜひkamal --helpコマンドを実行して、ご確認ください。

永続化の確認

最後にデータベースが本当に永続化されることを確認します。

  1. kamal deployでもう一度デプロイを走らせてください。ログを見ると、新しいDocker Imageがデプロイされ、新しいDockerコンテナが作成されるのが確認できると思います
  2. ブラウザで[ホスト名]/usersにアクセスしてください。表示されるUserがそのままで、リセットされていないことをご確認ください。これが永続化です!

変更箇所の振り返り

上記で行った内容をまとめますと、このようになります。本記事では分量が多くなっていますが、実際のステップ数が多くありません。

  1. Prismaのセットアップ
  2. SQLiteのデータベースファイルの保管場所を/storageに設定
  3. Prisma固有のセットアップがビルド時に行われるようにDockerfileを変更
    1. Prisma固有のフォルダ/prismaのコピーおよびnpx prisma generateの実行
    2. SQLiteのデータベースファイルの保管場所となる/storageフォルダの作成
    3. データベース マイグレーションの実行 npx prisma migrate deploy
  4. Kamalの設定(config/deploy.yml)を2箇所変更
    1. SQLiteのデータベースファイルが永続化されるDocker volume ("kamal_next_storage")に保管されるように設定
    2. Prismaにデータベースファイルの保管先を伝える("DATABASE_URL")

注意: SSG問題 export const dynamic = 'force-dynamic'の必要性

データを表示するウェブページを作成する際、export const dynamic = 'force-dynamic'に注意するように伝えました。これについて解説します。

export const dynamic = 'force-dynamic'とSSGとビルドの失敗

Next.jsでは、データベースやAPIなどの情報をあらかじめ取得しておいて、静的なページを先に作る機能があります。Pages RouterではこれをSSG (Static Site Generation)と呼び、getStaticProps()を使って実現します。ユーザがページにアクセスしたときではなく、Next.jsのビルドをするときに静的ページを作っておくので、サーバへの負荷が少なく、レスポンスが高速だというメリットがあります。

App Routerのデフォルトでは、通常SSGモードを使いますDynamic functionsが使われていない限り、ビルド時に情報を取得して、静的なページを作成します。

しかしデータベースを使うフルスタックのNext.jsアプリケーションでは、多くの場合、これは都合が悪いです。

  1. データベースを使った多くの場合、そのままだとSSGモードになります。つまりデータベースの内容が変更されても、ビルド時に作成された静的ページが表示されてしまい、変更内容がウェブページに反映されません
  2. 本番に公開する環境とビルド時環境は異なります。そして、データベースは本番環境からはアクセスできますが、通常はビルド環境からアクセスできません。つまりSSGモードでデータベースにアクセスしようとしても失敗します。その結果、ビルドが途中で止まります

これを避けるために、以下の対策が必要です。

  1. Pages Routerの場合は、getStaticProps()の中でデータベースにアクセスしない
  2. App Routerの場合、データベースにアクセスするページは、page.tsxもしくはpage.jsxファイルにexport const dynamic = 'force-dynamic'を記載する

export const dynamic = 'force-dynamic'が記載されていれば、Next.jsは「このページはSSGではない」と判断し、リクエストの都度にページを作成します)。ビルド時に静的ページを作ろうとせず、データベースへのアクセスも行いません。

またNext.js v15では改善されていますが、v14までのApp Routerのデフォルトキャッシュ戦略はわかりにくいです。併せてexport const revalidate = 0も記載しておいた方が無難です。

Next.jsも公認?

2024年10月9日、Rails World 2024でKamal 2が発表されてから2週間後、Vercelの社員でNext.jsの使い方を教えたり、教育用コンテンツを作成しているLee Robinson氏が下記のツイートをしました。Docker Composeをベースにし、Next.js + Postgres + NGINXを$4のVPSにデプロイする内容です。「えっ〜〜、Next.jsのホスティングで儲けているVercelがこんなことを言って良いの?」っていう驚きはありますが、これはKamalへの注目度をよく表していると思います

翌日、Lee Robinson氏はKamal 2についてもポストしました。

脚注
  1. npx prisma migrate dev --name initnpx prisma generateを実行するとPrisma Clientが作成されます。具体的にはnode_modules/.prisma/clientフォルダの中に、アプリ固有のファイルが作成されます。一般にnode_modulesフォルダに含まれるパッケージは書き換えられず、カスタマイズされません。Prismaはこの点が少し変わっています。このためpackage-lock.jsonに含まれているパッケージをインストールするだけでは不十分です。npx prisma generateをその後に実行する必要があります ↩︎

  2. マイグレーションとは、データベースのテーブルやフィールドを更新することを指します。コードを書いているとテーブルやフィールドを追加することがありますが、コードだけでなく、データベース側でもこれを追加する作業がマイグレーションです ↩︎

  3. SQLiteの場合はDATABASE_URLに機密情報が含まれないために、平文でGitに保管します。参考までにPostgresやMySQL等の場合はDATABASE_URLに機密情報を含めることが多いので、その場合はGitに保管しないように工夫します ↩︎

Discussion