一次 Astro 静态构建里被误导的 MissingSharp 排查记录
先记一次构建排查现场。
CI 报的是 MissingSharp,但硬盘上并不缺 sharp。页面里一行看似无害的 server import,在 Astro 静态构建阶段启动了一整条服务端副作用链,最后把错误带到了图片优化阶段的 native / CJS 包加载上。
现场现象
项目是一个 monorepo 里的 Astro 站点:
apps/japanese-official-websiteCI 在 test 分支上跑:
pnpm build:jp:staging构建前半段都正常:
vite building prerender environment
vite building ssr environment
vite building client environment
prerendering static routes失败发生在最后的图片优化阶段:
generating optimized images
MissingSharp: Could not find Sharp. Please install Sharp (`sharp`) manually into your project or migrate to another image service.从日志看,第一反应很容易是:
CI 没装 sharp?
CI 没装 devDependencies?
pnpm optionalDependencies 有问题?但这个判断很快被推翻了。
为什么不是简单的 sharp 没装
如果 CI 真的没有安装 devDependencies,构建大概率更早就会失败。这个项目里 vite 等构建工具也在开发依赖链路里,但日志已经走到了 Astro 图片优化阶段,说明构建工具链不是完全缺失。
后来在 origin/test 对应 commit 上做了干净复现:
pnpm install --frozen-lockfile
pnpm build:jp:staging安装阶段明确看到:
sharp install: Done而且在同一个 worktree 里,用普通 Node 直接加载 sharp 是成功的:
node --input-type=module -e "await import('sharp')"所以它不是硬盘上没有 sharp,也不是 sharp 完全不可解析。
问题藏在 Astro 构建产物里。
打开 Astro 包装过的错误
Astro 的 sharp image service 大概会这样加载 sharp:
async function loadSharp() {
let sharpImport;
try {
sharpImport = (await import('sharp')).default;
} catch {
throw new AstroError(MissingSharp);
}
sharpImport.cache(false);
return sharpImport;
}这里有一个排查陷阱:原始错误被 catch 掉以后,统一包装成了 MissingSharp。
为了确认原始错误,临时给 catch 加了一行日志:
} catch (error) {
console.error('[debug sharp import failed]', error);
throw new AstroError(MissingSharp);
}这时原始错误出现了:
ReferenceError: require is not defined in ES module scope也就是说,sharp 并不是不存在,而是在被动态 import 的过程中,某个 CJS 相关链路碰到了 ESM 作用域里的 require 问题。
问题来自一行 layout import
对比当前业务分支和 origin/test 后发现,apps/japanese-official-website 里真正多出来的代码只有一行:
import '@sugo/utils/server';它出现在:
apps/japanese-official-website/src/layouts/MainLayout/index.astro这行代码看起来像是在引一个 server 工具包,但它背后不是纯函数。
@sugo/utils/server 的 barrel 里做了这件事:
export { sdk } from './otel';
export { redis, cacheGet, cacheSet, cacheDel, cacheExists, cacheExpire, cacheIncr, cacheWrap } from './redis';也就是说,只要 import 这个 barrel,就会触发:
import ./otel
import ./redis而这两个模块都有顶层副作用。
CI 日志里在 MissingSharp 前出现过:
[OTel] Connecting to collector: http://localhost:4317
[OTel] Initializing SDK...
[OTel] SDK started successfully
[redis] error pid=... Port should be >= 0 and < 65536. Received type number (NaN).这说明 Astro 静态构建阶段确实执行了这条 server import 链。
otel 做了什么
packages/utils/src/server/otel.ts 里做了几件事:
import { metrics } from '@opentelemetry/api';
import { NodeSDK } from '@opentelemetry/sdk-node';
import { OTLPMetricExporter } from '@opentelemetry/exporter-metrics-otlp-grpc';
import { PeriodicExportingMetricReader } from '@opentelemetry/sdk-metrics';
import { RuntimeNodeInstrumentation } from '@opentelemetry/instrumentation-runtime-node';然后顶层创建 exporter:
const collectorUrl = process.env.OTEL_COLLECTOR_URL ?? 'http://localhost:4317';
const exporter = new OTLPMetricExporter({
url: collectorUrl,
});再创建 metric reader:
const metricReader = new PeriodicExportingMetricReader({
exporter,
exportIntervalMillis: exportInterval,
exportTimeoutMillis: 10000,
});然后启动 NodeSDK:
const sdk = new NodeSDK({
resource,
metricReader,
instrumentations: [new RuntimeNodeInstrumentation()],
});
sdk.start();再启动 host metrics:
new HostMetrics({
meterProvider,
metricGroups: ['process.cpu', 'process.memory'],
}).start();危险点主要在这里:
instrumentations: [new RuntimeNodeInstrumentation()]它会做 Node runtime instrumentation,间接引入 require-in-the-middle,去 patch Node 的模块加载逻辑。
在普通 Node SSR 服务运行时,这可能是合理的;但在 Astro 静态构建和 prerender 的 ESM 产物里,它就进入了一个很危险的位置。
触发链路
这次问题可以先抽象成这条链:
Astro build
-> prerender 页面
-> 执行 MainLayout frontmatter
-> import '@sugo/utils/server'
-> 顶层执行 ./otel
-> sdk.start()
-> RuntimeNodeInstrumentation 安装 require hook
-> Astro 图片优化阶段 import('sharp')
-> sharp 的 CJS/native 加载经过被 patch 的 require 链路
-> ESM 作用域里触发 require is not defined
-> Astro catch 后包装成 MissingSharp所以最终报错虽然是:
MissingSharp但更接近的原因是:
构建阶段执行了带全局副作用的 server instrumentation。为什么这类 import 危险
Astro 页面、layout、组件 frontmatter 里的顶层代码,不只是在浏览器运行,也会在构建、SSR、prerender 阶段运行。
所以如果在这里 import 一个模块,它有下面这些行为,就要非常小心:
顶层启动 SDK
顶层连接 Redis / DB / MQ / collector
顶层注册 require hook / import hook
顶层 patch Module.prototype.require
顶层读生产环境变量并马上创建客户端
顶层启动定时器、进程监听、host metrics
顶层加载 native/CJS 包并参与 Vite SSR 打包这些行为的问题不是「一定不能做」,而是不能被页面构建链路无意间触发。
页面里想要的可能只是一个函数,但 barrel 顺手把整个 server runtime 都初始化了。
这就是最隐蔽的地方。
本次修复
这次先做最小修复:从 JP 官网 layout 中删除这行:
import '@sugo/utils/server';删除后,在 origin/test 对应 worktree 上验证:
pnpm install --frozen-lockfile
pnpm build:jp:staging构建通过,图片优化也完整跑完:
generating optimized images
51/51
build Complete这说明 JP 官网构建失败和这条 server import 链路直接相关。
这次留下的边界
这次排查已经能定位直接触发点,但还有几条边界需要单独看。
第一条是 @sugo/utils/server 这个 barrel 的设计边界。
现在它同时导出了纯函数、Redis、OTel。只要有人为了一个 server helper import 它,就会触发 Redis 和 OTel。更稳的方式可能是拆成:
@sugo/utils/server/url
@sugo/utils/server/cache
@sugo/utils/server/otel或者至少保证 @sugo/utils/server 本身没有顶层副作用。
第二条是 OTel 初始化应该放在哪里。
如果是 SSR 服务运行期需要的 instrumentation,它应该在实际的 server runtime 入口初始化,而不是被任意页面 layout import 出来。
第三条是 Astro 构建阶段是否应该禁用这类 instrumentation。
例如用更明确的环境变量或执行阶段判断:
ASTRO_BUILD
NODE_ENV
import.meta.env不过这类 guard 只是兜底,更重要的还是避免页面构建链路 import 带副作用的 server runtime。
第四条是 Astro 对 sharp 错误的包装是否会误导排查。
MissingSharp 在这里隐藏了原始的 require is not defined in ES module scope。以后遇到类似问题,应该先想办法拿到原始错误,而不是只围绕最终错误文案查依赖安装。
先记一条临时规则
这次排查留下的临时规则是:
不要在 Astro 页面、layout、静态站点构建链路里 import server barrel。
如果必须 import server 代码,要确认它满足两个条件:
没有顶层副作用
不会启动网络连接、SDK、定时器、进程 hook
不会 patch Node module loader否则,错误可能不会出现在 import 当场,而是出现在后面某个看起来完全无关的阶段。
这次就是这样:真正引爆点在 OTel runtime instrumentation,但表面爆出来的是 Astro 图片优化阶段的 MissingSharp。