跳到正文

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

这次构建排查一开始被错误信息带偏了。

CI 报的是 MissingSharp,但硬盘上并不缺 sharp,普通 Node 也能直接加载它。继续展开原始异常、对照导入链后,问题定位到 layout 里一行会启动服务端初始化的 import。删除它后构建恢复;OTel 的模块加载 instrumentation 是重点嫌疑,具体触发环节尚未逐层隔离。

现场现象

项目是一个 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 的结果,都不支持缺包这个判断。

为什么不是简单的 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 构建产物加载 sharp 时发生了什么。

打开 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 问题。

MissingSharp 是外层标签,要继续看原始异常

问题来自一行 layout import

对比当前业务分支和 origin/test 后发现,apps/japanese-official-website 里真正多出来的代码只有一行:

import '@sugo/utils/server';

它出现在:

apps/japanese-official-website/src/layouts/MainLayout/index.astro

这行 import 会经由 @sugo/utils/server 的 barrel 加载 OTel 和 Redis:

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 链。

副作用链路为什么会撞到 sharp

OTel 的顶层初始化会启动 instrumentation,是继续隔离的重点。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();

NodeSDK 的配置中启用了运行时插桩:

instrumentations: [new RuntimeNodeInstrumentation()]

它会初始化 Node runtime instrumentation,依赖链还包含 require-in-the-middle 这类模块加载插桩工具。这是继续排查的源码线索;具体注册了哪些 hook,还要看依赖版本、配置和执行过程。

服务运行期可以有明确需要的 instrumentation。现在需要确认的是,这条初始化链进入 Astro 静态构建和 prerender 的 ESM 产物后,究竟哪一层触发了加载失败。删除导入的对照实验定位到了整条链,还没有逐层隔离到某个 hook。

沿导入和初始化顺序看,这次问题涉及下面这条链:

server import 与 MissingSharp 的证据链

外层错误仍然显示为:

MissingSharp

已有证据指向构建期执行的 server 初始化链,尚不能确定内部哪个环节导致了 sharp 加载失败。

这里还要区分 Astro 的两种脚本位置。组件文档说明,.astro 页面的 frontmatter 在服务端执行:静态页面在构建预渲染时运行,按需渲染页面在服务端请求阶段运行,frontmatter 本身不会发送到浏览器执行。浏览器交互代码则放在 <script> 或客户端组件里。layout 也是 Astro 组件,不能因为导入写在页面文件里,就把它当成浏览器代码。

在这些位置导入模块前,需要检查它是否会在顶层执行这些动作:

  • 启动 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 链路直接相关。

后续要补的构建期副作用边界

删除 server import 后构建恢复,继续安装 sharp 解决不了这次问题。后续需要沿导入副作用继续隔离,并明确哪些初始化允许进入构建阶段。

@sugo/utils/server 现在同时导出了纯函数、Redis、OTel。只要有人为了一个 server helper import 它,就会触发 Redis 和 OTel。可以考虑按用途拆分入口:

@sugo/utils/server/url
@sugo/utils/server/cache
@sugo/utils/server/otel

或者至少保证 @sugo/utils/server 本身没有顶层副作用。

SSR 服务运行期需要的 instrumentation,应在实际的 server runtime 入口初始化,避免由页面 layout 的导入间接启动。构建阶段也可以增加执行阶段检查,阻止这类初始化。

可以由项目自行定义 ASTRO_BUILD 这类执行阶段标记,并在构建入口传入;它不是 Astro 自动提供的通用变量。Astro 的环境变量文档区分了 mode、生产环境和服务端执行等信息,不能把 NODE_ENV=production 直接当成「当前不是构建阶段」的证明。

不过这类 guard 只是兜底,更重要的还是避免页面构建链路 import 带副作用的 server runtime。

错误诊断也需要保留原始异常。MissingSharp 在这里隐藏了 require is not defined in ES module scope;遇到类似问题,应先展开原始错误,再判断是否需要检查依赖安装。

这次先限制的是带顶层初始化副作用的 server barrel,不是所有服务端模块。纯函数和确实供构建期使用的服务端代码,仍可以在 Astro 的服务端执行阶段导入。

页面只需要纯函数时,还要确认它的导入链不会顺带启动网络连接、SDK、定时器、进程 hook,或 patch Node module loader。确实供构建期使用的服务端操作,应放在明确入口执行,并单独验证其副作用。

MissingSharp 仍可能来自缺包或其他加载错误。删除 server import 是这个现场的修复,不能当成所有同名报错的通用解法。

总结

这次 MissingSharp 是图片服务包装动态 import 失败后的外层错误,原始异常是 require is not defined in ES module scope。安装日志和普通 Node 加载排除了简单缺包的判断;删除 layout 中会启动 OTel、Redis 的 server import 后,构建与 51 张图片优化通过,修复范围落到了这条构建期导入链。OTel 模块加载 instrumentation 仍是重点嫌疑,具体触发环节尚未逐层隔离。

Astro 的静态页面和 layout 会在构建预渲染时执行服务端代码,因此导入一个 helper 也可能带入 SDK、网络连接和 module loader 副作用。类似问题先展开原始异常,再核对实际导入链和执行阶段;隔离顶层初始化不等于禁止所有服务端模块,构建期确实需要的操作仍应从明确入口执行并验证。