Next.jsのDockerビルドを25分→13分に高速化した話【GitHub Actions】

に公開

TL;DR(この記事で分かること)

  • GitHub ActionsでNext.jsをDockerビルドすると毎回フルリビルドされて遅い問題の解決策
  • BuildKitのキャッシュマウントがGitHub Actionsで効かない理由
  • .next/cacheを明示的に出し入れすることでビルド時間を25分→13分に短縮(48%削減)
  • コピペで使えるワークフロー例あり

はじめに:25分のビルド地獄からの脱出

ある日、PRを出すたびにGitHub Actionsのビルドが25分もかかっていることに気づきました。

「またビルド待ち」が口癖になっていた頃です😇

ログを見ると、pushするたびにWebpackがフル再コンパイル、ページも全部再生成。Next.jsのビルドキャッシュが全く効いていませんでした。

不思議なのは、npm run buildを直接GitHub Actions上で実行すると爆速なのに、Docker経由だと毎回ゼロからビルドされること。

「Dockerのレイヤーキャッシュ使ってるのに、なんでだろう」

この疑問から始まった高速化の旅を、この記事でシェアします。

問題の本質:Next.jsのキャッシュがDockerで消える

Next.jsのビルドキャッシュって何?

Next.jsは.next/cacheというディレクトリにビルド成果物を保存して、次回以降のビルドを爆速化します:

  • Webpackコンパイルキャッシュ - コンパイル済みモジュールとチャンク
  • TypeScriptキャッシュ - 型チェック結果(.tsbuildinfo
  • SWCキャッシュ - 変換済みのJavaScript/TypeScriptファイル
  • 画像最適化キャッシュ - 処理済み画像

ローカルや普通のCI環境でnpm run buildを再実行すると、Next.jsはこのキャッシュをチェックして、変更がないファイルは再コンパイルをスキップしてくれます。これが数分かかるビルドを数秒に短縮する秘密です。

なぜDockerだとキャッシュが消えるのか?

ネイティブ環境では.next/cacheがビルド間で保持されるため、これが自動的に機能します。

ところがDockerだと?

ビルド開始 → キャッシュ生成 → ビルド中に使用 → コンテナ終了 → キャッシュ消失

毎回ゼロからビルドが始まります。せっかく生成されたキャッシュが、コンテナと一緒に捨てられてしまうんです。

試行錯誤:BuildKitのキャッシュマウントが効かない罠

「よし、BuildKitのキャッシュマウント使えばいいじゃん!」

そう思って、最初はこう書きました:

RUN --mount=type=cache,target=/app/.next/cache \
    npm run build

GitHub Actionsのキャッシュも設定:

cache-from: type=gha
cache-to: type=gha,mode=max

これで完璧のはずが、ビルドは相変わらず遅いまま。毎回25分のフルビルド

ログを見ても、キャッシュヒットの形跡なし。なぜだろう?

原因:GitHub Actionsとキャッシュマウントの相性問題

調べてみると、GitHub Actionsのキャッシュバックエンド(type=gha)は、BuildKitのキャッシュマウントを正しくエクスポートしてくれないことが判明。

つまり:

  • キャッシュマウントは作られる ✅
  • ビルド中は使われる ✅
  • でも次回のビルドのために保存されない ❌

完全に罠でした。

解決策:キャッシュを明示的に出し入れする

ということで、発想を変えました。

BuildKitに任せるのではなく、Next.jsのキャッシュをDockerコンテナの内外で明示的に出し入れする作戦です。

具体的には、以下の3ステップ:

1. Dockerビルド前にキャッシュをリストア

- name: Cache Next.js build
  uses: actions/cache@v4
  with:
    path: .next/cache
    key: ${{ runner.os }}-nextjs-docker-${{ hashFiles('**/package-lock.json') }}
    restore-keys: |
      ${{ runner.os }}-nextjs-docker-

これでランナー上に.next/cacheがリストアされます。Dockerイメージをビルドする前に実行するのがポイント。

2. キャッシュをDockerに含める

.dockerignoreを作成して、重いファイルは除外するけど、.next/除外しないようにします:

node_modules
.git
.github

こうすれば、COPY . .でリストアされたキャッシュがDockerに含まれます。

3. ビルド後にキャッシュを抽出

Dockerビルドが終わったら、更新されたキャッシュを取り出します:

- name: Extract Next.js cache from container
  run: |
    CONTAINER_ID="$(docker create ${{ env.DOCKER_IMAGE_REPO }}:${{ env.DOCKER_IMAGE_TAG }})"
    mkdir -p .next
    docker cp "${CONTAINER_ID}:/app/.next/cache" .next/cache
    docker rm "${CONTAINER_ID}"

GitHub Actionsが、ジョブ終了時に自動でこれを保存してくれます。

ハマりポイント:キャッシュキーの設定ミス

実装してテストしたとき、最初は全然速くなりませんでした。

原因は、キャッシュキーに${{ github.sha }}を使っていたこと:

key: ${{ runner.os }}-nextjs-docker-${{ github.sha }} # ❌ NG!

これだと、コミットごとに別のキャッシュキーが生成されるので、毎回新規キャッシュ扱いになります。全く意味がない。

正しいキャッシュキーの設定

キャッシュキーはpackage-lock.jsonのハッシュをベースにするのが正解:

key: ${{ runner.os }}-nextjs-docker-${{ hashFiles('**/package-lock.json') }} # ✅ これが正解!

こうすることで:

  • 依存関係が変わらない限り、同じキャッシュキーが使われる
  • コミットをまたいでキャッシュが再利用される
  • package-lock.jsonが更新されたときだけキャッシュが無効化される

これで、ようやくキャッシュが効くようになりました!

全体の流れ

┌─────────────────────────────────────┐
    1. GitHub Actionsがキャッシュリストア  
    .next/cache → ランナーのファイル    
└─────────────────────────────────────┘
              ↓
┌─────────────────────────────────────┐
    2. Dockerビルドでキャッシュをコピー     
    COPY . . → キャッシュも含まれる     
    npm run build → キャッシュを再利用  
└─────────────────────────────────────┘
              ↓
┌─────────────────────────────────────┐
    3. 更新されたキャッシュを抽出           
    docker cp → コンテナからランナーへ   
└─────────────────────────────────────┘
              ↓
┌─────────────────────────────────────┐
    4. GHAが自動保存(次回ビルド用)         
└─────────────────────────────────────┘

完全なワークフロー例

- name: Cache Next.js build
  uses: actions/cache@v4
  with:
    path: .next/cache
    key: ${{ runner.os }}-nextjs-docker-${{ hashFiles('**/package-lock.json') }}
    restore-keys: |
      ${{ runner.os }}-nextjs-docker-

- name: Set up Docker Buildx
  uses: docker/setup-buildx-action@v3

- name: Build image
  uses: docker/build-push-action@v6
  with:
    context: .
    file: dockerfiles/nextjs/Dockerfile
    load: true
    tags: my-app:latest
    outputs: type=docker
    cache-from: type=gha
    cache-to: type=gha,mode=max

- name: Extract Next.js cache from container
  run: |
    CONTAINER_ID="$(docker create my-app:latest)"
    mkdir -p .next
    docker cp "${CONTAINER_ID}:/app/.next/cache" .next/cache
    docker rm "${CONTAINER_ID}"

結果:ビルド時間が半減した

実装後、劇的な改善が得られました:

項目 Before After 改善率
ビルド時間 25分 13分 48%削減
キャッシュサイズ 0 GB(毎回ゼロから) 約2GB(再利用) -

PRを出してから結果が返ってくるまでの時間が半分になり、開発速度が大幅に向上しました。

もう「ビルド待ち」で時間を無駄にすることはありません!

まとめ:この記事で学んだこと

キーポイント

  1. DockerでNext.jsをビルドすると、デフォルトでは.next/cacheが毎回消える

    • コンテナのライフサイクルと一緒にキャッシュも消失する
  2. BuildKitのキャッシュマウント(type=cache)はGitHub Actionsと相性が悪い

    • type=ghaバックエンドはキャッシュマウントを正しくエクスポートしない
  3. 解決策:キャッシュを明示的に出し入れする

    • ビルド前:GitHub Actionsキャッシュから.next/cacheをリストア
    • ビルド中:.dockerignoreで除外せず、COPY . .でDockerに含める
    • ビルド後:docker cpでコンテナから抽出して、次回のために保存
  4. キャッシュキーはpackage-lock.jsonのハッシュを使う

    • github.shaを使うと毎回別キャッシュになるので注意

得られた効果

  • ビルド時間:25分 → 13分(48%削減
  • 開発体験の向上
  • チーム全体の生産性アップ

おわりに

時には、エレガントな解決策じゃなくても、動くものが最高の解決策です。

BuildKitのキャッシュマウントが理想的に見えても、GitHub Actionsとの組み合わせで動かないなら、docker cpを使ってでも目的を達成する。それが実務です。

同じ問題で困っている方の参考になれば嬉しいです!

内容について、ご意見やツッコミもお寄せいただけると嬉しいです🥰

GitHubで編集を提案

Discussion