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点だ。

  1. ビルドコンテキストの転送に時間がかかる
  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 が頻繁に更新されるプロジェクトに効果的だ。

技術ブログの記事一覧へ