一个服务把二十多个配置项全都做成了环境变量,理由是"云原生标准做法"。上线三个月后,没人能说清生产环境到底有哪些值——docker inspect 能看一部分,编排平台的密钥管理里有一部分,还有一部分是部署脚本里 export 的。排查一次超时问题花了两小时,最后发现某个超时值被环境变量覆盖了。
环境变量与配置文件不是"新旧两种写法",它们是两种不同性质的东西:环境变量描述运行上下文,配置文件描述应用策略。放在错误的一侧,不是风格问题,而是可维护性直接下降。
1. 两者的能力差异
| 维度 | 环境变量 | 配置文件 |
|---|---|---|
| 结构 | 扁平的键值 | 可嵌套、可表达列表 |
| 类型 | 全部字符串 | 有类型 |
| 可见性 | 分散在进程环境里 | 集中、可 diff |
| 与代码的关系 | 完全脱离(可能不在仓库里) | 可进仓库、可评审 |
| 变更方式 | 重新部署或重启进程 | 支持热加载 |
| 密钥安全性 | 进程列表可读(/proc/<pid>/environ) |
需额外加密 |
| 跨平台一致性 | 好(12-factor) | 需要路径约定 |
第五项常被误解:把密钥放环境变量被当作"更安全",但同机上任何有权限读 /proc 的进程都能看到。它的优势是不进仓库,不是不可读。
2. 环境变量该放什么
| 内容 | 为什么适合 |
|---|---|
环境标识(APP_ENV=prod) |
描述这个进程在哪运行 |
| 密钥与令牌 | 不能进仓库,由平台注入 |
| 单例连接地址(DB/缓存) | 每个环境不同,且是扁平的 |
| 进程级开关(调试、profile) | 影响进程行为而非业务逻辑 |
| 部署版本标识 | 由 CI 注入,用于日志与链路 |
一个可复用的判据:换一台机器(但不换业务)要不要改。要改的是环境变量;不换机器也要改(业务规则调整)的是配置文件。这个判据不依赖任何框架约定,几乎不会判错。
共同点是这些都描述"这个进程在哪、以什么身份运行",而不是"业务怎么算"。它们天然扁平、天然按环境不同、天然由平台注入。
3. 配置文件该放什么
| 内容 | 为什么适合 |
|---|---|
| 超时、重试、批量大小 | 需要类型与注释,需要按环境微调 |
| 业务规则(阈值、档位) | 需要评审与版本历史 |
| 功能开关与灰度比例 | 需要运行期调整 |
| 列表型配置 | 环境变量表达列表很难看 |
| 多数据源/多租户定义 | 结构复杂 |
第四项是硬约束:用环境变量表达一个列表,只能靠逗号分隔的字符串,然后自己解析、自己处理转义、自己处理空值。这不是风格问题,而是把自己逼进了字符串处理。
// 配置文件能自然表达的东西,环境变量表达起来很别扭
gateways:
- name: primary
url: https://api.example.com
timeout_ms: 800
- name: fallback
url: https://backup.example.com
timeout_ms: 1200
// 环境变量版本:GATEWAYS="primary|https://...|800,fallback|https://...|1200"
// 一旦 url 里含逗号或竖线,解析就崩
4. 混合方案:一个入口,两种来源
实际系统里最稳的做法是配置文件为主,环境变量覆盖少数几项。
| 层 | 内容 | 优先级 |
|---|---|---|
| 代码内默认值 | 安全兜底 | 最低 |
| 镜像内配置文件 | 通用策略 | 中 |
| 挂载的配置文件 | 环境特定策略 | 高 |
| 环境变量 | 少数需要平台注入的项 | 最高 |
这个梯次还有一个隐含要求:每一层都必须能被单独验证。如果只测"最终值对不对",某一层被绕过时无法发现;分别测"只给默认值能启动""只给文件能启动""给环境变量能覆盖",才是完整的。
这个梯次的价值在于:默认值让服务在配置缺失时仍能启动,环境变量让平台能注入密钥与地址。而业务策略留在配置文件里,可评审、可 diff、可回滚。
// Spring Boot 的配置优先级天然支持这个梯次
// 1) 代码内默认:@Value 的默认值
// 2) jar 内 application.yml
// 3) 外部挂载 config/application.yml
// 4) 环境变量 SPRING_APPLICATION_JSON / 单项覆盖
app:
timeout-ms: 800 # 默认
retry: 1
features:
new-pricing: false
5. 覆盖关系的可观测性
混合方案的代价是"最终生效值是哪一层给的"变得不清楚。解法是把这件事打印出来。
// 启动自检:打印每个键的最终值与来源层
java -jar app.jar --debug-config 2>&1 | grep '^CFG '
// CFG app.timeout-ms=800 source=file(config/application.yml)
// CFG app.retry=3 source=env(APP_RETRY)
// CFG app.db.url=... source=env(DB_URL) [masked]
来源打印还有一处细节值得做对:同一份配置在不同层的差异要能看出来,而不只是打印最终值。
// 对比模式:只打印多层之间存在差异的键
java -jar app.jar --debug-config=diff 2>&1 | grep '^DIFF '
// DIFF app.timeout-ms file=800 env=2500 → 生效 env,差异需确认是否有意
DIFF 列表通常很短,但每一项都是值得看一眼的"人为覆盖"。
source=env 这一项尤其有价值:它直接回答了"这个值为什么和我预期的不一样"。
// 一个最小实现:把来源记录在解析时,而不是事后猜
record Resolved(String key, String value, String source) {}
List<Resolved> resolved = sources.stream()
.flatMap(s -> s.keys().stream().map(k -> new Resolved(k, s.get(k), s.name())))
.collect(...);
// 按优先级去重后打印;脱敏只作用于含 password/secret/token 的键
一份清单可以把归属判断固定下来,避免每次争论:
// 配置归属清单:五问,逐项回答后归属自然确定
1. 换机器要改吗? 是 → 环境变量
2. 需要评审与版本历史吗? 是 → 配置文件
3. 需要值班改动吗? 是 → 配置中心 / 环境变量
4. 是密钥吗? 是 → 平台注入(不进仓库、不进镜像)
5. 是列表或嵌套结构吗? 是 → 配置文件(环境变量表达不了)
按顺序回答,第一个"是"就决定了归属。第 4 项优先级最高,它是唯一不允许"图方便"的一项。
6. 排错:五种"配置不生效"
| 现象 | 成因 | 处理方向 |
|---|---|---|
| 改配置文件没反应 | 环境变量同名项覆盖 | 打印来源层 |
| 换了环境行为变了但配置一样 | 镜像内文件与环境文件叠加顺序错 | 确认挂载路径与优先级 |
| 容器里读不到配置文件 | 相对路径依赖工作目录 | 用绝对路径或 classpath |
| 密钥出现在日志里 | 启动打印未脱敏 | 按键名白名单脱敏 |
| 列表配置解析出多余空项 | 环境变量逗号分隔留了空串 | 改配置文件,或过滤空项 |
第二项有个具体的排查动作:把容器内外看到的值都打印一遍。同一个镜像在不同环境行为不同,绝大多数情况是挂载路径不同,导致外部文件根本没被读到——而服务不会报错,它安静地用了镜像内的默认值。
第一项最常见,也最难查,因为两处配置看起来都对。它的根因是没有人知道优先级——而优先级通常写在框架文档的某一段里,不在这个项目的文档里。
7. 边界:什么情况可以只用一种
| 场景 | 为什么 | 做法 |
|---|---|---|
| 单环境、无密钥的本地工具 | 无需区分 | 配置文件即可 |
| 极简容器(12-factor 严格遵循) | 项数少,扁平足够 | 全部环境变量 |
| 需要热更新的业务开关 | 环境变量无法热更 | 全部走配置中心 |
| 配置项少于五项 | 扁平不是负担 | 环境变量更直接 |
还有一层与运维协作相关的判断:谁能改。配置文件在仓库里,改它需要提交与发布;环境变量在平台上,改它需要平台权限。若一项配置需要"值班的人能改",那它必须在环境变量或配置中心里;若一项配置"只能由开发改",放在仓库里更安全。所以归属判断除了技术性质,还有权限性质。
最后一条实践建议:配置文件进仓库,环境变量进平台。这条不成立时(比如配置里有密钥),就该把密钥拆出来单独注入,而不是把整份配置搬进环境变量。混着放的后果是两边都不知道完整清单长什么样。
判断口径一句话:这个值需不需要被评审、需不需要有结构。需要,就进配置文件;只是"这个环境用哪个地址",就进环境变量。
8. 自测
- 环境变量与配置文件各自"描述"的是什么?这个区别如何决定归属?
- 把密钥放环境变量,优势到底是什么?为什么它不等于"不可读"?
- 为什么用环境变量表达列表是不合适的?举出它会在什么输入下崩掉。
- 混合方案的四层优先级是什么?"代码内默认值"这一层的价值是什么?
- 为什么"打印配置来源层"比写文档更能解决配置不生效的问题?
- 判断一个值该放哪里的最短问句是什么?