《Spring Boot 实战》3.2 敏感信息与密钥管理

本节从口令泄露的真实路径讲起:Git 历史、镜像层、进程参数、/proc 与日志各是怎么漏的;再比较环境变量与挂载文件的取舍,划清 Jasypt 这类加密方案的适用边界,给出 Vault / KMS / K8s Secret 的接入方式与密钥轮换对连接池、签名校验的实际影响。

本节目标:看清口令从哪几个地方漏出去,据此选择环境变量还是挂载文件,划清「配置文件加密」与「外部密钥管理」的边界,并掌握密钥轮换对连接池与签名校验的真实影响。
适用版本: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

所以有三条硬规矩:

  1. 绝不用命令行参数传凭据。它同时进 ps、进审计日志、进容器编排的 describe 输出。-D 系统属性同理。
  2. 环境变量只降低泄露面,不消除它。多租户共享主机、容器内跑多个进程、把环境块打进崩溃报告的 agent,都会让它漏。
  3. 要真正隔离,靠挂载文件加权限: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 配置中心接入 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「java」更多文章

  1. 《Spring Boot 入门》18.3 打包与运行
  2. 《Spring Boot 入门》18.2 实现
  3. 《Spring Boot 入门》18.1 需求与设计