Docker で npm install が毎回実行される・キャッシュが効かない時の5つの原因と対策【マルチステージビルド / BuildKit】
公開日:2026-07-25/カテゴリ:Docker・Node.js・インフラ/AnchorUp 技術ブログ
Docker でのビルド時間が長い原因の大半は npm install の繰り返し実行だ。ソースコードを1行変えるだけで毎回数分かかるのは、レイヤーキャッシュの仕組みを正しく使えていないことが原因である。本記事では、npm install キャッシュが効かない5つの原因と対策をまとめる。
npm install が毎回実行される5つの原因一覧
| # | 症状 | 原因 |
|---|---|---|
| 1 | ソース変更のたびに npm install が走る | COPY 命令の順序が間違っている |
| 2 | ビルドコンテキスト送信後に install が再実行される | .dockerignore に node_modules がない |
| 3 | キャッシュは効いているがダウンロードが遅い | BuildKit の --mount=type=cache を使っていない |
| 4 | 環境変数・ビルド引数を変えたら install が再実行された | ARG/ENV の変更がレイヤーキャッシュを破壊している |
| 5 | マルチステージビルドでキャッシュが共有されない | ステージをまたいだキャッシュの罠 |
原因1:COPY の順序が間違っている(最多ミス)
Docker のレイヤーキャッシュは、それ以前のレイヤーが変わったら無効化される。よくあるミスは package.json より先にソースコードを COPY することだ。
悪い例(ソースを先にコピー)
FROM node:22-alpine
WORKDIR /app
# ❌ ソースを先にコピーすると、.ts を1行変えるだけで
# この後の RUN npm install も再実行される
COPY . .
RUN npm install
CMD ["node", "dist/index.js"]
ソースファイルを1行変えるたびに COPY . . のレイヤーが変わり、以降の npm install もキャッシュが無効化されて毎回フルインストールが走る。
正しい例(package.json を先にコピー)
FROM node:22-alpine
WORKDIR /app
# ✅ package.json と lockfile だけ先にコピー
COPY package.json package-lock.json ./
# lockfile が変わらない限りキャッシュがヒットする
RUN npm ci --omit=dev
# その後でソースをコピー(ここが変わっても npm ci は再実行されない)
COPY . .
RUN npm run build
CMD ["node", "dist/index.js"]
package.json と package-lock.json が変わらない限り npm ci は一度しか実行されない。ソースコードを毎日変えてもインストールはキャッシュから復元される。
原因2:.dockerignore に node_modules を書いていない
.dockerignore を設定していないと、ホストの node_modules/ がビルドコンテキストとして Docker デーモンに転送される。これが問題になるのは以下の2点だ。
- ビルドコンテキストの転送に時間がかかる
COPY . .でホストのnode_modulesがコンテナ内に上書きされ、意図しないバイナリが混入する(特に Mac ↔ Linux 間でネイティブモジュールが壊れる)
.dockerignore の最低限の設定
node_modules
.git
dist
.next
*.log
node_modules を除外することで、ビルドコンテキストの転送が高速化し、COPY . . でも意図したファイルだけがコピーされるようになる。
確認方法:
# ビルドコンテキストのサイズを確認(docker build 前に転送されるサイズ)
docker build --no-cache . 2>&1 | head -5
# Sending build context to Docker daemon 1.234GB ← node_modules が含まれている証拠
# Sending build context to Docker daemon 512.0kB ← .dockerignore が効いている
原因3:BuildKit の --mount=type=cache を使っていない
Docker BuildKit(Docker 23+ ではデフォルト有効)には --mount=type=cache というキャッシュマウント機能がある。npm のダウンロードキャッシュ(~/.npm)をビルドをまたいで永続化できるため、lockfile が変わってパッケージを追加した時でも、すでにダウンロード済みのパッケージは再ダウンロードされない。
–mount=type=cache を使った書き方
FROM node:22-alpine
WORKDIR /app
COPY package.json package-lock.json ./
# npm キャッシュを /root/.npm に永続化
RUN --mount=type=cache,target=/root/.npm npm ci --omit=dev
COPY . .
RUN --mount=type=cache,target=/root/.npm npm run build
CMD ["node", "dist/index.js"]
この設定で、package-lock.json が更新されて新しいパッケージを追加した場合も、既存パッケージのダウンロードはスキップされ、差分だけがネットワーク転送される。
pnpm を使う場合
FROM node:22-alpine
RUN npm install -g pnpm
WORKDIR /app
COPY package.json pnpm-lock.yaml ./
RUN --mount=type=cache,target=/root/.local/share/pnpm/store pnpm install --frozen-lockfile --prod
COPY . .
CMD ["node", "dist/index.js"]
pnpm のストアパスは /root/.local/share/pnpm/store または pnpm store path で確認できる。
原因4:ARG/ENV がキャッシュを破壊している
ARG や ENV を RUN npm install より前に書くと、その変数の値が変わるたびに npm install のキャッシュも無効化される。
問題パターン:ARG が install より前にある
FROM node:22-alpine
WORKDIR /app
# ❌ BUILD_DATE が毎日変わると、以降のレイヤーがすべてキャッシュ無効化される
ARG BUILD_DATE
ENV BUILD_DATE=${BUILD_DATE}
COPY package.json package-lock.json ./
RUN npm ci # ← BUILD_DATE が変わるたびに再実行される
COPY . .
対策:変動する ARG/ENV は install より後に置く
FROM node:22-alpine
WORKDIR /app
# ✅ npm install はここで完了(ARG なし)
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
RUN npm run build
# 変動する ARG は最後(ここから下のキャッシュだけ無効化される)
ARG BUILD_DATE
ENV BUILD_DATE=${BUILD_DATE}
CMD ["node", "dist/index.js"]
ARG/ENV はできる限り Dockerfile の後半に置くのがベストプラクティスだ。
原因5:マルチステージビルドでのキャッシュ設計ミス
マルチステージビルドでは、各ステージのキャッシュは独立している。ステージをまたいで自動共有されるわけではない。
問題パターン:依存インストールを各ステージで繰り返している
# ❌ build ステージと runner ステージで別々に npm install
FROM node:22-alpine AS builder
WORKDIR /app
COPY . .
RUN npm install && npm run build
FROM node:22-alpine AS runner
WORKDIR /app
COPY . .
RUN npm install --omit=dev # ← また install している
COPY --from=builder /app/dist ./dist
CMD ["node", "dist/index.js"]
対策:dependencies インストール専用ステージを作る
# ✅ deps ステージでインストール → build/runner で COPY --from
FROM node:22-alpine AS deps
WORKDIR /app
COPY package.json package-lock.json ./
RUN --mount=type=cache,target=/root/.npm npm ci --omit=dev
FROM node:22-alpine AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
RUN npm run build
FROM node:22-alpine AS runner
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY --from=builder /app/dist ./dist
CMD ["node", "dist/index.js"]
deps ステージでインストールした node_modules を COPY --from=deps で後続ステージにコピーする。lockfile が変わらない限り deps ステージのキャッシュがヒットし、npm ci は再実行されない。
まとめ:npm install キャッシュ最適化チェックリスト
| 確認項目 | 対策 |
|---|---|
| COPY . . の前に npm install していないか | package.json / lockfile を先に COPY する |
| .dockerignore に node_modules を書いているか | node_modules・.git・dist を必ず除外 |
| BuildKit の --mount=type=cache を使っているか | RUN --mount=type=cache,target=/root/.npm を追加 |
| ARG/ENV が install より前にあるか | 変動する変数は Dockerfile の後半に移動 |
| マルチステージで重複インストールしていないか | deps ステージを作り COPY --from=deps で共有 |
最も効果が大きいのは「COPY の順序を直すこと」と「.dockerignore を設定すること」 の2つだ。この2つを直すだけで、ソースコード変更時のビルド時間が数分から数十秒に短縮されるケースが多い。BuildKit の cache mount は追加の最適化として、特に CI/CD 環境で lockfile が頻繁に更新されるプロジェクトに効果的だ。