Docker 基础体系 · 第 30/70 篇。示例以现代 Docker Engine、BuildKit 与 Compose 规范为基础,并明确 Linux 容器边界。
Docker 构建上下文与 .dockerignore:传输边界、缓存和 Secret 泄漏
Docker 构建时,Dockerfile 并不是直接在宿主机上逐条执行。客户端首先选择一个构建上下文(build context),再把 Dockerfile、经过 .dockerignore 过滤后的上下文文件,以及构建参数交给构建器。BuildKit 会把这些输入转换为构建图(LLB),随后在构建节点上执行 COPY、RUN 等步骤。
因此,下面三个问题实际上属于同一条数据流:
- 哪些文件能够进入构建器,构成“传输边界”;
- 这些文件如何影响构建缓存;
- 凭据为什么不能通过普通上下文、
ARG或ENV注入。
本文以现代 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
只能从这个上下文中读取源文件。
可以把一次构建抽象为:
其中:
- :
Dockerfile及其解析结果; - :经过忽略规则过滤后的构建上下文;
- :构建参数,例如
--build-arg; - :通过 BuildKit Secret 或 SSH Mount 提供的临时凭据;
- :基础镜像、网络响应、包仓库状态等外部输入;
- :构建结果,包括镜像层和构建缓存。
.dockerignore 的作用不是删除宿主机文件,而是改变 。它决定哪些路径可以成为普通 COPY 或 ADD 的输入,也减少客户端向构建器传递的文件量。
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 可能根据构建图按需读取或传输文件,而不是简单地把所有文件一次性发送完毕。
这不改变两个重要事实:
.dockerignore定义了普通本地上下文的逻辑可见边界;- 被排除的文件不能通过普通
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[镜像输出]
关键路径如下:
- 宿主机目录中的文件先经过
.dockerignore; - 过滤后的文件集合成为普通上下文;
COPY从这个集合读取文件;RUN默认不能直接读取宿主机上下文中的任意文件;- 只有通过
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 转换为一组有依赖关系的构建操作。每个操作大致可以看成:
其中:
- :第 步操作,例如
RUN或COPY; - :输入内容,例如父层、文件内容、挂载来源;
- :影响该步骤的元数据,例如参数、选项、部分文件元数据;
- :内容寻址哈希;
- :该步骤的缓存键。
如果相同的操作在相同的输入上执行,BuildKit 可以复用已有结果。某个步骤的缓存失效后,依赖它的后续步骤通常也需要重新执行。
2. .dockerignore 改变 COPY 的输入集合
考虑 Dockerfile:
FROM alpine:3.20
WORKDIR /app
COPY . .
RUN sha256sum app.txt
设过滤后的上下文为:
执行 COPY . . 后,COPY 的输入是 中的文件及其相关元数据。现在 .dockerignore 改为排除 README.md:
即使 app.txt 没有任何变化,COPY . . 的输入描述也发生了变化,因此该步骤可能失效,后续 RUN 也会重新执行。
反过来,如果原本已经排除 debug.log,而该文件在宿主机上不断变化,那么只要它始终不进入上下文,就不会因为它的变化导致 COPY . . 缓存失效。这正是 .dockerignore 能够减少无关构建输入的原因。
3. COPY 的缓存不是简单比较文件修改时间
对 COPY、ADD 等操作,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.json 和 package-lock.json 不变:
- 基础镜像步骤命中缓存;
- 复制锁文件步骤命中缓存;
npm ci步骤命中缓存;- 复制
src的步骤重新执行; - 后续步骤重新执行。
如果写成:
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 只主要保证第一项和临时文件生命周期,不能替代对构建器缓存、日志和共享权限的审计。
八、为什么 ARG 和 ENV 不适合传 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"]
执行过程是:
- 客户端从
src指定的文件读取 Secret; - 构建器启动包含 Secret Mount 的
RUN; - Secret 在该步骤中以
/root/.npmrc出现; npm ci使用该文件访问私有 registry;RUN结束后,挂载消失;- 只要命令没有主动复制它,Secret 不进入最终镜像层。
required=true 的作用是:如果调用方没有提供该 Secret,构建应立即失败,而不是让工具产生一个难以诊断的认证错误或意外使用匿名访问。
也可以使用环境变量形式提供 Secret,但仍应避免把值写入 Dockerfile 的 ARG 或 ENV:
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 进程中可见。实际可用的 env、target、required 等选项取决于 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 步骤的缓存键输入。于是可能发生:
- Dockerfile 没变;
- 基础镜像没变;
RUN命令没变;- Secret ID 仍然是
license; - 构建器复用了第一次构建的结果;
- 第二次构建没有真正使用许可证 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 \
.
每一步的边界如下:
.选择项目根目录作为默认上下文;.dockerignore排除.npmrc、.env、私钥和宿主机依赖目录;COPY package.json package-lock.json ./只把依赖描述文件写入镜像层;npm ci通过 Secret Mount 读取认证配置;npm ci结束后/root/.npmrc挂载消失;COPY src ./src只复制源代码;USER node让运行时不使用 root;- 最终镜像不应包含
.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 中硬编码的秘密;
ARG、ENV和命令日志中的秘密;- 被复制到缓存目录的秘密;
- 构建产物中生成的配置文件。
.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。更准确地说,若两次构建要得到等价结果,需要控制所有影响函数 的输入:
至少应满足:
- Dockerfile 和 Dockerfile frontend 等价;
.dockerignore规则等价;- 过滤后的上下文路径、内容和关键元数据等价;
- 基础镜像引用指向相同内容,最好使用 digest;
- 依赖锁文件和下载内容等价;
- 构建参数一致;
- Secret 只用于认证,不改变输出,或者在需要时通过公开的 revision 参数使缓存失效;
- 构建脚本不依赖当前时间、随机数、宿主机用户名等未声明输入。
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 构建环境中验证,尤其是依赖可执行权限、符号链接和原生扩展的项目。
十八、发现泄漏后的处理顺序
如果凭据曾经通过 ARG、ENV、普通 COPY、日志或缓存进入构建流程,应按“先失效,再清理”的顺序处理:
- 立即吊销或轮换凭据;
- 检查 registry、Git 服务、包仓库和云服务的访问日志;
- 删除或撤回已发布的受影响镜像;
- 清理本地和远程 BuildKit 缓存;
- 清理 CI 构建日志、构建记录和制品;
- 重新构建并重新扫描镜像、层和构建产物;
- 将凭据改为 Secret Mount 或 SSH Mount;
- 把敏感文件加入正确的
.dockerignore,并验证所有命名上下文; - 检查是否有脚本把 Secret 复制到缓存目录、日志或最终输出。
清理镜像标签并不一定删除底层内容;共享 registry、内容寻址层和远程缓存都有自己的保留策略。因此,恢复动作必须覆盖实际使用的存储系统,而不是只执行一次 docker image rm。
结语:把上下文视为输入边界,把缓存视为持久状态
构建上下文决定普通构建步骤“有机会看到什么”;.dockerignore 决定这个边界如何收缩;BuildKit 缓存决定输入和操作结果如何被复用;Secret Mount 和 SSH Mount 则提供不应进入普通镜像层的临时凭据通道。
三条因果关系必须明确:
- 文件没有被
.dockerignore排除,就可能属于构建器输入,即使 Dockerfile 没有显式使用它; - 文件进入普通
COPY层后,再删除不能可靠消除历史层和缓存中的内容; - Secret 内容通常不参与缓存键,因此凭据轮换不一定触发重建,必要时应使用不含秘密的公开版本号主动扰动缓存。
安全的构建不是把秘密藏在某个 Dockerfile 技巧后面,而是同时控制普通上下文、命名上下文、Dockerfile 参数、构建缓存、日志和最终镜像这几个相互独立的边界。
系列导航与关联阅读
- 系列入口:Docker 完整学习路线:从镜像与容器到安全、可观测和生产交付
- 上一篇:Docker 容器化测试:一次性依赖、健康等待、Fixture 和资源清理
- 下一篇:BuildKit 缓存深入:Layer、Cache Mount、远程缓存和失效诊断
- 延伸:Dockerfile 与 BuildKit:构建上下文、缓存挂载、Secret 和可重复构建
- 延伸:Docker Build Secret 与 SSH Mount:凭据注入、缓存和泄漏防护
官方资料
本文依据 Docker、OCI 与 CNCF 官方文档重新梳理;正文与生产检查清单由 WR BLOG 编写。

评论
0 条讨论