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

Docker 构建上下文与 .dockerignore:传输边界、缓存和 Secret 泄漏

Docker 构建时,Dockerfile 并不是直接在宿主机上逐条执行。客户端首先选择一个构建上下文(build context),再把 Dockerfile、经过 .dockerignore 过滤后的上下文文件,以及构建参数交给构建器。BuildKit 会把这些输入转换为构建图(LLB),随后在构建节点上执行 COPYRUN 等步骤。

因此,下面三个问题实际上属于同一条数据流:

  1. 哪些文件能够进入构建器,构成“传输边界”;
  2. 这些文件如何影响构建缓存;
  3. 凭据为什么不能通过普通上下文、ARGENV 注入。

本文以现代 Docker Engine、BuildKit 和 Compose 规范为基础,讨论 Linux 容器构建边界。文中的“宿主机文件”指 Docker 客户端选择的本地构建上下文中的文件,不表示这些文件已经位于某个正在运行的容器内。


一、构建上下文到底是什么

1. 构建上下文是构建器可以读取的输入集合

最常见的命令是:

docker build -t demo:latest .

最后的 . 表示当前目录是构建上下文。假设目录如下:

demo/
├── Dockerfile
├── package.json
├── package-lock.json
├── src/
│   └── index.js
├── .env
├── .git/
└── node_modules/

如果没有 .dockerignore,构建上下文逻辑上包含这些文件。Dockerfile 中的:

COPY package.json package-lock.json ./
COPY src ./src

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

可以把一次构建抽象为:

B=f(D,C,A,S,P)B = f(D, C, A, S, P)

其中:

  • DDDockerfile 及其解析结果;
  • CC:经过忽略规则过滤后的构建上下文;
  • AA:构建参数,例如 --build-arg
  • SS:通过 BuildKit Secret 或 SSH Mount 提供的临时凭据;
  • PP:基础镜像、网络响应、包仓库状态等外部输入;
  • BB:构建结果,包括镜像层和构建缓存。

.dockerignore 的作用不是删除宿主机文件,而是改变 CC。它决定哪些路径可以成为普通 COPYADD 的输入,也减少客户端向构建器传递的文件量。

2. -f 指定 Dockerfile,不会自动改变上下文

下面的命令经常被误解:

docker build -f docker/Dockerfile .

这里:

  • docker/Dockerfile 是 Dockerfile;
  • . 仍然是构建上下文;
  • COPY 的源路径仍然相对于 . 解析,而不是相对于 docker/ 解析。

因此,如果目录为:

project/
├── docker/
│   └── Dockerfile
└── app/
    └── main.py

Dockerfile 可以写:

COPY app /opt/app

但不能因为 Dockerfile 位于 docker/,就写成:

COPY ../app /opt/app

源路径必须位于上下文边界内。构建器会对路径进行规范化,不能通过 ../ 绕过这个边界。

3. 上下文的传输位置可能与客户端不同

传统构建流程中,Docker 客户端通常先打包上下文,再发送给 Docker daemon。现代 BuildKit 可能根据构建图按需读取或传输文件,而不是简单地把所有文件一次性发送完毕。

这不改变两个重要事实:

  1. .dockerignore 定义了普通本地上下文的逻辑可见边界;
  2. 被排除的文件不能通过普通 COPY 重新出现。

所以,“BuildKit 可能按需传输”不等于“未使用的敏感文件可以安全留在上下文中”。敏感文件是否进入构建器、远程构建节点、构建缓存或日志,仍然需要显式控制。


二、.dockerignore 如何改变上下文

1. .dockerignore 在发送上下文前生效

在上下文根目录放置 .dockerignore

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

一个更适合示例项目的版本可能是:

.git
node_modules
dist
coverage
.env
.env.*
!.env.example
*.pem
*.key

这里的规则含义是:

  • .git:排除 Git 元数据;
  • node_modules:排除宿主机依赖目录;
  • .env.env.*:排除环境文件;
  • !.env.example:重新允许提交模板文件;
  • *.pem*.key:排除常见密钥文件。

否定规则 ! 只能对之前匹配到的路径进行恢复。目录层级尤其容易出错。例如:

secrets
!secrets/example.txt

如果整个 secrets 目录已经被排除,某些路径匹配场景下仅恢复子文件并不足以让构建器遍历该目录。更稳妥的写法是明确恢复目录和文件,或避免把敏感目录作为普通上下文的一部分:

secrets/*
!secrets/
!secrets/example.txt

具体匹配行为还受到路径清理、通配符和 Docker 实现版本的影响,复杂规则应通过实际构建验证,而不是仅凭文件名直觉判断。

2. 根目录 .dockerignore 与 Dockerfile 专用忽略文件

当使用多个 Dockerfile 时,可以为某个 Dockerfile 提供专用忽略文件。例如:

Dockerfile
Dockerfile.dockerignore
lint.Dockerfile
lint.Dockerfile.dockerignore

执行:

docker build -f lint.Dockerfile .

时,lint.Dockerfile.dockerignore 的优先级高于根目录 .dockerignore。这允许构建不同目标时使用不同上下文过滤规则。

但这也引入了一个安全边界:检查某个 Dockerfile 是否安全时,必须同时检查它实际生效的 .dockerignore,不能只看根目录文件。

3. Dockerfile 与 .dockerignore 本身的特殊性

Dockerfile 和用于过滤上下文的 .dockerignore 会被构建客户端提供给构建器,以便完成构建;但它们不能像普通上下文文件那样被 COPY 到最终镜像中。下面的写法不能把 Dockerfile 当作普通源文件复制进镜像:

COPY Dockerfile /tmp/

这不应被理解为“Dockerfile 没有被传输”,而应理解为:

  • 它是构建定义的一部分;
  • 它不是普通 COPY 源文件;
  • 其内容仍可能通过构建定义、历史信息、CI 日志等渠道暴露。

因此,不应把机密直接写入 Dockerfile,以为 .dockerignore 能保护它。


三、上下文边界与 COPY 的数据流

可以将普通本地上下文的关系表示为:

flowchart LR
    H[宿主机目录] --> I[.dockerignore 过滤]
    I --> C[构建上下文 C]
    D[Dockerfile] --> G[BuildKit 构建图]
    C --> G
    A[ARG / 构建参数] --> G
    S[Secret / SSH Mount] --> G
    G --> L[中间层与缓存]
    L --> O[镜像输出]

关键路径如下:

  1. 宿主机目录中的文件先经过 .dockerignore
  2. 过滤后的文件集合成为普通上下文;
  3. COPY 从这个集合读取文件;
  4. RUN 默认不能直接读取宿主机上下文中的任意文件;
  5. 只有通过 COPY、绑定挂载、Secret Mount 或 SSH Mount 等机制,数据才会进入对应构建步骤。

例如:

FROM alpine:3.20

WORKDIR /app
COPY src/ ./src/
RUN find /app -maxdepth 2 -type f -print

如果 .dockerignore 包含:

src/

那么 COPY src/ ./src/ 会失败,典型表现是构建器找不到源路径或复制结果为空,具体错误文本可能随前端和版本不同而变化。

.dockerignore 过滤发生在构建开始阶段,而不是 COPY 执行时临时决定。因此,Dockerfile 中没有使用某个文件,并不代表该文件没有进入构建上下文;只有忽略规则或上下文选择本身排除了它,才能缩小边界。


四、命名上下文:不扩大默认上下文也能提供额外输入

BuildKit 支持命名上下文:

docker buildx build \
  --build-context docs=./docs \
  -t demo:latest .

Dockerfile 可以按上下文名称读取:

# syntax=docker/dockerfile:1

FROM alpine:3.20 AS build

COPY . /src
COPY --from=docs . /usr/share/docs
RUN find /src /usr/share/docs -type f -print

这里有两个独立的上下文:

  • 默认上下文 .
  • 名为 docs 的上下文 ./docs

--build-context 的源也可以是 Git、URL 或其他 BuildKit 支持的上下文类型。它们并不自动受到默认上下文根目录下 .dockerignore 的控制;每个上下文有自己的来源和过滤边界。

这带来一个重要安全结论:

检查了默认上下文的 .dockerignore,不代表检查了所有输入上下文。

例如,默认上下文排除了 .env,但命令又显式指定:

--build-context private=./private

那么 private 仍然可能包含凭据。构建审计必须枚举完整命令、Compose 配置或 CI 配置中的所有上下文来源。

命名上下文也不能访问宿主机任意路径。构建器只能读取被明确声明为上下文的内容,不能把 COPY --from=... 当成任意文件系统读取接口。


五、.dockerignore 与构建缓存的关系

1. BuildKit 缓存的基本条件

BuildKit 会将 Dockerfile 转换为一组有依赖关系的构建操作。每个操作大致可以看成:

Ki=H(Oi,Ii,Mi)K_i = H(O_i, I_i, M_i)

其中:

  • OiO_i:第 ii 步操作,例如 RUNCOPY
  • IiI_i:输入内容,例如父层、文件内容、挂载来源;
  • MiM_i:影响该步骤的元数据,例如参数、选项、部分文件元数据;
  • HH:内容寻址哈希;
  • KiK_i:该步骤的缓存键。

如果相同的操作在相同的输入上执行,BuildKit 可以复用已有结果。某个步骤的缓存失效后,依赖它的后续步骤通常也需要重新执行。

2. .dockerignore 改变 COPY 的输入集合

考虑 Dockerfile:

FROM alpine:3.20

WORKDIR /app
COPY . .
RUN sha256sum app.txt

设过滤后的上下文为:

C={app.txt,README.md}C = \{app.txt, README.md\}

执行 COPY . . 后,COPY 的输入是 CC 中的文件及其相关元数据。现在 .dockerignore 改为排除 README.md

C={app.txt}C' = \{app.txt\}

即使 app.txt 没有任何变化,COPY . . 的输入描述也发生了变化,因此该步骤可能失效,后续 RUN 也会重新执行。

反过来,如果原本已经排除 debug.log,而该文件在宿主机上不断变化,那么只要它始终不进入上下文,就不会因为它的变化导致 COPY . . 缓存失效。这正是 .dockerignore 能够减少无关构建输入的原因。

3. COPY 的缓存不是简单比较文件修改时间

COPYADD 等操作,BuildKit 通常根据文件内容和相关元数据计算输入,而不是仅依据宿主机文件的修改时间。修改时间通常不会单独导致 COPY 缓存失效,但文件内容、路径、权限、大小等变化可能会影响结果。

因此,下面的推理是不可靠的:

“文件的 mtime 没变,所以缓存一定命中。”

正确的判断应当是:构建器看到的输入快照、Dockerfile 操作及其选项是否等价。

4. RUN 的缓存不会自动检查所有外部状态

下面的 Dockerfile 存在常见误区:

FROM alpine:3.20

RUN apk add --no-cache curl

如果 Dockerfile 和基础镜像引用没有变化,BuildKit 可以复用这条 RUN 的缓存。它不会因为 Alpine 仓库今天发布了更新,就自动认为这条命令必须重新执行。

同理,RUN 命令访问的网络资源通常不是由普通上下文哈希直接表示的。要获得可重复构建,需要同时控制:

  • 基础镜像摘要;
  • 包管理器锁文件或精确版本;
  • 外部下载内容的校验和;
  • 网络源和依赖元数据;
  • 构建参数及其默认值;
  • 构建缓存是否来自可信来源。

例如:

FROM alpine:3.20

RUN apk add --no-cache \
        ca-certificates=20240226-r0 \
        curl=8.10.1-r0

版本号是否存在取决于具体仓库,不能盲目复制示例版本。生产构建应使用项目实际验证过的版本,或采用锁定依赖的构建流程。


六、合理的 Dockerfile 顺序如何利用缓存

下面是一个 Node.js 示例:

# syntax=docker/dockerfile:1

FROM node:22-bookworm-slim

WORKDIR /app

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

COPY src ./src

CMD ["node", "src/index.js"]

假设 src/index.js 修改,但 package.jsonpackage-lock.json 不变:

  1. 基础镜像步骤命中缓存;
  2. 复制锁文件步骤命中缓存;
  3. npm ci 步骤命中缓存;
  4. 复制 src 的步骤重新执行;
  5. 后续步骤重新执行。

如果写成:

FROM node:22-bookworm-slim

WORKDIR /app

COPY . .
RUN npm ci

那么任何未被 .dockerignore 排除的文件变化,都可能让 COPY . . 失效,进而使 npm ci 重新执行。

这种优化不是“把依赖安装永远缓存起来”,而是把变化频率不同的输入拆分成不同缓存边界。它成立的前提是:

  • 锁文件确实完整描述依赖;
  • npm ci 不依赖未复制的其他配置;
  • .dockerignore 没有误排除安装所需文件;
  • 安装过程没有依赖未声明的宿主机状态。

例如某些项目还需要 .npmrc、私有 registry 配置或原生编译工具。如果这些依赖没有通过安全方式提供,单纯调整 COPY 顺序只会制造失败或泄漏风险。


七、缓存挂载不是镜像层,也不是无条件安全的缓存

BuildKit 支持缓存挂载:

# syntax=docker/dockerfile:1

FROM debian:bookworm-slim

RUN --mount=type=cache,target=/var/cache/apt,sharing=locked \
    --mount=type=cache,target=/var/lib/apt,sharing=locked \
    apt-get update \
 && apt-get install -y --no-install-recommends ca-certificates \
 && rm -rf /var/lib/apt/lists/*

type=cache 的目录由构建器维护,通常用于复用下载内容。它与镜像层有不同生命周期:

  • 挂载目录中的内容不会因为 RUN 结束就自动写入镜像层;
  • 镜像最终只包含命令在容器根文件系统中留下的内容;
  • 缓存由构建器持久化,可能跨多个构建使用;
  • 缓存可以被清理,也可能被导出或由多个构建共享。

sharing=locked 表示多个并发构建访问该缓存时进行互斥,适合会修改缓存数据库的包管理器。它解决的是并发一致性问题,不是凭据保护问题。

如果命令把认证文件写入缓存目录:

RUN --mount=type=cache,target=/root/.cache \
    cp /run/secrets/npmrc /root/.cache/npmrc \
 && npm install

那么 Secret 虽然没有直接写入镜像层,却被复制进持久缓存。之后其他构建、缓存导出或缓存清理前的检查都可能暴露它。

所以必须区分:

  • 不进入镜像层
  • 不进入构建缓存
  • 不进入日志
  • 不被其他构建访问

Secret Mount 只主要保证第一项和临时文件生命周期,不能替代对构建器缓存、日志和共享权限的审计。


八、为什么 ARGENV 不适合传 Secret

下面的方式看似方便,但不安全:

docker build \
  --build-arg NPM_TOKEN="$NPM_TOKEN" \
  -t demo:latest .
FROM node:22-bookworm-slim

ARG NPM_TOKEN
RUN npm config set //registry.example.com/:_authToken="$NPM_TOKEN" \
 && npm ci \
 && npm config delete //registry.example.com/:_authToken

问题有三层。

1. ARG 是构建定义的一部分

ARG 的值可能出现在:

  • 构建进度日志;
  • CI 调试输出;
  • BuildKit 构建记录;
  • 缓存元数据或远程缓存关联信息;
  • 某些工具生成的历史信息。

即使最终镜像中没有这个环境变量,也不能把 ARG 当作秘密通道。

2. ENV 会进入镜像配置

ENV NPM_TOKEN=secret-value

这会使值进入镜像配置。使用:

docker image inspect demo:latest

通常可以看到环境变量配置。后续再用:

ENV NPM_TOKEN=

只是增加一层覆盖,并不等于删除旧层中的信息。

3. 删除文件不能抹掉旧层

下面的做法也不能可靠清除凭据:

RUN echo "$NPM_TOKEN" > /tmp/token \
 && do-something \
 && rm /tmp/token

如果凭据曾经进入某一层或命令记录,后续删除只改变新层的文件视图。镜像层通常是追加的,不会修改已经生成的旧层。

多阶段构建同样不是自动清除机制:

FROM alpine AS build
COPY secret.txt /tmp/secret.txt
RUN generate-output

FROM alpine
COPY --from=build /output /output

最终阶段可能不包含 secret.txt,但中间阶段仍可能存在于:

  • 本地构建缓存;
  • 远程缓存;
  • 构建记录;
  • 导出的中间结果;
  • 失败构建的诊断数据。

多阶段构建适合缩小最终镜像,不应被当成凭据销毁工具。


九、Secret Mount:让凭据只在需要的 RUN 中出现

BuildKit 提供 Secret Mount:

docker buildx build \
  --secret id=npmrc,src="$HOME/.npmrc" \
  -t private-node-app:latest .

Dockerfile:

# syntax=docker/dockerfile:1

FROM node:22-bookworm-slim

WORKDIR /app

COPY package.json package-lock.json ./

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

COPY src ./src

CMD ["node", "src/index.js"]

执行过程是:

  1. 客户端从 src 指定的文件读取 Secret;
  2. 构建器启动包含 Secret Mount 的 RUN
  3. Secret 在该步骤中以 /root/.npmrc 出现;
  4. npm ci 使用该文件访问私有 registry;
  5. RUN 结束后,挂载消失;
  6. 只要命令没有主动复制它,Secret 不进入最终镜像层。

required=true 的作用是:如果调用方没有提供该 Secret,构建应立即失败,而不是让工具产生一个难以诊断的认证错误或意外使用匿名访问。

也可以使用环境变量形式提供 Secret,但仍应避免把值写入 Dockerfile 的 ARGENV

docker buildx build \
  --secret id=API_TOKEN,env=API_TOKEN \
  -t demo:latest .
RUN --mount=type=secret,id=API_TOKEN,env=API_TOKEN,required=true \
    test -n "$API_TOKEN" \
 && ./download-private-assets.sh

这里的环境变量只在该 RUN 进程中可见。实际可用的 envtargetrequired 等选项取决于 BuildKit 与 Dockerfile 前端版本;现代 Docker Engine 通常已支持这些能力,但固定版本的 CI 仍应使用与其匹配的 Dockerfile frontend。

Secret Mount 的边界

下面的写法会破坏 Secret 的保护目标:

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

因为 /app/.npmrc 位于普通根文件系统中,RUN 结束后会进入镜像层。

下面的写法也可能泄漏:

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

如果构建日志显示标准输出,Secret 内容就已经进入日志。Secret Mount 不是“禁止进程读取秘密”,而是提供一个生命周期较短、默认不写入镜像层的输入;构建命令本身仍必须避免打印、复制或打包它。


十、Secret 不一定参与缓存键:为什么凭据轮换可能不生效

这是构建 Secret 中最容易被忽略的缓存问题。

假设 Dockerfile 为:

# syntax=docker/dockerfile:1

FROM alpine:3.20

RUN --mount=type=secret,id=license \
    ./install-from-private-repository.sh /run/secrets/license

第一次构建使用许可证 A,第二次构建使用许可证 B。对于 BuildKit,Secret 的内容默认不作为该 RUN 步骤的缓存键输入。于是可能发生:

  1. Dockerfile 没变;
  2. 基础镜像没变;
  3. RUN 命令没变;
  4. Secret ID 仍然是 license
  5. 构建器复用了第一次构建的结果;
  6. 第二次构建没有真正使用许可证 B。

这不是说 Secret 内容会进入缓存,而是相反:Secret 内容通常不用于决定缓存是否失效。这样可以避免凭据内容进入缓存键,但会导致“凭据变化而结果未重建”。

如果业务要求凭据轮换后强制执行该步骤,可以引入不包含秘密本身的缓存扰动值:

docker buildx build \
  --secret id=license,src=license.txt \
  --build-arg LICENSE_REVISION=2025-03-08 \
  -t demo:latest .
FROM alpine:3.20

ARG LICENSE_REVISION

RUN --mount=type=secret,id=license,required=true \
    echo "license revision: ${LICENSE_REVISION}" >&2 \
 && ./install-from-private-repository.sh /run/secrets/license

LICENSE_REVISION 应该是公开的版本号、轮换编号或提交 ID,而不是 Secret 内容。它的作用是让构建定义发生可控变化。

也可以使用:

docker buildx build --no-cache-filter install-step ...

或整体使用:

docker buildx build --no-cache ...

--no-cache 的语义是不要使用已有缓存,并不等于删除本地或远程缓存,更不等于清除已经泄漏的凭据。


十一、SSH Mount 与 Git 私有依赖

如果构建需要通过 SSH 访问私有 Git 仓库,不应把私钥复制到上下文:

COPY id_ed25519 /root/.ssh/id_ed25519

正确方向是使用 SSH Mount:

eval "$(ssh-agent -s)"
ssh-add "$HOME/.ssh/id_ed25519"

docker buildx build \
  --ssh default \
  -t private-app:latest .

Dockerfile:

# syntax=docker/dockerfile:1

FROM alpine:3.20

RUN apk add --no-cache git openssh-client

RUN --mount=type=ssh \
    mkdir -p -m 0700 /root/.ssh \
 && ssh-keyscan git.example.com >> /root/.ssh/known_hosts \
 && git clone git@git.example.com:team/private-lib.git /opt/private-lib

SSH Mount 提供的是构建器与 SSH agent 的转发通道,不是把私钥文件复制进镜像。需要注意:

  • ssh-keyscan 的结果应通过可信方式验证,盲目接受主机密钥可能遭受中间人攻击;
  • 构建日志不能打印 SSH 调试输出中的敏感信息;
  • 构建命令产生的源码、依赖包或配置文件可能间接包含凭据;
  • RUN 产生的输出仍会进入镜像层,SSH Mount 只保护 SSH 凭据本身。

SSH Mount 和 Secret Mount 解决的是不同输入:

  • Secret Mount:文件或环境变量形式的短期秘密;
  • SSH Mount:访问 SSH agent 或 SSH socket 的短期通道。

二者都要求 BuildKit。使用不支持相应 Dockerfile frontend 的旧构建器时,RUN --mount=... 可能被解析失败,或构建行为与预期不同。


十二、一个完整的安全构建示例

项目目录:

project/
├── Dockerfile
├── .dockerignore
├── package.json
├── package-lock.json
├── src/
│   └── index.js
└── .npmrc.example

.dockerignore

.git
node_modules
dist
coverage
.env
.env.*
.npmrc
*.pem
*.key

Dockerfile:

# syntax=docker/dockerfile:1

FROM node:22-bookworm-slim

WORKDIR /app

COPY package.json package-lock.json ./

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

COPY src ./src

USER node

CMD ["node", "src/index.js"]

构建:

docker buildx build \
  --secret id=npmrc,src="$HOME/.npmrc" \
  --tag example-app:latest \
  .

每一步的边界如下:

  1. . 选择项目根目录作为默认上下文;
  2. .dockerignore 排除 .npmrc.env、私钥和宿主机依赖目录;
  3. COPY package.json package-lock.json ./ 只把依赖描述文件写入镜像层;
  4. npm ci 通过 Secret Mount 读取认证配置;
  5. npm ci 结束后 /root/.npmrc 挂载消失;
  6. COPY src ./src 只复制源代码;
  7. USER node 让运行时不使用 root;
  8. 最终镜像不应包含 .npmrc

验证时可以检查:

docker run --rm example-app:latest sh -c '
  test ! -e /root/.npmrc &&
  test ! -e /app/.npmrc &&
  echo "secret file not present"
'

预期输出:

secret file not present

还可以检查镜像环境:

docker image inspect example-app:latest \
  --format '{{json .Config.Env}}'

不应看到 npm token。需要注意,这些检查只能证明最终镜像当前文件系统和配置没有明显泄漏,不能证明:

  • 构建日志没有输出凭据;
  • 本地构建缓存没有凭据;
  • 远程缓存没有敏感产物;
  • 私有依赖包本身没有嵌入凭据;
  • CI 系统没有记录命令行秘密。

十三、常见错误与失败路径

错误一:只因为文件没有 COPY,就认为它不会被传输

目录中有一个 500 MB 的 backup.tar,Dockerfile 没有复制它:

project/
├── Dockerfile
├── .dockerignore
└── backup.tar

如果 .dockerignore 没有排除它,构建客户端仍可能把它作为上下文输入处理。现代 BuildKit 可能按需读取,但它仍属于上下文边界内的数据。

应使用:

backup.tar

而不是依赖“Dockerfile 没有引用它”。

错误二:把 .dockerignore 当成 Secret 管理工具

.dockerignore 可以阻止 .env 进入默认普通上下文,但它不能保护:

  • 已经提交到 Git 历史的凭据;
  • 通过命名上下文显式提供的文件;
  • Dockerfile 中硬编码的秘密;
  • ARGENV 和命令日志中的秘密;
  • 被复制到缓存目录的秘密;
  • 构建产物中生成的配置文件。

.dockerignore 是输入过滤器,不是加密、权限隔离或凭据注入机制。

错误三:使用 COPY . . 后再删除敏感文件

COPY . .
RUN rm -f .env

如果 .env 没有被 .dockerignore 排除,它已经进入 COPY 对应的镜像层。后续删除不会消除旧层。正确做法是从源头排除,或者不要把秘密作为普通上下文输入。

错误四:以为 --no-cache 能完成泄漏恢复

docker buildx build --no-cache -t example-app:latest .

这只影响本次构建是否复用已有缓存。它不会:

  • 删除旧镜像层;
  • 删除 BuildKit 缓存;
  • 删除远程缓存;
  • 从 registry 撤回已经推送的镜像;
  • 清除 CI 日志;
  • 让已经泄漏的凭据失效。

如果 Secret 已经可能暴露,应立即轮换或吊销凭据,再清理受影响的镜像、缓存、构建记录和日志,并检查是否已经被拉取。

错误五:把包管理器缓存与 Secret 混为一谈

下面的构建可能在功能上成功:

RUN --mount=type=secret,id=npmrc \
    npm ci

但某些工具会把认证信息写入自己的缓存、配置或下载 URL。构建前应确认工具的行为,例如:

  • 是否把 token 写入缓存索引;
  • 是否把带认证信息的 URL 写入日志;
  • 是否会生成包含 registry 凭据的配置;
  • 是否会把私有源信息写入构建产物。

Secret Mount 只定义了输入的注入方式,不会替构建脚本审查副作用。


十四、诊断上下文、缓存和日志

1. 观察构建过程

使用纯文本进度可以看到更稳定的步骤信息:

docker buildx build \
  --progress=plain \
  --tag example-app:debug \
  .

重点观察:

  • 是否出现异常大的上下文传输;
  • 是否有意外的 COPY . .
  • 是否有命令把配置文件打印到标准输出;
  • 是否因 Secret 内容变化却命中缓存;
  • 是否把 /root/.npmrc、SSH 配置或 token 写入普通路径。

日志中不应出现:

TOKEN=...
Authorization: Bearer ...
-----BEGIN PRIVATE KEY-----

2. 验证 .dockerignore 是否真正生效

可以故意在 Dockerfile 中加入临时诊断:

FROM alpine:3.20
COPY . /context
RUN find /context -maxdepth 3 -type f -print | sort

执行构建并检查输出。如果预期被排除的 .env.git 或私钥仍然出现,说明:

  • 规则写错;
  • 使用的不是预期 Dockerfile 专用 .dockerignore
  • 文件来自另一个命名上下文;
  • 构建命令的上下文目录不是你以为的目录。

诊断完成后应删除这类步骤,避免把不必要文件写入镜像。

3. 检查最终镜像和构建缓存是两件事

最终镜像检查:

docker run --rm example-app:latest sh -c '
  find / -xdev \( -name ".npmrc" -o -name ".env" -o -name "*.key" \) -print 2>/dev/null
'

镜像配置检查:

docker image inspect example-app:latest

历史检查:

docker history --no-trunc example-app:latest

这些命令能发现部分泄漏,但不能完整审计 BuildKit 的所有内部缓存。docker history 可能显示 Dockerfile 指令或参数形式,而 Secret Mount 的内容通常不会作为普通环境变量显示;这不是绝对的“零泄漏证明”。

在使用 buildx 的环境中,还应结合构建器的磁盘使用信息检查缓存,例如:

docker buildx du

不同 builder 驱动、Docker Engine 版本和远程构建服务的展示内容可能不同。远程缓存还必须在对应 registry、对象存储或 CI 构建服务侧审计。


十五、构建上下文、缓存和可重复构建的共同边界

可重复构建不是只锁定一个 Dockerfile。更准确地说,若两次构建要得到等价结果,需要控制所有影响函数 ff 的输入:

(D1,C1,A1,S1,P1)(D2,C2,A2,S2,P2)(D_1, C_1, A_1, S_1, P_1) \equiv (D_2, C_2, A_2, S_2, P_2)

至少应满足:

  1. Dockerfile 和 Dockerfile frontend 等价;
  2. .dockerignore 规则等价;
  3. 过滤后的上下文路径、内容和关键元数据等价;
  4. 基础镜像引用指向相同内容,最好使用 digest;
  5. 依赖锁文件和下载内容等价;
  6. 构建参数一致;
  7. Secret 只用于认证,不改变输出,或者在需要时通过公开的 revision 参数使缓存失效;
  8. 构建脚本不依赖当前时间、随机数、宿主机用户名等未声明输入。

Secret 具有一个特殊性质:它既是构建输入,又通常不进入缓存键。这是安全和缓存行为之间的有意分离。若 Secret 只用于证明“有权限下载某个固定内容”,结果可以稳定;若 Secret 决定下载哪一份内容,则必须额外把内容版本显式纳入构建输入。


十六、Compose 中的上下文边界

Compose 配置中的构建通常类似:

services:
  app:
    build:
      context: .
      dockerfile: docker/Dockerfile
      secrets:
        - npmrc

secrets:
  npmrc:
    file: ~/.npmrc

Dockerfile:

# syntax=docker/dockerfile:1

FROM node:22-bookworm-slim
WORKDIR /app

COPY package.json package-lock.json ./

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

COPY src ./src
CMD ["node", "src/index.js"]

这里需要区分两类 Secret:

  • Compose 顶层 secrets:描述 Compose 如何从宿主机或其他来源提供秘密;
  • Dockerfile 的 RUN --mount=type=secret:描述构建步骤如何消费该秘密。

Compose 中的 build.context 决定普通上下文;dockerfile 通常相对于构建上下文解析。实际相对路径还受 Compose 文件位置和实现规范约束,团队应固定 Compose 版本并验证配置解析结果。无论使用 CLI 还是 Compose,都不能因为 Secret 写在 Compose 配置中,就把它当作普通 COPY 文件使用。


十七、Linux 容器边界下的几个实际细节

本文讨论的是 Linux 容器构建。构建上下文过滤发生在构建器处理宿主机目录的阶段,而不是在 Linux 容器内部执行一个“删除宿主机文件”的命令。

进入镜像后的文件属性还可能受到以下因素影响:

  • 宿主机与构建器的 UID/GID 映射;
  • COPY --chown 的显式设置;
  • 可执行权限;
  • 符号链接解析;
  • 大小写敏感性;
  • 文本文件换行符;
  • 不同文件系统对权限和元数据的表达差异。

例如:

COPY --chown=node:node src ./src

这会在复制时设置 Linux 文件所有者,避免运行时用户无法读取或写入文件。但它不会扩大上下文,也不会让被 .dockerignore 排除的文件重新可见。

Windows 宿主机可以参与 Linux 容器构建,但路径匹配、权限和换行符差异可能影响结果。生产构建应尽量在与目标一致的 Linux 构建环境中验证,尤其是依赖可执行权限、符号链接和原生扩展的项目。


十八、发现泄漏后的处理顺序

如果凭据曾经通过 ARGENV、普通 COPY、日志或缓存进入构建流程,应按“先失效,再清理”的顺序处理:

  1. 立即吊销或轮换凭据;
  2. 检查 registry、Git 服务、包仓库和云服务的访问日志;
  3. 删除或撤回已发布的受影响镜像;
  4. 清理本地和远程 BuildKit 缓存;
  5. 清理 CI 构建日志、构建记录和制品;
  6. 重新构建并重新扫描镜像、层和构建产物;
  7. 将凭据改为 Secret Mount 或 SSH Mount;
  8. 把敏感文件加入正确的 .dockerignore,并验证所有命名上下文;
  9. 检查是否有脚本把 Secret 复制到缓存目录、日志或最终输出。

清理镜像标签并不一定删除底层内容;共享 registry、内容寻址层和远程缓存都有自己的保留策略。因此,恢复动作必须覆盖实际使用的存储系统,而不是只执行一次 docker image rm


结语:把上下文视为输入边界,把缓存视为持久状态

构建上下文决定普通构建步骤“有机会看到什么”;.dockerignore 决定这个边界如何收缩;BuildKit 缓存决定输入和操作结果如何被复用;Secret Mount 和 SSH Mount 则提供不应进入普通镜像层的临时凭据通道。

三条因果关系必须明确:

  • 文件没有被 .dockerignore 排除,就可能属于构建器输入,即使 Dockerfile 没有显式使用它;
  • 文件进入普通 COPY 层后,再删除不能可靠消除历史层和缓存中的内容;
  • Secret 内容通常不参与缓存键,因此凭据轮换不一定触发重建,必要时应使用不含秘密的公开版本号主动扰动缓存。

安全的构建不是把秘密藏在某个 Dockerfile 技巧后面,而是同时控制普通上下文、命名上下文、Dockerfile 参数、构建缓存、日志和最终镜像这几个相互独立的边界。


系列导航与关联阅读

官方资料

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