返回博客

next build 触发 instrumentation 噪声:模块级去重为什么会失效

某次 Docker 构建时,我盯着 CI 日志发现同一行警告像复读机一样刷了将近二十遍:

[logger] INGEST_URL/INGEST_KEY 未配 · 后续 server-side 错误不会上报
[logger] INGEST_URL/INGEST_KEY 未配 · 后续 server-side 错误不会上报
... (还有十几行) ...
Generating static pages (0/47) ...
[logger] INGEST_URL/INGEST_KEY 未配 · 后续 server-side 错误不会上报

这个警告本身是正确的——INGEST_KEY 这类运行时密钥我们刻意不打进镜像,通过 docker-composeenv_file 在容器启动时注入。但构建日志看起来像是配置错了,信噪比极差。

现象

next build(无论是本地构建还是 CI 里的 docker build)期间,instrumentation.ts 里注册的初始化逻辑被多次触发,每次都走到"环境变量未配"的警告分支,且代码里已有的模块级去重 flag 完全没有起作用:

let warnedOnce = false
export function log(rec) {
  if (!INGEST_KEY) {
    if (!warnedOnce) {
      console.warn('[logger] INGEST_KEY 未配...')
      warnedOnce = true
    }
    return
  }
  // ...
}

warnedOnce 设为 true 之后,下一条警告依然照常出现。

根因

两件事叠加在一起:

第一:next build 会启动临时 Next 服务

next build 的 "Collecting page data" 阶段并不只是编译代码。它会在构建器内部启动一个临时的 Next 服务,用来执行每个静态页的数据收集。这个临时服务同样会触发 instrumentation.tsregister() 钩子。也就是说,register() 不只在 next start 时运行,next build 期间也会触发。

第二:静态页收集是多 worker 并行的,每个 worker 是独立 Node 进程

Next 14 的静态页收集器按 CPU 核数开多个 worker 并行处理页面。每个 worker 是一个独立的 Node 进程,模块状态完全隔离——也就是说,let warnedOnce = false 在每个 worker 里各有一份副本,互不可见。

坑:模块级 singleton 跨不过 Next worker 边界

let flag = false / Map / Set 这类模块级单例在 Next.js 里是每个 worker 一份,不是每次部署一份。next build 有多少个静态页 worker,warnedOnce 就被重置多少次。next dev HMR 每次代码变更也会重启服务,同样如此。Vercel serverless 冷启动也是同一个坑。

正解

process.env.NEXT_PHASE 检测当前是否处于构建阶段,然后直接 early-return。日志上报是运行时概念,构建期间什么都不需要做。

const INGEST_URL = process.env.INGEST_URL
const INGEST_KEY = process.env.INGEST_KEY
 
// next build 的静态页收集阶段同样会触发 instrumentation.register()
// 构建时没有 INGEST_KEY 是预期行为,不是配置错误
const IN_BUILD_PHASE = process.env.NEXT_PHASE === 'phase-production-build'
 
let dropWarned = false
 
export async function serverLog(rec) {
  if (IN_BUILD_PHASE) return   // ← 这一行解决全部问题
  if (!INGEST_URL || !INGEST_KEY) {
    if (!dropWarned) {
      console.warn('[logger] INGEST_KEY 未配...')
      dropWarned = true
    }
    return
  }
  // ... 正常上报 ...
}

process.env.NEXT_PHASE 由 Next.js 在任何用户代码运行前写入,可以放心在模块顶层读取(模块加载时一次性解析):

| 值 | 时机 | |---|---| | phase-production-build | next build | | phase-production-server | next start 生产服务 | | phase-development-server | next dev |

只需对 phase-production-build early-return。phase-development-server 保留正常警告行为反而有用——它会提醒你本地 .env.local 还没配好。

正解:用 NEXT_PHASE 而不是 NODE_ENV 区分构建和运行时

NODE_ENV=productionnext buildnext start 时都成立,无法区分二者。NEXT_PHASE 才是可靠的信号。同一个 module 里有多个运行时副作用(DB ping、Redis 初始化、遥测上报),可以统一提取:

const IS_RUNTIME = process.env.NEXT_PHASE !== 'phase-production-build'
 
export function pingDb()         { if (!IS_RUNTIME) return; /* ... */ }
export function reportTelemetry() { if (!IS_RUNTIME) return; /* ... */ }

验证

改动前后用 grep 计数:

# 改前:输出 18(或更多,取决于静态页数量 × worker 数)
docker build . 2>&1 | grep -c 'INGEST_KEY 未配'
 
# 改后:输出 0
docker build . 2>&1 | grep -c 'INGEST_KEY 未配'

同时验证运行时行为未受影响——不带 NEXT_PHASE 直接跑 Node,警告依然出现一次:

node -e "import('./lib/logger.mjs').then(m => m.serverLog({msg:'test'}))"
# → "[logger] INGEST_KEY 未配..." ✓ 运行时警告保留

一句话外卖

Next.js 的 instrumentation.register() 在构建期间也会触发,且多 worker 架构让模块级去重完全失效——只有 NEXT_PHASE === 'phase-production-build' 才能精准区分构建和运行时。