はじめに
Docker Compose で node_modules を名前付きボリュームとして管理している環境で、docker compose build し直したのにパッケージが見つからないという問題に遭遇しました。
この記事では、実際に発生した事象をもとに名前付きボリュームの仕組みを解説し、原因の特定と対処ができるようになることを目指します。
発生した事象
Docker Compose で開発していて、ブランチを切り替えたあとに docker compose up -d --build でビルドし直しました。
新しいライブラリが追加されていましたが、Dockerfile に pnpm install があるので問題ないだろうと思っていました。
しかし、起動直後に以下のエラーが発生しました。
Error [ERR_MODULE_NOT_FOUND]: Cannot find package '@ai-sdk/google'
imported from /app/src/someModule.ts
at packageResolve (node:internal/modules/esm/resolve:767:81)
...
え、なんで!ビルドし直したのに!
Dockerfile で pnpm install しているのだから、最新の package.json に書かれたパッケージはすべてインストールされているはず。
なのに「見つからない」と言われる。この原因が 名前付きボリューム にありました。
この記事で話すこと・話さないこと
話すこと
- 名前付きボリュームの初期化ルールと
node_modulesが更新されない原因 -
docker compose up -d --buildの内部フロー - 解決方法
話さないこと
- Docker や Volume 自体の基礎的な説明
- バインドマウントと名前付きボリュームの使い分けの詳細
Docker の Volume について
Docker の Volume は、コンテナのライフサイクルとは独立してデータを永続化するための仕組みです。
コンテナを削除しても Volume のデータは残り続けます。
Volume には主に以下の2種類があります。
- バインドマウント: ホスト側のディレクトリをコンテナにマウントする
- 名前付きボリューム: Docker が管理する名前付きのストレージ領域
Volume の詳しい解説は以下の記事がとてもわかりやすいので、そちらを参照してください。
この記事では、名前付きボリュームが node_modules に対してどのように振る舞うかに絞って解説します。
docker compose up -d --build の内部フロー
docker compose up -d --build は、概念的には以下の流れです(実際には変更がなければ create/recreate がスキップされることがあります)。
1. build(イメージ構築)
→ Dockerfile を実行する
→ pnpm install はイメージ内のファイルシステムに書き込まれる
→ この時点では volume は関係ない
2. create/recreate(必要時)
→名前付きボリュームをコンテナにマウントする
→ volume が存在しなければ新規作成される
→ volume がすでに存在すれば既存のものがそのまま使われる
3. start(コンテナ起動)
→ command(または CMD)を実行する
ここで重要なのは、build の時点では volume はまだ関係ないということです。
Dockerfile の pnpm install でインストールされたパッケージは、あくまでイメージ内のファイルシステムに書き込まれます。
volume がマウントされるのは、そのあとの create/recreate のタイミングです。
つまり、build でいくら最新のパッケージをインストールしても、create/recreate で既存の volume がマウントされると、イメージ側の node_modules は隠されてしまいます。
名前付きボリュームの初期化ルール
今回の docker-compose.yml では、node_modules を名前付きボリュームとしてマウントしています。
volumes:
- app_node_modules:/app/node_modules
名前付きボリュームがイメージの中身をコピーするタイミングには明確なルールがあります。
| volume の状態 | 何が起きるか |
|---|---|
| 空(初回) | イメージ内の node_modules が volume にコピーされる |
| 中身あり(2回目以降) | volume の中身がそのまま使われる(イメージ側は完全に無視) |
デフォルト挙動では、空の名前付きボリュームを初めてマウントしたときだけ、イメージ内の内容が volume にコピーされます(volume-nocopy で無効化可能)。
2回目以降は、イメージをどれだけ rebuild しても volume の中身は一切更新されません。
これが、docker compose build し直しても新しいパッケージが反映されない原因です。
なぜ名前付きボリュームを使うのか
そもそもなぜ node_modules を名前付きボリュームにしているのでしょうか。
開発環境では、ホストのソースコードをコンテナに同期するためにバインドマウントを使います。
volumes:
- ./:/app # バインドマウント(プロジェクト全体を同期)
- app_node_modules:/app/node_modules # 名前付きボリューム(node_modules を守る)
バインドマウントはホスト側のディレクトリをそのままコンテナにマウントします。
ここで問題になるのが、バインドマウントの「上書き(obscure)」という動作です。
Docker 公式ドキュメントにも以下のように記載されています。
If you bind mount a file or directory into a directory in the container in which files or directories exist, the pre-existing files are obscured by the mount.
(バインドマウントすると、コンテナ内に元からあったファイルは隠される)
つまり、Dockerfile で pnpm install して /app/node_modules にパッケージをインストールしても、バインドマウントでホスト側のディレクトリをマウントした瞬間、コンテナ内の node_modules は隠されてしまいます。
ホスト側に node_modules がなければ空になりますし、macOS でインストールした node_modules をそのままコンテナ(Linux)で使うと、OS の違いによりエラーになることがあります。
名前付きボリュームを使うことで、バインドマウントの上書きから node_modules を守ることができます。
コンテナ内で pnpm install した Linux 向けの node_modules をそのまま保持できるわけです。
ただし、その副作用として今回のような「volume の中身が古いまま残る」問題が発生します。
ブランチ切替で壊れる流れを時系列で追う
ここまでの内容をふまえて、実際にブランチ切替で壊れるまでの流れを時系列で見ていきます。
1. ブランチA で初回 docker compose up
名前付きボリュームが空なので、イメージ内の node_modules が volume にコピーされます。
コンテナは volume 側の node_modules を参照するので、正常に動作します。
2. develop に切り替えて docker compose up -d --build
develop には @ai-sdk/google が追加されています。
build でイメージは新しくなりますが、名前付きボリュームにはすでに中身があります。
コンテナが参照するのは volume 側です。
volume にはブランチA 時点の node_modules しかないので、@ai-sdk/google は見つかりません。
結果
ソースコードは develop の最新なのに、node_modules はブランチA のまま。
この不整合が ERR_MODULE_NOT_FOUND の原因です。
--build をつけてもイメージが再構築されるだけで、名前付きボリュームは独立して存在し続けます。
コンテナを --force-recreate で作り直しても、volume は消えません。
解決方法
方法A: node_modules の volume だけ削除して再ビルド
DB データなど他の volume は残したまま、node_modules の volume だけを削除する方法です。
docker compose down
docker volume rm <プロジェクト名>_app_node_modules
docker compose up -d --build
volume 名はプロジェクト名がプレフィックスとしてつきます。
docker volume ls で実際の名前を確認できます。
方法B: 全 volume を削除して再ビルド
-v オプションですべての volume を削除する方法です。
docker compose down -v
docker compose up -d --build
手軽ですが、データベースやストレージなど 他の volume のデータもすべて消えるので注意してください。
方法C: 起動時に毎回 pnpm install を実行する
docker-compose.yml の command で、起動時に pnpm install を挟む方法です。
app:
command: sh -c "pnpm install --frozen-lockfile && pnpm run dev"
コンテナ内のプロセスとして pnpm install が実行されるので、volume の中身を直接書き換えることができます。
--frozen-lockfile をつけると pnpm-lock.yaml に差分がない場合はほぼ一瞬で終わります。
ブランチ切替のたびに volume を手動で削除する必要がなくなりますが、起動が数秒〜十数秒遅くなります。
より適切な方法をご存知の方がいれば、ぜひコメントで教えていただけると嬉しいです。
この問題が発生するタイミング早見表
| 操作 | volume 削除が必要か |
|---|---|
package.json にパッケージを追加・削除した |
必要 |
pnpm-lock.yaml が変更された |
必要 |
| ブランチ切替(依存関係が異なる場合) | 必要 |
| ソースコードだけの変更 | 不要 |
最後に
docker compose build し直したのにパッケージが見つからないという問題は、名前付きボリュームの仕組みを知らないと原因にたどり着くのが難しいです。
ポイントをまとめると以下の通りです。
-
docker compose up -d --buildの build 時点では volume は関係ない - 空の名前付きボリュームを初回マウントするときだけ(デフォルトで)イメージの中身がコピーされる
- 2回目以降は volume の中身がそのまま使われ、イメージ側は無視される
-
package.jsonの変更やブランチ切替時は volume の削除が必要
ERR_MODULE_NOT_FOUND が出たら、まず名前付きボリュームを疑ってみてください。
参考文献
- https://qiita.com/aki_55p/items/63c47214cab7bcb027e0
- https://docs.docker.com/engine/storage/volumes/
- https://docs.docker.com/engine/storage/bind-mounts/
株式会社シンシア
株式会社シンシアでは、実務未経験のエンジニアの方や学生エンジニアインターンを採用し一緒に働いています。
※ シンシアにおける働き方の様子はこちら
弊社には年間100人以上の実務未経験の方に応募いただき、技術面接を実施しております。
この記事が少しでも学びになったという方は、ぜひ wantedly のストーリーもご覧いただけるととても嬉しいです!