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

Docker 离线与受限网络:镜像同步、依赖缓存、签名和补丁

离线部署不是“把几条 docker pull 命令改成从 U 盘加载”。一个 Docker 构建或运行流程通常同时依赖四类数据:

  1. 基础镜像及其所有平台变体
  2. Dockerfile 中执行的依赖下载,例如 aptpipnpm、Go modules;
  3. BuildKit 的构建缓存
  4. 镜像的身份与安全证明,例如 digest、签名、SBOM 和 provenance。

只复制最终镜像,通常只能解决第 1 类问题;只复制 BuildKit cache,也不能自动获得第 2 类依赖;只验证 tag,更不能证明部署的内容没有变化。

本文以现代 Docker Engine、BuildKit 和 Compose 规范为基础,讨论 Linux 容器。Windows 容器具有不同的基础镜像、内核和隔离边界,不能直接套用本文的 Linux 结论。


一、先区分四种“离线”

“离线”不是单一状态。不同状态决定应采用不同的同步策略。

1. 受限网络

构建机或运行机仍然能够访问部分网络,但受到代理、白名单、带宽或 DNS 限制。例如:

  • 只能访问企业 Registry;
  • 只能通过 HTTP 代理访问公网;
  • 只能访问内部软件仓库;
  • 外部网络可以访问,但不允许任意域名。

这类环境可以使用 Registry mirror、代理、内部 PyPI/npm/apt 仓库以及预热缓存。

2. 构建离线、运行在线

构建环境无法访问公网,但运行环境可以从内部 Registry 拉取镜像。此时重点是把构建依赖和构建缓存带入构建环境。

3. 运行离线、构建在线

构建机能够联网,运行环境完全隔离。重点是导出完整镜像、镜像索引、签名和验证材料,并在隔离环境导入内部 Registry。

4. 完全物理隔离

构建机、Registry 和运行机都没有公网连接,只能通过受控介质传递文件。此时同步对象不能只是一个 tag,而应包括:

  • 镜像 manifest 或 index;
  • 所有镜像层;
  • 必要的构建依赖;
  • 签名与验证元数据;
  • SBOM、漏洞扫描结果和发布清单;
  • 可选的 BuildKit 外部缓存。

因此,离线交付的基本对象可以表示为:

A=(I,D,C,S,M)A = (I, D, C, S, M)

其中:

  • II:镜像内容,包括 manifest、index 和 layers;
  • DD:构建时依赖,例如 .deb、Python wheel、npm 包;
  • CC:构建缓存;
  • SS:签名、证书和验证策略;
  • MM:元数据,例如 SBOM、构建 provenance、版本清单。

只复制 II,并不等于复制了完整的可重建交付物 AA


二、镜像的真实身份:Tag、Digest 和 Manifest

1. Tag 是可变名称

下面两个引用看起来都像固定版本:

nginx:1.27
nginx:latest

但 tag 只是 Registry 中的一个名称,可以在之后被重新指向另一个 manifest。即使仓库管理员没有恶意操作,重新构建、上游发布或镜像同步也可能改变 tag 所指向的内容。

因此:

docker pull nginx:1.27

表达的是“解析当前的 1.27”,不是“永远取得同一份字节”。

2. Digest 是内容地址

镜像 digest 通常形如:

sha256:2d429b9e73a...

它是 manifest 或 image index 的内容摘要。对同一个 registry 中的引用:

nginx@sha256:2d429b9e73a...

Docker 请求的是特定内容,而不是当前 tag。

获取实际 digest:

docker pull nginx:1.27
docker image inspect nginx:1.27 \
  --format '{{json .RepoDigests}}'

可能得到:

["docker.io/library/nginx@sha256:..."]

这里需要注意两点:

  • digest 绑定的是 manifest 或 index,不是简单地对单个 layer 文件做摘要;
  • 同一组 layer 如果包装在不同 manifest 中,manifest digest 仍然可以不同。

3. 多架构镜像还存在 Image Index

一个 tag 可能指向一个 OCI Image Index 或 Docker manifest list。它包含:

linux/amd64 -> digest A
linux/arm64 -> digest B

客户端根据目标平台选择其中一项。因此,同一个:

alpine:3.20

linux/amd64linux/arm64 上可能拉取不同内容。

可以检查远端索引:

docker buildx imagetools inspect alpine:3.20

输出会列出不同平台的 manifest digest。

离线同步时必须先确定部署平台:

docker image inspect myapp:1.0 \
  --format 'OS={{.Os}} ARCH={{.Architecture}}'

如果需要支持多个平台,必须同步整个 index 及其引用的各平台 manifest 和 layers。只执行一次:

docker save myapp:1.0 -o myapp.tar

不能自动保证你已经取得了所有目标架构。docker save 保存的是本地镜像存储中已有的内容;本地只有 amd64 变体时,导出的也只有 amd64 内容。


三、两种镜像同步路径:Registry 复制和离线归档

1. Registry 复制适合持续同步

生产环境通常应优先使用内部 Registry,而不是让每台 Docker Engine 都通过 U 盘导入镜像。

一个典型数据流如下:

flowchart LR
    O[外部 Registry] --> S[同步机]
    S --> A[签名与清单校验]
    A --> R[内部 Registry]
    R --> B[离线构建机]
    R --> N[离线运行节点]

同步机可以使用 Registry-aware 工具,例如 skopeocrane 或专用 Registry 复制系统。它们能够直接处理远端 manifest 和 layers,避免必须先把镜像加载进 Docker Engine。

skopeo 为例:

skopeo copy \
  docker://docker.io/library/alpine:3.20 \
  docker://registry.intra.example/base/alpine:3.20

这条命令的前提是:

  • 同步机能够访问源 Registry;
  • 同步机能够认证内部 Registry;
  • 目标仓库允许写入;
  • 工具版本支持源和目标 Registry 的 manifest 格式。

然后在隔离环境验证:

docker pull registry.intra.example/base/alpine:3.20
docker image inspect registry.intra.example/base/alpine:3.20 \
  --format '{{json .RepoDigests}}'

不要只比较源 tag 和目标 tag。 应在同步前记录源 digest,在同步后读取目标 digest,并比较二者:

源:
docker.io/library/alpine@sha256:SOURCE_DIGEST

目标:
registry.intra.example/base/alpine@sha256:TARGET_DIGEST

在没有重新压缩或转换 manifest 的情况下,二者应一致。若不一致,应确认:

  • 同步工具是否转换了 manifest;
  • 源仓库是否在同步期间移动了 tag;
  • 目标 Registry 是否做了内容重写;
  • 实际比较的是 index digest 还是某个平台 manifest digest。

2. docker save / docker load 适合一次性交付

没有 Registry 时,可以使用 Docker 镜像归档:

docker pull --platform linux/amd64 registry.intra.example/app/api:1.4.2
docker save \
  --output api-1.4.2-amd64.tar \
  registry.intra.example/app/api:1.4.2
sha256sum api-1.4.2-amd64.tar

将 tar 文件和校验清单传入离线环境后:

sha256sum -c api-1.4.2-amd64.tar.sha256
docker load --input api-1.4.2-amd64.tar
docker image ls

docker save 保存的是镜像及其 layer,docker load 将它们重新装入本地镜像存储。它不等价于完整复制 Registry:

  • 不会替你复制 Registry 中的 tag 历史;
  • 不会自动复制 OCI referrers 中的签名、SBOM 或 provenance;
  • 不会把远端仓库的访问控制策略带过来;
  • 不会把构建所需的 apt/npm/Python 依赖带过来。

导入后应通过 digest 验证,而不是仅看 tag:

docker image inspect registry.intra.example/app/api:1.4.2 \
  --format '{{json .RepoDigests}}'

对于运行节点,通常还应把离线 tar 导入后重新打上内部仓库名并推送:

docker tag registry.intra.example/app/api:1.4.2 \
  registry.airgap.local/app/api:1.4.2

docker push registry.airgap.local/app/api:1.4.2

此时要重新记录目标 Registry 返回的 digest。镜像内容通常不变,但目标引用已经发生变化。


四、Registry Mirror 能解决什么,不能解决什么

Docker Engine 可以配置 Registry mirror,使 Docker Hub 的镜像拉取经过内部缓存。例如 daemon 配置可能包含:

{
  "registry-mirrors": [
    "https://mirror.intra.example"
  ]
}

修改 /etc/docker/daemon.json 后通常需要重启 Docker:

sudo systemctl restart docker
docker info

docker info 的输出中应能看到 Registry Mirrors。

但 Registry mirror 的作用是镜像拉取缓存,不是通用网络代理。它不能自动缓存:

RUN apt-get update && apt-get install -y curl
RUN pip install -r requirements.txt
RUN npm ci
RUN go mod download

因为这些命令访问的分别可能是:

  • Debian/Ubuntu 软件源;
  • PyPI;
  • npm Registry;
  • Go module proxy。

如果 Dockerfile 中的 FROM 能从镜像 mirror 获取,但 RUN apt-get update 仍访问公网,构建依然会失败。

另外,pull-through cache 通常是“有请求才拉取”。在物理隔离环境中,第一次请求没有上游网络,不能把它当作已经预热的离线仓库。真正离线的内部 Registry 必须提前完成同步,或者在联网区生成并导入所需对象。


五、依赖缓存不是依赖归档

1. BuildKit layer cache 的基本机制

BuildKit 会根据 Dockerfile 指令、输入文件、基础镜像和相关构建参数判断某一层能否复用。例如:

FROM python:3.12-slim
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY src/ /app/

如果只修改 src/requirements.txt 没变,前面的依赖安装层通常可以复用。

但缓存命中要求构建机拥有对应的缓存记录和结果。换一台没有缓存的机器,Dockerfile 本身不会携带这些层。

可以显式导出本地 BuildKit 缓存:

docker buildx build \
  --file Dockerfile \
  --tag registry.intra.example/app/api:1.4.2 \
  --cache-to type=local,dest=./buildkit-cache,mode=max \
  --output type=docker \
  .

在离线构建机导入:

docker buildx build \
  --file Dockerfile \
  --tag registry.airgap.local/app/api:1.4.2 \
  --cache-from type=local,src=./buildkit-cache \
  --output type=docker \
  .

这里:

  • --cache-to 将 BuildKit 可导出的缓存记录写到目录;
  • mode=max 通常比默认模式保存更多中间结果;
  • --cache-from 让后续构建尝试使用这些结果;
  • --output type=docker 将最终镜像加载到当前 Docker Engine。

缓存命中不是正确性的唯一来源。缓存可能被清理、失效或因 Dockerfile 改动而无法使用,所以构建必须在缓存完全不存在时仍有明确的依赖获取路径。

2. Cache mount 是构建机状态,不是天然的离线包仓库

常见写法是:

# syntax=docker/dockerfile:1

FROM python:3.12-slim

RUN --mount=type=cache,target=/root/.cache/pip \
    pip install -r requirements.txt

这个 cache mount 可以减少同一 BuildKit builder 上重复下载。但是它有一个重要边界:它主要是 builder 的持久化缓存状态,不应被误认为会自动随最终镜像或普通 cache export 成为可验证的依赖归档。

如果构建机损坏、换了 builder 或在另一台隔离机器上构建,/root/.cache/pip 不一定存在。依赖的离线交付应使用显式目录、内部包仓库或可验证的包归档。


六、把依赖变成可交付的离线输入

离线构建的可靠条件是:

Dockerfile 的每一个外部输入,要么已经进入构建上下文,要么能从离线可达的软件仓库获取,要么由已验证的构建缓存提供。

1. Python:wheelhouse + 哈希锁定

在线准备依赖目录:

python -m pip download \
  --requirement requirements.txt \
  --dest wheelhouse

这会下载 requirements 中的包及其依赖,但不保证一定得到适合目标平台的 wheel。若包含本地编译包,还应在目标 Linux、Python 版本和架构条件下准备相应 wheel。

requirements.txt 可以使用哈希约束,例如:

requests==2.32.3 \
    --hash=sha256:HASH_OF_WHEEL_OR_SDIST

离线 Dockerfile:

FROM python:3.12-slim

WORKDIR /app

COPY wheelhouse/ /wheelhouse/
COPY requirements.txt .

RUN python -m pip install \
      --no-index \
      --find-links=/wheelhouse \
      --require-hashes \
      -r requirements.txt

COPY src/ .
CMD ["python", "main.py"]

这几个选项分别保证不同事情:

  • --no-index:不访问 Python 包索引;
  • --find-links=/wheelhouse:只从本地目录查找包;
  • --require-hashes:要求 requirements 中的文件摘要匹配;
  • COPY wheelhouse/:将依赖作为构建输入,而不是依赖一个可能不存在的缓存。

如果 wheelhouse 缺少某个传递依赖,构建会明确失败,而不是偷偷访问公网。这是期望的失败方式,因为它暴露了离线交付集合不完整的问题。

2. Debian/Ubuntu:软件包缓存必须包含传递依赖和索引

一个常见但不可靠的 Dockerfile 是:

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

即便提前缓存了一个 curl.deb,也可能缺少 libcurl、证书包或其他依赖。

更稳妥的做法是在线环境准备完整软件包集合,并在离线环境配置本地源。例如在与目标镜像相同的发行版和架构环境中下载:

mkdir -p debs
apt-get download curl

apt-get download 通常只下载指定包,不会自动解决全部依赖。生产流程应使用能解析并下载依赖闭包的工具,或者维护一个内部 Debian/Ubuntu 仓库。把 .deb 放在目录中后可以生成简单的本地索引:

dpkg-scanpackages debs /dev/null | gzip -9c > debs/Packages.gz

Dockerfile:

FROM debian:bookworm-slim

COPY debs/ /opt/debs/

RUN printf 'deb [trusted=yes] file:/opt/debs .\n' \
      > /etc/apt/sources.list.d/offline.list \
 && apt-get update \
 && apt-get install -y --no-install-recommends curl \
 && rm -rf /var/lib/apt/lists/*

这里的 trusted=yes 只适合说明本地实验,不适合生产。它关闭了 APT Release 签名信任的一部分保护。生产中应建立签名的内部 APT 仓库,并把内部仓库公钥以受控方式放入基础镜像,再使用正常的签名校验。

APT 还具有发行版和架构约束:

  • Debian bookworm 的包不应随意用于 Ubuntu;
  • amd64 包不能直接替代 arm64 包;
  • 仓库索引和包文件必须来自兼容的发行版快照;
  • apt-get update 得到的索引也属于构建输入,应记录其来源和时间。

3. npm、Go 和其他生态

npm 可以使用预先填充的缓存,但缓存目录格式和 npm 版本有关;更可控的方式是搭建内部 npm Registry,或准备离线包集合并让安装命令严格禁止联网。Go modules 通常应使用内部 module proxy 或提前填充 GOMODCACHE,并保留 go.sum

这些工具有共同规律:

可离线安装锁定版本+完整依赖闭包+来源可达+校验可验证\text{可离线安装} \Rightarrow \text{锁定版本} + \text{完整依赖闭包} + \text{来源可达} + \text{校验可验证}

“下载过一次”只是缓存状态,不是交付保证。


七、构建上下文、.dockerignore 和离线文件

离线依赖经常通过 Docker build context 传入:

project/
├── Dockerfile
├── requirements.txt
├── wheelhouse/
└── src/

执行:

docker buildx build --load -t app:offline ./project

BuildKit 会把上下文发送给 builder。若 .dockerignore 中写了:

wheelhouse/

那么 Dockerfile 的:

COPY wheelhouse/ /wheelhouse/

会失败或复制不到文件。

因此,离线构建问题中出现“文件明明在目录里,但构建看不到”的第一诊断点就是:

cat .dockerignore

还要注意构建上下文不应无差别包含整个缓存目录。大型缓存会:

  • 增加传输时间;
  • 改变上下文摘要;
  • 导致无关修改使缓存失效;
  • 增加供应链审查范围。

依赖归档应使用明确目录和清单,而不是把开发机的整个 home 目录复制给构建器。


八、签名、digest 和校验和是三种不同保证

1. 校验和保证文件传输未被意外改变

例如:

sha256sum image.tar > image.tar.sha256
sha256sum -c image.tar.sha256

它能验证 tar 文件在传输后仍与发布时相同,但前提是 .sha256 文件本身通过可信渠道获得。若攻击者可以同时替换 tar 和校验文件,校验没有安全意义。

2. Digest 保证引用的内容身份

镜像 digest 适合写进部署清单:

services:
  api:
    image: registry.airgap.local/app/api@sha256:IMAGE_DIGEST

这使 Compose 不再依赖可变 tag。仍然需要确保 digest 来源可信,例如由发布系统签名或由受控清单发布。

3. 签名把身份和策略绑定到内容

镜像签名通常表达类似命题:

某个可信主体批准了 digest 为 D 的镜像用于某个用途。

签名验证至少涉及:

  • 被签名的对象到底是 manifest 还是 index;
  • 签名者身份;
  • 信任根或证书链;
  • 签名是否覆盖目标 digest;
  • 签名是否过期或被撤销;
  • 当前部署策略是否允许该签名者。

因此,“镜像能运行”不等于“镜像已签名”,“有签名”也不等于“当前环境信任该签名者”。

4. Docker Content Trust 与 OCI 签名工具不能混为一谈

Docker Content Trust 基于 Notary v1 的签名和元数据体系,常见使用方式是:

export DOCKER_CONTENT_TRUST=1
docker pull registry.example.com/app/api:1.4.2

它对支持该机制的 Docker Registry 和 tag 签名流程有特定要求。Notary v1、Docker Content Trust 与现代 OCI artifact 生态并不是同一个签名格式。企业应先确定使用哪一种信任系统,而不是在不同工具之间假设签名可以互认。

现代供应链也常使用 Cosign 等 OCI 签名工具。例如,签名通常与镜像 digest 关联,而不是与 tag 关联:

cosign sign \
  --key cosign.key \
  registry.airgap.local/app/api@sha256:IMAGE_DIGEST

验证:

cosign verify \
  --key cosign.pub \
  registry.airgap.local/app/api@sha256:IMAGE_DIGEST

命令和存储方式取决于 Cosign 版本、密钥模式和 Registry 对 OCI referrers 的支持。不能只复制镜像 layers 后期待签名自动存在,因为签名可能作为独立 OCI artifact 存储在 Registry 中。

使用复制工具时,应确认是否复制了:

  • image index;
  • 签名对象;
  • SBOM;
  • provenance;
  • OCI referrers 关系。

若工具只执行普通镜像复制,往往只复制主镜像,不复制附属 artifact。离线交付应把“镜像 digest + 签名 digest + SBOM digest”一起写入发布清单,并在目标 Registry 中分别验证。


九、签名验证的完整流程

一个可审计的离线流程可以是:

在线发布区

docker buildx build \
  --platform linux/amd64 \
  --push \
  --tag registry.online.example/app/api:1.4.2 \
  .

docker buildx imagetools inspect \
  registry.online.example/app/api:1.4.2

从输出中记录:

registry.online.example/app/api@sha256:IMAGE_DIGEST

然后对 digest 签名:

cosign sign --key cosign.key \
  registry.online.example/app/api@sha256:IMAGE_DIGEST

再生成 SBOM,并将其与该 digest 关联。最后导出一份不可随意修改的发布清单:

application: api
version: 1.4.2
platform: linux/amd64
image: registry.online.example/app/api@sha256:IMAGE_DIGEST
signature: SIGNATURE_REFERENCE_OR_DIGEST
sbom: SBOM_REFERENCE_OR_DIGEST
builder: BUILDER_ID
source_revision: GIT_COMMIT

传输区

复制:

  • 镜像本体;
  • 签名和其他 referrer artifact;
  • 发布清单;
  • 签名公钥或验证证书;
  • 每个文件的外层校验和。

离线区

先校验传输文件,再同步或加载到内部 Registry:

sha256sum -c transfer.sha256

随后验证镜像身份:

docker pull registry.airgap.local/app/api@sha256:IMAGE_DIGEST
docker image inspect \
  registry.airgap.local/app/api@sha256:IMAGE_DIGEST

最后验证签名:

cosign verify \
  --key cosign.pub \
  registry.airgap.local/app/api@sha256:IMAGE_DIGEST

如果验证失败,不应通过改成 tag、关闭校验或重新签名来“修复”流程。应区分失败原因:

  • digest 不一致:镜像同步错误或引用解析错误;
  • 签名找不到:referrer 未复制或目标 Registry 不支持对应索引;
  • 签名不匹配:签名针对另一 digest;
  • 公钥不可信:信任根没有正确分发;
  • 证书策略失败:签名者不符合环境策略。

十、Compose 中的离线部署

Compose 文件可以使用内部 Registry 和 digest:

services:
  api:
    image: registry.airgap.local/app/api@sha256:IMAGE_DIGEST
    platform: linux/amd64
    ports:
      - "8080:8080"

部署前检查:

docker compose config
docker compose pull
docker compose up -d

docker compose config 用于确认变量替换、合并文件和最终配置;它不验证镜像是否已存在,也不验证签名。

在完全离线环境中,如果镜像已经通过 docker load 导入,则可以跳过 pull,但 Compose 中的镜像引用必须能匹配本地镜像。使用 digest 时尤其要检查本地镜像是否带有对应的 repo digest:

docker image inspect \
  registry.airgap.local/app/api@sha256:IMAGE_DIGEST

如果本地只存在:

api:1.4.2

而 Compose 要求:

registry.airgap.local/app/api@sha256:...

Docker 不会仅因为 layer 内容相同就自动把二者视为同一个引用。可以在导入后重新打 tag,或更稳定地先推送到内部 Registry,再让 Compose 从内部 Registry 使用 digest。


十一、补丁不是在运行容器里执行 apt upgrade

1. 容器补丁的正确对象是镜像构建

正在运行的容器是由镜像创建的可写容器层。直接进入容器执行:

docker exec -it api sh
apt-get update
apt-get upgrade

即使命令成功,也只修改当前容器的可写层:

  • 新建容器不会继承这些修改;
  • 容器删除后修改丢失;
  • 运行节点之间状态不一致;
  • 无法可靠对应源码、镜像 digest 和漏洞扫描结果。

正确流程是更新 Dockerfile 或依赖锁文件,重新构建新镜像,测试后发布新的 immutable digest:

FROM debian:bookworm-slim

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

然后:

docker buildx build \
  --tag registry.airgap.local/app/api:1.4.3 \
  --push \
  .

部署清单改为新 digest,而不是把旧 tag 重新指向新内容后不留记录。

2. 基础镜像补丁会改变上层构建结果

假设:

FROM debian:bookworm-slim
RUN apt-get install -y curl

即使 Dockerfile 不变,debian:bookworm-slim 这个 tag 重新指向新 digest 后,后续构建的基础输入也可能改变。对于可重复构建,应固定基础镜像:

FROM debian:bookworm-slim@sha256:BASE_DIGEST

补丁发布时显式修改 BASE_DIGEST,这样差异是可审查的。

3. 什么时候可以只重建,什么时候必须重新下载

如果离线构建区有可靠的 BuildKit cache,并且新基础镜像及其补丁层已经导入,构建可能只需复用缓存。但以下情况会迫使重新取得数据:

  • 基础镜像 digest 变化;
  • apt 索引或包版本变化;
  • requirements.txt 锁文件变化;
  • 缓存被清理;
  • Dockerfile 指令或构建参数变化;
  • 目标架构变化。

因此,补丁流程必须准备“缓存全部失效”的路径。否则第一次缓存清理就会暴露离线环境根本没有依赖源的问题。


十二、补丁发布的一个完整算例

假设生产环境当前运行:

registry.airgap.local/app/api@sha256:OLD_DIGEST

安全团队要求升级 OpenSSL。发布区执行:

  1. 更新基础镜像 digest 或内部 APT 仓库快照;
  2. 重新构建;
  3. 运行测试;
  4. 生成 SBOM;
  5. 扫描新镜像;
  6. 对新 digest 签名;
  7. 将镜像及相关 artifact 同步到离线 Registry;
  8. 在离线区验证;
  9. 更新 Compose 或部署清单。

离线验证:

docker pull registry.airgap.local/app/api@sha256:NEW_DIGEST

cosign verify \
  --key cosign.pub \
  registry.airgap.local/app/api@sha256:NEW_DIGEST

docker run --rm \
  registry.airgap.local/app/api@sha256:NEW_DIGEST \
  /app/healthcheck

确认应用健康后,再修改 Compose:

services:
  api:
    image: registry.airgap.local/app/api@sha256:NEW_DIGEST

执行更新:

docker compose up -d --no-build api
docker compose ps
docker compose logs --tail=100 api

这里的 --no-build 表示部署阶段不尝试本地构建;如果镜像不在本地或内部 Registry 中,命令应失败,而不是隐式访问公网。

回滚只需把引用改回已验证的旧 digest:

image: registry.airgap.local/app/api@sha256:OLD_DIGEST

然后:

docker compose up -d --no-build api

这要求旧镜像仍保留在内部 Registry 或节点本地。因此清理策略不能只按 tag 或最近访问时间删除,还应保留当前版本和可回滚版本。


十三、故障路径与诊断顺序

1. pull 失败,但 mirror 看起来正常

先判断失败的是基础镜像还是构建命令中的其他下载:

docker pull registry.intra.example/base/alpine:3.20
docker buildx build --progress=plain .

如果 FROM 成功而 RUN apt-get update 失败,问题不在 Docker Registry mirror,而在 APT 源、代理或离线包仓库。

2. 缓存显示存在但没有命中

使用普通日志观察 BuildKit:

docker buildx build --progress=plain .

常见原因:

  • Dockerfile 中 COPY . . 太早,源码变化导致后续层失效;
  • requirements.txt 实际发生变化;
  • 基础镜像 digest 变化;
  • cache-from 指向错误目录;
  • builder 实例不是导出缓存时使用的实例;
  • 目标平台不同。

可以查看 builder:

docker buildx ls
docker buildx inspect --bootstrap

缓存只能加速,不能作为唯一依赖来源。验证方法是复制一份环境,删除或不提供 cache,确认仍能通过离线仓库或本地依赖目录完成构建。

3. docker load 成功但 Compose 找不到镜像

检查本地引用:

docker image ls
docker image inspect api:1.4.2

如果 Compose 使用的是内部 Registry 全名,而导入后的镜像只有短 tag,应重新打 tag:

docker tag api:1.4.2 registry.airgap.local/app/api:1.4.2

若 Compose 使用 digest,则还必须确认对应 repo digest 存在,不能只比较镜像 ID。

4. 签名验证找不到签名

应先确认验证的对象:

registry.airgap.local/app/api@sha256:D

再检查同步流程是否只复制了主镜像。主镜像 layer 存在而签名不存在,通常说明 OCI referrer artifact 没有复制,或目标 Registry/复制工具对相关 artifact 支持不足。

不要对新 tag 直接验证并据此推断安全性。签名通常绑定 digest;tag 被重新指向后,原签名不会自动覆盖新内容。

5. 离线构建执行了网络访问

可以在隔离构建网络中显式阻断出口,使用:

docker buildx build --network=none .

但这会影响需要本地仓库访问的步骤;它适合验证 Dockerfile 是否意外依赖公网,不应被误解为依赖自动变得可用。

更实用的验证方式是:

  • 构建节点没有默认路由;
  • DNS 不解析公网域名;
  • 只允许访问内部 Registry 和内部包仓库;
  • 构建日志中不存在外部下载;
  • 删除 BuildKit cache 后重新构建仍成功。

十四、清理和保留策略

镜像、layer、BuildKit cache、签名 artifact 和包缓存是不同类型的对象,不能用一个清理命令解决所有问题。

Docker 镜像清理

docker image prune

它主要清理悬空镜像。更激进的:

docker image prune -a

可能删除没有容器引用的旧镜像,从而破坏离线回滚能力。

BuildKit 缓存清理

docker buildx prune

这可能删除后续离线重建需要的缓存。离线环境中应先确认:

  • 完整依赖归档已经存在;
  • 内部包仓库可用;
  • 当前版本和回滚版本镜像已推送;
  • 重新构建测试已经通过。

Registry 清理

Registry 的垃圾回收通常需要先删除 manifest 引用,再执行垃圾回收;具体行为取决于 Registry 实现和存储后端。只删除 tag 不一定立即删除 layer,因为同一 layer 可能仍被其他 manifest 引用。

清理前应根据 digest 建立保留集合:

当前生产 digest
最近一个可回滚 digest
审计要求保留的历史 digest
签名、SBOM 和 provenance 对应 artifact

只以 latest 或时间标签为依据清理,无法可靠判断回滚和审计需求。


十五、一个可执行的离线交付目录

可以把一次发布组织成如下结构:

release-api-1.4.2/
├── manifest.txt
├── transfer.sha256
├── images/
│   └── api-1.4.2-amd64.tar
├── buildkit-cache/
├── wheelhouse/
├── debs/
├── sbom/
│   └── api-1.4.2.spdx.json
├── signatures/
│   └── api-signature.bundle
└── keys/
    └── cosign.pub

其中 manifest.txt 至少记录:

name=api
version=1.4.2
platform=linux/amd64
image_digest=sha256:...
base_image_digest=sha256:...
source_revision=...
dockerfile_sha256=...
sbom_sha256=...
signature_reference=...

发布检查可以用脚本实现:

set -eu

sha256sum -c transfer.sha256

docker load --input images/api-1.4.2-amd64.tar

docker image inspect \
  registry.airgap.local/app/api@sha256:IMAGE_DIGEST >/dev/null

cosign verify \
  --key keys/cosign.pub \
  registry.airgap.local/app/api@sha256:IMAGE_DIGEST

docker run --rm \
  registry.airgap.local/app/api@sha256:IMAGE_DIGEST \
  /app/healthcheck

示例中的 IMAGE_DIGEST 必须替换为发布清单中的真实 digest;生产脚本应从已校验的清单读取,而不是让操作员手工输入。脚本失败应停止后续部署,不能用 || true 把验证错误隐藏掉。


十六、必须保留的边界

最后需要明确几个容易混淆的边界:

  • 镜像同步不等于依赖同步:Registry 只解决镜像对象,不自动提供 APT、PyPI、npm 或 Go module;
  • 缓存不等于归档:BuildKit cache 可以失效或被清理,离线依赖应有显式来源;
  • tag 不等于版本不可变:部署和审计应使用 digest;
  • digest 不等于签名:digest 能识别内容,但不能说明谁批准了它;
  • 签名不等于漏洞修复:签名证明发布者批准了内容,不代表内容没有漏洞;
  • 补丁不等于修改运行容器:补丁应进入新的镜像构建和新的 digest;
  • docker save 不等于复制整个 Registry:签名、SBOM、索引和 referrer 可能需要单独同步;
  • Linux 容器不等于完整虚拟机:容器共享宿主机 Linux 内核。镜像离线且完整,并不意味着宿主机内核、CPU 指令集、设备驱动和安全策略已经满足运行要求。

一个可审计、可恢复的离线交付,应能回答四个问题:

  1. 运行的到底是哪一个 digest?
  2. 该 digest 所需的所有镜像和依赖是否已完整进入隔离环境?
  3. 谁签署了它,离线环境依据什么信任该签名?
  4. 补丁发布后,旧版本是否仍可验证并回滚?

只要这四个问题都有可验证答案,离线部署就不再是一次性的文件搬运,而成为具有身份、依赖闭包、验证和恢复路径的发布流程。


系列导航与关联阅读

官方资料

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