工程師與貓
Published on

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

Authors
  • avatar
    Name
    Alex Yu
    Twitter

上週部署後,監控突然出現一堆 _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

參考資料