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。 由此确认两条规则:
- Compose 只读"项目目录"下的
.env。 它不去build.context(这里是./app)里找。app/.env对 Compose 完全不可见——它是应用自己的配置文件。 - 两份文件的作用域不同,因此也各有各的泄漏面:
- 项目根
.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 看不到它,只有翻镜像历史才看得到。
多阶段构建在这里有帮助,但不彻底:
- 有帮助:
ARG声明在 builder 阶段,就不会出现在最终镜像的 config 里;builder 阶段整个不进入最终产物。所以"在 builder 里临时用一下凭据"比在单阶段里安全。 - 不彻底:那条凭据仍然留在builder 阶段的层里。如果你的构建产物(
docker save的 tar、CI 的中间镜像)会被别人拿到,它依然可被提取。
这一节涉及的
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、属主限定)与可以轮换(改文件内容、重启进程即可,不需要重建镜像或容器)。所以这两种方式的取舍应按"敏感度"分档,而不是按"哪个更现代":
- 可公开的(端口号、日志级别、环境名)→ 环境变量,随便放;
- 敏感的(数据库口令、API key、私钥)→ 只读文件挂载或密钥服务,绝不写进
compose.yml或 Dockerfile。
五、决策表:一条信息该走哪条边界
| 你要放的东西 | 该不该进构建上下文 | 该不该进镜像层 | 运行时怎么给 |
|---|---|---|---|
| 应用源码、依赖清单 | ✅ 是(构建需要) | ✅ 是(运行需要) | — |
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 里对上下文与镜像层做自动化扫描 | 密钥扫描要同时扫描上下文与构建产出的层,只扫源码树会漏掉构建期注入的 |
动手
- 列出你项目里
build.context指向的目录,把该目录下的每一个文件按第 05 节决策表分类; - 逐项回答:它在不在上下文里?进了镜像会怎样?
- 用
docker compose config展开一次,确认哪些值是从.env注入的、哪些是写死在compose.yml里的; - 判断标准:能说出每一条凭据穿过三道边界时分别处于什么状态,以及在哪一道边界上被挡住;
- 完成标志:对每一条凭据能回答"如果我现在把 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 的风险面是版本库,与镜像无关的另一条泄漏路径 |
自测
- 三道边界分别由什么控制?为什么说前两道上的错误"不可撤回"?正确的做法为什么必须落在第一道?
- 一个项目根目录和一个
app/子目录下各有一个.env。Compose 会读哪一个?另一个会被谁读? ARG与ENV在"docker inspect能否读出值"这一点上有什么区别?为什么说ARG也不是放密钥的地方?- 有人主张"环境变量比文件挂载安全"。请指出这个说法错在哪,并说明环境变量的真实优点是什么。
- 团队发现某条凭据曾进过镜像。列出完整的处置清单,并说明其中哪一步是重新构建无法替代的。
- 多阶段构建"能缓解但不能解决"凭据进镜像的问题——分别说清它缓解了哪一部分、没解决哪一部分。
现在能解释什么
- 配置与密钥要穿过三道边界(上下文 → 镜像层 → 运行时),前两道上的错误会永久留在已推送的镜像里。
- 危险的那一份配置文件往往不是"看起来像密钥的那份",而是位于
build.context之内的那份。 - Compose 只读项目目录的
.env;build.context里的.env属于应用,且必须被.dockerignore挡住。 ARG不在最终 config 里,但可能留在镜像历史里;ENV持久保存在 config。两者都不适合放密钥。- 环境变量的优点是方便而非保密;敏感值应走只读文件挂载或密钥服务。
- 凭据一旦进过镜像,轮换是必选项,重新构建不能替代它。
下一章处理容器的终止:信号到底发给了谁,以及为什么你写好的优雅退出逻辑可能一次都没被执行过。