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

Dockerfile 与 BuildKit:构建上下文、缓存挂载、Secret 和可重复构建

Dockerfile 描述的是“如何从输入构造镜像”,而不是一个普通的 Shell 脚本。构建时至少存在三类输入:

  1. Dockerfile 及其指令;
  2. 构建上下文(build context)中的文件;
  3. 基础镜像、软件源、Git 仓库、Secret 等外部输入。

BuildKit 是现代 Docker 的构建后端。它将 Dockerfile 转换为低层构建图(Low-Level Build, LLB),根据依赖关系并行执行步骤,并使用内容寻址的缓存复用已完成的结果。理解构建上下文、缓存挂载和 Secret,最终都要回答一个问题:

哪些数据参与镜像结果,哪些数据只参与构建过程,哪些数据应该被缓存但不能进入镜像?


一、先区分三种“缓存”

Docker 构建中经常把不同层次的缓存混为一谈。

1. 镜像层缓存

例如:

FROM debian:bookworm
RUN apt-get update && apt-get install -y curl
COPY app /usr/local/bin/app

每条指令通常会产生一个文件系统快照。若某条指令及其依赖输入没有变化,BuildKit 可以直接复用该步骤的结果。

这类缓存的结果会成为镜像的一部分。RUN 产生的文件、COPY 复制的文件都会进入后续镜像层。

2. 构建器内部的步骤缓存

BuildKit 不只是简单地“按 Dockerfile 行号缓存”。它会把 Dockerfile 转换成构建图,并为操作计算缓存键。缓存键通常与以下信息有关:

  • 指令及其参数;
  • 基础镜像的内容身份;
  • COPYADD 输入文件的元数据和内容;
  • 构建参数;
  • 部分挂载类型及其选项;
  • 前置步骤的结果。

因此,以下两条命令虽然文本相同,但基础镜像不同,不能认为结果一定相同:

RUN gcc -O2 -o app main.c

RUN 的缓存结果还依赖前面的根文件系统状态。基础镜像标签 debian:bookworm 重新指向了新的 digest 后,构建器可能重新执行后续步骤。

3. RUN --mount=type=cache 的目录缓存

缓存挂载是一个供构建步骤读写的持久化目录,例如包管理器下载缓存:

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

这个目录的内容不会自动进入镜像层。它存在于构建器管理的缓存存储中,供后续构建复用。

这三者的关键差异是:

类型 是否进入最终镜像 主要用途
镜像层 产生运行时文件
步骤缓存 间接是 跳过已完成的构建步骤
cache mount 加速下载、编译中间产物

缓存挂载中的文件即使改变,也不应被当作镜像内容输入。构建脚本必须能够在缓存为空、部分损坏或包含旧文件时正常工作。


二、构建上下文:Dockerfile 能看到什么

2.1 构建上下文的定义

执行:

docker build -t example-app .

最后的 . 是构建上下文目录。客户端会将该目录中的文件提供给构建器,Dockerfile 中的:

COPY . /app

只能从这个上下文中读取文件。

一个常见项目结构如下:

project/
├── Dockerfile
├── .dockerignore
├── go.mod
├── go.sum
├── cmd/
│   └── server/
│       └── main.go
├── internal/
└── .git/

project/ 下执行:

docker build -t example-app .

上下文根目录是 project/,所以可以写:

COPY go.mod go.sum ./
COPY cmd ./cmd

但不能写:

COPY /home/alice/secrets.txt /run/secrets/

也不能通过 COPY .. 越出上下文边界。

这个限制不是 Dockerfile 的语法偏好,而是构建输入边界:构建器只应接收调用者明确提供的上下文。

2.2 .dockerignore 先决定上下文内容

.dockerignore 在上下文发送或读取前过滤文件:

.git
.gitignore
.env
.env.*
node_modules
dist
build
coverage
*.log

它具有两个作用:

  1. 减少发送给构建器的数据;
  2. 防止不应参与构建或不应暴露给构建器的文件被读取。

例如:

COPY . /src

若没有忽略 node_modules,本地依赖目录可能被复制进镜像;若没有忽略 .git,Git 历史可能进入构建上下文并影响缓存与构建时间。

但是,.dockerignore 不是 Secret 保护机制。即使某个文件被忽略,也不代表它可以安全地作为构建 Secret 使用;应明确选择 Secret 挂载,而不是依赖“某条 COPY 不会复制它”。

.dockerignore 的模式匹配接近 .gitignore,但两者不是同一个文件、也不是同一套语义。不要假设 Git 忽略的文件一定会被 Docker 忽略。

2.3 上下文会影响缓存键

假设有:

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

此时只改变 src/app.js,不会改变这条 COPY 的输入,因此 npm ci 这一步通常仍可命中缓存。

如果改成:

COPY . .
RUN npm ci

那么任意源代码、文档或日志变化都可能导致 COPY 结果变化,后面的 npm ci 也会失去缓存。

因此,分离“依赖描述文件”和“业务源代码”不是形式上的优化,而是对缓存依赖图的精确建模:

COPY package.json package-lock.json ./
RUN --mount=type=cache,target=/root/.npm \
    npm ci

COPY src ./src
RUN npm run build

前两步只依赖锁文件;编译步骤才依赖源代码。

2.4 不要把构建上下文当作 Secret 通道

下面的做法有风险:

COPY .npmrc /root/.npmrc
RUN npm install
RUN rm -f /root/.npmrc

删除文件只会在后续层产生一个“删除记录”,早期层仍可能包含 Secret。即使最终文件系统中看不到它,镜像历史或层内容仍可能泄露。

正确方式是 Secret 挂载:

# syntax=docker/dockerfile:1

FROM node:22-bookworm AS deps
WORKDIR /app

COPY package.json package-lock.json ./

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

构建:

docker build \
  --secret id=npmrc,src="$HOME/.npmrc" \
  -t example-node .

/root/.npmrc 只在该 RUN 执行期间可见,不会作为普通文件写入镜像层。这里仍有一个边界:如果命令把 Token 打印到标准输出、写入构建产物,或复制到另一个目录,Secret 仍然可能泄露。挂载机制只能限制默认可见范围,不能阻止构建脚本主动泄露内容。


三、BuildKit 的执行模型:从 Dockerfile 到构建图

启用 BuildKit 后,Dockerfile 不再只是线性脚本。可以把一个步骤抽象为:

Si=fi(Ri1,Ii,Mi)S_i = f_i(R_{i-1}, I_i, M_i)

其中:

  • Ri1R_{i-1} 是前一步产生的根文件系统;
  • IiI_i 是本步骤的显式输入,例如指令、COPY 文件;
  • MiM_i 是临时挂载,例如 cache mount、Secret mount;
  • SiS_i 是本步骤生成的结果状态。

但不是所有挂载都以相同方式参与结果:

  • bind 挂载提供只读或临时输入,适合读取其他阶段或上下文文件;
  • cache 挂载提供可复用的外部缓存,不应成为最终层内容;
  • secret 挂载提供临时敏感输入;
  • ssh 挂载提供 SSH agent 通道。

一个简化的数据流如下:

flowchart LR
    A[Dockerfile] --> B[BuildKit 前端]
    C[构建上下文] --> B
    D[基础镜像] --> E[LLB 构建图]
    B --> E
    E --> F[缓存查询]
    G[cache mount 存储] <--> F
    H[Secret 提供者] --> I[临时 Secret mount]
    I --> E
    F --> J[执行 RUN/COPY]
    J --> K[镜像层与配置]

关键路径是:

  1. Docker 客户端确定上下文;
  2. Dockerfile 前端解析指令;
  3. BuildKit 根据依赖生成构建图;
  4. 查询可复用的步骤缓存;
  5. 对未命中的节点执行命令;
  6. 将根文件系统变化提交为镜像层;
  7. 将 cache mount 等外部缓存单独保存。

BuildKit 可以并行执行互不依赖的节点。并行并不意味着所有构建脚本都天然安全:如果多个步骤同时写同一个缓存目录,包管理器或编译器可能发生竞争。


四、缓存挂载:缓存的是目录,不是镜像层

4.1 基本语法

常用形式是:

RUN --mount=type=cache,target=/path/to/cache \
    command

常见选项包括:

  • id=:指定缓存标识,便于多个步骤共享;
  • target=:容器内挂载点;
  • sharing=:并发策略,常见值为 sharedprivatelocked
  • from=:从某个构建阶段作为来源,具体用途依赖挂载类型和 Dockerfile frontend 版本。

缓存挂载的初始内容可能为空,也可能来自之前的构建。命令必须把它当作“不可信但可选的加速数据”。

4.2 Go 示例:模块下载缓存

# syntax=docker/dockerfile:1

FROM golang:1.23-bookworm AS build
WORKDIR /src

COPY go.mod go.sum ./

RUN --mount=type=cache,id=gomod-cache,target=/go/pkg/mod \
    --mount=type=cache,id=gobuild-cache,target=/root/.cache/go-build \
    go mod download

COPY . .

RUN --mount=type=cache,id=gomod-cache,target=/go/pkg/mod \
    --mount=type=cache,id=gobuild-cache,target=/root/.cache/go-build \
    CGO_ENABLED=0 go build -trimpath -o /out/server ./cmd/server

逐步分析:

  1. 先复制 go.modgo.sum
  2. go mod download 只依赖模块描述文件;
  3. 下载内容进入 /go/pkg/mod 缓存挂载,而不是镜像层;
  4. 再复制源代码;
  5. 编译时复用模块缓存和 Go 编译缓存;
  6. /out/server 位于正常根文件系统中,会进入下一阶段可复制的结果。

如果删除 BuildKit 缓存,构建仍应成功,只是需要重新下载和编译。若删除缓存后构建失败,说明构建错误地把缓存目录当成了必需输入。

4.3 Debian/Ubuntu 的 apt 缓存

apt 至少涉及索引文件和下载包缓存。可以这样写:

FROM debian:bookworm

RUN rm -f /etc/apt/apt.conf.d/docker-clean \
    && --mount=type=cache,target=/var/cache/apt,sharing=locked \
       --mount=type=cache,target=/var/lib/apt/lists,sharing=locked \
       apt-get update \
    && apt-get install -y --no-install-recommends ca-certificates curl \
    && rm -rf /var/lib/apt/lists/*

这里有两个需要注意的事实:

  • apt-get updateapt-get install 应在同一个 RUN 中,避免索引和安装步骤跨层失配;
  • sharing=locked 让多个并发构建在使用同一缓存时互斥,降低包管理器数据库并发写入的风险。

不同发行版的包管理器对缓存目录和并发的要求不同,不能把 apt 的目录直接套用给 apk、dnf 或其他工具。

4.4 缓存挂载不是依赖固定机制

错误示例:

RUN --mount=type=cache,target=/root/.cache/pip \
    test -f /root/.cache/pip/some-wheel.whl

这把“缓存中碰巧存在某个文件”当成了构建前提。换一台机器、清理一次缓存或更换缓存 ID 后,构建会失败。

正确逻辑应是:

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

pip 自己判断缓存是否可用,不可用时从索引重新下载。

4.5 缓存并发与污染

如果两个构建同时写同一个缓存目录:

RUN --mount=type=cache,id=shared,target=/root/.cache/tool \
    tool build

工具若不能安全并发写入,可能出现损坏文件、半写入归档或锁竞争。可按情况选择:

RUN --mount=type=cache,id=tool-cache,target=/root/.cache/tool,sharing=locked \
    tool build
  • shared:多个执行者可以同时使用;
  • private:为执行者提供独立缓存;
  • locked:同一缓存的使用者排队。

这是缓存状态的并发控制,不是镜像层锁,也不等价于分布式构建系统的全局事务。


五、Secret:让敏感输入参与执行,但不进入镜像

5.1 Secret 的生命周期

以文件方式挂载 Secret 时,生命周期可以表示为:

构建命令读取本地 Secret
        ↓
BuildKit 将 Secret 传给目标执行节点
        ↓
RUN 容器中出现临时文件
        ↓
命令结束
        ↓
挂载消失,不作为镜像层提交

Dockerfile:

# syntax=docker/dockerfile:1

FROM alpine:3.20 AS fetch
WORKDIR /work

RUN --mount=type=secret,id=license,target=/run/secrets/license \
    test -s /run/secrets/license \
    && ./download-private-artifact \
         --license /run/secrets/license \
         --output /work/artifact.tar.gz

构建:

docker build \
  --secret id=license,src=./license.txt \
  -t private-artifact .

这里 license.txt 不应出现在上下文中,也不应通过 COPY 传入。命令读取它,并将下载得到的 artifact.tar.gz 作为正常构建产物保存。

5.2 Secret 不会自动使缓存失效

这是一个容易造成安全问题的边界:Secret 内容通常不会作为普通缓存键输入。也就是说,Secret 文件从旧 Token 改为新 Token,不一定会强制重新执行同一条 RUN

如果构建结果确实依赖 Secret 的具体内容,例如私有仓库凭据对应了不同版本的内部包,就不能只依赖 Secret 变化触发更新。应显式引入非敏感版本标识:

docker build \
  --secret id=npmrc,src="$HOME/.npmrc" \
  --build-arg PRIVATE_DEP_SNAPSHOT=2025-03-01 \
  -t example .
ARG PRIVATE_DEP_SNAPSHOT
RUN echo "private dependency snapshot: ${PRIVATE_DEP_SNAPSHOT}" \
    && --mount=type=secret,id=npmrc,target=/root/.npmrc \
       npm ci

这里的 PRIVATE_DEP_SNAPSHOT 不是 Secret,而是用于表达“私有依赖内容已经变化”的公开缓存失效信号。它应由依赖发布流程可靠维护,不能随意用当前时间戳,否则每次构建都会失去缓存。

5.3 不要把 Secret 放入构建参数

错误方式:

docker build --build-arg TOKEN="$TOKEN" .

构建参数可能出现在构建记录、日志或元数据中;它们不适合承载敏感凭据。Secret mount 才是针对敏感输入设计的机制。

同样,以下命令也会泄露 Secret:

RUN --mount=type=secret,id=token \
    cat /run/secrets/token

即使文件没有进入层,内容也可能进入构建日志。诊断时应避免打印凭据,并检查构建工具的详细日志、缓存导出和 CI 日志保留策略。


六、多阶段构建与临时挂载的边界

多阶段构建可以把构建工具和中间产物留在构建阶段:

# syntax=docker/dockerfile:1

FROM golang:1.23-bookworm AS build
WORKDIR /src

COPY go.mod go.sum ./
RUN --mount=type=cache,id=gomod-cache,target=/go/pkg/mod \
    go mod download

COPY . .
RUN --mount=type=cache,id=gomod-cache,target=/go/pkg/mod \
    --mount=type=cache,id=gobuild-cache,target=/root/.cache/go-build \
    CGO_ENABLED=0 go build -trimpath -o /out/server ./cmd/server

FROM gcr.io/distroless/static-debian12:nonroot
COPY --from=build /out/server /server
ENTRYPOINT ["/server"]

状态变化是:

  1. build 阶段的根文件系统包含 Go 工具链和源码;
  2. cache mount 提供模块与编译缓存,但不属于阶段结果;
  3. /out/server 是明确的构建产物;
  4. 最终阶段只复制该产物;
  5. Secret、源码、编译缓存不会因为存在于构建阶段而自动进入最终镜像。

但多阶段构建不能修复构建脚本主动写入的泄露。例如,如果程序把 Token 嵌入二进制,最终阶段仍会包含它。是否泄露取决于产物内容,而不是阶段数量。


七、Compose 中的上下文、缓存和 Secret

Compose 的构建配置可以表达上下文和 Secret:

services:
  app:
    build:
      context: .
      dockerfile: Dockerfile
      secrets:
        - npmrc
      cache_from:
        - type=local,src=.buildx-cache
      cache_to:
        - type=local,dest=.buildx-cache-new,mode=max
    image: example-node:dev

secrets:
  npmrc:
    file: ${HOME}/.npmrc

对应 Dockerfile:

# syntax=docker/dockerfile:1

FROM node:22-bookworm
WORKDIR /app

COPY package.json package-lock.json ./

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

COPY . .
CMD ["node", "server.js"]

这里要区分三件事:

  • build.context 决定 COPY 可读取的文件;
  • secrets 决定哪些敏感数据可以临时挂载;
  • cache_tocache_from 决定构建缓存是否导出、导入。

缓存导出目录本身应被 .dockerignore 排除:

.buildx-cache
.buildx-cache-new

否则缓存归档可能被重新纳入上下文,导致上下文膨胀或缓存依赖异常。

使用本地目录导出缓存时,替换目录应谨慎处理。例如:

docker compose build
rm -rf .buildx-cache
mv .buildx-cache-new .buildx-cache

实际项目应先确认构建成功,再替换旧缓存;不要在构建失败后无条件删除可用缓存。


八、可重复构建:相同输入是否得到相同输出

8.1 定义

可重复构建(reproducible build)通常要求:

在明确相同的构建输入、工具链和环境条件下,多次构建产生内容等价或字节级相同的产物。

设构建输出为:

O=F(D,B,L,T,E,N,C)O = F(D, B, L, T, E, N, C)

其中:

  • DD:Dockerfile 和源代码;
  • BB:基础镜像内容;
  • LL:锁定的依赖版本;
  • TT:构建工具链;
  • EE:环境变量、平台、时区、语言环境;
  • NN:网络获取到的外部内容;
  • CC:缓存状态。

理想的可重复构建要求:当真正输入相同时,OO 不应依赖不可控的 NN 和缓存偶然状态 CC

缓存应满足:

有缓存更快\text{有缓存} \Rightarrow \text{更快}

而不是:

有缓存才能成功\text{有缓存} \Rightarrow \text{才能成功}

如果缓存为空,结果仍应等价;缓存只能改变执行路径和时间。

8.2 固定基础镜像

标签是可变名称:

FROM debian:bookworm

digest 是内容身份:

FROM debian:bookworm@sha256:<digest>

使用 digest 可以固定基础镜像内容,但也带来更新责任:安全更新不会自动进入构建,必须由依赖更新流程重新选择并验证新的 digest。

可以先查询镜像摘要:

docker buildx imagetools inspect debian:bookworm

输出中会包含不同平台的 manifest 信息。若同时要求平台可重复,还应显式固定平台:

docker buildx build \
  --platform=linux/amd64 \
  -t example:fixed \
  .

这里的 linux/amd64 是本文讨论的 Linux 容器边界。Linux 容器中的文件权限、符号链接、设备文件和 OverlayFS 行为不能直接推导为 Windows 容器行为。

8.3 固定依赖而不仅是直接版本

以下配置仍不充分:

requests==2.32.3

如果安装过程没有锁定传递依赖、索引内容或下载文件校验,同样的命令可能在不同时间得到不同结果。

更可靠的做法是使用锁文件、哈希校验和固定软件源快照。例如 Python 项目可以使用带哈希的锁定依赖文件,并执行:

COPY requirements.lock ./

RUN --mount=type=cache,target=/root/.cache/pip \
    pip install \
      --require-hashes \
      --no-cache-dir \
      -r requirements.lock

--require-hashes 要求每个安装包具有校验哈希。它不能保证软件源永远可用,但能防止“同名版本被替换后静默接受”。

8.4 时间、路径与归档元数据

构建不可重复的常见来源包括:

  • 编译器将当前时间写入二进制;
  • 归档文件保存文件的原始修改时间;
  • 构建路径被嵌入调试信息;
  • 文件遍历顺序未定义;
  • 时区或 locale 改变;
  • 生成随机 ID;
  • npm installpip install 等访问浮动依赖;
  • 使用 latest 或未固定的远程资源。

Go 示例中的:

go build -trimpath

可以去除本地构建路径,减少路径差异,但它不能自动固定所有依赖,也不能消除程序自身写入的时间和随机数。

对支持该约定的工具,可以设置:

ARG SOURCE_DATE_EPOCH=1704067200
ENV SOURCE_DATE_EPOCH=$SOURCE_DATE_EPOCH

SOURCE_DATE_EPOCH 只是一个约定,不是 Docker 强制执行的时间冻结开关。只有读取它的工具才会据此生成稳定时间。

8.5 COPY 的文件元数据

COPY 不只是复制字节,还涉及权限、属主、符号链接和文件模式。可以显式指定:

COPY --chown=nonroot:nonroot app /app

但这不等于所有元数据都被统一。若构建要求字节级复现,应检查:

  • 源文件权限是否稳定;
  • 用户和组是否固定;
  • 生成归档时是否固定时间和排序;
  • 不同主机的 Git checkout 是否产生不同换行符;
  • 是否依赖宿主机文件系统的特殊属性。

不要把“镜像配置中的某些时间字段由构建器处理”误认为“整个镜像必然字节级可复现”。最终应比较实际产物,而不是只比较 Dockerfile 文本。


九、如何验证构建是否真正可重复

9.1 清空缓存后构建两次

一个基础验证流程:

docker builder prune -af

docker buildx build \
  --pull=false \
  --platform=linux/amd64 \
  --output=type=oci,dest=image-a.tar \
  .

docker builder prune -af

docker buildx build \
  --pull=false \
  --platform=linux/amd64 \
  --output=type=oci,dest=image-b.tar \
  .

然后比较:

sha256sum image-a.tar image-b.tar

若摘要不同,不能直接断定 Dockerfile 不可重复,因为 OCI tar 包本身可能包含归档顺序或元数据差异。应进一步检查:

  • 镜像 config;
  • manifest;
  • 各层 digest;
  • 层内文件内容、权限、属主和时间;
  • 构建工具输出中的时间与路径。

9.2 比较缓存命中与冷构建结果

应至少测试两种状态:

  1. 冷构建:没有本地步骤缓存和目录缓存;
  2. 热构建:复用已有 BuildKit 缓存。

如果两次生成的程序不同,优先检查构建脚本是否读取了 cache mount 中的旧文件,或是否把缓存目录误当作完整输入。

可以查看构建过程:

docker buildx build --progress=plain -t example:test .

--progress=plain 会显示更适合诊断的步骤日志。日志中应区分:

  • CACHED:复用了步骤结果;
  • 实际执行的 RUN:重新运行;
  • 外部缓存导入或导出;
  • Secret 是否被错误打印。

9.3 检查镜像内容和层历史

构建成功后:

docker image inspect example:test
docker history example:test

这些命令可帮助确认:

  • 最终镜像使用了哪个基础镜像;
  • 层大小是否异常;
  • 是否有把依赖缓存、源码或包管理器目录写入最终阶段;
  • 是否错误地将命令行 Secret 作为参数写入历史。

docker history 不是完整的取证工具;若怀疑 Secret 进入层内容,应导出镜像并检查每一层,而不是只看最终容器文件系统。


十、常见失败路径与诊断

失败一:COPY failed: file not found

典型原因是文件不在构建上下文中:

docker build -f docker/Dockerfile .

此时上下文仍是当前目录 .,而不是 docker/。如果 Dockerfile 中写:

COPY ../go.mod ./

不能越过上下文根目录。

修复方式是扩大或调整上下文:

docker build -f docker/Dockerfile .

并确保 go.mod 位于 . 下;或者:

docker build -f Dockerfile ./project

./project 成为上下文根目录。

失败二:修改一个文件后整个依赖安装重新执行

检查 COPY 是否过早复制了整个项目:

COPY . .
RUN npm ci

应将稳定的依赖描述文件提前复制:

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

同时检查 .dockerignore,避免构建输出、日志或编辑器临时文件不断改变上下文输入。

失败三:缓存目录存在,但构建结果错误

原因可能是工具读取了旧缓存而没有校验,或多个构建并发写入同一缓存。诊断步骤:

  1. 删除或更换 id=,执行一次冷构建;
  2. 暂时移除 type=cache 挂载,确认逻辑是否正确;
  3. 使用 sharing=lockedprivate
  4. 检查工具是否拥有自己的缓存校验和;
  5. 确认缓存目录没有被复制到产物中。

失败四:Secret 轮换后仍命中旧构建

这是因为 Secret 内容通常不作为普通缓存失效输入。应:

  • 显式更新非敏感依赖快照参数;
  • 使用新的缓存 ID;
  • 在确需验证时使用 --no-cache,但不要把它作为日常依赖更新机制;
  • 检查命令是否真的读取了挂载路径,而不是使用了宿主机环境变量中的旧值。

失败五:本地构建成功,CI 构建失败

常见原因包括:

  • 本地 cache mount 中已经存在依赖,CI 缓存为空;
  • 本地 Dockerfile 使用了未声明的宿主机环境;
  • CI 的平台不同,例如 linux/arm64linux/amd64
  • 私有仓库 Secret 没有配置;
  • 软件源或 Git 仓库在 CI 网络中不可达;
  • .dockerignore 在本地和 CI 使用的上下文不同。

最有效的验证不是复制本地缓存,而是在本地执行一次无缓存、指定平台、无未声明环境依赖的构建。


十一、规范保证、实现行为与工程选择

需要明确三种层次:

Dockerfile 和 Compose 规范

规范定义了 COPYRUN、多阶段构建、Secret 和 cache mount 等接口的语义边界。Dockerfile frontend 的版本会影响可用语法,应通过语法声明明确要求:

# syntax=docker/dockerfile:1

较新的 frontend 能力不应无条件假设旧构建器支持。现代 Docker Engine 默认使用 BuildKit,但在旧环境、特殊 CI 配置或显式切换时仍应确认实际构建后端。

BuildKit 的常见实现行为

BuildKit 通常:

  • 按依赖图并行执行;
  • 使用内容寻址缓存;
  • 将 cache mount 与镜像层分开管理;
  • 支持本地、注册表、GitHub Actions 等缓存导入导出方式;
  • 在构建阶段临时提供 Secret 和 SSH mount。

缓存存储的位置、垃圾回收策略和跨机器共享能力取决于构建器配置。不能把某台机器上缓存永久存在的行为当作 Dockerfile 的规范保证。

工程上的可重复性选择

固定 digest、锁文件、哈希校验、显式平台和冷构建验证,能够减少不确定性,但会增加依赖更新和供应链维护成本。完全离线构建还需要预先准备基础镜像、依赖包和软件源镜像;仅仅启用 BuildKit cache 并不能使构建自动离线。

一个可靠的 Dockerfile 因此应满足以下因果关系:

明确上下文
  → COPY 输入可追踪
稳定依赖文件先复制
  → 依赖安装缓存可复用
cache mount 只用于加速
  → 缓存为空仍能成功
Secret 使用临时挂载
  → 凭据不进入普通层
固定基础镜像和依赖
  → 外部输入受控
清理并验证冷/热构建
  → 可重复性有证据,而不是假设

这套关系比单独记忆某个参数更重要:构建上下文定义输入边界,BuildKit 构建图定义依赖关系,缓存挂载改变执行成本,Secret 控制敏感数据生命周期,而可重复构建则要求这些机制不会把隐含状态伪装成确定性输入。


系列导航与关联阅读

官方资料

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