Next.js のプロジェクトを VPS 上の Docker にデプロイする【その1】

前回のシリーズの続きです。

前回は Next.js の開発環境を Docker 上に構築しました。

今回のシリーズでは、前回作ったプロジェクトを本番環境の Docker にデプロイすることを目標とします。

タスク整理

Next.js で作った自作サイトをVPSで公開する方法について考えてみた」のタスクの洗い出しより、さらに細かいタスクをの整理をしていきます。現在は「3.Next.js プロジェクトを Docker 上で動かせるようにする。」の部分まで終わっている状態です。

以下の図のように、GitHub を通してサイトの更新を行なっていくことを目標としています。

  1. GitHub にリポジトリを作成する
  2. 本番環境用の Docker イメージを作成する
  3. 本番環境でサイトを起動するために Docker Compose を利用する
  4. 本番環境に Docker 環境を用意する
  5. 本番環境の Docker で動かす

図の①->②の自動化は大変なので、次回のシリーズでやることにします。
今回のシリーズでは、本番環境でサイトが表示されること、手動で GitHub 上に push/pull を行なってサイトの更新ができることの2点を目指します。

GitHub にリポジトリを作成する

STEP.1
リポジトリの作成
まずは GitHub にリポジトリを作成します。

GitHubにログインして、右上の + より新規にリポジトリを作成します。

プライベートリポジトリを作成しました。

STEP.2
リポジトリの紐付け
GitHub のリポジトリと、前回作成した開発環境の Next.js プロジェクトを紐付けます。

# 作業ディレクトリに移動
$ cd ~/NextProjects/donuts-site

# Next.js のプロジェクト作成時に .git も作成されているので git init 等は必要ない
# リモートリポジトリ(GitHub)との紐付けを行う
$ git remote add origin git@github.com:ユーザ名/DonutsSite.git

これで GitHub のリポジトリに push できる状態になりました。

STEP.3
.gitignore の確認

Next.js ではプロジェクト作成時に自動で .gitignoreファイルも作成されます。
内容は以下の通りです。

.gitignore
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.

# dependencies
/node_modules
/.pnp
.pnp.js

# testing
/coverage

# next.js
/.next/
/out/

# production
/build

# misc
.DS_Store
*.pem

# debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
.pnpm-debug.log*

# local env files
.env*.local

# vercel
.vercel

# typescript
*.tsbuildinfo
next-env.d.ts

今回は使わないものもありますが、そのままにしておきます。(.vercel 等)
node_modules等はアップロードしてしまうととても膨大になってしまいますし、本番環境でも膨大なファイルを pull してきてしまうことになるので、.gitignoreにちゃんと書かれていることを確認しておきます。

STEP.4
GitHub にプッシュする
.gitignoreの確認ができたら、GitHub にプッシュします。

# GitHub にプッシュする
$ git push -u origin main

本番環境用の Docker イメージを作成する

Dockerfile に本番環境用のマルチステージビルドを利用した記述を行います。
Next.js 公式サンプルの Dockerfile を読み解いてみた時に、マルチステージビルドについても詳しく調査を行っています。

# 作業ディレクトリに移動
$ cd ~/NextProjects/donuts-site

# 本番環境用の Dockerfile を作成
$ touch prod.Dockerfile
prod.Dockerfile
##### deps ステージ #####
FROM node:16-alpine AS deps
RUN apk add --no-cache libc6-compat
WORKDIR /app

COPY package.json yarn.lock* package-lock.json* pnpm-lock.yaml* ./
RUN \
  if [ -f yarn.lock ]; then yarn install --frozen-lockfile; \
  elif [ -f package-lock.json ]; then npm ci; \
  elif [ -f pnpm-lock.yaml ]; then yarn global add pnpm && pnpm i; \
  else echo "Lockfile not found." && exit 1; \
  fi

##### builder ステージ #####
FROM node:16-alpine AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .

RUN yarn build

##### runner ステージ #####
FROM node:16-alpine AS runner
WORKDIR /app

ENV NODE_ENV=production

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

COPY --from=builder /app/public ./public
COPY --from=builder /app/package.json ./package.json

COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static

USER nextjs

EXPOSE 3000

ENV PORT 3000

CMD ["node", "server.js"]
Dockerfile の動作を確認

Dockerfile から正しくイメージが作成され、サイトが起動できることを確認します。

# 作業ディレクトリに移動
$ cd ~/NextProjects/donuts-site

# docker build -f Dockerfile のファイル名 -t 作成するするイメージ名 もとになるDockerfileの場所
$ docker build -f prod.Dockerfile -t donut-site-image .

# image が作成できたかの確認
$ docker image ls

REPOSITORY           TAG       IMAGE ID       CREATED          SIZE
donut-site-image     latest    7b4c93a5e0a8   11 seconds ago   916MB

# イメージからコンテナを作成して起動する
$ docker run --name donut-site donut-site-image

Listening on port 3000

http://localhost:3000 を見ても表示されませんでした。

Docker のキャッシュを削除して再度上記のコードブロックを実行しました。(参考: Dockerでビルドした際に作られるBuild Cacheを削除する

# Docker の キャッシュを削除する
$ docker builder prune

しかし表示されない…😢

サイトを表示できるようにする

上記メモにて Dockerfile よりイメージを作成してサイトを起動後にブラウザにアクセスしても、うまく表示ができませんでした。

前回の記事時点からnode_modulesフォルダを削除したことを思い出しました。実際にはnode_modulesdeps ステージにてインストールされるはず…。

なのでnode_modulesを復活させるのではなく、根本の原因を探すため、ライブラリ周りから調査してみます。

コンテナ内を探索する

# 作業ディレクトリに移動
$ cd ~/NextProjects/donuts-site

# docker build -f Dockerfile のファイル名 -t 作成するするイメージ名 もとになるDockerfileの場所
$ docker build -f prod.Dockerfile -t donut-site-image .

# image が作成できたかの確認
$ docker image ls

REPOSITORY           TAG       IMAGE ID       CREATED          SIZE
donut-site-image     latest    7b4c93a5e0a8   11 seconds ago   916MB


# イメージからコンテナを作成して起動する(バックグラウンド -d で起動する)
$ docker run --name donut-site -d donut-site-image

# コンテナの状態を確認
$ docker container ls

CONTAINER ID   IMAGE              COMMAND                  CREATED         STATUS         PORTS                                         NAMES
6cc446e57ada   donut-site-image   "docker-entrypoint.s…"   3 minutes ago   Up 2 minutes   3000/tcp                                      donut-site

コンテナの中に入り、実際のツリー構造をみてみます。

# コンテナの中にはいる
$ docker container exec -it donut-site sh

# app内を確認する
/app $ ls

node_modules  package.json  public        server.js

# node_modules の中身を確認
/app $ cd node_modules/
/app/node_modules $ ls

@next                    @swc                     next                     react                    react-dom                use-sync-external-store

# .next の中身を確認
/app $ cd .next
/app/.next $ ls

BUILD_ID                      package.json                  react-loadable-manifest.json  routes-manifest.json          static
build-manifest.json           prerender-manifest.json       required-server-files.json    server
-it オプション

-iオプションは、コンテナに操作端末(キーボード)を繋ぎます。
-tオプションは、特殊キーを使用可能にします。
この2つのオプションをまとめて-itとして指定することで、コンテナの中身をキーボードで操作することができます。
docker exec のドキュメント

sh を利用

docker container execを bash で実行しようとした場合に以下のエラーが出ました。

$ docker container exec -it donut-site bash

OCI runtime exec failed: exec failed: container_linux.go:380: starting container process caused: exec: "bash": executable file not found in $PATH: unknown

bash が利用できない場合は shを利用すると、コンテナ内の操作ができるようになります。

参照: docker exec 実行時に「OCI runtime exec failed: exec failed: container_linux.go:349: starting container process caused exec: bash: executable file not found in $PATH: unknown」が発生した場合の対応方法

前回の開発環境で Docker Compose を用いた場合はうまく起動しているので、そちらと見比べてみました。

# コンテナの情報
$ docker container ls

CONTAINER ID   IMAGE                     COMMAND                  CREATED         STATUS         PORTS                                         NAMES
b44921f166f7   donuts-site_donuts-site   "docker-entrypoint.s…"   6 minutes ago   Up 4 minutes   0.0.0.0:3000->3000/tcp                        donuts-site

# コンテナの中にはいる
$ docker container exec -it donuts-site sh

# app 内
/app # ls

next-env.d.ts   next.config.js  node_modules    package.json    pages           public          styles          tsconfig.json   yarn.lock

# node_modules 内
/app # cd node_modules/
/app/node_modules # ls

@babel                                 eslint-plugin-import                   is-glob                                prelude-ls
... たくさん
公式サンプルを動かしてみる

一向に解決できないので一度公式サンプルを動かしてみることにします。

# 作業ディレクトリに移動
$ cd ~/NextProjects

# サンプルから作成
$ npx create-next-app --example with-docker nextjs-docker

# 出来上がったフォルダに移動
$ cd nextjs-docker

# イメージの作成
$ docker build -t nextjs-docker .

# コンテナの作成と起動
$ docker run -p 3000:3000 nextjs-docker

http://localhost:3000/ にアクセスして表示されました。

バックグラウンドで起動して中に入り、中身の確認を行ってみます。

# バックグラウンドでコンテナを起動
$ docker run -p 3000:3000 -d nextjs-docker

# コンテナの確認
$ docker container ls                                                                                                                                         
CONTAINER ID   IMAGE            COMMAND                  CREATED          STATUS          PORTS                                         NAMES
726632b1e953   nextjs-docker    "docker-entrypoint.s…"   43 seconds ago   Up 42 seconds   0.0.0.0:3000->3000/tcp                        wizardly_goldwasser

# コンテナの中に入る
$ docker container exec -it wizardly_goldwasser sh    

# app の中身を確認
/app $ ls

node_modules  package.json  public        server.js

# node_modules の中身を確認
/app $ cd node_modules/
/app/node_modules $ ls

@next                    next                     react                    use-sync-external-store
@swc                     object-assign            react-dom

特に相違はなさそうです。

違いといえば、コンテナ起動時のdocker run -p 3000:3000 -d nextjs-dockerです。

元プロジェクトに戻ってポート指定を試してみます😢

ポートの指定

ファイル等は特に変えずに、コンテナ起動時にポートの指定-p 3000:3000を追加して実行してみます。

# 作業ディレクトリに移動
$ cd ~/NextProjects/donuts-site

# docker build -f Dockerfile のファイル名 -t 作成するするイメージ名 もとになるDockerfileの場所
$ docker build -f prod.Dockerfile -t donut-site-image .

# image が作成できたかの確認
$ docker image ls

REPOSITORY           TAG       IMAGE ID       CREATED          SIZE
donut-site-image     latest    14a4d9642166   42 minutes ago   121MB


# イメージからコンテナを作成して起動する
$ docker run --name donut-site -p 3000:3000 donut-site-image

表示できました😂数時間ハマってたのに😂

ポートの指定について

ポート指定をせずにコンテナを起動した場合は、3000/tcp
ポート指定をしてコンテナを起動した場合は、0.0.0.0:3000->3000/tcp
となっていました。

ポート番号はホストのポート番号:コンテナのポート番号という指定になります。
ポート番号が同じ場合には->移行は表示されません。

つまり、ホスト側のポートが3000/tcp指定されていたためhttp://localhost:3000にアクセスしても表示できなかったわけです。

この3000/tcpが意味することとはなんでしょうか?

Docker Docs のコンテナーのネットワークページによると、-p 8080:80/tcpは、コンテナーの TCP ポート 80 を Docker ホスト上のポート 8080 に割り当てますという意味になります。TCP ポートの他に UDP ポートも利用できます。

今回の3000/tcpは、コンテナーの TCP ポート 3000ということになります。

これまでのシリーズでは特に指定なしで動いていたような気もしますが、ちゃんと指定しているサンプルを動かしているパターンと docker-compose.yml の方で指定していたパターンで動いていたのかもしれません。

基本的には、Docker コンテナ内で通信を行う場合には、ポートフォワーディング(ホストマシンのポートをコンテナポートに紐付けて、コンテナの外から来た通信をコンテナポートに紐づけることができる機能)を設定が必須であるという認識でよさそうです。

おわりに

沼ってしまったので、今回の記事はここまでにします。
たったひとつのポート指定漏れで泣いちゃうところでしたが、理解できてよかったです。
今回のように、コマンド実行時のポート指定の有無でサイトが表示できない…といったことも Docker Compose を用いて docker-compose.yml に定義してあげれば、ミスも減りそうですね。

ということで、次回は本番環境用のdocker-compose.ymlを用意していこうと思います。