一次 Astro 静态构建里被误导的 MissingSharp 排查记录

先记一次构建排查现场。

CI 报的是 MissingSharp,但硬盘上并不缺 sharp。页面里一行看似无害的 server import,在 Astro 静态构建阶段启动了一整条服务端副作用链,最后把错误带到了图片优化阶段的 native / CJS 包加载上。

现场现象

项目是一个 monorepo 里的 Astro 站点:

apps/japanese-official-website

CI 在 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