Next.js開発環境構築にdocker composeを使い倒した話
やったこと
Next.jsのプロジェクトを新規作成し、追加で必要なパッケージをインストールしてローカルホストで起動するまでをDockerで行いました。
複数のComposeファイルを使いますが、「docker nextjs環境構築」などで検索した限りではあまり出てこないやり方でした。よくある方法と比較してComposeファイルによるスクリプト化を最大限に利用した方法となっています。
両者を比較した結論としては以下のようになります。
🙆♂️本記事の方法が有効な場面
- create-next-appのインタラクティブな操作をしたくない
- インストールしたいパッケージがたくさんある
🙅♂️本記事の方法が有効でない場面
- create-next-appのインタラクティブな操作が問題ない
- インストールしたいパッケージがない、または少数
この結論の根拠は最後の他の方法との比較と実用性の検討をご覧ください。
動作環境
Macbook Pro intel core i5
macOS Big Sur 11.7.8
Docker Desktop 4.20.1
Docker Compose 2.18.1
VSCode 1.79.2
Dev Containers 0.295.0
Docker入門
Dockerを触ったことがない民だったので、入門のために以下の本で概要をつかみました。
公式のチュートリアルをやったけど、いざ自分で何かやろうとするとよくわからんという状態だったのが、これを一通りやった後は(いちいちググらずともまずは)自分で考えて試行錯誤できるレベルになります。その意味では本当に「よくわからない」は終わりました。ディレクトリ構成
最終的には以下のようなディレクトリ構成になります。
最初はdockerfile, docker-compose.yml, docker-compose.base.ymlと空のsrcディレクトリのみがあり、create-next-appによりsrc以下のファイルが生成されます。
.
├── docker-compose.yml
├── docker-compose.base.yml
├── dockerfile
└── src
├── README.md
├── app
├── next-env.d.ts
├── next.config.js
├── node_modules
├── package-lock.json
├── package.json
├── postcss.config.js
├── public
├── tailwind.config.js
└── tsconfig.json
環境構築手順
- dockerfile作成
- docker-compose.yml作成
- docker-compose.base.yml作成
- docker compose -f docker-compose.base.yml run --rm base→npx create-next-app実行
- docker compose -f docker-compose.base.yml run --rm installer→パッケージをインストール
- docker compose up→ローカルホスト起動
ファイルを準備してサービスを順番に起動しているだけです。baseとinstallerのコンテナは処理が完了したら不要になるので --rm
オプションを付けておきます。パッケージインストール中の表示が邪魔な場合は -d
オプションを代わりに付けますが、ネットワーク環境か何かの原因でcreate-next-appが失敗することがあり(下記と同様のエラー)、そのときに気付けなくてやっかいかもしれません。
以下、手順を追いながら詰まったところや工夫したところを説明していきます。
ファイル準備
1. dockerfile作成
dockerfileを作成します。
今回は扱いませんが、baseステージはテスト環境や本番環境のベースとしても利用する想定です。
# (1)
FROM node:20.4.0-bookworm-slim as base
WORKDIR /src
# (2)
RUN [ "npm", "install", "-g", "npm@9.8.0" ]
FROM base as dev
ENV NODE_ENV=development
COPY ./src/package*.json ./
# (3)
RUN [ "npm", "install" ]
(1)FROM node:20.4.0-bookworm-slim as base
以下の記事を参考にAlpine系ではなくDebianのslim系イメージを選択しました。
元動画はこちらです。イメージ選択以外にもマルチステージビルドのやり方など勉強になりました。 2023年6月にDebianの最新バージョンはbookwormになったのでそれを採用しています。本記事を書き始めた時点のNodeイメージの最新バージョンだった20.3.1ではcreate next appするとエラーが発生するバグがあったのですが、投稿前により新しい20.4.0が出てバグが解消したので備忘録としてだけ残しておきます。
node:20.3の問題
node:20.3.1-bookworm-slimはcreate-next-app中にText file busyエラーが発生します。
$ docker compose run --rm app npx create-next-app .
[+] Building 0.0s (0/0)
[+] Building 0.0s (0/0)
Need to install the following packages:
create-next-app@13.4.7
Ok to proceed? (y) y
sh: 1: create-next-app: Text file busy
どうやらnode20.3系のイメージの問題のようで、20.2系なら動きます。
あるいは公式のsupported tagsに入っている18.16.1-bullseye-slimを使う方がよいかもしれません。(2)RUN [ "npm", "install", "-g", "npm@9.8.0" ]
新しいnpmがあったので更新しておきました。あとで再ビルドしたときに動作の差異が出ないように、latestではなく9.8.0と具体的なバージョンを指定しています。
(3)RUN [ "npm", "install" ]
devではvolume trickを使うのでパッケージをインストールしておきます。
2. docker-compose.yml作成
docker container run
するときにオプションで設定していたものたちをdocker-compose.ymlに書いていきます。
こちらは通常作成するdocker-compose.ymlと同じような内容です。
services:
dev:
build:
context: .
target: dev
volumes:
# (1)
- type: bind
source: ./src
target: /src
# (2)
- type: volume
source: node_modules
target: /src/node_modules
# (3)
command: [ "npm", "run", "dev" ]
ports:
- "3000:3000"
tty: true
stdin_open: true
volumes:
node_volumes:
(1)bind mount
バインドマウントする際にshort syntaxを使うと厄介な問題があるようなので、long syntaxを使います。
sourceのディレクトリが存在しない場合、long syntaxではエラーを吐くのであらかじめ空のsrcディレクトリを作っておきます。short syntaxでは自動的にディレクトリを作成してくれますが、この機能は廃止予定のようです。もしディレクトリが存在しない場合、Docker は自動的にディレクトリを作成します。このホスト・パスの自動生成機能は廃止予定です。
https://docs.docker.jp/engine/userguide/dockervolumes.html
(2)volume trick
最終的に使用することになるdevではvolume trickを使い、ホストのnode_modulesでコンテナのnode_modulesが上書きされないようにします。
(3)command: [ "npm", "run", "dev" ]
本来はCMDでnpmを実行するのはアンチパターンですが、今回はやってしまいます。
3. docker-compose.base.yml作成
こちらが本記事の方法の特色になります。
プロジェクトの初期設定だけに必要なbaseサービスとinstallerサービスはdocker-compose.base.ymlに外出ししてしまいます。
docker-compose.base.ymlのファイル名に関する補足
標準では docker compose hoge
を実行するとdocker-compose.ymlとdocker-compose.override.ymlが読み込まれます。
baseサービスとinstallerサービスを記述するファイル名をdocker-compose.override.ymlにした場合、実行するコマンドは以下のようになります。
docker compose run --rm base
docker compose run --rm installer
docker compose up dev
docker compose run
のときにファイルを指定しなくてよくなる分、 docker compose up
でdevを指定しなければいけなくなります。これだとdevがたとえばdbサービスに依存しているときに困るので、docker-compose.base.ymlというファイル名にしています。
services:
base:
build:
context: .
target: base
# (1)
image: node_base
# (2)
volumes:
- type: bind
source: ./src
target: /src
# (3)
entrypoint: [ "npx", "-y", "create-next-app", "."]
command: [ "--ts", "--tailwind", "--eslint", "--app", "--no-src-dir", "--import-alias", "@/*" ]
installer:
# (1)'
image: node_base
volumes:
- type: bind
source: ./src
target: /src
# (4) (5)
entrypoint: [ "npm", "install" ]
command: [ "prettier", "eslint-config-prettier"]
(1)image
buildとimageを同時に指定すると、ビルドされたイメージにimageで指定したタグが付き、そのタグで参照できるようになります。
installerサービスではbaseサービスでビルドしたイメージを再利用することで不要なビルドを防ぎ、時間と容量を節約しています。(2)volume trick
この段階でvolume trickを使おうとするとホストにも空のnode_modulesができてしまい、create-next-appを実行すると
The directory src contains files that could conflict:
node_modules/
というエラーを吐いて終了してしまうため、baseではvolume trickを使うことを断念しています。
その結果としてホストにも中身のあるnode_modulesができてしまいます。ちなみにこの問題は通常の docker compose run npx create-next-app
を実行する方法でも同様に発生します。
(3)entrypoint: [ "npx", "-y", "create-next-app", "."]
exec形式でENTRYPOINTとCMDを記述すると、CMDの内容がENTRYPOINTに引数として渡されます。これを利用してcreate-next-appのオプションをすべてCMDで指定しておくことでインタラクティブな操作をなくしています。
(4)entrypoint: [ "npm", "install" ]
ENTRYPOINTで npm install
を実行させ、CMDでインストールしたいパッケージを指定します。上記のdockerfileでは2つだけですが、ESLint関連のconfigやpluginをたくさんインストールしたい場合など、パッケージ名を手打ちするよりもスクリプト化した方が間違いが起こりにくく再現性も高いと思います。
(5)entrypoint: [ "npm", "install" ]
standaloneモードでビルドする場合、Next.jsはパッケージがdependenciesかdevDependenciesかに依存しないので、—-save-devは付けていません。
コマンド実行
3. docker compose -f docker-compose.base.yml run --rm base
ENTRYPOINTに指定した npx create-next-app .
を実行します。srcディレクトリをバインドマウントしているので、作成されたファイルがホストに反映されます。
もし
RUN npx create-next-app .
CMD [ "npm", "run", "dev" ]
のようにdockerfileを記述した場合、ホストのsrcディレクトリは空なので、バインドマウントするとコンテナのsrcディレクトリの中身が消えてしまいます。それを防ぐためにビルド時ではなくコンテナ起動時にプロジェクトを初期化しています。標語的に言えば、バインドマウントで消えないようにするためには「イメージの中に静的にプロジェクトを含むのではなく、コンテナ起動時に動的にプロジェクトを生成する」必要があります。
4. docker compose -f docker-compose.base.yml run --rm installer
コピーしておいたpackage.jsonにCMDでインストールするパッケージが追加され、ホストのpackage.jsonにも反映されます。installerとdevを分けずに
RUN npm install <package names>
CMD [ "npm", "run", "dev" ]
とした場合、ホストではパッケージを追加インストールしていないので、バインドマウントした時点でホストのpackage.jsonがコンテナに反映されることで追加したパッケージの記述が消えてしまいます。それを防ぐためにビルド時ではなくコンテナ起動時にパッケージをインストールしています。これも「静的にパッケージを含むのではなく動的にインストール」です。
5. docker compose up
めでたくローカルホストで起動しました。
他の方法との比較と実用性の検討
「docker nextjs環境構築」などで検索してよく出てくるやり方だと、シンプルなdockerfile(本記事でいえばbaseステージのみのような内容)を使って
docker compose run --rm app npx create-next-app .
でNext.jsプロジェクトを作成し、 docker compose up
で npm run dev
を実行します。
必要なパッケージがあれば
docker compose run --rm app npm install <package names>
を実行します。
この方法でやっているものとして、こちらの記事はNext.jsの環境構築を調べ始めた際とても参考にさせていただきました。
この方法と本記事の方法の違いは、要するに docker compose run
で実行するものをdocker-compose.base.ymlに記述するかどうかにあります。前者をコマンドライン方式、後者をdocker-compose方式と呼ぶことにします。
比較結果は次の表のようになります。
コマンドライン方式 | docker-compose方式 | |
---|---|---|
準備するスクリプト | 単純 | 複雑 |
コマンド入力の回数 | 3回 | 3回 |
インタラクティブな操作 | なし | あり |
パッケージインストール | コマンド | スクリプト |
docker-compose方式より先に上記記事などでコマンドライン方式にたどり着いていたのですが、Dockerの哲学である
「できるだけ多くのことをスクリプトにあらかじめ記述しておき手入力を減らすことで、システム構成の見通しをよくするとともに再現性と再利用性を担保すること」
という観点からは不満を感じました。そこで自分なりに望ましいやり方を考えた結果、docker-compose方式に至りました。
docker-compose方式はdocker-compose.base.ymlを作成する分、記述量が増えます。一度しか実行しないコマンドのためにそこまでするのはかえってロスのような気がします。
しかしcreate-next-appを実行する際にインタラクティブな操作をしたくない場合、オプションをすべてコマンドラインに打ち込むのは苦行ですし、"-y"の場所を間違えたり"."を打ち忘れたりすると入力を求められてしまうので、そういったときにはbaseサービスを作っておくのは便利だと思います。
あるいは、いくつものパッケージをインストールしたいときにコマンドラインでパッケージ名をすべて打つのは大変です。例えばこちらの記事でインストールしているパッケージを一気に書くと以下のようになります。
npm install prettier eslint-config-prettier \
@typescript-eslint/parser @typescript-eslint/eslint-plugin \
prettier-plugin-tailwindcss eslint-plugin-import \
husky lint-staged
さすがにこれを実際に一気に書くことはありませんが、何度かに分けてインストールしていると抜け漏れが発生したり、他のプロジェクトでも同じものをインストールしようとしたときの再現性に不安があったり、単純に面倒くさかったりします。コマンドライン方式で満足できず他の方法を模索しようと思ったきっかけもここにあります。
これらに対して、installerサービスに記述しておけばあとからチェックできますし、再利用するほどコスパがよくなります。
まとめ
結論としては以下のようにまとめられます。
- インタラクティブな操作が問題ないのであればcreate-next-appはコマンドラインで実行
- インストールしたいパッケージがたくさんある場合はdocker-compose方式でinstallerサービスを作るのもあり
- create-next-appのインタラクティブな操作をしたくないならbaseサービスを活用するのがよい
個人的にはコマンドライン方式に抱いた疑問を整理でき、記事を書く過程でかなり勉強にもなったのでよかったです。
実用性に関しては有用になりえる場面もあればかえって手数が増える場面もありそうですが、何かの参考になれば幸いです。
最後までお読みいただきありがとうございました。
Discussion
リンク先のコメントで「Vercelなどのプラットフォームでホスティングするかoutput: standaloneを使う場合、Next.jsはdependenciesとdevDependenciesの区別に依存していません」と言っています。
「
npx create-next-app@latest
するとpackage.jsonのdependenciesに全部入ってますけど、devDependenciesがproductionに入らないように適切にグルーピングするべきじゃないですか?」というissueに対して上記の回答をしているので、インストール時に--save-devを付けてdevDependenciesを区別する必要はないと考えられます。また、next.config.jsのoutputの説明にも以下のように書かれています。
ここからも、node_modulesの中でproductionの稼働に必要なものはNext.jsが判定してくれるので、開発者はdevDependenciesかどうかを気にしなくてもよいと読めます。
Next.js 13からこのようになっているようです。
なるほど。確かにNext.jsではサーバー・コンポーネントとクライアント側(use client)でも使用するJSとの2種類がありますので、Next.jsがよしなに判別しているんですね。ありがとうございます。
1年近く前の記事へのコメント失礼します
コマンドの詳細な説明など大変ためになる記事でした、ありがとうございました 🙇
1点 docker-compose.yml の 11 行目で volume 指定のエラーが出ていたので報告させてください。
指定する volume の名前が node_modules となっており、実際に存在する volume が node_volumes となっているため、11 行目の source に node_volumes に書き換える必要がありました 🙏