Docker 基础体系 · 第 34/70 篇。示例以现代 Docker Engine、BuildKit 与 Compose 规范为基础,并明确 Linux 容器边界。

Docker 可重复依赖安装:锁文件、镜像源、缓存、校验和离线

在 Docker 构建中,“依赖安装成功”与“每次安装得到同一组依赖”是两件不同的事。前者只要求当前网络、当前镜像源和当前包仓库能够完成安装;后者还要求依赖版本、解析结果、下载内容、基础镜像以及构建过程中使用的外部状态都受到约束。

本文讨论 Linux 容器中的依赖安装,示例基于现代 Docker Engine、BuildKit 和 Compose 规范。重点不是某一个语言生态的命令,而是建立一套可验证的因果链:

锁文件
  ├── 约束依赖图和版本
  ├── 记录下载位置与完整性信息
  └── 让安装器拒绝未预期的解析结果
镜像源
  ├── 决定从哪里取得依赖
  └── 影响可用性、内容和供应链边界
缓存
  ├── 减少重复下载
  └── 不天然保证内容正确或构建可复现
校验和
  ├── 绑定“下载到的字节”
  └── 防止源返回了不同内容却仍被接受
离线
  ├── 把网络状态从构建过程中移除
  └── 要求依赖及其元数据事先完整准备

1. 先定义“可重复”

设一次依赖安装的输入为:

I=(L,S,B,T,C,E)I = (L, S, B, T, C, E)

其中:

  • LL:锁文件及其内容;
  • SS:软件包源,包括仓库地址、镜像和索引;
  • BB:基础镜像及系统包仓库状态;
  • TT:实际下载到的包内容;
  • CC:缓存状态;
  • EE:安装器、运行时和构建工具的版本。

安装结果记为:

R=F(I)R = F(I)

如果两次构建的 II 不同,不能仅因为 Dockerfile 文本相同,就推断两次得到的 RR 相同。

例如,下面三种情况都会导致结果变化:

  1. package.json 使用 ^1.2.3,镜像源今天解析为 1.2.9,下周解析为 1.2.10
  2. FROM node:22-alpine 没有固定镜像摘要,标签指向了新的基础镜像;
  3. 锁文件中的下载地址相同,但仓库重新上传了同名且不同字节的归档文件。

因此,“可重复”至少有三个层次:

1.1 依赖图可重复

依赖名称、版本和传递依赖关系一致。例如:

app
├── express@4.21.2
├── accepts@1.3.8
└── body-parser@1.20.3

这主要由锁文件保证。

1.2 依赖字节可重复

不仅版本一致,下载到的压缩包、源码包或二进制包的字节也一致。这需要锁文件中的完整性字段、包仓库校验和,或显式的 SHA-256 校验。

1.3 完整镜像可重复

依赖、基础镜像、编译器、系统包、文件时间戳和构建输出都一致。即使依赖完全固定,编译过程写入当前时间、随机 UUID 或非确定性归档顺序,也可能导致最终镜像摘要不同。

本文主要解决前两层,并说明它们与第三层的边界。


2. 锁文件到底锁住了什么

锁文件是包管理器保存的一次依赖解析结果。它通常包含:

  • 直接依赖和传递依赖;
  • 每个包的精确版本;
  • 依赖之间的关系;
  • 下载 URL 或仓库标识;
  • 某些生态中的完整性校验值;
  • 有时还包括平台、CPU 架构或可选依赖信息。

以 Node.js 为例,package.json 可以声明:

{
  "dependencies": {
    "express": "^4.21.0"
  }
}

^4.21.0 是一个范围,不是一个确定版本。它允许满足兼容规则的多个版本。安装器需要执行解析:

^4.21.0
  ├── 当前索引中找到 4.21.2
  ├── 解析 express 的传递依赖
  ├── 为每个传递依赖选择版本
  └── 将完整结果写入 package-lock.json

之后,package-lock.json 保存了具体结果。Dockerfile 应使用:

RUN npm ci

而不是:

RUN npm install

对 npm 项目而言,npm ci 的关键语义是:

  1. 要求存在锁文件;
  2. 根据锁文件安装,而不是重新生成一个新的解析结果;
  3. 通常会清理已有的 node_modules
  4. package.json 与锁文件不一致时失败,而不是静默修改锁文件。

这使错误尽早暴露。例如,修改了 package.json 却没有重新提交锁文件,构建可能出现类似:

npm error `npm ci` can only install packages when your package.json and package-lock.json are in sync

这里的失败是有价值的:它说明源码声明与构建输入不一致。

2.1 锁文件不是所有东西的锁

锁文件通常不能单独保证以下内容:

  • 基础镜像没有变化;
  • 操作系统包没有变化;
  • 镜像源没有被替换;
  • 下载 URL 返回的字节没有变化;
  • 安装脚本没有读取网络或执行时间相关逻辑;
  • 构建工具没有变化;
  • 所有平台上的可选依赖都相同。

因此,下面的 Dockerfile 仍然不具备完整的供应链可重复性:

FROM node:22-alpine

COPY package.json package-lock.json ./
RUN npm ci

package-lock.json 固定了 npm 依赖图,但 node:22-alpine 是可变标签,Alpine 仓库中的系统包也可能随时间变化。

更严格的形式是固定基础镜像摘要:

FROM node:22-alpine@sha256:<经过验证的镜像摘要>

摘要应由实际信任的镜像来源取得并审查。不能随意把某次构建输出中的摘要复制到项目里而不确认它对应的架构和内容。

2.2 完整性字段与版本字段的区别

版本字段回答:

我期望安装哪个版本?

完整性字段回答:

下载到的文件是否正是这个版本对应的那份内容?

例如,锁文件中可能有:

{
  "resolved": "https://registry.example/npm/pkg/-/pkg-1.2.3.tgz",
  "integrity": "sha512-..."
}

resolved 是位置,integrity 是内容约束。只验证 URL 而不验证内容,无法防止仓库返回不同字节的归档文件。


3. 一个可运行的 Node.js 构建示例

项目目录:

.
├── Dockerfile
├── package.json
├── package-lock.json
└── src/
    └── server.js

package.json

{
  "name": "repeatable-demo",
  "private": true,
  "version": "1.0.0",
  "type": "module",
  "scripts": {
    "start": "node src/server.js"
  },
  "dependencies": {
    "express": "4.21.2"
  }
}

注意这里使用精确版本,进一步减少开发者误解;但真正的传递依赖仍由 package-lock.json 固定。

src/server.js

import express from "express";

const app = express();

app.get("/", (_req, res) => {
  res.send("ok");
});

app.listen(3000, "0.0.0.0", () => {
  console.log("listening on 3000");
});

Dockerfile:

# syntax=docker/dockerfile:1

FROM node:22-alpine@sha256:<已审查的摘要> AS build

WORKDIR /app

# 依赖描述变化时,只有这一层及后续层需要重建
COPY package.json package-lock.json ./

# cache mount 只缓存 npm 下载内容,不把缓存目录写入最终镜像
RUN --mount=type=cache,id=npm-cache,target=/root/.npm,sharing=locked \
    npm ci --ignore-scripts

COPY src ./src

FROM node:22-alpine@sha256:<与构建阶段一致或已审查的摘要> AS runtime

WORKDIR /app
ENV NODE_ENV=production

COPY --from=build /app/package.json /app/package-lock.json ./
COPY --from=build /app/node_modules ./node_modules
COPY --from=build /app/src ./src

USER node
EXPOSE 3000
CMD ["node", "src/server.js"]

构建:

DOCKER_BUILDKIT=1 docker buildx build \
  --progress=plain \
  -t repeatable-demo:dev \
  .

运行:

docker run --rm -p 3000:3000 repeatable-demo:dev

预期访问结果:

$ curl http://127.0.0.1:3000/
ok

这个 Dockerfile 中有四个不同机制:

  1. COPY package.json package-lock.json ./ 将依赖描述单独放在前面,使源码变化不会导致依赖层必然重建;
  2. npm ci 使用锁文件并在不一致时失败;
  3. --mount=type=cache 缓存 npm 下载内容,减少后续构建的网络访问;
  4. 多阶段构建只把运行所需文件复制到运行阶段。

最后一点属于镜像体积和运行时边界优化,不等于依赖已经被校验。校验仍由包管理器和锁文件完成。


4. 镜像源:位置变化也会改变构建输入

镜像源是包管理器访问的仓库地址或仓库代理。例如 npm 默认使用公共 registry,企业环境可能使用:

https://npm-mirror.example.com/

镜像源影响三个方面:

  1. 可达性:构建环境是否能访问它;
  2. 内容供应:它提供哪些版本、平台包和元数据;
  3. 信任边界:依赖经过哪个代理、缓存或重新打包环节。

仅仅把公共源替换为内部源,不会自动提高可重复性。内部源必须满足至少一个条件:

  • 透明代理上游内容,并保留原始完整性信息;
  • 对包进行内容寻址和不可变存储;
  • 维护经过审查的制品仓库;
  • 以仓库快照或版本化仓库提供历史一致性。

4.1 在 Dockerfile 中配置源

可以使用构建参数传入源地址:

ARG NPM_REGISTRY=https://registry.npmjs.org/

RUN --mount=type=cache,id=npm-cache,target=/root/.npm,sharing=locked \
    npm ci --ignore-scripts --registry="${NPM_REGISTRY}"

构建时:

docker buildx build \
  --build-arg NPM_REGISTRY=https://npm-mirror.example.com/ \
  -t repeatable-demo:mirror .

但是,构建参数通常会出现在构建配置、历史记录或构建日志中,因此不应将访问令牌直接写入参数。带认证的源应使用 BuildKit secret:

RUN --mount=type=secret,id=npmrc,target=/root/.npmrc,required=true \
    --mount=type=cache,id=npm-cache,target=/root/.npm,sharing=locked \
    npm ci --ignore-scripts

构建:

docker buildx build \
  --secret id=npmrc,src="$HOME/.npmrc" \
  -t repeatable-demo:private .

/root/.npmrc 只在该 RUN 步骤中挂载,不应通过 COPY .npmrc 放进镜像。这样可以避免认证令牌进入镜像层。

4.2 锁文件中的 URL 与源切换

不同包管理器对“锁文件中已有下载 URL 时是否使用配置源”的行为并不完全相同。不能简单假设:

npm config set registry https://npm-mirror.example.com/
npm ci

就一定会把锁文件中的所有下载地址重写为内部源。

可靠做法有两种:

  1. 在受控环境中使用内部源生成并验证锁文件;
  2. 保留锁文件中的内容完整性字段,并在构建中验证内部源返回的字节与锁文件一致。

如果镜像源返回的包内容不同,npm ci 应因完整性校验失败而停止。若某个生态的锁文件不包含内容校验,必须通过制品仓库不可变策略、显式 SHA-256 校验或签名验证补足这一层。


5. Docker 构建缓存不等于依赖缓存

Docker 至少有两类容易混淆的缓存。

5.1 层缓存

对于:

COPY package.json package-lock.json ./
RUN npm ci

BuildKit 会根据指令、文件内容、相关构建输入等判断是否命中该步骤的缓存。只要 package-lock.json 内容不变,RUN npm ci 这一层通常可以复用。

层缓存命中时,命令可能根本不会再次执行。因此它提高速度,但也意味着“没有重新安装”:

首次构建:执行 npm ci,生成 node_modules
再次构建:命中 RUN 层,直接复用上一层结果

如果要验证依赖安装命令确实被重新执行,可以修改锁文件,或显式使用新的构建缓存。不要通过无意义地加入时间戳来强制重建,因为那会破坏可预测性。

5.2 BuildKit cache mount

下面的挂载:

RUN --mount=type=cache,target=/root/.npm \
    npm ci

/root/.npm 作为 BuildKit 管理的缓存目录。该目录通常不会成为镜像最终文件系统的一部分:

npm 下载包
   ↓
/root/.npm(BuildKit cache mount)
   ↓
npm ci 解包
   ↓
/app/node_modules(写入当前构建层)

因此:

  • npm 缓存可以提高后续构建速度;
  • node_modules 仍然应由当前步骤生成;
  • 删除或丢失缓存不应改变正确结果,只应导致重新下载;
  • 缓存本身不是锁文件,也不是内容校验机制。

5.3 并发与缓存污染

多个构建可能同时使用同一个缓存 ID:

RUN --mount=type=cache,id=npm-cache,target=/root/.npm,sharing=locked \
    npm ci

sharing=locked 的意图是让需要互斥访问的包管理器避免并发修改同一缓存。它可能降低并发度,但减少缓存数据库损坏或竞争条件。

应注意缓存键的边界。不同 Node 版本、不同架构或不同包管理器版本共用一个可变缓存,通常是可用性风险,而不是依赖正确性的证明。可以把关键维度纳入 ID:

RUN --mount=type=cache,id=npm-node22-linux-amd64,target=/root/.npm,sharing=locked \
    npm ci

实际项目可以按平台、Node 主版本和仓库边界规划缓存。缓存来自不可信来源时,还要考虑缓存投毒:攻击者如果能写入共享缓存,可能诱导后续构建读取恶意内容。包管理器的完整性校验能降低风险,但不应把共享缓存当作信任根。


6. 校验和:校验“版本”还是校验“字节”

校验和是对输入字节计算出的摘要。以 SHA-256 为例:

d=SHA256(x)d = \operatorname{SHA256}(x)

其中 xx 是下载文件的全部字节,dd 是摘要。构建时重新计算下载文件的摘要,并比较期望值:

期望:d_expected
实际:SHA256(downloaded_file)
条件:两者相等才继续

如果只声明:

需要 openssl 3.0.0

这只是版本约束;如果声明:

需要 openssl-3.0.0.tar.gz
SHA256=abc123...

才把版本和具体字节绑定起来。

6.1 Dockerfile 对远程文件的校验

Dockerfile 的 ADD 支持对远程 URL 使用 --checksum

ADD --checksum=sha256:<已审查摘要> \
    https://downloads.example.org/tool-1.2.3.tar.gz \
    /tmp/tool.tar.gz

这只适合 Dockerfile 明确下载单个远程文件的场景。它不是通用的包管理器依赖锁定方案,也不能替代 npm、pip、Maven 等生态自身的锁文件。

对于需要解压、签名验证或多文件处理的制品,更明确的写法是下载后验证:

ARG TOOL_URL=https://downloads.example.org/tool-1.2.3.tar.gz
ARG TOOL_SHA256=<已审查摘要>

RUN set -eux; \
    wget -O /tmp/tool.tar.gz "$TOOL_URL"; \
    echo "$TOOL_SHA256  /tmp/tool.tar.gz" | sha256sum -c -; \
    tar -xzf /tmp/tool.tar.gz -C /opt; \
    rm /tmp/tool.tar.gz

预期结果是:

/tmp/tool.tar.gz: OK

摘要不匹配时,sha256sum -c 退出非零,后续命令因 set -e 不再执行。

6.2 校验和不是签名

SHA-256 只能说明:

当前文件与某个已知摘要相同。

如果攻击者能够同时替换文件和项目中的摘要,单独的摘要无法建立来源信任。数字签名还需要:

  • 签名文件;
  • 可信公钥或信任根;
  • 签名验证过程;
  • 对公钥分发和轮换的管理。

因此供应链验证通常分成两层:

签名验证:文件由哪个密钥签署,是否属于可信发布者
校验和验证:文件字节是否是期望内容

包管理器锁文件中的 integrity 字段主要是内容完整性约束,不应被描述为发布者签名。


7. 离线构建的真实条件

--network=none 只表示构建步骤不能访问网络,不会自动把缺少的依赖变出来。

使用 BuildKit 时,可以对单个步骤限制网络:

RUN --network=none \
    --mount=type=cache,id=npm-cache,target=/root/.npm,sharing=locked \
    npm ci --offline --ignore-scripts

也可以在构建命令层面指定:

docker buildx build \
  --network=none \
  -t repeatable-demo:offline .

构建能否成功取决于以下条件:

  1. package-lock.json 已经存在并与 package.json 一致;
  2. npm 缓存中已经有锁文件所需的所有包;
  3. 包管理器所需的元数据也在缓存中;
  4. 安装过程不会下载安装脚本使用的额外文件;
  5. 基础镜像已经在本地或构建节点缓存中;
  6. Dockerfile 不执行其他网络访问。

例如,下面的阶段需要联网:

RUN npm ci

下面的阶段才可能离线:

RUN npm ci --offline

但“以前成功联网构建过”并不等于“缓存一定完整”。原因包括:

  • 之前命中过层缓存,实际没有执行当前安装;
  • 之前只安装了另一份锁文件;
  • 可选依赖因平台不同而未下载;
  • 安装脚本额外访问了网络;
  • BuildKit cache mount 没有被导出或没有被带到离线构建节点;
  • 缓存被清理或使用了不同的缓存 ID。

7.1 可验证的离线准备流程

一种明确的流程是把在线准备和离线构建分成两个阶段。

在线节点:

docker buildx build \
  --progress=plain \
  --cache-to=type=local,dest=.buildkit-cache,mode=max \
  -t repeatable-demo:prepared \
  .

.buildkit-cache 以及基础镜像同步到离线构建节点。离线节点:

docker buildx build \
  --progress=plain \
  --network=none \
  --cache-from=type=local,src=.buildkit-cache \
  -t repeatable-demo:offline \
  .

这要求两个节点的构建环境、平台和缓存布局兼容。导出的 BuildKit 缓存是构建加速数据,不是对依赖来源的签名证明;项目仍应把锁文件、基础镜像摘要和依赖制品清单作为可审查输入保存。

更强的离线方案是把依赖归档或内部制品仓库一并同步,而不是依赖某个临时构建缓存。缓存丢失时,制品仓库仍能重新填充缓存;只有缓存而没有制品源,恢复能力较弱。


8. 使用 Debian/Ubuntu 系统包时,锁定方式不同

语言包管理器通常有锁文件;APT 的常见安装方式却经常只写版本:

RUN apt-get update \
 && apt-get install -y --no-install-recommends ca-certificates curl=某个版本 \
 && rm -rf /var/lib/apt/lists/*

这仍然不能完全等同于可重复安装,因为:

  • apt-get update 获取的是当前仓库索引;
  • 同一个版本号可能在不同仓库快照中对应不同内容;
  • 传递依赖可能因索引变化而改变;
  • 仓库可能删除旧版本。

至少应做到:

RUN apt-get update \
 && apt-get install -y --no-install-recommends \
      ca-certificates \
      curl \
 && rm -rf /var/lib/apt/lists/*

updateinstall 放在同一个 RUN 中,是为了避免 Docker 层缓存复用过期索引。分开写:

RUN apt-get update
RUN apt-get install -y curl

可能出现第一层被缓存、第二层使用旧索引的情况。

要进一步提高可重复性,需要使用固定的发行版基础镜像、版本化或快照化的软件源,并保存包版本及其校验信息。企业离线环境通常会同步经过批准的 .deb 制品和仓库索引,再在受控仓库中安装,而不是让构建时临时访问公共源。

APT 的下载缓存还可能被 BuildKit cache mount 复用,但这只影响下载速度;软件包是否来自可信快照,仍由仓库和校验机制决定。


9. 多阶段构建如何配合依赖固定

多阶段构建把“构建依赖”和“运行依赖”分开:

FROM node:22-alpine@sha256:<digest> AS build
WORKDIR /app
COPY package*.json ./
RUN --mount=type=cache,id=npm-cache,target=/root/.npm,sharing=locked \
    npm ci --ignore-scripts
COPY src ./src

FROM node:22-alpine@sha256:<digest> AS runtime
WORKDIR /app
ENV NODE_ENV=production
COPY --from=build /app/package.json /app/package-lock.json ./
COPY --from=build /app/node_modules ./node_modules
COPY --from=build /app/src ./src
USER node
CMD ["node", "src/server.js"]

数据流是:

package.json + lock
        ↓
    npm ci
        ↓
  build/node_modules
        ↓
    COPY --from
        ↓
    runtime 镜像

这里有两个常见误解:

  • 多阶段构建不会自动固定依赖;
  • 只复制 node_modules 不会自动证明它与锁文件匹配。

依赖固定发生在构建阶段的安装命令中;多阶段构建主要限制运行时包含的工具、源码和缓存。

对于需要原生编译的依赖,构建阶段可能包含编译器和开发头文件,运行阶段只保留运行库。此时必须保证构建阶段和运行阶段的 libc、CPU 架构及 ABI 兼容。例如 Alpine 通常使用 musl,而 Debian 通常使用 glibc,不能把为一种 libc 构建的原生模块随意复制到另一种基础镜像。


10. Compose 负责编排,不负责替代锁定

Compose 可以声明构建上下文、Dockerfile、参数和缓存来源,但它不会替包管理器生成锁文件,也不会自动验证依赖摘要。

基本配置:

services:
  app:
    build:
      context: .
      dockerfile: Dockerfile
      args:
        NPM_REGISTRY: https://npm-mirror.example.com/

构建:

docker compose build --progress=plain

如果 Compose 实现和当前规范支持在构建配置中设置构建网络,可以使用:

services:
  app:
    build:
      context: .
      network: none

但需要区分规范支持、Docker Compose 版本和底层 builder 能力。对关键离线流程,直接使用 docker buildx build --network=none 更容易明确实际行为,并在 CI 中固定命令和 builder 配置。

运行时的:

services:
  app:
    network_mode: none

与构建时的网络限制不是一回事。前者限制容器运行时网络,后者限制 Dockerfile RUN 步骤访问网络,不能混用。


11. 失败表现与诊断路径

11.1 锁文件不一致

现象:

npm ci can only install packages when package.json and package-lock.json are in sync

诊断:

npm install --package-lock-only
git diff -- package-lock.json

如果该命令产生了锁文件变化,说明声明文件和锁文件不一致。应在开发环境重新解析、审查差异并提交锁文件,而不是在 Dockerfile 中偷偷执行 npm install 修改构建输入。

11.2 完整性校验失败

现象可能类似:

integrity checksum failed

原因包括:

  • 镜像源返回了损坏文件;
  • 代理缓存了错误内容;
  • 锁文件与当前源的制品不匹配;
  • 缓存被污染;
  • 下载被中间设备修改。

诊断时不要第一时间删除锁文件。应先清理或隔离缓存,直接从受信源重新下载,并比较制品摘要:

sha256sum package.tgz

如果包管理器使用的是 SHA-512 integrity,应使用对应工具或让包管理器重新报告实际摘要,不要把 SHA-256 和 SHA-512 字符串混用。

11.3 离线模式缺少缓存

现象:

ENOTCACHED
cache mode is 'only-if-cached'

这表示本地缓存没有满足当前锁文件所需的包或元数据。解决路径是:

  1. 在线环境使用同一份锁文件执行一次真实安装;
  2. 导出或同步正确的 BuildKit 缓存;
  3. 确认平台和 Node 版本一致;
  4. 再执行 --network=none 验证。

不能通过删除 --offline 来宣称离线构建成功;那只是重新允许网络。

11.4 构建命中了旧缓存

使用:

docker buildx build --progress=plain .

查看每一步是否显示缓存命中。需要验证安装流程时,可使用新的 cache ref 或显式清理对应 BuildKit 缓存。docker history 可以查看镜像层和指令摘要,但它不能显示所有 secret、cache mount 的内容,也不能证明依赖签名有效。


12. 一个最小的验证闭环

可重复依赖安装至少应形成以下闭环:

提交代码
  ↓
检查 package.json 与锁文件一致
  ↓
固定基础镜像摘要
  ↓
固定或审查镜像源
  ↓
执行 npm ci / 等价的锁文件安装命令
  ↓
验证包管理器完整性字段
  ↓
必要时验证制品签名或显式 SHA-256
  ↓
在无网络模式下重建
  ↓
比较依赖清单和关键制品摘要

可以在 CI 中执行:

docker buildx build \
  --progress=plain \
  --network=none \
  --load \
  -t repeatable-demo:offline \
  .

若离线构建成功,只能证明当前构建节点拥有所需输入,不能证明所有未来节点都拥有这些输入。因此还应保存:

  • 锁文件;
  • 基础镜像名称和摘要;
  • 使用的源及仓库快照信息;
  • 依赖制品清单;
  • BuildKit 缓存或离线制品仓库;
  • 构建工具和目标平台信息。

最终需要明确区分三种结果:

  1. 缓存命中:这次没有重新执行安装;
  2. 锁文件安装成功:安装器按预期依赖图执行;
  3. 离线且校验通过:构建不依赖实时网络,并且实际制品符合完整性约束。

只有第三种才同时覆盖了标题中的“缓存、校验和、离线”边界;而要达到整个镜像字节级一致,还需要继续固定构建时间、文件排序、编译器行为和基础镜像内容。


系列导航与关联阅读

官方资料

本文依据 Docker、OCI 与 CNCF 官方文档重新梳理;正文与生产检查清单由 WR BLOG 编写。