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

Docker Build Secret 与 SSH Mount:凭据注入、缓存和泄漏防护

在构建阶段访问私有包仓库、私有 Git 仓库或云服务时,构建过程通常需要密码、令牌、SSH 私钥或 SSH Agent。最危险的做法是把这些凭据写入 Dockerfile、构建上下文、ARGENV,或者先复制进镜像再删除。

BuildKit 提供两类专用挂载:

  • type=secret:将普通敏感数据以临时文件或环境变量的形式注入某一个 RUN
  • type=ssh:将 SSH Agent 的 Unix socket 转发到某一个 RUN,使 Git、SSH 客户端能够使用外部 Agent 中的密钥。

它们解决的是“凭据如何进入构建步骤”,而不是“构建过程自动变得安全”。凭据仍可能通过文件复制、命令输出、下载结果、缓存产物或远程构建器泄漏。


1. 先区分四个边界

理解 Secret Mount 和 SSH Mount,首先要区分四种边界。

1.1 构建上下文边界

构建上下文是客户端发送给构建器、供 COPYADD 使用的文件集合:

docker build -t example-app .

这里的 . 是上下文目录。默认情况下,目录中的文件可能被发送给 BuildKit,除非被 .dockerignore 排除。

因此,下面的文件即使没有被 COPY,也不应放入上下文:

.env
credentials.json
id_rsa
secrets/

.dockerignore 可以减少传输,也可以避免误用:

.git
.env
*.pem
*.key
secrets/

但它不是完整的密钥安全边界:

  1. 它只影响构建上下文中的文件;
  2. 复制前已经进入上下文的文件可能被其他构建配置使用;
  3. 如果构建器、CI 工作区或远程构建服务本身不可信,.dockerignore 不能解决信任问题;
  4. 一个文件只要曾经被写入镜像层,就可能在后续层中保留,即使后来删除。

例如:

FROM alpine:3.20

COPY .env /tmp/.env
RUN rm /tmp/.env

rm 只改变了后一个层的文件系统视图。前一个层仍可能包含 .env。因此,“复制后删除”不等于“没有进入镜像”。

1.2 镜像输出边界

镜像不是一个单一文件,而是由多个只读层和镜像配置组成。构建步骤的最终文件系统变化会成为层内容,某些指令还会进入历史或配置元数据。

以下写法会直接把凭据放入镜像配置:

ARG NPM_TOKEN
ENV NPM_TOKEN=$NPM_TOKEN

即使之后执行:

RUN unset NPM_TOKEN

也不能可靠地消除已经写入的镜像配置或历史信息。

1.3 BuildKit 缓存边界

BuildKit 会为构建步骤生成缓存结果。缓存的目标是复用“输入相同的构建结果”,但凭据内容不会被当作普通文件输入放入镜像层。

这带来一个容易忽略的事实:

Secret 的内容不会因为改变而自动使使用它的 RUN 失效。

如果一个步骤根据令牌访问不同的私有源,而其他输入没有变化,BuildKit 可能直接复用旧结果。凭据没有进入缓存键,并不意味着“使用凭据产生的文件”不会进入缓存。

1.4 构建器信任边界

Secret Mount 和 SSH Mount 通常不会把凭据写入最终镜像,但构建步骤仍在构建器上执行。

如果使用远程 BuildKit builder,那么构建器运营者或具有构建器访问权限的进程,理论上可以观察:

  • 构建步骤访问的文件和 socket;
  • 构建容器的进程;
  • 网络请求;
  • 构建日志;
  • 由凭据生成的输出。

所以,“不进入镜像”不等于“不离开客户端”。它只说明凭据不会按普通方式成为镜像结果的一部分。


2. RUN --mount 的生命周期

Secret Mount 和 SSH Mount 都是 RUN 指令的挂载,不是 COPY,也不是永久的镜像文件。

以 Secret Mount 为例:

# syntax=docker/dockerfile:1

FROM alpine:3.20

RUN --mount=type=secret,id=license,required \
    test "$(cat /run/secrets/license)" = "demo-license" && \
    echo "license accepted"

构建时提供 Secret:

printf '%s' 'demo-license' > ./license.txt

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

执行过程可以抽象为:

  1. BuildKit 接收 id=license 对应的 Secret;
  2. 创建这一个 RUN 的临时挂载;
  3. 将内容放在默认路径 /run/secrets/license
  4. 执行 shell 命令;
  5. RUN 结束后卸载该挂载;
  6. 只把命令结束时的文件系统差异作为构建结果处理。

在这个例子中,test 读取了 Secret,但没有把内容写入持久文件,因此最终镜像不应包含 demo-license

required 很重要:

RUN --mount=type=secret,id=license,required ...

如果没有提供这个 Secret,构建应当失败,而不是继续执行并产生一个实际上没有完成认证的镜像。省略 required 时,缺少 Secret 可能导致挂载不可用,随后由命令本身报错;显式声明可以让意图和错误位置更清楚。

默认路径可以改写:

RUN --mount=type=secret,id=license,target=/etc/myapp/license \
    my-tool --license-file /etc/myapp/license

也可以将 Secret 暴露为环境变量:

RUN --mount=type=secret,id=npm_token,env=NPM_TOKEN,required \
    npm config set //registry.example.com/:_authToken "$NPM_TOKEN" && \
    npm ci

文件方式通常更容易控制生命周期。环境变量方式虽然方便,但会使凭据进入当前进程环境;子进程、调试输出或错误诊断工具可能暴露它。

Secret Mount 常见参数包括:

RUN --mount=type=secret,\
id=license,\
target=/run/secrets/license,\
uid=1000,\
gid=1000,\
mode=0400 \
    ...

其中:

  • id 必须与 docker build --secret id=... 对应;
  • target 指定容器内路径;
  • uidgidmode 控制挂载文件的访问属性;
  • 默认目标路径是 /run/secrets/<id>

这些参数解决的是“谁能读”和“文件放在哪里”,不能防止程序主动复制内容。


3. Secret 的安全条件:挂载本身不保证无泄漏

可以把一个构建步骤简化为:

O=f(F,M,C,N)O = f(F, M, C, N)

其中:

  • FF 是步骤开始前的文件系统;
  • MM 是普通挂载,例如缓存目录;
  • CC 是 Secret 或其他凭据;
  • NN 是网络和外部服务状态;
  • OO 是命令执行后的文件系统和输出。

Secret 不进入最终镜像,至少需要满足以下条件:

CL(O)C \notin L(O)

这里的 L(O)L(O) 表示最终镜像层、镜像配置和相关构建产物中包含的敏感信息。

但仅满足这一项仍然不够,还需要:

Clog(f)C \notin \text{log}(f)

即凭据不出现在构建日志、错误消息、调试输出中;并且:

derived(C)L(O)\text{derived}(C) \notin L(O)

即使没有原始凭据,凭据产生的敏感衍生结果也不能泄漏。例如,用令牌下载的私有配置文件、包含访问令牌的 .npmrc、包含私有 URL 的构建产物,都可能是敏感数据。

3.1 正确的读取方式

FROM alpine:3.20

RUN --mount=type=secret,id=api_token,required \
    token="$(cat /run/secrets/api_token)" && \
    test -n "$token" && \
    echo "token is available without printing it"

这里没有:

echo "$token"
printf '%s\n' "$token"
set -x

这些调试写法可能直接将凭据写入构建日志。

3.2 错误的持久化方式

RUN --mount=type=secret,id=api_token \
    cp /run/secrets/api_token /tmp/token

即使挂载在 RUN 结束时消失,/tmp/token 仍然是该步骤的文件系统结果,会进入后续镜像状态。

下面这种分层删除更危险:

RUN --mount=type=secret,id=api_token \
    cp /run/secrets/api_token /tmp/token

RUN rm /tmp/token

第二个 RUN 只生成删除差异。第一个层仍然可能含有 /tmp/token

如果确实需要临时生成文件,应尽量在同一个 RUN 中完成使用和清理:

RUN --mount=type=secret,id=api_token,required \
    set -eu; \
    token="$(cat /run/secrets/api_token)"; \
    generate-artifact --token "$token" --output /out/artifact; \
    unset token

但是,这仍然不能保证安全,因为 generate-artifact 可能把令牌写入输出,或者工具自身开启调试日志。安全性取决于整个命令链的行为,而不是 unset 这一个动作。


4. 完整的 Secret 构建例子

下面的例子模拟从私有服务下载一个依赖文件。服务地址是示意性的,命令结构可以直接用于真实的 curl 请求。

Dockerfile

# syntax=docker/dockerfile:1

FROM alpine:3.20 AS build

RUN apk add --no-cache curl

RUN --mount=type=secret,id=private_token,target=/run/secrets/private_token,required \
    set -eu; \
    token="$(cat /run/secrets/private_token)"; \
    curl --fail --silent --show-error \
      --header "Authorization: Bearer ${token}" \
      --output /tmp/private-package.tar.gz \
      https://packages.example.com/private-package.tar.gz; \
    tar -xzf /tmp/private-package.tar.gz -C /opt; \
    rm -f /tmp/private-package.tar.gz

FROM alpine:3.20

COPY --from=build /opt /opt

构建:

chmod 600 ./private-token

docker build \
  --secret id=private_token,src=./private-token \
  -t private-package-demo .

每一步的作用是:

  1. --secret 将本地文件传给 BuildKit,而不是放入构建上下文;
  2. target 明确规定容器内路径;
  3. required 使凭据缺失时立即失败;
  4. curl 只在这个 RUN 中使用令牌;
  5. 压缩包被解开后删除;
  6. 多阶段构建只从 /opt 复制预期产物到最终镜像。

这里仍有两个边界:

  • 如果 /opt 中的文件本身包含令牌,最终镜像仍然会泄漏;
  • 如果 curl 的错误信息包含请求头,或者启用了会打印请求头的调试模式,日志仍可能泄漏。

验证镜像时,不应只检查当前容器文件系统,还要检查历史和构建日志:

docker history --no-trunc private-package-demo
docker run --rm private-package-demo \
  sh -c 'find / -name "*token*" -o -name ".npmrc" 2>/dev/null'

这些检查不能证明绝对没有泄漏,但可以发现常见的明文文件和指令参数问题。


5. 缓存:凭据不入缓存键,不代表结果不被缓存

BuildKit 缓存通常会根据指令、输入文件、基础镜像和其他构建输入识别可复用步骤。Secret 的值不会被作为普通输入参与缓存校验。因此下面的 Dockerfile 可能产生意外结果:

FROM alpine:3.20

RUN --mount=type=secret,id=channel_token,required \
    token="$(cat /run/secrets/channel_token)" && \
    curl --fail \
      -H "Authorization: Bearer ${token}" \
      https://packages.example.com/channel/index.txt \
      -o /opt/index.txt

第一次构建:

printf '%s' 'token-a' > token.txt
docker build --secret id=channel_token,src=token.txt -t channel-demo .

后来服务端内容改变,或者换成了 token-b,再次执行:

printf '%s' 'token-b' > token.txt
docker build --secret id=channel_token,src=token.txt -t channel-demo .

如果其他缓存输入没有变化,BuildKit 可能复用原来的 RUN 结果。原因是:

  1. Secret 的实际内容不能被放入缓存键,否则缓存元数据可能暴露秘密;
  2. 构建器并不知道“令牌变化一定意味着输出变化”;
  3. 因此,Secret 变化不会自动触发该步骤重新执行;
  4. 但第一次请求得到的 /opt/index.txt 仍可能作为缓存结果保存。

这是一种“安全性和正确性分离”的设计:凭据不作为缓存输入泄漏,但依赖凭据的输出可能被错误复用。

5.1 需要新鲜结果时禁用缓存

最直接的诊断方式:

docker build --no-cache \
  --secret id=channel_token,src=token.txt \
  -t channel-demo .

如果只想让某个阶段失效,可以在现代 Docker/BuildKit 中使用阶段级缓存控制,例如:

docker build \
  --no-cache-filter build \
  --secret id=channel_token,src=token.txt \
  -t channel-demo .

build 是 Dockerfile 中的阶段名。实际使用前应确认本地 Docker 版本和 builder 支持该选项。

5.2 使用非敏感的缓存扰动值

如果构建结果依赖一个外部版本号,可以将版本号作为普通、非敏感输入:

FROM alpine:3.20

ARG PRIVATE_INDEX_REVISION

RUN --mount=type=secret,id=channel_token,required \
    token="$(cat /run/secrets/channel_token)" && \
    curl --fail \
      -H "Authorization: Bearer ${token}" \
      "https://packages.example.com/index.txt?revision=${PRIVATE_INDEX_REVISION}" \
      -o /opt/index.txt

构建:

docker build \
  --build-arg PRIVATE_INDEX_REVISION=2025-03-01 \
  --secret id=channel_token,src=token.txt \
  -t channel-demo .

这里 PRIVATE_INDEX_REVISION 可以进入缓存键;真正的令牌不能放到 --build-arg 中。

不要使用下面这种“用 Secret 内容生成缓存键”的做法:

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

因为它同时把 Secret 暴露给构建参数、命令历史、CI 日志或镜像元数据。

5.3 缓存导出也需要审查

构建缓存可以导出到本地目录、注册表或 CI 缓存服务。即使 Secret 本身不在缓存元数据中,以下内容仍可能进入缓存:

  • 下载的私有依赖;
  • 私有配置;
  • 包管理器生成的认证文件;
  • 包含令牌的构建产物;
  • 命令错误输出或构建日志。

因此,构建缓存的访问控制应至少和私有镜像仓库相当。不要把含有私有依赖或敏感构建结果的缓存随意导出到公共注册表。


6. SSH Mount:转发 Agent,而不是复制私钥

SSH Mount 的核心对象不是私钥文件,而是 SSH Agent socket。

构建命令:

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

docker build \
  --ssh github="$SSH_AUTH_SOCK" \
  -t private-git-demo .

Dockerfile:

# syntax=docker/dockerfile:1

FROM alpine:3.20 AS source

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

COPY known_hosts /root/.ssh/known_hosts
RUN chmod 0644 /root/.ssh/known_hosts

RUN --mount=type=ssh,id=github,required \
    git clone --depth=1 \
      git@github.com:example/private-repository.git \
      /src

FROM alpine:3.20

COPY --from=source /src/output /opt/output

执行路径是:

  1. 本地 ssh-agent 持有私钥;
  2. Docker 客户端将指定 Agent 的 socket 交给 BuildKit;
  3. BuildKit 在该 RUN 中提供一个临时 SSH socket;
  4. 容器内的 git 连接这个 socket;
  5. Agent 在外部完成签名操作;
  6. 私钥本身不需要复制到构建容器;
  7. RUN 结束后 socket 挂载消失。

--ssh github="$SSH_AUTH_SOCK" 中的 github 是 mount ID,必须与 Dockerfile 中的 id=github 对应。

如果使用默认 Agent,可以简写:

docker build --ssh default .

对应:

RUN --mount=type=ssh,id=default,required git clone ...

也可以让 BuildKit 使用一个私钥文件作为 SSH 输入:

docker build --ssh github=$HOME/.ssh/id_ed25519 .

但在 CI 中优先使用短生命周期的 Agent,并限制 Agent 中加载的密钥。SSH Mount 减少了私钥复制,但它不能阻止构建步骤使用 Agent 完成签名操作,也不能阻止恶意构建命令反复尝试访问 Agent。


7. SSH 主机密钥验证不能省略

SSH 认证包含两个方向:

  • 客户端证明自己拥有私钥;
  • 客户端验证连接到的服务器确实是目标服务器。

SSH Mount 主要解决第一部分,不会自动解决第二部分。不能因为使用了 Agent,就关闭主机密钥验证:

-o StrictHostKeyChecking=no

这种写法会让中间人攻击更容易发生。

更可靠的方式是把经过独立渠道验证的主机密钥放入构建上下文中的普通文件:

known_hosts

然后:

COPY known_hosts /root/.ssh/known_hosts
RUN chmod 0644 /root/.ssh/known_hosts

known_hosts 通常不是秘密,可以放入上下文;但它是完整性材料,应通过 Git 仓库审查、供应商公布的指纹或管理员确认进行验证。

下面这种方式在临时测试中常见,但不应直接作为生产信任根:

RUN ssh-keyscan github.com >> /root/.ssh/known_hosts

原因是 ssh-keyscan 获取到的内容本身没有证明其来源可靠。如果网络路径被攻击,扫描结果可能把攻击者的主机密钥写入 known_hosts


8. SSH Mount 与缓存的实际边界

下面的步骤访问私有仓库:

RUN --mount=type=ssh,id=github,required \
    git clone --depth=1 git@github.com:example/private-repository.git /src

SSH socket 本身不会成为最终镜像文件,但 /src 会成为该步骤的输出。如果仓库内容变化,构建缓存未必会自动知道,因为 Git 远端状态不是一个稳定的本地输入。

可能出现的现象是:

CACHED [source 3/3] RUN git clone ...

此时新提交并没有被拉取,最终镜像仍使用旧的缓存结果。

常见的修复方法有三种。

8.1 显式禁用缓存

docker build --no-cache --ssh github="$SSH_AUTH_SOCK" .

适合手工确认和需要强制获取最新代码的构建,但会降低整体缓存复用率。

8.2 将提交或版本作为显式输入

ARG SOURCE_REVISION

RUN --mount=type=ssh,id=github,required \
    git clone git@github.com:example/private-repository.git /src && \
    cd /src && \
    git checkout "$SOURCE_REVISION"

构建时:

docker build \
  --build-arg SOURCE_REVISION=0123456789abcdef \
  --ssh github="$SSH_AUTH_SOCK" \
  -t private-git-demo .

这里的 SOURCE_REVISION 是公开的版本标识,不是凭据。它使“要构建哪个提交”成为可审计、可参与缓存判断的输入。

8.3 在构建系统中固定源码输入

更可重复的方案是先在受控流程中获取源码,再将固定提交的源码作为上下文或前置产物。这样 Docker 构建不需要访问一个会变化的 Git 分支,缓存和供应链审计也更容易建立。


9. Secret Mount、SSH Mount、缓存挂载和运行时 Secret 的区别

这些机制经常被混淆。

9.1 Secret Mount

RUN --mount=type=secret,id=token ...

用途是向构建步骤提供普通敏感数据。挂载只在这一个 RUN 中存在,默认以文件形式出现。

9.2 SSH Mount

RUN --mount=type=ssh,id=github ...

用途是让 SSH 客户端通过 Agent socket 使用密钥。它不是把私钥复制进镜像。

9.3 Cache Mount

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

Cache Mount 用于加速重复构建,内容可能在多个构建之间复用。它不是 Secret Mount,也不应放入令牌或私钥。

例如,不要把 ~/.npmrc 中的令牌放入缓存目录。

9.4 运行时 Secret

运行容器时注入 Secret 是另一个生命周期:

docker run \
  --mount type=secret,src=api_token,dst=/run/secrets/api_token \
  example-app

这和构建时的:

RUN --mount=type=secret,id=api_token ...

没有自动继承关系。构建时 Secret 不会因为存在于 docker build 命令中,就自动出现在最终容器中;运行时 Secret 也不会自动供 Dockerfile 使用。


10. Docker Compose 中的构建 Secret 和 SSH

Compose 文件可以声明构建阶段要提供的 Secret 和 SSH Agent。

示例:

services:
  app:
    build:
      context: .
      dockerfile: Dockerfile
      secrets:
        - private_token
      ssh:
        - github

secrets:
  private_token:
    file: ./secrets/private-token

Dockerfile:

# syntax=docker/dockerfile:1

FROM alpine:3.20

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

RUN --mount=type=secret,id=private_token,required \
    token="$(cat /run/secrets/private_token)" && \
    test -n "$token" && \
    echo "private token mounted"

RUN --mount=type=ssh,id=github,required \
    ssh -o BatchMode=yes -T git@github.com || test $? -eq 1

启动构建:

docker compose build

前提是当前环境中存在可用的 SSH Agent,例如:

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

这里有两个容易混淆的 secrets 概念:

services:
  app:
    secrets:
      - runtime_token

这是运行容器时使用的 Secret;而:

services:
  app:
    build:
      secrets:
        - private_token

这是构建阶段使用的 Secret。它们生命周期不同,不能因为名称相同就认为自动共享。

Compose 的具体 Secret 来源可以来自本地文件或环境变量等方式,实际可用字段受 Compose 实现版本影响。应以当前 Docker Compose 对 Build Specification 的支持为准,并确认敏感值没有通过 YAML、Shell 或 CI 日志打印出来。


11. 常见失败表现和诊断路径

11.1 Unknown flag: mount

例如:

unknown flag: mount

通常说明没有使用支持 BuildKit 的构建器,或者 Dockerfile 前端能力过旧。

检查:

docker version
docker buildx version
docker buildx ls

现代 Docker Engine 通常默认使用 BuildKit;旧环境可能需要显式启用:

DOCKER_BUILDKIT=1 docker build .

Dockerfile 顶部应声明现代语法:

# syntax=docker/dockerfile:1

如果企业环境使用固定的 BuildKit builder,还要确认 builder 本身支持 Secret Mount 和 SSH Mount。

11.2 secret ... not found

例如:

secret "private_token" not found

检查三个名字是否一致:

docker build \
  --secret id=private_token,src=./private-token \
  .
RUN --mount=type=secret,id=private_token,required ...

Compose 中:

build:
  secrets:
    - private_token

还要确认 src 文件存在,并且 Docker 客户端进程有权限读取。

11.3 SSH_AUTH_SOCK 为空

检查:

echo "$SSH_AUTH_SOCK"
ssh-add -l

如果 Agent 中没有密钥,可能看到:

The agent has no identities.

启动并加载密钥:

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

在 CI 中,需要确认:

  • Agent 在构建命令所在的进程环境中;
  • socket 路径对 Docker 客户端可见;
  • 运行构建的用户与加载密钥的用户一致;
  • CI 的密钥没有被错误地打印。

11.4 Git 报主机验证错误

例如:

Host key verification failed.

这通常不是 SSH 私钥认证失败,而是缺少正确的 known_hosts。应先解决主机密钥验证,而不是直接设置:

StrictHostKeyChecking=no

11.5 构建成功但仍使用旧依赖

如果日志显示:

CACHED

而远程私有仓库或私有包已发生变化,应首先怀疑缓存,而不是凭据无效。使用:

docker build --no-cache ...

进行对照。如果无缓存时结果正确,说明需要引入版本输入或调整缓存策略。

11.6 Secret 在镜像中被发现

按以下顺序排查:

  1. 搜索 Dockerfile 中的 ARGENVCOPYADD
  2. 检查 Secret 是否被复制到持久路径;
  3. 检查是否在后续层删除了前一层创建的文件;
  4. 检查构建日志中是否有 echoset -x、详细 HTTP 日志;
  5. 检查下载的配置和构建产物是否包含凭据;
  6. 检查外部缓存和构建产物仓库;
  7. 一旦确认真实凭据暴露,应立即撤销或轮换,而不是只删除镜像标签。

12. 构建日志和 shell 行为也属于泄漏面

即便 Dockerfile 没有 COPY Secret,也可能因为 shell 语义泄漏。

危险示例:

RUN --mount=type=secret,id=token \
    set -x; \
    curl -H "Authorization: Bearer $(cat /run/secrets/token)" \
      https://packages.example.com/file

set -x 可能把展开后的完整命令写入日志,其中包含真实令牌。

更安全的形式是让令牌只存在于变量或文件中,并关闭详细输出:

RUN --mount=type=secret,id=token,required \
    set -eu; \
    token="$(cat /run/secrets/token)"; \
    curl --fail --silent --show-error \
      -H "Authorization: Bearer ${token}" \
      https://packages.example.com/file \
      -o /tmp/file

不过,curl、包管理器和自定义脚本仍可能在异常时输出请求头或环境变量。安全审查必须覆盖实际工具的日志行为,而不能只看 Dockerfile 表面。

还应避免将 Secret 放在指令文本中:

RUN curl -H "Authorization: Bearer abc123" ...

即使 abc123 是临时令牌,它也可能出现在:

  • Dockerfile 仓库;
  • CI 日志;
  • 镜像历史;
  • 代码审查系统;
  • 构建缓存元数据。

13. 多阶段构建只能缩小范围,不能自动消除泄漏

多阶段构建适合把“获取依赖”和“最终运行环境”分开:

FROM alpine:3.20 AS fetch

RUN --mount=type=secret,id=token,required \
    fetch-private-artifact --token-file /run/secrets/token --out /opt/app

FROM alpine:3.20

COPY --from=fetch /opt/app /opt/app

最终阶段没有 Secret Mount,也没有获取工具,攻击面更小。但是安全条件仍然是:

最终复制的 /opt/app 不包含凭据或敏感衍生数据

以下情况仍会泄漏:

RUN --mount=type=secret,id=token \
    fetch-private-artifact \
      --token-file /run/secrets/token \
      --out /opt/app \
      --write-config /opt/app/.config

如果 .config 中保存了令牌,最终阶段复制整个 /opt/app 就会把令牌带入镜像。

因此,多阶段构建的关键不是“有两个 FROM”,而是明确最终阶段允许复制哪些路径,并审查这些路径的内容。


14. Linux 容器边界

本文示例针对 Linux 容器和 Linux BuildKit 构建环境:

  • Secret 默认路径使用 /run/secrets/...
  • SSH Mount 使用 Unix socket;
  • uidgidmode 使用 Linux 文件权限语义;
  • ssh-agent、OpenSSH 客户端和 /root/.ssh 也遵循 Linux 行为。

在 Docker Desktop 上,即使宿主机是 macOS 或 Windows,Linux 容器构建通常仍由 Linux 虚拟机中的 builder 执行;凭据最终需要被提供给实际的 BuildKit builder,而不是简单地认为“宿主机路径天然可见”。

Windows 容器有不同的文件系统、进程和 socket 语义,不能直接套用本文的路径、权限和 Agent 行为。


15. 一个完整的数据流模型

构建时的凭据流可以表示为:

sequenceDiagram
    participant C as Docker 客户端/CI
    participant B as BuildKit builder
    participant R as RUN 容器
    participant S as 私有服务或 Git 主机
    participant I as 镜像/缓存输出

    C->>B: 发送构建上下文、Dockerfile、Secret 或 SSH Agent 输入
    B->>R: 创建临时 Secret 文件或 SSH socket
    R->>S: 使用凭据发起 HTTPS/SSH 请求
    S-->>R: 返回依赖、源码或构建数据
    R->>B: 返回文件系统差异和命令输出
    B->>I: 保存可缓存的构建结果
    B-->>R: RUN 结束,卸载 Secret/SSH Mount

关键路径有三条:

  1. 凭据进入路径:客户端或 CI 将 Secret、Agent 连接交给 builder;
  2. 使用路径:只有带有 --mountRUN 可以直接读取对应挂载;
  3. 结果路径:命令生成的文件、日志和缓存可能离开该 RUN,因此必须审查衍生输出。

Secret Mount 和 SSH Mount 只控制第二条路径的注入方式,不会自动控制第三条路径。


16. 生产构建中的检查原则

可以将检查分成“凭据来源、使用过程、结果验证”三部分。

凭据来源

  • 不把凭据放入构建上下文;
  • 不把凭据放入 ARGENV、Dockerfile 文本或 Git 仓库;
  • 使用短生命周期、最小权限的令牌;
  • SSH 构建优先使用受限 Agent,而不是复制私钥;
  • 确认远程 builder 是可信的。

使用过程

  • 对必须存在的挂载使用 required
  • Secret 只在需要的 RUN 中挂载;
  • 不使用 set -x
  • 不打印 HTTP 请求头、环境变量和 Secret 文件;
  • SSH 始终验证 known_hosts
  • 不把 Secret 复制到缓存目录;
  • 不把包含凭据的配置文件作为最终产物。

结果验证

  • docker history --no-trunc 检查命令和参数;
  • 在最终镜像中搜索不应存在的凭据文件;
  • 检查多阶段复制路径;
  • 审查构建缓存导出位置和访问权限;
  • 对私有依赖、构建产物和镜像执行 SBOM、漏洞扫描及策略检查;
  • 发现泄漏后轮换凭据,并清理镜像、缓存和日志中的敏感副本。

SBOM 和镜像扫描不能替代 Secret 泄漏检查:SBOM 主要描述软件组成,漏洞扫描主要识别已知风险,二者通常不会证明“某个令牌没有出现在历史层或构建日志中”。凭据注入安全需要单独的构建审计和输出检查。


17. 最小安全示例

一个同时使用私有包令牌和私有 Git 的 Dockerfile 可以组织为:

# syntax=docker/dockerfile:1

FROM alpine:3.20 AS build

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

COPY known_hosts /root/.ssh/known_hosts
RUN chmod 0644 /root/.ssh/known_hosts

RUN --mount=type=ssh,id=github,required \
    git clone --depth=1 \
      git@github.com:example/private-dependency.git \
      /src

ARG PACKAGE_REVISION

RUN --mount=type=secret,id=package_token,target=/run/secrets/package_token,required \
    set -eu; \
    token="$(cat /run/secrets/package_token)"; \
    curl --fail --silent --show-error \
      -H "Authorization: Bearer ${token}" \
      "https://packages.example.com/app-${PACKAGE_REVISION}.tar.gz" \
      -o /tmp/app.tar.gz; \
    mkdir -p /opt/app; \
    tar -xzf /tmp/app.tar.gz -C /opt/app; \
    rm -f /tmp/app.tar.gz

FROM alpine:3.20

COPY --from=build /src/output /opt/app
COPY --from=build /opt/app /opt/app

构建:

docker build \
  --build-arg PACKAGE_REVISION=2025-03-01 \
  --secret id=package_token,src=./secrets/package-token \
  --ssh github="$SSH_AUTH_SOCK" \
  -t app:2025-03-01 .

这个设计的因果关系是:

  • Git 私钥留在 Agent 中,由 SSH Mount 提供 socket;
  • 包管理令牌以临时文件提供;
  • 包版本作为公开的构建输入,避免用令牌扰动缓存;
  • Secret 和 SSH 挂载只出现在需要它们的步骤;
  • 最终镜像只复制构建产物;
  • known_hosts 独立于私钥,负责服务器身份验证;
  • 最终是否安全,仍取决于 /src/output/opt/app 是否包含敏感文件。

Secret Mount 和 SSH Mount 的正确抽象是“临时凭据通道”,不是“凭据保险箱”。它们避免了最常见的 COPYARGENV 泄漏,但不会阻止构建命令把凭据写入层、日志、缓存或最终产物。只有同时理解挂载生命周期、缓存输入、构建上下文和输出边界,才能把凭据注入变成可审计的构建机制。


系列导航与关联阅读

官方资料

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