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

为什么不是简单的 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

这行代码导入的是一个 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 链。

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

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 产物里,这段初始化会影响构建期的模块加载。

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

本例:顶层 OTel 初始化怎样影响 sharp 加载

所以最终报错虽然是:

MissingSharp

更接近的原因在构建阶段:带全局副作用的 server instrumentation 被执行了。

这类 import 的危险点在于,Astro 页面、layout、组件 frontmatter 里的顶层代码,不只是在浏览器运行,也会在构建、SSR、prerender 阶段运行。

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

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

后面要补的不是 sharp,而是副作用边界

删除 server import 后构建恢复,直接触发点已经落到这条导入链上。继续安装 sharp 解决不了这次问题;要沿导入副作用往下查,先补好下面四处边界。

第一条是 @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。以后遇到类似问题,应该先想办法拿到原始错误,而不是只围绕最终错误文案查依赖安装。

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

如果必须 import server 代码,先确认几件事:

没有顶层副作用
不会启动网络连接、SDK、定时器、进程 hook
不会 patch Node module loader

否则,错误可能不会出现在 import 当场,而是出现在后面某个看起来完全无关的阶段。

这次删除 server import 后,图片优化恢复成功,能确认这条导入链是触发条件。OTel 的模块加载 instrumentation 是沿源码追到的重点嫌疑;没有逐层隔离实验,就不把它概括成所有 MissingSharp 的原因。下次先展开被包装的原始异常,再沿副作用链定位。