Spring Boot 从 3.2 起能在不重启的情况下换 HTTPS 证书,前提是给 SSL bundle 打开 reload-on-update。我在 4.1.1 上实测,直接覆盖文件、certbot 那种改软链、Kubernetes Secret 卷那种切 ..data,三种换法都能在 10 秒左右生效。坑在另一头:要是只换了证书、私钥还是旧的,Boot 照样会把这对不匹配的东西装上去,之后所有 HTTPS 请求都握手失败,打开 verify-keys 才能挡住。

先说配置

SSL bundle 是 3.1 加进来的,用属性统一配置证书和信任材料,Web 服务器、数据访问客户端、RestTemplate、WebClient 都能引用同一个 bundle。3.2 的 Release Notes 加了 SSL Bundle Reloading,bundle 自己要显式开 reload-on-update,消费方也得支持重载,官方文档列的是 Tomcat 和 Netty 两种 Web 服务器。

实验项目从 start.spring.io 生成,Spring Boot 4.1.1,只加了 Spring Web MVC 和 Actuator,内嵌 Tomcat。证书全部用 PEM,用命令行参数传进去:

java -jar target/ssldemo-0.0.1-SNAPSHOT.jar --server.port=18444 \
  --server.ssl.bundle=web \
  --spring.ssl.bundle.pem.web.keystore.certificate=file:certs/b/tls.crt \
  --spring.ssl.bundle.pem.web.keystore.private-key=file:certs/b/tls.key \
  --spring.ssl.bundle.pem.web.reload-on-update=true

写进 application.properties 也一样,键名照抄就行。

所有场景放在同一个 verify.sh 里一次跑完。证书用 openssl 现场生成三套自签 EC 证书,CN 分别是 app-v1、app-v2、app-v3。看服务端当前用的是哪张证书,就拿 openssl s_client 连上去读 subject。脚本里几个辅助函数是这样的:

gen() { openssl req -x509 -newkey ec -pkeyopt ec_paramgen_curve:P-256 -nodes -days 30 -subj "/CN=$1" -keyout "$2" -out "$3" 2>/dev/null; }
served() { local o; o=$(timeout 5 openssl s_client -connect 127.0.0.1:$1 </dev/null 2>/dev/null | openssl x509 -noout -subject 2>/dev/null); echo "${o:-handshake failed}"; }
health() { local o; o=$(curl -sSk --max-time 5 -o /dev/null -w 'http=%{http_code}' https://127.0.0.1:$1/actuator/health 2>&1); echo "curl -k https://127.0.0.1:$1/actuator/health -> $o"; }

日志里 +10s 这种写法,是从文件改完开始,每秒连一次,直到读到新证书为止用了多少秒。

### 环境
openjdk version "21.0.12.1" 2026-08-18
OpenSSL 3.5.7 9 Jun 2026 (Library: OpenSSL 3.5.7 9 Jun 2026)
<version>4.1.1</version>
2026-10-19 08:23:28 CST

### 准备三套证书 app-v1 / app-v2 / app-v3
$ gen app-v1 certs/src/v1.key certs/src/v1.crt
$ gen app-v2 certs/src/v2.key certs/src/v2.crt
$ gen app-v3 certs/src/v3.key certs/src/v3.crt

不开 reload-on-update,改了文件也没用

先看默认情况。启动后把证书和私钥都覆盖成 v2,等 20 秒:

### A: 不开 reload-on-update,直接覆盖文件
$ java -jar target/ssldemo-0.0.1-SNAPSHOT.jar --server.port=18443 --server.ssl.bundle=web --spring.ssl.bundle.pem.web.keystore.certificate=file:certs/a/tls.crt --spring.ssl.bundle.pem.web.keystore.private-key=file:certs/a/tls.key 
启动后: subject=CN=app-v1
$ cp certs/src/v2.crt certs/a/tls.crt
$ cp certs/src/v2.key certs/a/tls.key
覆盖 20 秒后: subject=CN=app-v1
--- a.log 启动后的证书加载与 WARN/ERROR 日志

还是 app-v1。日志里也没有任何证书重新加载的记录。默认 Boot 只在启动时读一次文件,证书续期以后不重启就不会生效。

打开之后,覆盖文件 10 秒左右生效

### B: reload-on-update=true,直接覆盖文件(quiet-period 默认 10s)
$ java -jar target/ssldemo-0.0.1-SNAPSHOT.jar --server.port=18444 --server.ssl.bundle=web --spring.ssl.bundle.pem.web.keystore.certificate=file:certs/b/tls.crt --spring.ssl.bundle.pem.web.keystore.private-key=file:certs/b/tls.key --spring.ssl.bundle.pem.web.reload-on-update=true
启动后: subject=CN=app-v1
$ cp certs/src/v2.crt certs/b/tls.crt
$ cp certs/src/v2.key certs/b/tls.key
+10s subject=CN=app-v2
curl -k https://127.0.0.1:18444/actuator/health -> http=200
--- b.log 启动后的证书加载与 WARN/ERROR 日志
2026-10-19T08:24:05.415+08:00  INFO 261791 --- [ssldemo] [-bundle-watcher] o.a.t.util.net.NioEndpoint.certificate   : Connector [https-jsse-nio-18444], TLS virtual host [_default_], certificate type [UNDEFINED] configured from keystore [Set directly - location unknown] using alias [tomcat] with trust store [null]

10 秒后换成了 app-v2,health 正常。Tomcat 打出的那行 NioEndpoint.certificate 日志就是重新配置连接器证书的记录,线程名 ssl-bundle-watcher 在日志里被截成了 -bundle-watcher。

这 10 秒来自 spring.ssl.bundle.watch.file.quiet-period,4.1.1 里默认 10s。看 FileWatcher 的实现,监听线程拿到文件事件后先攒着,一直等到连续一个 quiet period 内没有新事件,才触发重载。证书和私钥先后写进去,只要间隔没超过 10 秒,就只会合并成一次重载。

certbot 那种改软链的目录

certbot 的 live/ 目录里放的是软链,真实文件在 archive/ 里。按 certbot 文档的说法,续期拿到的新证书和旧证书存在一起,再把 live 里的软链改指到新证书。这里照这个结构搭了一个目录,live/tls.crt 和 live/tls.key 是指向 archive/ 里 1 号文件的软链:

cp certs/src/v1.crt certs/c/archive/fullchain1.pem && cp certs/src/v1.key certs/c/archive/privkey1.pem
ln -s ../archive/fullchain1.pem certs/c/live/tls.crt && ln -s ../archive/privkey1.pem certs/c/live/tls.key

启动、改软链、等重载,输出如下:

$ java -jar target/ssldemo-0.0.1-SNAPSHOT.jar --server.port=18445 --server.ssl.bundle=web --spring.ssl.bundle.pem.web.keystore.certificate=file:certs/c/live/tls.crt --spring.ssl.bundle.pem.web.keystore.private-key=file:certs/c/live/tls.key --spring.ssl.bundle.pem.web.reload-on-update=true
启动后: subject=CN=app-v1
$ cp certs/src/v2.crt certs/c/archive/fullchain2.pem
$ cp certs/src/v2.key certs/c/archive/privkey2.pem
$ ln -sfn ../archive/fullchain2.pem certs/c/live/tls.crt
$ ln -sfn ../archive/privkey2.pem certs/c/live/tls.key
+10s subject=CN=app-v2
curl -k https://127.0.0.1:18445/actuator/health -> http=200
--- c.log 启动后的证书加载与 WARN/ERROR 日志
2026-10-19T08:24:19.058+08:00  INFO 262208 --- [ssldemo] [-bundle-watcher] o.a.t.util.net.NioEndpoint.certificate   : Connector [https-jsse-nio-18445], TLS virtual host [_default_], certificate type [UNDEFINED] configured from keystore [Set directly - location unknown] using alias [tomcat] with trust store [null]

也生效了。FileWatcher 注册监听时会顺着软链往下找,软链本身和它指向的文件都在监听范围里,所以改软链目标也能被发现。

Kubernetes Secret 卷那种 ..data 切换

Secret 挂成卷以后,kubelet 的写法是:真实文件放在一个带时间戳的隐藏目录里,..data 是指向这个目录的软链,tls.crt 再指向 ..data/tls.crt。更新时先写新目录,再新建一个临时软链,用 rename 原子地替换 ..data,最后删掉旧目录。下面按这个顺序模拟,目录名简化成了 ..v1、..v2、..v3。初始目录这样搭:

mkdir -p certs/d/..v1 && cp certs/src/v1.crt certs/d/..v1/tls.crt && cp certs/src/v1.key certs/d/..v1/tls.key
ln -s ..v1 certs/d/..data && ln -s ..data/tls.crt certs/d/tls.crt && ln -s ..data/tls.key certs/d/tls.key

然后启动应用,连续轮换两次:

$ java -jar target/ssldemo-0.0.1-SNAPSHOT.jar --server.port=18446 --server.ssl.bundle=web --spring.ssl.bundle.pem.web.keystore.certificate=file:certs/d/tls.crt --spring.ssl.bundle.pem.web.keystore.private-key=file:certs/d/tls.key --spring.ssl.bundle.pem.web.reload-on-update=true
启动后: subject=CN=app-v1
# 第一次轮换 v1 -> v2
$ mkdir certs/d/..v2
$ cp certs/src/v2.crt certs/d/..v2/tls.crt
$ cp certs/src/v2.key certs/d/..v2/tls.key
$ ln -s ..v2 certs/d/..data_tmp
$ mv -T certs/d/..data_tmp certs/d/..data
$ rm -rf certs/d/..v1
+10s subject=CN=app-v2
# 第二次轮换 v2 -> v3
$ mkdir certs/d/..v3
$ cp certs/src/v3.crt certs/d/..v3/tls.crt
$ cp certs/src/v3.key certs/d/..v3/tls.key
$ ln -s ..v3 certs/d/..data_tmp
$ mv -T certs/d/..data_tmp certs/d/..data
$ rm -rf certs/d/..v2
+11s subject=CN=app-v3
curl -k https://127.0.0.1:18446/actuator/health -> http=200
--- d.log 启动后的证书加载与 WARN/ERROR 日志
2026-10-19T08:24:32.681+08:00  INFO 262740 --- [ssldemo] [-bundle-watcher] o.a.t.util.net.NioEndpoint.certificate   : Connector [https-jsse-nio-18446], TLS virtual host [_default_], certificate type [UNDEFINED] configured from keystore [Set directly - location unknown] using alias [tomcat] with trust store [null]
2026-10-19T08:24:42.921+08:00  INFO 262740 --- [ssldemo] [-bundle-watcher] o.a.t.util.net.NioEndpoint.certificate   : Connector [https-jsse-nio-18446], TLS virtual host [_default_], certificate type [UNDEFINED] configured from keystore [Set directly - location unknown] using alias [tomcat] with trust store [null]

两次都生效,日志里也正好两条重载记录。第二次轮换时,启动那会儿登记过的 ..v1 已经被删了,照样没问题,因为 ..data 这个名字所在的目录一直在被监听。

Kubernetes 文档里写得很清楚,用 subPath 挂载 Secret 的容器收不到 Secret 更新。证书想走热更新,就得整卷挂载。探针怎么配跟这件事是两回事,之前单独写过一篇 Spring Boot on Kubernetes:Liveness 与外部依赖检查的边界。

只换证书不换私钥,HTTPS 直接挂掉

前面几种都是证书和私钥一起换。现实里也有分开写的时候,比如手工先拷了证书,过一会儿才想起私钥;或者自己写的续期脚本两步之间隔得久。下面先只换证书,20 秒后再补私钥:

### E: reload-on-update=true,只换了证书没换私钥
$ java -jar target/ssldemo-0.0.1-SNAPSHOT.jar --server.port=18447 --server.ssl.bundle=web --spring.ssl.bundle.pem.web.keystore.certificate=file:certs/e/tls.crt --spring.ssl.bundle.pem.web.keystore.private-key=file:certs/e/tls.key --spring.ssl.bundle.pem.web.reload-on-update=true
启动后: subject=CN=app-v1
$ cp certs/src/v2.crt certs/e/tls.crt
只换证书 20 秒后: subject=CN=app-v2
curl -k https://127.0.0.1:18447/actuator/health -> curl: (35) TLS connect error: error:030000EA:digital envelope routines::provider signature failure
http=000
$ cp certs/src/v2.key certs/e/tls.key
补上私钥 20 秒后: subject=CN=app-v2
curl -k https://127.0.0.1:18447/actuator/health -> http=200
--- e.log 启动后的证书加载与 WARN/ERROR 日志
2026-10-19T08:24:56.634+08:00  INFO 263512 --- [ssldemo] [-bundle-watcher] o.a.t.util.net.NioEndpoint.certificate   : Connector [https-jsse-nio-18447], TLS virtual host [_default_], certificate type [UNDEFINED] configured from keystore [Set directly - location unknown] using alias [tomcat] with trust store [null]
2026-10-19T08:25:16.654+08:00  INFO 263512 --- [ssldemo] [-bundle-watcher] o.a.t.util.net.NioEndpoint.certificate   : Connector [https-jsse-nio-18447], TLS virtual host [_default_], certificate type [UNDEFINED] configured from keystore [Set directly - location unknown] using alias [tomcat] with trust store [null]

只换证书后,Boot 照样重载了,服务端发出去的已经是 app-v2 的证书,可签名用的还是 v1 的私钥。客户端校验签名失败,curl 报 provider signature failure,health 拿不到响应。这段时间里每次 HTTPS 握手都会失败。应用日志里却只有一条 INFO,没有任何报错。补上私钥再过一个 quiet period,又恢复了。

加上 verify-keys 就能挡住

PEM bundle 的 keystore 有个 verify-keys 属性,检查私钥和证书里的公钥是否配对。它也是 3.2 加的,4.1.1 里默认还是 false。同样的操作,打开它再来一次:

### F: 同 E,再加 verify-keys=true
$ java -jar target/ssldemo-0.0.1-SNAPSHOT.jar --server.port=18448 --server.ssl.bundle=web --spring.ssl.bundle.pem.web.keystore.certificate=file:certs/f/tls.crt --spring.ssl.bundle.pem.web.keystore.private-key=file:certs/f/tls.key --spring.ssl.bundle.pem.web.reload-on-update=true --spring.ssl.bundle.pem.web.keystore.verify-keys=true
启动后: subject=CN=app-v1
$ cp certs/src/v2.crt certs/f/tls.crt
只换证书 20 秒后: subject=CN=app-v1
curl -k https://127.0.0.1:18448/actuator/health -> http=200
$ cp certs/src/v2.key certs/f/tls.key
+10s subject=CN=app-v2
curl -k https://127.0.0.1:18448/actuator/health -> http=200
--- f.log 启动后的证书加载与 WARN/ERROR 日志
2026-10-19T08:25:40.034+08:00 ERROR 264603 --- [ssldemo] [-bundle-watcher] o.s.boot.autoconfigure.ssl.FileWatcher   : Unexpected SSL reload error
java.lang.IllegalStateException: Private key in keystore matches none of the certificates in the chain
2026-10-19T08:26:00.100+08:00  INFO 264603 --- [ssldemo] [-bundle-watcher] o.a.t.util.net.NioEndpoint.certificate   : Connector [https-jsse-nio-18448], TLS virtual host [_default_], certificate type [UNDEFINED] configured from keystore [Set directly - location unknown] using alias [tomcat] with trust store [null]

这次重载被拒绝了,FileWatcher 打出 ERROR,原因是 Private key in keystore matches none of the certificates in the chain。服务端继续用旧的 app-v1,health 一直是 200。等私钥补上,下一次重载通过,换成 app-v2。

E 和 F 只差一个 verify-keys=true。打开它以后,配不上对的证书最多是装不上,旧证书接着服务,HTTPS 不会断。代价是这次换证书悄悄失败了,能看出来的只有那条 FileWatcher 的 ERROR,旧证书离过期又近了一点。

证书什么时候过期,3.4 起也能从 Actuator 看。4.1.1 里先打开 management.info.ssl.enabled=true,/actuator/info 就会列出 SSL bundle 里证书的有效期、签发者和主题。另外还有一个 SSL 证书健康指标,证书无效时状态变成 OUT_OF_SERVICE。多久算快过期由 management.health.ssl.certificate-validity-warning-threshold 决定,默认 14 天。

参考来源

— 感谢阅读 —
广告

一起交流

分享你的思考,让讨论更进一步。