本节目标:看清口令从哪几个地方漏出去,据此选择环境变量还是挂载文件,划清「配置文件加密」与「外部密钥管理」的边界,并掌握密钥轮换对连接池与签名校验的真实影响。
适用版本:Spring Boot 4.1.x(Java 21)
3.2 敏感信息与密钥管理
3.1 节把配置分成了三层,其中凭据层故意留白。本节专门填这块。book-loan 服务需要管理的敏感值不多但都致命:PostgreSQL 口令、Redis 口令、对象存储的 access key、以及签发登录 Token 的签名私钥。很多团队的处理方式是「先写进 application-prod.yml,以后再改」——本节要说清这个「以后」的代价。
明文口令会从哪几个地方漏出去
把口令写进配置文件后,它至少会同时出现在五个地方,而且大多数不在你的直接控制下:
| 泄露面 | 具体位置 | 谁可能看到 | 能否事后清除 |
|---|---|---|---|
| Git 历史 | 所有含该文件的历史提交 | 有仓库读权限的所有人、fork、镜像 | 几乎不能(要 rewrite 历史) |
| 镜像层 | 每一层 COPY 过的文件内容 | 能拉镜像的所有人 | 不能(层是只读的) |
| 进程参数 | ps aux 的命令行 | 同主机同用户、监控 agent | 不能(已进审计日志) |
| 进程环境 | /proc/<pid>/environ | 同用户、root、部分 agent | 不能 |
| 应用日志 | 异常栈、调试打印、Actuator | 日志系统、聚合平台 | 难(已索引) |
这五条里,Git 历史是最不可逆的。一旦明文口令被推上去,即使下一个 commit 删掉,git log -p 仍能看到。所谓「删掉就好」在仓库有 fork 或 CI 缓存时完全无效,唯一彻底的补救是轮换该口令。
镜像层同理。COPY application-prod.yml /app/config/ 之后,即使后续层删除该文件,docker history 与镜像层的 tar 里依然保留原文件内容。所以凭据绝不能进镜像,这与 3.1 节把覆盖层放在镜像外是同一条原则。
环境变量与挂载文件的取舍
排除「写进配置文件」后,剩下两条主流路径:环境变量与挂载文件。它们不是「谁更先进」,而是各有明确的泄露面和运维成本:
| 维度 | 环境变量 | 挂载文件(Secret volume / configtree) |
|---|---|---|
| 传递方式 | docker run -e / K8s env | 只读挂载到容器内路径 |
| 进程可见性 | /proc/<pid>/environ 可读 | 仅文件权限控制 |
| 子进程继承 | 会继承给所有子进程 | 不继承,子进程读不到 |
| 崩溃转储 / 堆转储 | 常包含环境块 | 不包含 |
K8s describe pod | 明文 env 会显示 | 只显示挂载,不显示内容 |
| 轮换 | 需重启 Pod | 需重启 Pod(除非支持热重载) |
| 适用场景 | 短小、单值凭据 | 大块凭据、证书、结构化密钥 |
结论不是「一律用文件」,而是按凭据形态选:单个口令、Token 用环境变量足够方便;证书、私钥、多字段的结构化密钥(比如数据库的 host/user/password 三件套)更适合挂载文件,并且能通过 spring.config.import=configtree: 一次性读成属性(见 3.1 节)。
无论选哪种,都要做到:不进镜像、不进 Git、不进日志。
/proc 与 ps:环境变量并不安全
「环境变量比配置文件安全」是个常见误解。在同一台主机上,只要有权限读 /proc,就能看到目标进程的完整环境块:
# 环境变量以 NUL 分隔,转成换行便于查看
tr '\0' '\n' < /proc/21874/environ | grep -i -E 'password|secret|token|key'
而命令行参数更直白,ps 直接可见:
# 这种写法把口令暴露给了同主机所有用户
java -jar book-loan-1.0.0.jar --spring.datasource.password=Pr0dP@ss
# ps aux 输出片段
java -jar book-loan-1.0.0.jar --spring.datasource.password=Pr0dP@ss
所以有三条硬规矩:
- 绝不用命令行参数传凭据。它同时进
ps、进审计日志、进容器编排的describe输出。-D系统属性同理。 - 环境变量只降低泄露面,不消除它。多租户共享主机、容器内跑多个进程、把环境块打进崩溃报告的 agent,都会让它漏。
- 要真正隔离,靠挂载文件加权限:
0400且属主为运行用户,配合 tmpfs(内存文件系统)挂载,避免落盘。
Kubernetes 的 Secret 默认以挂载卷形式给出,应用侧用 configtree: 读取;若图省事写成 env.valueFrom.secretKeyRef,就把 Secret 又变成了环境变量,泄露面回到 /proc 那一条。这是个常见的选择性失误。
Jasypt 之类加密方案的适用边界
「配置文件加密」是一类折中方案:用 Jasypt 之类的库把值加密成 ENC(...) 写在配置里,启动时用主密钥解密。
# application-prod.yml
spring:
datasource:
password: "ENC(6Xy9k2Q...base64...==)"
它确实解决了两个具体问题:
- 明文不再出现在 Git 与镜像层里,静态扫描不会命中。
- 无需引入外部系统,改造成本低。
但它的边界必须说清,否则会给人「已经安全了」的错觉:
| 问题 | 说明 |
|---|---|
| 主密钥往哪放 | 解密用的 jasypt.encryptor.password 仍要靠环境变量或启动参数传入,泄露面被平移而非消除 |
| 轮换成本 | 换主密钥要重新加密所有 ENC(...) 值并重新发布,无法只轮换单个凭据 |
| 无审计与租约 | 谁在何时读了哪个密钥,无从记录;也无法做到「租约到期自动失效」 |
| 粒度粗 | 通常一把主密钥保护全部值,一把泄露则全部泄露 |
因此合理的定位是:Jasypt 适合「把明文从仓库里拿掉」这一件事,适合合规基线要求「配置不得含明文口令」但暂时没有密钥管理基础设施的场景。它不是密钥管理,不能替代 Vault / KMS,也不该被当成长期方案。一旦团队能接入外部密钥系统,应优先迁移。
外部密钥管理:Vault / KMS / K8s Secret
真正的密钥管理把「存储」「访问控制」「审计」「轮换」四个职责交给专门系统,应用只负责在启动时取用。三种主流方案的定位不同:
| 方案 | 定位 | 接入方式 | 适合 |
|---|---|---|---|
| HashiCorp Vault | 动态凭据 + 租约 | Spring Cloud Vault,spring.config.import=vault://... | 需要短期凭据、集中审计 |
| 云 KMS(AWS/GCP/Azure) | 信封加密、托管主密钥 | SDK 解密数据密钥,或与配置源结合 | 已深度使用某朵云 |
| K8s Secret | 集群内分发与挂载 | configtree: 挂载读取 | 已在 K8s、凭据静态即可 |
Vault 的独特价值在动态凭据:为每次部署签发一个有租约的数据库账号,租约到期自动回收,泄露的窗口被压缩到租约时长内。Spring Cloud Vault 以 Config Data 方式接入,形如:
spring:
config:
import: "vault://secret/book-loan"
K8s Secret 则是最低门槛的方案,配合 configtree: 读取:
spring:
config:
import: "optional:configtree:/run/secrets/"
# Secret 挂载后的目录形态
ls -l /run/secrets/
# -r-------- 1 app app 32 Oct 9 10:00 spring.datasource.password
云 KMS 的典型用法是信封加密:KMS 里存主密钥,用它加密一份数据密钥,数据密钥再加密真正的配置。应用启动时调 KMS 解出数据密钥,本地解密配置。好处是主密钥永不出 KMS,坏处是引入一次网络调用与一个外部依赖。
选型顺序可以简化为:已有 Vault 就用 Vault;纯 K8s 且凭据静态就用 Secret + configtree;深度绑定单云且要托管就用 KMS。三者也可以组合,比如 KMS 保护 Vault 的 unseal key。
密钥轮换对应用的影响
轮换是密钥管理里最容易在应用侧翻车的一环,因为改了配置不等于改了运行中的连接。
数据库口令轮换:HikariCP 在启动时用当前口令建连接池,池内连接会长期复用。你在配置中心把口令改掉,已在跑的连接不受影响;新连接仍会用旧口令去建,直到池被重建。也就是说,口令一旦在数据库侧失效,池里的连接会陆续报认证失败,而应用不会自动重新读取新口令。可行做法有两条:
- 双凭据窗口:先在数据库创建新口令(旧口令仍有效),滚动重启让新实例用新口令建池,全部切换后再撤销旧口令。这样任何时刻都有一把有效口令,避免全量故障。
- 动态数据源:用 Vault 数据库引擎签发短期账号,应用按租约主动刷新,把「轮换」变成持续发生的常态,而不是一次高风险操作。
签名私钥轮换(book-loan 的登录 Token):不能直接换掉,否则所有已签发 Token 立刻失效。正确做法是引入 kid(key id)头,验签时按 kid 找对应公钥,保留「上一把公钥」一段时间,等旧 Token 自然过期后再移除。轮换因此是两个阶段:先加新密钥(双密钥并存),再摘旧密钥。
连接获取时机:4.1 新增了 spring.datasource.connection-fetch=lazy,让数据源在需要时才获取连接,而不是启动即建。这在凭据来自外部系统、且启动时凭据尚未就绪的场景下有用——但它是行为改变,不是轮换方案,别指望它自动帮你重读口令。
# 惰性获取连接(4.1 新增),适用于凭据晚于启动就绪的场景
spring.datasource.connection-fetch=lazy
把凭据接进 book-loan:一个完整例子
把前面的原则落成代码。book-loan 的凭据统一收在一个绑定对象里,来源是 configtree: 挂载目录或环境变量,二者通过 relaxed binding 都能映射进来:
import org.springframework.boot.context.properties.ConfigurationProperties;
@ConfigurationProperties(prefix = "bookloan.secrets")
public class BookLoanSecrets {
/** 对应挂载文件 bookloan.secrets.db-password */
private String dbPassword;
/** 对应挂载文件 bookloan.secrets.redis-password */
private String redisPassword;
private String storageAccessKey;
private String storageSecretKey;
// getters / setters 省略
}
对应的挂载目录只要文件名对得上,内容就会被读进属性:
/run/secrets/
├── bookloan.secrets.db-password
├── bookloan.secrets.redis-password
├── bookloan.secrets.storage-access-key
└── bookloan.secrets.storage-secret-key
启动时做一次存在性校验,让「凭据没挂上」在启动阶段就失败,而不是等到第一次访问数据库才暴露:
import org.springframework.boot.ApplicationArguments;
import org.springframework.boot.ApplicationRunner;
import org.springframework.stereotype.Component;
@Component
public class SecretPresenceCheck implements ApplicationRunner {
private final BookLoanSecrets secrets;
public SecretPresenceCheck(BookLoanSecrets secrets) {
this.secrets = secrets;
}
@Override
public void run(ApplicationArguments args) {
require(secrets.getDbPassword(), "bookloan.secrets.db-password");
require(secrets.getRedisPassword(), "bookloan.secrets.redis-password");
// 只打键名,绝不打值
}
private void require(String value, String key) {
if (value == null || value.isBlank()) {
throw new IllegalStateException("missing secret: " + key);
}
}
}
这段校验只输出键名,不输出值——这一点比校验本身更重要。凭据处理代码里凡是拼日志的地方,都要问一句「这里会不会把值打出去」。
日志与异常里的凭据
日志泄露往往不是有人主动打印口令,而是间接带出来的:
- 连接串进异常:JDBC 抛出的异常常带完整 URL。若 URL 里嵌了
?password=...(有些驱动支持),就会随异常进入日志。口令不要写进 JDBC URL,用独立属性传。 - 对象
toString():把BookLoanSecrets加@Data(Lombok)会生成含全部字段的toString(),任何一次log.info("{}", secrets)都会全量泄露。要么别对凭据类用@Data,要么覆写toString()只返回字段名。 - HTTP 客户端日志:开启
logging.level.org.springframework.web.client=debug后,请求头里的Authorization会被打出来。排障时临时开可以,但别让它常驻生产配置。
一条实用的护栏:在 CI 里加一个正则扫描,命中 password=、secret=、token= 后面跟非占位符值时就失败。它挡不住所有情况,但能挡住「不小心把明文提交上去」这一类。
常见坑
- 把凭据放进命令行参数:
--spring.datasource.password=...会出现在ps和审计日志里,是最常见的低级失误。 - 用
env.valueFrom.secretKeyRef传大块密钥:等于把 Secret 退化成环境变量,/proc依然可读。 - 以为 Jasypt 就安全了:主密钥仍要分发,轮换仍要全量重加密,且无审计。
- 轮换口令后只重启一半实例:新旧口令并存期没设计好,会出现「一半实例连不上」的混合状态。
- 轮换签名密钥不保留旧公钥:所有在途 Token 立刻验签失败,用户被强制登出。
- Actuator
/env未鉴权:即使 Spring Boot 默认对敏感属性做了脱敏(显示为******),把该端点暴露给未授权访问仍是风险,生产应限制或关闭。
小结
凭据泄露有五条主要路径,其中 Git 历史与镜像层最不可逆。环境变量与挂载文件各有取舍:前者方便但进程可见,后者隔离好但要管权限。Jasypt 只能做到「把明文从仓库拿掉」,真正的密钥管理交给 Vault / KMS / K8s Secret,分别对应动态凭据、信封加密、集群分发三种定位。轮换的关键认知是「改配置不等于改运行态」——数据库要设计双凭据窗口,签名密钥要先加后摘。
阅读导航:上一节:3.1 多环境配置策略 · 下一节:3.3 配置中心接入 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。