KEEL · 龙骨 · A CURRICULUM FOR THE AI ERA

05 · 配置与密钥跨边界:东西是在哪一步漏出去的 — keel 龙骨

这一章回答:一份配置或密钥要经过哪几道边界才能被应用读到?在哪一道边界上做错,会让它永久留在镜像里?

这一章回答:一份配置或密钥要经过哪几道边界才能被应用读到?在哪一道边界上做错,会让它永久留在镜像里?

这个问题之所以容易被低估,是因为错误做法在正常路径上不报错。把密钥写进 Dockerfile 的 ENV 里,本地构建成功、容器正常启动、功能完全正确——唯一的代价在几个月后:镜像躺在 registry 里,任何人拉下来都能读出那串口令。

现场

一个项目的镜像被推到了团队的 registry。做代码审查时有人注意到 .dockerignore 只有一行:

node_modules

而 app/ 目录下有这些文件:

文件 内容
app/.env DATABASE_URL=postgresql://app:supersecret@db:5432/orders、JWT_SECRET=dev-only-secret
app/notes.local.md 个人笔记
app/debug.log 本地日志

Dockerfile.single 里有 COPY . .。于是那两行凭据进了镜像。审查时的争论点是"这个 .env 是开发用的假密码,要不要紧"——这个争论本身就是错的问题。真正该问的是:这套边界设计在真实凭据上会不会漏? 而答案是会。

(上面那两串值是本系列实验用的假值,可以放心出现在这里。它们也确实没能进镜像——本项目的 .dockerignore 里有 .env 一行,第 01 章的统计把 app/.env 归在"被排除"那一栏。)

一、三道边界

配置和密钥从"你写在某个文件里"到"应用进程读到",要穿过三道边界。每一道边界都有可能把它留在某个持久化的地方:

flowchart TD
  subgraph B1["边界一 · 你的工作目录"]
    SRC["app/.env(凭据)<br/>app/debug.log<br/>app/node_modules"]
    IGN{".dockerignore<br/>排除了吗?"}
    SRC --> IGN
  end

  subgraph B2["边界二 · 构建过程(引擎进程)"]
    CTX["构建上下文<br/>(已过滤)"]
    LAYER["镜像层与 image config<br/>(只读,可提取)"]
    CTX --> LAYER
  end

  subgraph B3["边界三 · 运行时(容器进程)"]
    ENVV["进程环境变量"]
    MOUNT["挂载的文件 / 卷"]
    APP["应用进程读到的值"]
    ENVV --> APP
    MOUNT --> APP
  end

  IGN -->|"排除"| DROP["根本不进入传输<br/>磁盘上还在,镜像里没有 ✅"]
  IGN -.->|"没排除"| CTX
  CTX -.->|"COPY 进层<br/>历史层可被提取 ⚠️"| LEAK["凭据永久留在镜像中"]
  LAYER -->|"docker run / compose up"| ENVV
  MOUNT --> APP

三道边界的本质区别:

边界 谁决定 做错之后的修法
一 · 上下文 .dockerignore 改 .dockerignore,重新构建——旧镜像里的副本仍在
二 · 镜像层 Dockerfile 里怎么写 改 Dockerfile,重新构建并重新推送——旧 tag 上的层还在 registry 里
三 · 运行时 compose / 编排系统怎么注入 改配置,重启容器——不涉及镜像

关键性质:前两道边界上的错误是不可"撤回"的。 只要凭据进过某一层,那一层就在所有已推送的镜像里;改 Dockerfile 只影响新构建的镜像。所以正确做法只有一条:让它一开始就不进上下文——这是唯一不需要善后的位置。

二、边界一:两个 .env 的分工

实验项目里有两个 .env,内容故意不同,这是本节的核心证据:

# dockerlab/.env                     ← 项目目录下的,给 Compose 用
APP_TAG=1.4.2
DATABASE_URL=postgresql://app:localdev@db:5432/orders
DB_PASSWORD=localdev
API_PORT=8080

# dockerlab/app/.env                 ← 应用目录下的,给应用自己用
DATABASE_URL=postgresql://app:supersecret@db:5432/orders
JWT_SECRET=dev-only-secret

解析 compose.yml 时,api 的 DATABASE_URL 解析成了哪个?(第 04 章实测输出)

    environment:
      DATABASE_URL: postgresql://app:localdev@db:5432/orders

是 localdev 那一份,来自项目目录的 .env。 由此确认两条规则:

  1. Compose 只读"项目目录"下的 .env。 它不去 build.context(这里是 ./app)里找。app/.env 对 Compose 完全不可见——它是应用自己的配置文件。
  2. 两份文件的作用域不同,因此也各有各的泄漏面:
    • 项目根 .env:在 build.context 之外,COPY . . 够不到它。它的风险是被提交进 git(绝大多数 .gitignore 会挡住,但要确认)。
    • app/.env:在构建上下文之内。它的风险是被 COPY . . 打进镜像,且必须靠 .dockerignore 挡住。

第 2 条是本章最容易踩的一处:危险的那一份,恰好是"看起来只是应用配置文件"的那一份。 判断方法不是看文件名,而是看它在不在 build.context 指向的目录里。

同理,notes.local.md、debug.log 这些"跟密钥无关"的文件也在上下文里;它们进镜像的问题不是泄漏凭据,而是第 01 章那两个(体积、缓存不稳定)。.dockerignore 要挡的是一类文件,不是一条:

node_modules
.git
.env
*.log
notes.local.md
dist
Dockerfile*
compose*.yml

三、边界二:ARG 与 ENV 都不是放密钥的地方

很多人以为"不用 ENV、改用 ARG 就安全了"。这两个东西的可见性确实不同,但都不适合放密钥:

ARG(构建参数) ENV(环境变量)
生效范围 只在构建期 构建期 + 运行时,且持久保存在镜像 config
能否被 docker inspect 读出 否(不在最终 config 里) 能,直接可读
是否可能留在镜像历史里 能——若被用在 RUN 里,展开后的命令会记录进镜像历史(docker history 可见) 能(config 里就有)
适合放什么 版本号、构建目标、非敏感的编译开关 默认端口、NODE_ENV 这类可公开的默认值

最典型的错误长这样:

# ❌ 凭据留在镜像历史里
ARG NPM_TOKEN
RUN npm config set //registry.npmjs.org/:_authToken ${NPM_TOKEN} && npm ci

${NPM_TOKEN} 在 RUN 里被展开,于是展开后的整条命令成为该层的一部分。任何拿到镜像的人都能把它读出来。这甚至比写进 ENV 更难发现——docker inspect 看不到它,只有翻镜像历史才看得到。

多阶段构建在这里有帮助,但不彻底:

这一节涉及的 docker history / docker inspect 行为依据 Docker 官方对 build arguments 的说明与镜像 config 的构成。本机 Docker daemon 未运行,没有实测输出。要自己验证,跑 docker history --no-trunc <image> 一眼就能看到展开后的 RUN 命令。

正确的做法是根本不在构建期使用长期凭据:用构建时挂载(BuildKit 的 --mount=type=secret,挂载点不进入任何层)或在流水线里用短时令牌(OIDC 换取的一次性凭据)。这类做法属于流水线侧,见《CI 流水线与质量门禁》的供应链安全章节。

四、边界三:运行时注入的四种方式

到了运行时,凭据已经不在镜像里,问题变成"用什么形式递给进程"。四种方式,安全性递减:

方式 形态 谁能读到 适用
密钥文件挂载(只读) 卷里一个文件,路径由 *_FILE 约定 容器内进程;文件权限可收紧 数据库口令、TLS 私钥
密钥管理服务 进程启动时从外部拉取 只有授权的主体 生产首选
env_file compose 读文件后注入进程环境 进程环境 + docker inspect 可见 非敏感的整组配置
environment: 明文 直接写在 compose.yml 里 进程环境 + docker inspect + compose.yml 本身(可能进 git) 只放可公开的值

这里有一个必须澄清的误判:"环境变量比文件安全"是错的。

进程环境变量在多数 Linux 上是可读的——同一台机器上权限足够的进程可以通过 /proc/<pid>/environ 读到它;docker inspect 也能看到 environment 字段。环境变量的真正优点是方便,不是保密。

而挂载文件的好处是权限可控(0400、属主限定)与可以轮换(改文件内容、重启进程即可,不需要重建镜像或容器)。所以这两种方式的取舍应按"敏感度"分档,而不是按"哪个更现代":

五、决策表:一条信息该走哪条边界

你要放的东西 该不该进构建上下文 该不该进镜像层 运行时怎么给
应用源码、依赖清单 ✅ 是(构建需要) ✅ 是(运行需要) —
package-lock.json ✅ ✅(要复现依赖解析) —
.env(含任何凭据) ❌ 不进 ❌ 不进 只读挂载 / 密钥服务
本地日志、.git、node_modules、个人笔记 ❌ 不进 ❌ 不进 —
Dockerfile / compose.yml ❌ 不进(构建时才读) ❌ 不进 —
默认端口、NODE_ENV — ✅(作为可公开的默认值写进 ENV) environment 可覆盖
TLS 证书 / 私钥 ❌ ❌ 只读挂载 / 密钥服务
构建期的 registry 凭据 ❌(不入层) ❌ 构建时挂载 secret / OIDC 短时令牌

生产边界

教学替身 真实替换点 要注意什么
dockerlab/.env + app/.env 两份假凭据 真实的多环境配置矩阵(dev/staging/prod) 边界一、二上的错误不可撤回;只改配置不改历史,旧镜像里的副本仍在
卷挂载的密钥文件 密钥管理服务(Vault / 云厂商 KMS / 编排系统的 Secret 对象) 密钥服务解决的是轮换与审计;文件挂载解决的是不进镜像。两者都要
compose.yml 里的 environment: 编排系统的 Secret 对象 + *_FILE 约定 检查清单要覆盖"哪些值被写死在配置文件里",而不只是"哪些写进了 Dockerfile
手工检查 .dockerignore CI 里对上下文与镜像层做自动化扫描 密钥扫描要同时扫描上下文与构建产出的层,只扫源码树会漏掉构建期注入的

动手

  1. 列出你项目里 build.context 指向的目录,把该目录下的每一个文件按第 05 节决策表分类;
  2. 逐项回答:它在不在上下文里?进了镜像会怎样?
  3. 用 docker compose config 展开一次,确认哪些值是从 .env 注入的、哪些是写死在 compose.yml 里的;
  4. 判断标准:能说出每一条凭据穿过三道边界时分别处于什么状态,以及在哪一道边界上被挡住;
  5. 完成标志:对每一条凭据能回答"如果我现在把 Dockerfile 改对,需要做什么才能让旧镜像里的副本不再构成风险"——答案必须包含撤销/轮换那份凭据,而不只是"重新构建"。

故障注入

注入方式 观察什么 说明的现象
从 .dockerignore 删掉 .env 一行,重新构建 上下文统计里 app/.env 出现在哪一栏 它进入上下文 → 进入某层 → 历史层可被提取(第 01 章)
把 ARG 声明的凭据用在 RUN 里,再改回不写 ARG 重新构建后旧镜像里还能不能找到它 改 Dockerfile 只影响新构建;旧镜像必须作废 + 凭据轮换
在 shell 里 export DATABASE_URL=... 后跑 config 解析出的值来自哪一份 shell 环境变量优先于 .env,.env 静默失效(第 04 章)
在容器里 cat /proc/1/environ(需 daemon) 环境变量是否可读 环境变量不是保密手段,只是方便手段
把一份凭据同时写进 compose.yml 与 .env 谁会赢、compose.yml 是否可能进 git 明文写进 compose.yml 的风险面是版本库,与镜像无关的另一条泄漏路径

自测

  1. 三道边界分别由什么控制?为什么说前两道上的错误"不可撤回"?正确的做法为什么必须落在第一道?
  2. 一个项目根目录和一个 app/ 子目录下各有一个 .env。Compose 会读哪一个?另一个会被谁读?
  3. ARG 与 ENV 在"docker inspect 能否读出值"这一点上有什么区别?为什么说 ARG 也不是放密钥的地方?
  4. 有人主张"环境变量比文件挂载安全"。请指出这个说法错在哪,并说明环境变量的真实优点是什么。
  5. 团队发现某条凭据曾进过镜像。列出完整的处置清单,并说明其中哪一步是重新构建无法替代的。
  6. 多阶段构建"能缓解但不能解决"凭据进镜像的问题——分别说清它缓解了哪一部分、没解决哪一部分。

现在能解释什么

下一章处理容器的终止:信号到底发给了谁,以及为什么你写好的优雅退出逻辑可能一次都没被执行过。

进入 keel 阅读