工程師與貓
ESC
Content
    ↑↓ navigate open esc close
    Published on

    Next.js 多架構部署踩坑:為什麼 _next/static 會 403

    Authors
    • avatar
      Name
      Alex Yu

    上週部署後,監控突然出現一堆 _next/static 的 403 錯誤。不是全部使用者都遇到,是「時好時壞」的那種。

    直覺先去看 S3 bucket,靜態資源明明有上傳啊。再仔細比對路徑才發現:瀏覽器請求的 buildId 跟 S3 上的對不上

    背景

    我們的 Next.js 應用部署在 K8s 上,叢集裡混著 amd64 和 arm64 兩種架構的節點。CI/CD 流程會分別 build 兩個架構的 Docker image,再用 manifest 合成一個 multi-arch image。

    靜態資源的部分,會在 build 完成後從 image 裡把 .next/static 抽出來上傳到 S3,透過 CDN 提供服務。

    架構大概長這樣:

    CI/CD Pipeline:
      build amd64 image → push ghcr.io/org/app:tag-amd64
      build arm64 image → push ghcr.io/org/app:tag-arm64
      create manifest  → ghcr.io/org/app:tag
     
    Upload to S3:
      docker create <image>
      docker cp → S3:/app/_next/static/

    問題:buildId 不一致

    Next.js 的靜態資源路徑長這樣:

    /_next/static/{buildId}/_buildManifest.js
    /_next/static/{buildId}/_ssgManifest.js
    /_next/static/chunks/...

    其中 buildId 是在 build 時產生的。我們原本的 next.config.js 是這樣寫的:

    // next.config.js
    const nextConfig = {
      generateBuildId: async () => {
        return Date.now().toString() // ← 問題在這
      },
    }

    Date.now() 當 buildId,看起來沒什麼問題。但在 multi-arch build 的情境下就爆了:

    amd64 build 開始時間:14:30:00 → buildId: "1741182600000"
    arm64 build 開始時間:14:30:03 → buildId: "1741182603000"

    兩個架構的 build 不可能在同一毫秒開始,所以 buildId 一定不同

    更麻煩的是,就算 buildId 一致,webpack 產出的 chunk hash 在不同架構上也不同:

    ┌────────┬──────────────────────────────┐
    │  架構  │       webpack chunk hash     │
    ├────────┼──────────────────────────────┤
    │ amd64  │ webpack-f48077c0701255c5.js  │
    │ arm64  │ webpack-b830c9fcc5f5478e.js  │
    └────────┴──────────────────────────────┘

    這不是 webpack 的 bug。webpack 在 5.96.1 修過「同架構重複 build 產出不一致」的問題(FlagIncludedChunksPlugin 的 module 排序不確定性),但跨架構是另一個層級的事——SWC、sharp 這類 native modules 在不同 CPU 架構上編譯出的結果本來就不同,這不是 webpack 能解決的。

    截至 Next.js 16,這個問題仍然 open,也不太可能被修復。

    403 怎麼發生的

    問題出在上傳靜態資源的步驟。原本的 workflow 只從其中一個架構的 image 抽取靜態資源:

    # .github/workflows/prod.yaml(修改前)
    - name: Extract and upload static assets to Amazon S3
      env:
        IMAGE: ${{ env.GHCR_IMAGE }}:${{ needs.build-and-push.outputs.image_tag }}
      run: |
        id=$(docker create "$IMAGE")
        docker cp $id:/app/.next/static - > next-static-tar-files
        docker rm -v $id
        tar -xvf next-static-tar-files
        aws s3 sync static/ s3://${{ vars.STATIC_BUCKET }}/_next/static/

    這裡 docker create 預設會 pull 跟當前 runner 相同架構的 image。假設 runner 是 amd64,那上傳到 S3 的就是 buildId: "1741182600000" 的靜態資源。

    但使用者的請求如果被 K8s 分配到 arm64 的 Pod,HTML 裡面引用的是 buildId: "1741182603000" 的路徑。S3 上沒有這個路徑,403。

    使用者 → CDN → K8s (arm64 Pod)
    
             HTML: /_next/static/1741182603000/_buildManifest.js
    
             CDN → S3: 沒有這個路徑 → 403

    修復

    兩件事要做:讓 buildId 一致,以及確保兩個架構的靜態資源都有上傳。

    1. 用 git commit SHA 當 buildId

    Date.now() 換成 git commit SHA,這樣不管什麼架構、什麼時間 build,buildId 都一樣:

    // next.config.js
    const { execSync } = require('child_process')
     
    const nextConfig = {
      generateBuildId: async () => {
        if (process.env.BUILD_ID) {
          return process.env.BUILD_ID // ← CI/CD 傳入 git SHA
        }
        return execSync('git rev-parse --short HEAD').toString().trim() // ← local 開發用
      },
    }

    Dockerfile 加上 BUILD_ID build arg:

    ARG BUILD_ID
    ENV BUILD_ID=${BUILD_ID}

    CI/CD 傳入 github.sha

    # .github/workflows/prod.yaml
    - uses: docker/build-push-action@v6
      with:
        build-args: |
          BUILD_ID=${{ github.sha }}

    2. 上傳兩個架構的靜態資源

    前面提到,就算 buildId 一致了,跨架構的 chunk hash 仍然不同。所以這步不是「保險起見」,是必要的。從兩個架構的 image 都抽取靜態資源上傳:

    # .github/workflows/prod.yaml(修改後)
    - name: Extract and upload static assets to Amazon S3
      env:
        IMAGE_TAG: ${{ needs.build-and-push.outputs.image_tag }}
      run: |
        set -euo pipefail
        for arch in amd64 arm64; do
          echo "Extracting static assets from ${arch} image"
          id=$(docker create "${{ env.GHCR_IMAGE }}:${IMAGE_TAG}-${arch}")
          docker cp "$id:/app/.next/static" - | tar -x
          docker rm -v "$id"
        done
        aws s3 sync static/ s3://${{ vars.STATIC_BUCKET }}/_next/static/ \
          --cache-control "public, max-age=31536000"

    這裡有個小細節:docker create + docker cp 不需要 buildx,即使在 amd64 的 runner 上也能從 arm64 image 抽取檔案。因為 docker create 只是建立 container layer,不需要真的執行 binary。

    驗證

    部署後確認 S3 上只有一組 buildId 的靜態資源(因為兩個架構的 buildId 現在一致了),403 錯誤歸零。

    # 確認 buildId 一致
    aws s3 ls s3://bucket/_next/static/ --recursive | grep buildManifest
    # _next/static/de70b4e/_buildManifest.js  ← 只有一組

    重點整理

    • Date.now() 當 buildId 在單一架構沒問題,但 multi-arch build 會產生不同的值
    • 就算 buildId 一致,跨架構的 chunk hash 仍然不同(native modules 的差異),這個問題到 Next.js 16 都還沒修
    • Multi-arch 環境下,從所有架構的 image 都上傳靜態資源是必要的,不只是防禦性措施
    • docker create + docker cp 可以跨架構抽取檔案,不需要 buildx

    參考資料