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

- 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
參考資料
- Next.js - generateBuildId
- vercel/next.js#45659 - arm64 generateBuildId chunk hash 不一致
- vercel/next.js#49230 - 即使設定 generateBuildId,不同環境的 chunk hash 仍不同
- webpack#18837 - Output chunks change when build stays the same
- vercel/next.js#63201 - builds are non-deterministic
- vercel/next.js Discussion #65856 - chunk hash 不是單純由 BUILD_ID 決定
