Vercel 构建超过 45 分钟被强杀 —— 排查与修复

Vercel 构建跑了 45 分钟被 'Build exceeded maximum duration' 强杀,通常是缓存失效、页面生成失控,或某个构建后脚本卡死。

上周还 8 分钟跑完的 Vercel 构建,今天跑了 45 分钟被强杀,日志里写着 Build exceeded maximum duration of 45m。本地 next buildastro build 4 分钟跑完。原因几乎不是代码一夜变慢,而是:构建缓存被回收导致全冷安装、getStaticPaths 意外返回了远超预期的页面数、content 扫描走进了奇怪的 node_modules,或者某个 post-build 钩子(sitemap、RSS、搜索索引)卡在网络调用上。

常见原因

按实际出现频率排序。

1. 构建缓存被回收,整次冷装

Vercel 构建缓存目录在 .vercel/cache/,跨部署保留 node_modules.next/cache 和各框架缓存。项目闲置约 7 天,缓存会被回收,下一次构建就是冷装 + 冷框架缓存,耗时可能翻 4-6 倍。

如何识别:正常构建日志开头是 Restored build cache from ...,慢的那次显示 No build cache foundBuild cache miss

2. getStaticPaths / content collection 返回的页面数失控

paths: posts.flatMap(p => tags.map(t => p)) 这种写错(应该按 tag 映射)会让生成的页面数翻 10-100 倍。每页几百毫秒,5 万页就能把 45 分钟吃完。

如何识别:日志显示 Generating static pages (47832/250000),和上次绿色构建的页面数一对比立刻露馅。

3. 构建后脚本卡在网络请求上

sitemap、RSS、OG 图、搜索索引同步调用外部 API 时,可能按 HTTP 超时(默认 120 秒)卡住每一项。几百项叠加就爆预算。

如何识别next build / astro build 部分 5 分钟跑完,但 node scripts/build-sitemap.mjs 之类的脚本卡在日志里不再输出。

4. 内存压力导致末尾 GC 抖动

构建进程堆耗尽进入 V8 慢 GC 模式后,最后 20% 的页面可能比前 80% 慢 10 倍。最后要么 OOM 要么超时。

如何识别:每批页面耗时明显变慢(前 1000 页 2 分钟,下一个 1000 页 8 分钟),或者出现 FATAL ERROR: ... heap out of memory

5. 大型依赖安装(puppeteer、sharp-libvips、Playwright)

每次冷构建都下载 300 MB Chromium 的 postinstall,单独就能加 5-10 分钟。叠加冷缓存就把构建挤爆。

如何识别:安装日志里出现 Downloading Chromium ...Downloading libvips ...,单步耗时比整个 install 的剩余部分都长。

6. monorepo 全图 tsc 类型检查

tsc --noEmit 跨 200 个包却没启用 project references / incremental,每次都把所有文件走一遍。冷缓存下单这一步能耗 10-20 分钟。

如何识别:日志里有一段 tsc 调用,5 分钟以上不输出任何内容才进入下一步。

7. 体量过大的 bundle 在压缩阶段卡住

如果某个客户端 bundle 涨到 50 MB 以上(常见是某处动态 import 误带进 aws-sdk v2 或 mongodb),Terser/SWC minify 会变成原来 10 倍耗时。

如何识别:日志 Optimizing bundle...Minifying... 卡了好几分钟。比对上次绿色构建的 bundle 体积。

开始排查前

  • 抓完整的失败构建日志,Vercel 在 Deployments → Build Logs 里。
  • 弄清楚是被卡 45 分钟强杀、OOM 杀、还是某行后再无输出。
  • 对比上次绿色构建的耗时和页面数,与失败构建最后一条页面计数比对。
  • 本地 vercel build --prod 必须能跑完,才有和 CI 做 A/B 的基础。

需要收集的信息

  • 构建开始/结束时间戳和最后一条成功日志。
  • next.config.js / astro.config.mjs,确认 ISR/SSG/output: static 配置。
  • 健康构建与失败构建的页面生成数对比。
  • package.jsonscripts.build 以及 postbuild 钩子。
  • 本地 du -sh node_modules/ 的冷装大小。
  • 是否使用 Turbo / Nx / Lerna,构建图什么样。

分步修复

按性价比排序。

步骤 1:禁用缓存重跑,定位冷启动还是构建本身

Vercel 面板里 Deployments → … → Redeploy → 取消勾选 “Use existing build cache”。如果冷构建依然 45 分钟,问题在构建本身;如果只在首次冷构建出现,那就是缓存回收。

步骤 2:构建前先打印页面数

在 prebuild 阶段加一个断言:

// scripts/check-page-count.mjs
import { glob } from "glob";
const files = await glob("src/content/**/*.{md,mdx}");
console.log(`[precheck] content files: ${files.length}`);
if (files.length > 10000) {
  console.error("[precheck] page count over threshold");
  process.exit(1);
}

配成 prebuild 钩子。源数据爆炸时直接快速失败。

步骤 3:把 post-build 脚本用超时包起来

任何带网络调用的构建后步骤都该有硬超时:

# package.json scripts.postbuild
"postbuild": "timeout 300 node scripts/build-sitemap.mjs || echo 'sitemap step skipped'"

5 分钟到点直接干掉,构建继续。下次部署再生成 sitemap。一份过期 sitemap 也比被强杀强。

步骤 4:monorepo 切到 Turborepo 任务图

// turbo.json
{
  "pipeline": {
    "build": {
      "dependsOn": ["^build"],
      "outputs": [".next/**", "!.next/cache/**"],
      "cache": true
    }
  }
}

根目录跑 turbo run build --concurrency=4 取代 npm run build。包之间命中缓存可省 60-80%。

步骤 5:重型二进制不要在构建期下载

Chromium / Playwright 不要在 Vercel 安装阶段下载。函数里改用 @sparticuz/chromium,或者截图任务挪到独立 worker(Render、Fly、AWS Lambda 层),构建只 fetch URL。

# package.json
"postinstall": "echo 'skipping chromium download in build'",

Vercel 环境变量里设 PUPPETEER_SKIP_DOWNLOAD=true

步骤 6:大量 SSG 页面切到 ISR / 按需生成

如果 getStaticPaths 返回 5 万多页而多数页面访问稀疏:

// pages/posts/[slug].tsx
export async function getStaticPaths() {
  const topPosts = await fetchTopPosts(500); // 构建期只生成前 500 页
  return {
    paths: topPosts.map(p => ({ params: { slug: p.slug } })),
    fallback: "blocking", // 其余首次请求时生成
  };
}

构建时间随预生成集线性下降。运行期表现见 Next.js ISR revalidation stuck

步骤 7:调大 Node 堆并二分找瓶颈

# vercel.json 或环境变量
NODE_OPTIONS="--max-old-space-size=8192"

然后二分:临时注释掉所有 post-build 脚本,看框架自己 build 多久;差值就是 post-build 的代价,再逐个回插定位。

验证

  • 构建时长回到上次绿色构建的 1.5 倍以内。
  • 修复后下一次构建日志显示 Restored build cache
  • 页面生成数符合预期,不再出现莫名的 10 倍。
  • 每个 post-build 脚本都有清晰的开始/结束行。
  • --force 不带缓存测试一次,依然能在 45 分钟内跑完。

长期预防

  • 构建时长报警:任何部署超过滚动平均 1.5 倍就触发通知。
  • 所有 postinstall 重下载都用环境变量 gate,CI 上可跳过。
  • 每次构建末尾打一行 [build-stats] pages=N duration=Xs bundle-size=Y 方便日后 grep 趋势。
  • 把缓存 miss 视为一种已知代价,对闲置项目每周跑一次预热部署。
  • 包数超过 3 的 monorepo 必须上 turbonx 的输出缓存。
  • getStaticPaths 只预生成 Top-N 热门,长尾交给 ISR / 按需。

常见坑

  • 升级到 Enterprise 想”拉高上限” —— Vercel 45 分钟上限对所有套餐都一样,要的是真修复不是换账单。
  • 给已经 OOM 抖动的构建加并发 —— 只会更糟。
  • 拼命缓存 node_modules 却忘了 .next/cache —— 真正提速的部分在那里。
  • 一遇问题就”清缓存重跑”,把每次冷启动 4-6 倍的代价吃满。
  • 给卡死的网络调用加 sleep 60 重试而不是硬超时,相关 hang 模式见 Vercel stuck building

常见问答

Q: 我是 Pro,能不能把构建上限提到 60 分钟?

不行,45 分钟是 Hobby/Pro/Enterprise 通用平台上限。超过就要拆 monorepo、ISR fallback,或者搬到 GitHub Actions 预构建产物。

Q: 缓存恢复了但构建还是慢。

缓存恢复只对写了缓存目录的框架有效。确认 .next/cache.astro/node_modules/.cache/ 没有被某个 clean 步骤删掉,也看下 vercel.jsonbuildCommand 里有没有 rm -rf

Q: 要不要把构建搬到 GitHub Actions,只把产物推给 Vercel?

如果构建稳定超过 30 分钟,建议这么做。CI runner 上跑 vercel deploy --prebuilt,标准 Vercel 端流程参考 Vercel build failed

Q: 能看到缓存为什么被回收吗?

不能,Vercel 不暴露缓存 LRU 事件。经验上:项目闲置 7 天以上、构建命令变更、Node 主版本变更都会让缓存失效,提前心里有数即可。

标签: #排查 #Vercel #构建 #timeout #CI