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

Docker 配置与 Secret:环境变量、文件注入、轮换和泄漏防护

在 Docker 中,“配置”和“Secret”解决的是两个相似但安全属性不同的问题:

  • 配置(configuration):影响程序行为、连接地址、日志级别、功能开关等,通常可以公开或低敏感地管理。
  • Secret:能够直接授予权限或身份的值,例如数据库密码、API Token、私钥、签名密钥和云服务凭证。

二者都可能通过环境变量或文件进入容器,但“放进环境变量”并不会自动变成 Secret;“放进文件”也不会自动获得加密、轮换和访问控制。安全性取决于传输路径、存储位置、容器内可见范围、进程生命周期和运维工具的行为。

本文讨论现代 Docker Engine、BuildKit 和 Compose 规范,重点是 Linux 容器边界。Docker Desktop、Windows 容器和 Kubernetes Secret 的实现边界不同,不能直接套用本文结论。


1. 先区分三类数据流

一个 Secret 从运维人员手中到应用进程,通常会经过以下阶段:

flowchart LR
    A[凭证来源] --> B[客户端命令或 Compose 文件]
    B --> C[Docker API]
    C --> D[Engine / Swarm 控制面]
    D --> E[容器文件系统或环境]
    E --> F[应用进程]
    F --> G[日志、崩溃转储、子进程、外部服务]

每一段都有不同的泄漏风险:

  1. 来源阶段:Shell 历史、CI 日志、配置仓库、密码管理器导出文件。
  2. 客户端阶段:命令行参数、Compose 插值结果、docker compose config 输出。
  3. Engine API 阶段:客户端与 Docker daemon 之间的访问权限;远程 daemon 若未正确保护,风险等同于主机控制权风险。
  4. 容器注入阶段:环境变量、bind mount、Swarm secret mount。
  5. 应用阶段:错误日志、调试接口、子进程继承、内存转储。
  6. 运维阶段docker inspect、镜像层、备份、监控和日志采集系统。

因此,Secret 管理不是“选择一个字段”的问题,而是要回答:

Secret 在每一个生命周期阶段以什么形式存在,谁能够读取,何时被替换,旧值如何失效?


2. 环境变量是什么,以及它为什么不适合作为高敏感 Secret

2.1 环境变量的语义

Linux 进程启动时会获得一组键值形式的环境变量,例如:

DATABASE_HOST=db
DATABASE_PORT=5432
LOG_LEVEL=info

程序通常通过 getenv() 或语言运行时读取它们。Docker 使用 --env--env-file 或 Compose 的 environment/env_file 为容器进程设置环境变量。

示例:

docker run --rm \
  --env APP_MODE=production \
  --env DATABASE_HOST=db.internal \
  alpine:3.20 \
  sh -c 'printf "mode=%s host=%s\n" "$APP_MODE" "$DATABASE_HOST"'

预期输出:

mode=production host=db.internal

这里的变量只存在于该容器进程及其子进程的环境中。它不是 Docker Secret 对象,也不会因为名字叫 PASSWORD 就获得额外保护。

2.2 环境变量的可见性和复制行为

环境变量有几个重要属性:

  1. 会进入进程启动配置。Docker daemon 通常能够在容器元数据中记录环境配置。

  2. 会被子进程继承。应用启动脚本、Shell、迁移工具和外部命令都可能拿到同一份值。

  3. 容易被诊断工具显示。例如:

    docker inspect <container>
    

    对于通过容器配置设置的环境变量,输出通常会包含 Config.Env

  4. 可能进入崩溃转储、调试快照和错误报告

  5. 可能被应用主动打印。常见问题包括启动时打印全部配置、HTTP /debug/config 接口返回环境变量、异常对象包含连接字符串。

在 Linux 上,进程还可能通过 /proc/<pid>/environ 暴露环境内容。能否读取取决于用户身份、进程隔离、ptrace 限制和容器配置,但不能把 /proc 视为可靠的 Secret 保险箱。

2.3 env_file 不是 Secret 管理机制

Compose 中常见:

services:
  api:
    image: example/api:1.0
    env_file:
      - .env.production

.env.production 只是由 Compose 客户端读取并传给容器的普通文件。它通常具有以下特点:

  • 文件内容可能保存在源码目录、CI 工作区或备份中;
  • 变量最终仍然进入容器环境;
  • docker inspect 可能显示最终环境;
  • docker compose config 可能渲染出经过插值的配置;
  • Docker Engine 不会因为文件名包含 .env 而加密它。

因此,env_file 适合集中管理普通配置,也可以在某些低风险场景承载凭证,但它不能替代 Secret 机制。

2.4 Compose 插值和容器环境是两个不同阶段

以下 Compose 文件:

services:
  api:
    image: example/api:1.0
    environment:
      DATABASE_URL: "postgres://${DB_USER}:${DB_PASSWORD}@db:5432/app"

包含两个阶段:

  1. Compose 客户端先从 Shell、默认 .env--env-file 获取 DB_USERDB_PASSWORD
  2. 客户端完成字符串插值后,再把完整的 DATABASE_URL 传给容器。

这意味着即使容器内只定义了一个 DATABASE_URL,密码仍然在 Compose 客户端和最终环境配置中出现。运行:

docker compose config

可能直接输出插值后的完整连接字符串。

一个更安全的方向是让应用从文件读取凭证,而不是把完整连接字符串拼进环境变量:

services:
  api:
    image: example/api:1.0
    environment:
      DATABASE_HOST: db
      DATABASE_PORT: "5432"
      DATABASE_NAME: app
      DATABASE_USER_FILE: /run/secrets/db_user
      DATABASE_PASSWORD_FILE: /run/secrets/db_password

前提是应用确实实现了 _FILE 约定或等价的文件读取逻辑。Docker 不会自动把 DATABASE_PASSWORD_FILE 变成密码;这只是应用约定。


3. 文件注入的核心模型

将 Secret 注入为文件,意味着:

  1. Docker 在容器文件系统中提供一个路径;
  2. 应用启动或运行时打开该路径;
  3. Secret 不必进入进程的初始环境;
  4. 应用仍然会把读取出的值放入内存,必要时传给数据库或其他服务。

示例应用逻辑可以抽象为:

from pathlib import Path
import os

def read_setting(name: str, default_path: str | None = None) -> str:
    path = os.getenv(f"{name}_FILE", default_path)
    if path:
        return Path(path).read_text(encoding="utf-8").rstrip("\n")
    value = os.getenv(name)
    if value is None:
        raise RuntimeError(f"missing {name} or {name}_FILE")
    return value

db_password = read_setting("DATABASE_PASSWORD", "/run/secrets/db_password")

这里的安全属性是:

  • 环境变量中保存的是路径,不是密码;
  • Secret 文件不需要写入镜像;
  • 应用可以在启动时读取一次,也可以在请求前或收到信号后重新读取;
  • 文件权限仍然重要,容器内拥有足够权限的进程仍可能读取它。

文件注入并不意味着应用永远不会接触明文。应用必须用明文密码建立认证连接,因此真正要控制的是:

哪个进程能读、明文存在多久、是否被复制到不必要的位置,以及旧凭证何时失效。


4. Compose Secret:开发和单机部署中的文件映射

4.1 一个可运行的 Compose 示例

准备目录:

secret-demo/
├── compose.yaml
└── secrets/
    └── db_password

写入测试值:

mkdir -p secret-demo/secrets
printf 'dev-only-password\n' > secret-demo/secrets/db_password
chmod 600 secret-demo/secrets/db_password
cd secret-demo

compose.yaml

services:
  reader:
    image: alpine:3.20
    command:
      - sh
      - -ec
      - |
        printf 'secret path: '
        test -f /run/secrets/db_password
        echo yes
        printf 'secret length: '
        wc -c < /run/secrets/db_password
        printf 'secret value is not printed\n'
    secrets:
      - db_password

secrets:
  db_password:
    file: ./secrets/db_password

启动:

docker compose up --abort-on-container-exit --exit-code-from reader

预期结果类似:

reader-1  | secret path: yes
reader-1  | secret length: 19
reader-1  | secret value is not printed

db_password 的内容为 dev-only-password\n,长度是 19 个字节。Compose 将该 Secret 暴露在容器内的:

/run/secrets/db_password

其中:

  • 顶层 secrets 声明 Secret 来源;
  • 服务级 secrets 授权该服务使用它;
  • file 指定宿主机上的源文件;
  • 容器内的路径默认是 /run/secrets/<secret-name>,也可以通过长语法指定 targetuidgidmode,具体支持程度应以所用 Compose CLI 版本为准。

4.2 Compose 文件 Secret 与 Swarm Secret 不是同一个安全实现

这是最容易混淆的边界。

在普通的 docker compose up 场景中,Compose 通常把本地 Secret 文件映射到容器。它解决的是:

  • 不把值写进镜像;
  • 不把值直接放进环境变量;
  • 通过 Compose 声明哪些服务获得哪个文件。

但宿主机上的源文件仍然是普通文件。Docker Compose 不会自动提供 Swarm 那种控制面加密和集群 Secret 对象语义。

而在 Swarm 中,Secret 是 Docker Engine 的集群资源:

docker swarm init
printf 'production-password-v1\n' | \
  docker secret create db_password_v1 -

创建服务:

docker service create \
  --name api \
  --secret source=db_password_v1,target=db_password \
  alpine:3.20 \
  sh -c 'while :; do sleep 3600; done'

在任务容器内,Secret 通常以:

/run/secrets/db_password

出现,并由 Swarm 管理其分发。Swarm Secret 的设计包括:

  • Secret 只授予明确声明的服务;
  • Secret 不写入镜像层;
  • Secret 在 Swarm 控制面传输和存储时使用相应的加密保护机制;
  • Secret 在任务容器内以只读文件形式提供;
  • 删除服务或撤销授权后,任务会被重新安排或更新,从而移除旧授权。

这里的“加密存储”是 Swarm 控制面和 Raft 数据的机制,不等价于“应用内存中的值已加密”,也不等价于“拥有宿主机 root 权限的人无法读取”。宿主机管理员、Docker daemon 管理员和被授予容器访问权的进程仍属于高信任边界。

docker secret 只有在 Swarm 已初始化后才可使用。下面的命令不是普通 standalone Engine 的通用 Secret API:

docker secret ls

如果未启用 Swarm,通常会失败,而不是创建一个本地 Secret 文件。


5. Docker Secret 的权限边界

Secret 的关键安全属性不是“文件存在”,而是“哪些服务和进程能够获得文件”。

Compose 中,只有声明了 Secret 的服务才会被注入:

services:
  api:
    image: example/api:1.0
    secrets:
      - db_password

  worker:
    image: example/worker:1.0
    # 没有 secrets,因此不能假设它能读取 db_password

secrets:
  db_password:
    file: ./secrets/db_password

如果 workerapi 共享同一个可写卷,而 api 把密码复制到卷中,那么 Compose 的服务级 Secret 隔离就被应用行为绕过了。类似地,以下做法会扩大暴露面:

services:
  api:
    volumes:
      - shared-data:/data

若应用把 /run/secrets/db_password 复制为 /data/password.txt,任何能读取该卷的服务都可能获得密码。

Linux 容器内的 root 也不是理想的隔离单位。非 root 用户、Capabilities、Seccomp、只读根文件系统等控制可以减少攻击面,但不能改变“被授权的进程可以读取 Secret”这一事实。一个拥有足够权限的进程可能:

  • 读取 Secret 文件;
  • 附加或调试目标进程;
  • 读取共享 PID namespace 中的进程信息;
  • 利用应用或内核漏洞突破容器边界。

因此,Secret 授权应当按服务和进程最小化,不能因为容器使用了 read_only: true 就认为 Secret 自动安全。


6. Secret 轮换:更新值不是更新文件

6.1 轮换的严格定义

Secret 轮换至少包含四个动作:

  1. 创建或生成新凭证;
  2. 让应用获得新凭证;
  3. 验证应用已经使用新凭证;
  4. 使旧凭证失效。

只替换一个文件或修改一个变量,只完成了第 2 步的一部分,不能称为完整轮换。

设当前凭证为 S0S_0,新凭证为 S1S_1。如果服务在时间区间 [t0,t1][t_0,t_1] 内仍可能使用 S0S_0,认证系统就不能在 t0t_0 立即撤销 S0S_0。常见安全顺序是:

生成 S1
  ↓
把 S1 加入认证系统,S0 和 S1 暂时并存
  ↓
让应用加载并验证 S1
  ↓
撤销 S0

若认证系统只允许一个活动凭证,则需要先更新应用,再更新服务端认证,或者使用短暂的双凭证窗口,否则轮换会造成中断。

6.2 Swarm 中的不可变 Secret 轮换

Swarm Secret 通常按对象创建后使用。不要把轮换理解为“覆盖 db_password_v1 的内容”;更可靠的模型是创建新名称:

printf 'production-password-v2\n' | \
  docker secret create db_password_v2 -

查看服务:

docker service inspect api --pretty

更新服务,使任务改用新 Secret:

docker service update \
  --secret-rm db_password_v1 \
  --secret-add source=db_password_v2,target=db_password \
  api

这里发生了几个状态变化:

  1. Service specification 从 db_password_v1 改为 db_password_v2

  2. Swarm 为服务创建更新任务;

  3. 新任务挂载新的 /run/secrets/db_password

  4. 旧任务按更新策略停止;

  5. 旧 Secret 不再被服务授权;

  6. 运维人员确认所有任务健康后,才撤销数据库中的旧密码;

  7. 最后删除不再使用的 Secret 对象:

    docker secret rm db_password_v1
    

删除 Secret 对象之前,必须确认没有服务仍然引用它,否则删除可能失败或造成后续更新问题。生产环境还应明确更新策略、并发数、健康检查和回滚行为。如果新凭证无效,服务更新可能处于部分完成状态;此时应根据 docker service ps api 和服务健康状态决定回滚,而不是立即删除旧凭证。

6.3 Compose 中的轮换

Compose 的本地文件 Secret 没有统一的集群轮换控制器。常见流程是:

printf 'dev-password-v2\n' > secrets/db_password.new
chmod 600 secrets/db_password.new
mv secrets/db_password.new secrets/db_password
docker compose up -d --force-recreate reader

mv 在同一文件系统中通常是原子的,但这只保证宿主机目录的替换动作,不保证已经打开旧文件的应用会看到新内容:

  • 应用启动时读取一次:必须重启或显式 reload;
  • 应用长时间保持文件描述符:替换路径不会改变已经打开的旧 inode;
  • 某些 bind mount 文件场景中,宿主机重命名后,容器内挂载仍可能指向原文件对象;
  • 应用若支持 SIGHUP 或定时重新打开文件,可以减少重启,但必须验证其实现。

因此,Compose 文件 Secret 的轮换通常需要“替换源文件 + 重新创建容器”,而不是只替换宿主机文件。

验证新值时不要打印 Secret:

docker compose exec reader sh -c '
  test -s /run/secrets/db_password &&
  printf "secret exists, bytes=" &&
  wc -c < /run/secrets/db_password
'

还应验证应用本身已经使用新凭证,例如执行一次受控的数据库连接检查,而不是只验证文件存在。


7. 构建时 Secret:BuildKit 与运行时 Secret 是两条链路

运行时 Secret 解决“容器启动后如何获得凭证”;构建时 Secret 解决“构建过程中如何访问私有依赖”。

危险写法:

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

构建参数可能出现在:

  • 构建命令;
  • 构建缓存元数据;
  • 镜像历史;
  • 错误输出;
  • 生成的配置文件或某一层文件系统。

即使最后删除配置,包含凭证的那一层也可能已经进入镜像历史或缓存。

BuildKit 的 Secret mount 用法:

Dockerfile

# syntax=docker/dockerfile:1

FROM node:22-alpine AS build
WORKDIR /src

COPY package*.json ./

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

COPY . .
RUN npm run build

FROM nginx:1.27-alpine
COPY --from=build /src/dist /usr/share/nginx/html

构建:

DOCKER_BUILDKIT=1 docker build \
  --secret id=npmrc,src="$HOME/.npmrc" \
  -t example/frontend:build .

机制是:

  1. 客户端把 Secret 通过 BuildKit 会话发送给构建器;
  2. 执行带 RUN --mount=type=secret 的步骤时,Secret 以临时文件挂载;
  3. 该文件不应进入最终镜像层;
  4. RUN 步骤结束后,挂载消失;
  5. 构建结果只保留命令产生的文件。

安全前提是构建命令不能把 Secret 复制出去。例如下面仍然会泄漏:

RUN --mount=type=secret,id=token \
    cat /run/secrets/token > /tmp/token.txt

如果 /tmp/token.txt 没有在同一条 RUN 中安全删除,或者被复制到后续阶段,Secret 就进入了构建结果。正确做法是让凭证只用于认证,并确保生成物不包含凭证。

还要区分:

  • --secret:传递文件或环境形式的构建 Secret;
  • --ssh:转发 SSH agent 或 SSH socket,用于访问私有 Git;
  • ARG/ENV:普通构建参数和环境,不应承载高敏感值。

可以检查镜像历史:

docker history --no-trunc example/frontend:build

这不是证明“绝对没有泄漏”的完整审计,但能发现把 Token 写进 Dockerfile 命令行等明显错误。


8. Secret 泄漏的典型路径

8.1 镜像层泄漏

以下 Dockerfile 会把密码写入镜像层:

FROM alpine:3.20

RUN echo 'password=bad-example' > /app/config.ini
RUN rm /app/config.ini

删除文件只是在新层中标记删除,旧层仍可能包含原内容。镜像导出、缓存、镜像扫描器或拥有镜像读取权限的人可能恢复它。

修复方法不是“最后删除”,而是:

  • 构建时使用 BuildKit Secret mount;
  • 不把凭证复制到构建产物;
  • 使用外部凭证注入;
  • 若凭证已经进入镜像或仓库,立即轮换,而不是只删除提交记录。

8.2 命令行和 Shell 历史

危险示例:

docker run --rm -e DB_PASSWORD='real-password' example/api:1.0

即使 Docker 本身不在所有地方保留完整命令行,Shell history、CI 命令回显、进程审计和执行平台日志都可能保留它。

文件方式:

printf '%s' 'real-password' > /tmp/db_password
docker run --rm \
  --mount type=bind,src=/tmp/db_password,dst=/run/secrets/db_password,ro \
  example/api:1.0
rm -f /tmp/db_password

这减少了命令行暴露,但 /tmp 文件在容器启动前已经存在,且删除后仍可能被备份、审计或取证工具看到。它不是高保证的 Secret 存储,只是避免把值直接写入参数。

8.3 日志泄漏

下面的代码即使使用了文件 Secret,也会泄漏:

PASSWORD="$(cat /run/secrets/db_password)"
echo "connecting with password=$PASSWORD"

应记录非敏感元数据:

PASSWORD="$(cat /run/secrets/db_password)"
echo "database credential loaded; length=${#PASSWORD}"

长度本身也可能属于敏感信息,是否记录取决于威胁模型。更稳妥的是只记录“已加载”和版本标识,不记录内容、长度或哈希。

8.4 连接字符串和错误信息

即使应用没有直接打印密码,数据库客户端错误也可能包含完整 DSN:

dial tcp postgres://user:password@db:5432/app: connection refused

应用应分别传递 Host、Port、User、Password,并对异常信息做脱敏。不要把带密码的 URL 当作普通错误上下文、指标标签或 tracing span 属性。

8.5 调试和诊断工具

以下命令本身具有高权限,执行前应确认输出不会进入工单、聊天记录或 CI 日志:

docker inspect <container>
docker compose config
docker image history --no-trunc <image>
docker logs <container>

诊断 Secret 时优先验证:

docker compose exec api sh -c '
  test -r /run/secrets/db_password &&
  printf "readable secret file\n"
'

而不是:

docker compose exec api cat /run/secrets/db_password

前者能验证路径和权限,后者会把明文带到终端、终端录制系统和复制粘贴缓存中。


9. 权限、只读根和文件权限的实际关系

Secret 文件通常应当只读。Compose 长语法示意:

services:
  api:
    image: example/api:1.0
    read_only: true
    tmpfs:
      - /tmp
    secrets:
      - source: db_password
        target: db_password
        mode: 0400

secrets:
  db_password:
    file: ./secrets/db_password

这里有四种不同控制:

  • read_only: true:使容器根文件系统只读,减少攻击者落地工具和修改系统文件的能力;
  • tmpfs: /tmp:为需要临时写入的目录提供内存文件系统;
  • mode: 0400:限制文件权限,但具体属主和容器用户必须匹配;
  • Secret 注入:决定文件是否存在于该服务内。

mode: 0400 若文件属主是 root,而应用以非 root 用户运行,应用可能得到:

Permission denied

这是安全配置和可用性之间的直接关系。可以显式设置 uidgid,但 Compose 非 Swarm 实现对这些字段的实际行为可能依赖版本;部署前应在目标 Compose CLI 上验证。不要盲目把权限改成 0444 来“解决问题”,因为这会让容器内更多用户能够读取凭证。

即便根文件系统只读,Secret 文件仍可能可读;只读根不等于 Secret 不可复制。应用本身需要避免将 Secret 写入 /tmp、共享卷或缓存目录。


10. 失败路径与诊断方法

10.1 文件不存在

现象:

open /run/secrets/db_password: no such file or directory

检查顺序:

docker compose config --services
docker compose ps
docker compose exec api ls -l /run/secrets

原因通常是:

  • 服务没有在 services.<name>.secrets 中声明;
  • 顶层 Secret 名称拼写不一致;
  • 应用使用了错误的 target
  • 容器尚未通过 Compose 重建;
  • 使用了普通 docker run,却误以为 Compose 的 Secret 声明会自动生效。

10.2 权限被拒绝

现象:

open /run/secrets/db_password: permission denied

检查容器用户:

docker inspect --format '{{.Config.User}}' <container>

在容器内检查:

docker compose exec api id
docker compose exec api ls -ln /run/secrets

重点比较运行用户的 UID/GID 与文件属主及权限。修复时优先调整正确的 UID/GID 和权限,而不是把 Secret 复制到应用用户可读的公共目录。

10.3 应用启动成功但认证失败

文件存在并不证明内容正确。常见原因包括:

  • Secret 文件末尾多了换行;
  • 应用没有去除换行;
  • Secret 的名称对应错版本;
  • 数据库已撤销旧密码,但应用仍持有启动时读取的旧值;
  • 轮换只更新了容器文件,没有更新认证服务端;
  • Secret 注入到了一个没有实际使用的路径。

可以在不打印内容的情况下验证字节数和末尾字节:

docker compose exec api sh -c '
  wc -c < /run/secrets/db_password
  tail -c 1 /run/secrets/db_password | od -An -t x1
'

如果末尾是 0a,表示包含换行。是否应去掉换行取决于生成工具和应用协议;不能一概而论。

10.4 轮换后旧任务仍在工作

在 Swarm 中检查任务状态:

docker service ps api

重点看:

  • 是否仍有旧版本任务处于 Running;
  • 更新是否暂停;
  • 健康检查是否失败;
  • 新任务是否因 Secret 权限或认证失败退出。

在 Compose 中检查容器创建时间:

docker compose ps
docker inspect --format '{{.Created}}' "$(docker compose ps -q api)"

如果只替换了宿主机文件而没有重建容器,应用可能继续使用旧文件描述符或旧进程内存中的凭证。


11. 不同注入方式的安全边界

方式 典型用途 主要暴露面 是否自动加密
environment 普通配置、低敏感参数 容器元数据、进程环境、子进程、日志和调试工具
env_file 批量普通配置 源文件、Compose 插值、最终环境和元数据
Compose secrets.file 单机开发或 Compose 部署 宿主机源文件、绑定挂载、拥有 Docker 权限者 通常不是 Secret 对象级加密
Swarm Secret Swarm 服务运行时凭证 被授权服务、宿主机高权限边界、应用内存 Swarm 控制面提供加密保护
BuildKit --secret 构建时访问私有仓库或 API 构建客户端、构建器、错误输出和错误生成物 按 BuildKit 会话处理,不应进入镜像层
--build-arg / Dockerfile ARG 普通构建参数 命令、历史、缓存、层和构建日志

表格不能替代具体判断:即使使用 Swarm Secret,如果应用把它打印到日志,仍然会泄漏;即使使用文件注入,如果宿主机源文件被提交到 Git,仍然已经失守。


12. 生产部署中的选择逻辑

可以按部署模式判断:

普通单机 Compose

适合:

  • 本地开发;
  • 测试环境;
  • 单机服务且宿主机文件权限、备份和访问控制已经明确。

建议使用:

secrets:
  db_password:
    file: ./secrets/db_password

但应把 Secret 文件放在受控目录中,并加入 .gitignore

secrets/*
.env*

.gitignore 只防止普通 Git 操作误提交,不会删除已经提交的历史,也不限制 CI、备份或宿主机管理员访问。

Swarm

适合需要 Docker 原生集群 Secret 对象、服务级授权和滚动更新语义的场景。应使用版本化 Secret 名称轮换,并结合健康检查和双凭证窗口。

更大规模平台

如果部署平台已经有专门的凭证系统,应让 Docker 容器通过受控代理、节点身份或外部 Secret 管理器获取短期凭证。Docker Secret 不是通用的密码生成、自动轮换和审计系统;它主要负责在 Docker 工作负载边界内提供运行时注入。


13. 一个完整的低泄漏 Compose 模式

下面的模式把普通配置和 Secret 分开:

services:
  api:
    image: example/api:1.0
    user: "10001:10001"
    read_only: true
    tmpfs:
      - /tmp
    environment:
      APP_ENV: production
      DATABASE_HOST: db
      DATABASE_PORT: "5432"
      DATABASE_NAME: app
      DATABASE_USER_FILE: /run/secrets/db_user
      DATABASE_PASSWORD_FILE: /run/secrets/db_password
    secrets:
      - db_user
      - db_password
    depends_on:
      db:
        condition: service_healthy

  db:
    image: postgres:16
    environment:
      POSTGRES_DB: app
      POSTGRES_USER_FILE: /run/secrets/db_user
      POSTGRES_PASSWORD_FILE: /run/secrets/db_password
    secrets:
      - db_user
      - db_password
    volumes:
      - db-data:/var/lib/postgresql/data
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U app -d app"]
      interval: 5s
      timeout: 3s
      retries: 10

volumes:
  db-data:

secrets:
  db_user:
    file: ./secrets/db_user
  db_password:
    file: ./secrets/db_password

这个例子成立有几个前提:

  1. 使用的 PostgreSQL 镜像支持 POSTGRES_USER_FILEPOSTGRES_PASSWORD_FILE
  2. example/api 应用支持对应的 _FILE 约定;
  3. db_userdb_password 文件的权限允许容器内实际运行用户读取;
  4. read_only 不会阻止应用写入必须存在的临时目录,因为 /tmp 单独使用了 tmpfs;
  5. 数据库持久化数据放在卷中,但 Secret 不应被应用复制到该卷。

需要特别注意,Compose 的 depends_on 健康条件只解决启动顺序和健康检查条件,不解决 Secret 轮换、数据库凭证同步或应用重新加载。服务依赖不是凭证生命周期管理。


14. 最小验证闭环

上线前应验证的是“实际运行状态”,而不是只检查 YAML 是否漂亮:

docker compose config --quiet
docker compose up -d
docker compose ps

然后验证路径和权限,但不输出内容:

docker compose exec api sh -c '
  test -r /run/secrets/db_password &&
  test ! -r /run/secrets/nonexistent &&
  printf "secret path and access check passed\n"
'

验证环境中没有明文密码时,可以检查变量名而非变量值:

docker inspect "$(docker compose ps -q api)" \
  --format '{{range .Config.Env}}{{println .}}{{end}}' |
  grep -E 'PASSWORD|TOKEN|PRIVATE_KEY' || true

这只能发现常见命名形式,不能证明没有泄漏;完整连接字符串可能使用 DATABASE_URL,而不包含 PASSWORD 字样。

最后检查日志:

docker compose logs --no-color api |
  grep -Ei 'password|token|secret|private.key' || true

日志扫描同样只是辅助检测。凭证一旦曾经进入日志,删除当前日志文件也不代表它已经从集中日志、备份、缓存和历史索引中消失;正确处置是立即轮换凭证,并按照日志平台的数据删除和保留策略清理暴露内容。


15. 最终判断标准

选择环境变量还是文件注入,不能只看应用接口是否方便,而应分别回答四个问题:

  1. 配置是否需要被 Docker 元数据和进程环境直接看到?
  2. 应用是否能从文件读取,并控制读取时机?
  3. Secret 来源文件或 Swarm 控制面是否受到合适保护?
  4. 轮换时应用、认证服务和旧凭证撤销是否有明确顺序?

低敏感配置可以使用环境变量。高敏感凭证通常应优先使用运行时文件注入,构建阶段使用 BuildKit Secret,避免写入镜像层、命令行和日志。单机 Compose 的 secrets.file 改善了应用接口和配置组织,但不应被误认为加密存储;Swarm Secret 提供更强的 Docker 原生对象和分发语义,但仍不能防止已授权进程、宿主机高权限用户或应用自身主动泄漏。

真正完整的 Secret 防护链路是:

不进入源码
→ 不进入镜像层
→ 不进入命令行和普通日志
→ 只授权需要它的服务
→ 应用从受控文件读取
→ 轮换时新旧凭证有明确过渡
→ 验证新凭证生效
→ 撤销并清理旧凭证

缺少其中任何一步,Secret 都可能仍然以另一种形式存在于环境、文件、缓存、日志、进程内存或备份中。


系列导航与关联阅读

官方资料

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