我最近接手了一个项目:一个 Node.js 应用用 Vite 编译前端。在浏览器托管的开发环境里一切正常,可迁移到 Azure Container Registry 之后,镜像构建就失败了。构建报告缺少 Vite 可执行文件,而在此之前依赖安装那一步已经出过错。
容器构建最终只留下一条简单的错误:
sh: 1: vite: not found
Vite早已出现在devDependencies里。构建阶段在调用构建脚本之前已经执行过npm ci。Dockerfile看起来毫无异常,于是最初的排查都集中在依赖选择或NODE_ENV上。
丢失的二进制文件其实源于更早的一次失败。npm ci曾试图从一个仅存在于浏览器端开发环境中的包托管站点下载数百个压缩包。它打印出Exit handler never called!,留下一个空的node_modules目录,却没有向Docker报告明确的失败状态。接下来的镜像层继续执行到npm run build,然后报出了它所需的第一个缺失的可执行文件。
锁文件把npm指向了一个无法访问的主机。本教程将演示如何检查安装是否完成、如何找出受影响的压缩包URL,以及如何在编译应用之前检查依赖层。修复方案是修改公共包的URL前缀并增加构建检查。包版本、完整性校验值、应用代码和Vite配置均保持不变。
目录
开始之前
你需要一个带有package.json的Node.js项目、版本为2或3的package-lock.json、对应的容器构建日志,以及Docker。示例使用受支持的Node 24镜像,以及一个把构建产物输出到dist目录的Vite项目。请根据你自己的项目检查依赖兼容性和输出目录。
在改动任何URL之前,先确认受影响的包来自公共registry还是私有registry。下文的.invalid主机名只是原始内部主机的替身。请先审查该前缀所服务的包,再决定如何调整。
构建是如何一步步走向失败的
这个应用最初诞生于一个浏览器托管的开发环境,后来迁移到了 Azure Container Registry 构建。开发平台充当了公共 npm 包的中转站。它在重新生成package-lock.json时,把自己的内部 tarball 地址写进了resolved字段。
这次故障横跨了两个环境:
在开发环境里,npm 能访问包代理,因此锁文件里保留了那些代理地址。
Azure Container Registry 的镜像构建器拿到同一份锁文件,却解析不了代理的主机名。
依赖安装打印出
Exit handler never called!,留下一个空的node_modules。而在这次事故中,安装失败的状态并没有阻断下一个 Docker 层的执行。下一层执行了
npm run build,调用 Vite。由于 Vite 可执行文件根本没装上,构建便报出vite: not found。
下面的排查步骤沿着这条链反向推进:从缺失的可执行文件追溯到包的来源,然后修复 URL,并对每次独立的安装加以防护。
图 1. 容器构建报告了这条故障链中最后一环的错误。
第 1 步:确认npm ci是否装过任何东西
该项目使用npm ci——对于需要可重复性的容器构建,这是正确的命令。npm 文档说明,该命令会在安装前先删除已有的node_modules目录,并且把锁文件视为冻结状态,不做任何更新。
正是这些特性让这次故障表现得格外清晰:构建无法退回到之前本地安装的包。一旦 tarball 下载失败,依赖目录里就不会留下任何可用的东西。
Dockerfile 把依赖安装和源码编译分成了两步。第 3 步会对比原始顺序与修改后的构建流程。
后面的vite: not found报错并不能说明 npm 到底装没装上这个包。先在出故障的同一环境里查看依赖目录:
test -d node_modules && find node_modules -mindepth 1 -maxdepth 1 | head
test -x node_modules/.bin/vite
在这次构建中,第一条检查显示没有已安装的包,第二条则确认 Vite 可执行文件不存在。二进制文件缺失也可能是漏装了开发依赖导致的,所以在改动NODE_ENV或包清单之前,先检查一下目录内容。
安装成功后,目录列表里应该能看到各个包名。test -x命令本身不会有任何输出,想了解结果,就要在它执行完之后立即查看退出状态码:
echo $?
如果缺少 Vite 可执行文件,预期结果如下:
1
返回值为0说明该可执行文件存在且可执行。
检查点:依赖目录为空说明问题出在安装步骤。如果目录里有其他包,只是缺 Vite,还要检查安装时是否漏装了开发依赖。以下命令只查看依赖目录,不会修改它。
第 2 步:找出锁文件会把包下载到哪里
一个典型的报错条目是这样的:
{
"node_modules/example-package": {
"version": "1.2.3",
"resolved": "http://packages.internal.invalid/npm/example-package/-/example-package-1.2.3.tgz",
"integrity": "sha512-"
}
}
上方的.invalid主机名替换了真实的内部主机名,其结构与历史 lockfile 保持一致。
npm 文档将resolved定义为获取包的来源地址。对于 registry 中的包,这个值可以是指向 tarball 的 URL。因此,构建机上配置的 registry 对本次事故毫无帮助——lockfile 早已告诉 npm 应该从哪里下载每个 tarball。
我统计了历史文件中受影响的条目:1,038 条依赖里有 528 条指向内部主机名,而外部构建环境根本无法解析这些主机。
npm 的调试输出提示了更早的故障。主机名解析失败出现在Exit handler never called!之前,这个顺序很关键:exit-handler 消息描述的是 npm 如何结束,而下载失败才解释了依赖树为什么是空的。
我没有去搜某个具体的包名,而是把 lockfile 当作数据来检查。下面这段小脚本会按主机名对所有 HTTPresolved值分组:
const fs = require('node:fs')
const lock = JSON.parse(fs.readFileSync('package-lock.json', 'utf8'))
const hosts = {}
for (const entry of Object.values(lock.packages ?? {})) {
if (!entry.resolved?.startsWith('http')) continue
const host = new URL(entry.resolved).host
hosts[host] = (hosts[host] ?? 0) + 1
}
console.table(hosts)
盘点结果显示,受影响的是 528 条记录,而不是一个损坏的压缩包。这也让修复边界一目了然:resolved 中记录的包地址需要修改,版本号和完整性校验值则保持不变。如果主机列表中包含私有 registry,请先停下来,弄清它提供哪些包,再动手改写。
历史盘点记录中有这一行。表格使用的是示例中的替换后主机名:
| 主机 | Resolved URL 数量 |
|---|---|
packages.internal.invalid |
528 |
实际的主机名和数量取决于你的 lockfile。这里的盘点统计的是 HTTP 压缩包地址,因此总数可能与依赖条目数不一致。
检查点:找出无法访问的主机,确认其中哪些包来自公共 npm registry。在选定替换前缀之前,先记录受影响的数量。仅凭主机名统计,还不足以证明改写这些 URL 是安全的。
第 3 步:只改写已知的公共包 URL 前缀
重新生成 lockfile 可能在修复 registry 的同时混入依赖更新。保持包版本和完整性校验值不变,能让修复更容易审查。
这个应用中受影响的包都来自公共 npm registry。开发平台以代理方式获取这些包,并保留了它们的完整性校验值。历史修复在容器安装之前,替换了 URL 前缀。
在本演练中,请将以下代码保存为 rewrite-lockfile.cjs。它只修改带有指定前缀的 resolved 字段,并在写入文件前检查是否存在缺失的完整性校验值,以及旧主机上的意外路径。
const fs = require('node:fs')
const oldPrefix = 'http://packages.internal.invalid/npm/'
const newPrefix = 'https://registry.npmjs.org/'
const oldHostname = new URL(oldPrefix).hostname
const filename = 'package-lock.json'
const lock = JSON.parse(fs.readFileSync(filename, 'utf8'))
if (!lock.packages || typeof lock.packages !== 'object') {
throw new Error('Expected a lockfile with a packages object (version 2 or 3)')
}
let rewritten = 0
for (const entry of Object.values(lock.packages)) {
if (typeof entry.resolved !== 'string') continue
if (!entry.resolved.startsWith(oldPrefix)) continue
if (!entry.integrity) throw new Error('An affected package has no integrity value')
entry.resolved = newPrefix + entry.resolved.slice(oldPrefix.length)
rewritten += 1
}
for (const entry of Object.values(lock.packages)) {
if (typeof entry.resolved !== 'string') continue
if (!entry.resolved.startsWith('http')) continue
if (new URL(entry.resolved).hostname === oldHostname) {
throw new Error('The old hostname remains under an unexpected URL prefix')
}
}
fs.writeFileSync(filename, JSON.stringify(lock, null, 2) + '\n')
console.log(`Rewrote ${rewritten} resolved URLs`)
依赖阶段执行脚本后,输出条数应与使用已批准前缀的URL数量一致。用于验证本教程的小型测试项目运行结果如下:
Rewrote 40 resolved URLs
该测试项目与历史应用相互独立,历史应用中有528条受影响记录。请以自己文件的清点结果作为预期数量。已修复的文件返回零属于正常情况;若未修复的文件返回零,请检查前缀。
该脚本并不能证明某个包是公开的,这一点需要另行确认。npm的完整性校验会随后验证下载的字节是否与lockfile一致。
使用.dockerignore将本地依赖和配置文件排除在构建上下文之外:
node_modules
dist
.git
.env*
.npmrc
这个示例安装的是公开软件包。如果使用私有镜像仓库,则需要一个可访问的专用端点,并在构建阶段进行安全认证。
下面第一段代码展示了原始 Dockerfile 中受影响的指令,第二段是教程的完整构建过程。它使用 Node 24 并带有防护的安装方式,请先确认与你的应用兼容。
修改前:原始安装与构建流程
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
RUN npm run build
优化后:URL 重写与依赖检查
使用两个构建阶段,这样源码改动就不会让依赖安装阶段失效:
FROM node:24-slim AS dependencies
WORKDIR /app
COPY package.json package-lock.json rewrite-lockfile.cjs ./
RUN node rewrite-lockfile.cjs \
&& npm ci --include=dev \
&& test -x node_modules/.bin/vite
FROM node:24-slim AS build
WORKDIR /app
COPY . .
COPY --from=dependencies /app/node_modules ./node_modules
COPY --from=dependencies /app/package-lock.json ./package-lock.json
RUN npm run build
修改后的 Dockerfile 让安装过程的每个环节都清晰可见:
URL 重写在
npm ci下载包之前,先把地址替换为已批准的公共包前缀。--include=dev明确声明要安装开发依赖。Vite 可执行文件检查能在安装后缺少 Vite 时让依赖阶段直接失败。
独立的依赖阶段先安装依赖,应用构建阶段再复制这些依赖并编译源码。
第二个阶段在复制应用源码之后才复制修复过的 lockfile。否则,COPY . . 会把原始 lockfile 重新带回构建镜像。此外,.dockerignore 也能防止本地 node_modules 覆盖第一阶段安装的依赖。
即使构建环境设置了 NODE_ENV=production,--include=dev 也会安装作为开发依赖的 Vite。这个镜像本身是构建环境,需要编译器和构建工具。请选择与项目兼容、且仍在支持期内的 Node 版本。
对历史 lockfile 的对比发现 528 个字段有变化,全部是 resolved 字段。version 和 integrity 字段没有任何变化,内部主机名也完全消失。上面示例脚本同样不会改动无关字段,尽管 JSON 格式可能有所调整。
图 2. 可移植性修复改变了包的下载地址,同时保持依赖身份不变。
可执行文件检查确保:如果 Vite 仍然缺失,依赖阶段就会失败。Docker 会在这一阶段停下,而不会继续执行应用构建。
检查点:核对重写数量与清单是否一致,确认旧的内部主机名已彻底清除,并对比修复前后的版本和 integrity 值。依赖阶段必须完成 npm ci --include=dev 和 Vite 可执行文件检查两项任务。第 5 步将构建镜像并验证应用输出。
第 4 步:在每一处独立安装应用防护
持续集成任务和容器镜像构建各自执行了独立的安装。修复工作流运行器并不会影响 Dockerfile 里的 npm ci。镜像构建拿到的是仓库的原始文件,并在另一个环境里创建自己的依赖层。
重写应该在依赖文件进入安装环境之后立刻执行。Docker 的缓存优化指南建议先安装依赖,再复制频繁变动的源码文件。示例中的依赖阶段正是这条边界——只改动源码时,只会重建应用阶段。
在每一处独立安装前应用同样的防护:
持续集成里的类型检查安装
容器构建中的安装
多阶段 Dockerfile 中运行时阶段的安装
图 3:工作流修复不会自动修复容器构建内的安装。
这种位置安排还避免了一个有误导性的局部修复。否则可能出现这种情况:类型检查通过了,镜像构建却失败了——因为两个任务用同一份 lockfile 各自执行了安装。
第 5 步:验证依赖树与信任边界
这次修复是安全的,因为受影响的条目都对应公开的 npm 包,背后是一个透明的开发平台代理。完整性校验字符串依然存在,重写也没有改变任何包的版本。
但对私有包、打过补丁的包,或者同一版本会返回不同字节内容的 registry 来说,同样的替换就不安全了。可移植的构建应该让这些包继续走可访问的私有 registry,并为其配置身份认证。
在别处套用这一模式之前,我会先盘点所有 resolved 主机名,把公开包和私有包区分开,再对比改动前后的包版本与完整性值。只要还有未经批准的主机名,构建就应该失败。
npm CLI 的一个 issue 记录了同样的 Exit handler never called! 报错,原因是从内部 registry 下载包时超时。这条消息本身无法说明包的来源。在这次事故中,是 tarball 的 URL 和失败的主机名确定了根因。
构建镜像并验证输出:
docker build --progress=plain -t vite-install-check .
docker run --rm vite-install-check sh -c \
'test -x node_modules/.bin/vite && test -s dist/index.html'
构建必须通过npm ci、Vite 可执行文件检查和npm run build这三道关卡。最后一条命令会验证最终镜像中的 Vite 构建产物是否符合预期。如果你的项目使用其他目录,请相应调整输出检查。本例中的镜像只负责编译应用,如需生产环境的 Web 服务器,请单独选择一个服务阶段。
这个最简示例跑通,并不代表现有应用的每个依赖都没问题。请对真正要上线的项目执行同样的构建。一旦发现缺少某个工具,先检查安装结果及其之前的报错信息,再考虑更换构建工具。