🏄‍♂️

Next.js開発環境構築にdocker composeを使い倒した話

2023/07/14に公開
5

やったこと

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を触ったことがない民だったので、入門のために以下の本で概要をつかみました。
https://zenn.dev/suzuki_hoge/books/2022-03-docker-practice-8ae36c33424b59
公式のチュートリアルをやったけど、いざ自分で何かやろうとするとよくわからんという状態だったのが、これを一通りやった後は(いちいちググらずともまずは)自分で考えて試行錯誤できるレベルになります。その意味では本当に「よくわからない」は終わりました。

ディレクトリ構成

最終的には以下のようなディレクトリ構成になります。
最初は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

環境構築手順

  1. dockerfile作成
  2. docker-compose.yml作成
  3. docker-compose.base.yml作成
  4. docker compose -f docker-compose.base.yml run --rm base→npx create-next-app実行
  5. docker compose -f docker-compose.base.yml run --rm installer→パッケージをインストール
  6. docker compose up→ローカルホスト起動

ファイルを準備してサービスを順番に起動しているだけです。baseとinstallerのコンテナは処理が完了したら不要になるので --rm オプションを付けておきます。パッケージインストール中の表示が邪魔な場合は -d オプションを代わりに付けますが、ネットワーク環境か何かの原因でcreate-next-appが失敗することがあり(下記と同様のエラー)、そのときに気付けなくてやっかいかもしれません。
https://stackoverflow.com/questions/75502903/npm-err-code-econnreset-when-doing-create-react-app
以下、手順を追いながら詰まったところや工夫したところを説明していきます。

ファイル準備

1. dockerfile作成

dockerfileを作成します。
今回は扱いませんが、baseステージはテスト環境や本番環境のベースとしても利用する想定です。

dockerfile
# (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系イメージを選択しました。
https://zenn.dev/jrsyo/articles/e42de409e62f5d
元動画はこちらです。イメージ選択以外にもマルチステージビルドのやり方など勉強になりました。
https://www.youtube.com/watch?v=Z0lpNSC1KbM
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系なら動きます。
https://github.com/nodejs/docker-node/issues/1912
あるいは公式のsupported tagsに入っている18.16.1-bullseye-slimを使う方がよいかもしれません。
https://hub.docker.com/_/node/

(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と同じような内容です。

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を使います。
https://zenn.dev/sarisia/articles/0c1db052d09921#fn-bf4c-1
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を実行するのはアンチパターンですが、今回はやってしまいます。
https://www.creationline.com/lab/29422#:~:text=Dockerfile内でnodeバイナリを直接起動

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が読み込まれます。
https://docs.docker.jp/v1.11/compose/extends.html
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というファイル名にしています。

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で指定したタグが付き、そのタグで参照できるようになります。
https://docs.docker.jp/v1.12/compose/compose-file.html#build
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は付けていません。
https://github.com/vercel/next.js/issues/43066#issuecomment-1319969015

コマンド実行

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 upnpm run dev を実行します。
必要なパッケージがあれば

docker compose run --rm app npm install <package names>

を実行します。
この方法でやっているものとして、こちらの記事はNext.jsの環境構築を調べ始めた際とても参考にさせていただきました。
https://zenn.dev/temple_c_tech/articles/setup-next-on-docker
この方法と本記事の方法の違いは、要するに 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サービスを活用するのがよい

個人的にはコマンドライン方式に抱いた疑問を整理でき、記事を書く過程でかなり勉強にもなったのでよかったです。
実用性に関しては有用になりえる場面もあればかえって手数が増える場面もありそうですが、何かの参考になれば幸いです。
最後までお読みいただきありがとうございました。

GitHubで編集を提案

Discussion

TFTF

standaloneモードでビルドする場合、Next.jsはパッケージがdependenciesかdevDependenciesかに依存しないので、—-save-devは付けていません。
とはどこに記述されていますでしょうか?該当のページに見当たりませんでした。

K-HojoK-Hojo

リンク先のコメントで「Vercelなどのプラットフォームでホスティングするかoutput: standaloneを使う場合、Next.jsはdependenciesとdevDependenciesの区別に依存していません」と言っています。

Next.js is not relying on dependencies vs. devDependencies either if you host on platforms like Vercel or use output: "standalone".

npx create-next-app@latestするとpackage.jsonのdependenciesに全部入ってますけど、devDependenciesがproductionに入らないように適切にグルーピングするべきじゃないですか?」というissueに対して上記の回答をしているので、インストール時に--save-devを付けてdevDependenciesを区別する必要はないと考えられます。

また、next.config.jsのoutputの説明にも以下のように書かれています。

During a build, Next.js will automatically trace each page and its dependencies to determine all of the files that are needed for deploying a production version of your application.
ビルドの間、Next.jsは自動的にそれぞれのページとその依存性を追跡し、productionをデプロイするのに必要なファイルのすべてを特定します。

Next.js can automatically create a standalone folder that copies only the necessary files for a production deployment including select files in node_modules.
Next.jsは自動的にstandaloneフォルダを生成でき、それはnode_modulesの中の選ばれたファイルを含めてproductionのデプロイに必要なファイルのみをコピーします。

https://nextjs.org/docs/app/api-reference/next-config-js/output#automatically-copying-traced-files

ここからも、node_modulesの中でproductionの稼働に必要なものはNext.jsが判定してくれるので、開発者はdevDependenciesかどうかを気にしなくてもよいと読めます。

TFTF

なるほど。確かに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 に書き換える必要がありました 🙏

    volumes:
        ~~~~
        - type
            ✕ source: node_modules
            〇 source: node_volumes

        ~~~~

volumes:
    node_volumes: