RestTemplate HTTPS 证书信任问题排查
RestTemplate HTTPS 证书信任问题排查
一、真实场景
某天接到告警:线上 SpringBoot2 服务用 RestTemplate 请求 https://id.vk.com 报 SSLHandshakeException: PKIX path building failed。
查日志发现:
- 上周还好好的,今天突然全崩
- 依赖的其他 HTTPS 接口都正常
- 服务器没变,代码没改
唯一线索——环境用的是 JDK 8u211(2019 年 1 月发布)。
二、核心结论
RestTemplate 发 HTTPS 请求时,默认使用 JDK 自身的证书信任库(cacerts),跟 CentOS7 的系统证书毫无关系。
| 角色 | 证书来源 | 管理方式 |
|---|---|---|
| Java 应用 | $JAVA_HOME/jre/lib/security/cacerts | JDK 自带,独立更新 |
| 操作系统 | /etc/pki/ca-trust/ | update-ca-trust 管理 |
这是 Java "一次编写到处运行"的设计取舍——跨平台一致性让 SSL 行为不依赖宿主 OS,但也意味着你在系统里装的自签名 CA、企业内部证书、甚至某些较新的公共根 CA,Java 默认不认。
三、先找到 JDK:没有 $JAVA_HOME 怎么办?
现实是很多服务器上 $JAVA_HOME 根本没配,但 java 命令照样能用。找到 JDK 路径是找到 cacerts 的前置条件,给三种路子:
方法一:顺着 which + readlink 一路追
# 先看 java 从哪来
which java
# 输出示例:/usr/bin/java
# 解开软链接链
readlink -f $(which java)
# 输出示例:/usr/lib/jvm/jdk-1.8.0_481-oracle-x64/jre/bin/java一路追到 jre 那一层,cacerts 就在同层的 lib/security/ 下:
/usr/lib/jvm/jdk-1.8.0_481-oracle-x64/jre/lib/security/cacerts方法二:find 全局搜索 cacerts
find / -name "cacerts" -type f 2>/dev/null预期返回两条:
/usr/lib/jvm/jdk-1.8.0_481-oracle-x64/jre/lib/security/cacerts # JDK 自带
/etc/pki/ca-trust/extracted/java/cacerts # CentOS 系统同步的第一个是 RestTemplate 实际用的,第二个是 CentOS 为了方便 Java 应用读取系统证书而自动生成的副本。
方法三:包管理器查
rpm -qa | grep java-1.8
rpm -qa | grep jdk
ls -la /etc/alternatives/java # 如果用了 alternatives 管理四、案例实战:排查 id.vk.com
4.1 查看当前证书链
先看对方现在给了什么证书:
keytool -printcert -sslserver id.vk.com:443返回三张证书:
Certificate #0 — 叶证书
Owner: CN=*.vk.com
Issuer: CN=WR1, O=Google Trust Services, C=US
有效期: 2026-07-14 ~ 2026-10-12
SAN: *.vk.com, vk.ru, vk.cc, api.vk.com, ...
Certificate #1 — 中间 CA
Owner: CN=WR1, O=Google Trust Services, C=US
Issuer: CN=GTS Root R1, O=Google Trust Services LLC, C=US
有效期: 2023-12 ~ 2029-02
Certificate #2 — 根 CA
Owner: CN=GTS Root R1, O=Google Trust Services LLC, C=US
Issuer: CN=GlobalSign Root CA, OU=Root CA, O=GlobalSign nv-sa, C=BE
有效期: 2020-06 ~ 2028-01解析出完整信任链:
📜 *.vk.com (WR1 签发)
└── 🔏 WR1 (GTS Root R1 签发)
└── 🔐 GTS Root R1 (GlobalSign Root CA 交叉签名)
└── 🔒 GlobalSign Root CA (JDK 内置根)4.2 关键发现
- 2023-12-13 — WR1(Google Trust Services 中间 CA)签发
- GTS Root R1(Google Trust Services 的根 CA)在 2020 年 6 月才签发
这意味着在 2023 年底之前,vk.com 用的是另一套旧证书链(可能是 GeoTrust / Symantec 等旧根签发)。切换到 Google Trust Services 新链后,旧证书到期不再提供服务。
4.3 检查 JDK 版本
java -version
# java version "1.8.0_211"
# Java(TM) SE Runtime Environment (build 1.8.0_211-b12)8u211,2019 年 1 月发布。它的 cacerts 快照里还没有 GTS Root R1(2020 年才签发)。
4.4 检查 JDK 信任库
keytool -list -keystore /usr/lib/jvm/jdk-1.8.0_211-oracle-x64/jre/lib/security/cacerts \
-storepass changeit 2>/dev/null | grep -i "gts\|google"
# ❌ 空结果,说明 JDK 8u211 不认识这套链
keytool -list -keystore /usr/lib/jvm/jdk-1.8.0_211-oracle-x64/jre/lib/security/cacerts \
-storepass changeit 2>/dev/null | grep -i globalsign
# ✅ 输出 globalsignrootca,说明 GlobalSign 根在信任库中4.5 交叉签名为什么没救?
理论上,Java 的 PKIX 路径构建器应该能走通这条交叉签名链:
GTS Root R1 → (issuer: GlobalSign Root CA) → GlobalSign Root CA (在 cacerts 中) → 信任 ✅但 JDK 8u211 上的实际问题:
- GTS Root R1 不在 cacerts 中,路径构建器不认识它
- 它同时具备根 CA 和中间 CA 的双重属性(自签名 + 被 GlobalSign 交叉签名),旧版 PKIX 实现在处理这种跨 CA 交叉签名场景时容易出现路径构建失败
4.6 真正的验证:SSLTest.java
keytool -printcert 只是打印证书信息,不做信任验证。要真正验证 JDK 是否信任,必须让 Java 做一次 SSL 握手。
import javax.net.ssl.*;
import java.security.cert.X509Certificate;
public class SSLTest {
public static void main(String[] args) throws Exception {
if (args.length == 0) {
System.out.println("Usage: java SSLTest <host1:port> ...");
return;
}
for (String target : args) {
String host = target.contains(":") ? target.split(":")[0] : target;
int port = target.contains(":") ? Integer.parseInt(target.split(":")[1]) : 443;
testSSL(host, port);
}
}
static void testSSL(String host, int port) {
try {
SSLSocketFactory factory = (SSLSocketFactory) SSLSocketFactory.getDefault();
try (SSLSocket socket = (SSLSocket) factory.createSocket(host, port)) {
socket.setSoTimeout(5000);
socket.startHandshake();
X509Certificate cert = (X509Certificate) socket.getSession().getPeerCertificates()[0];
String cn = cert.getSubjectX500Principal().getName();
System.out.println("OK " + host + ":" + port + " subject=" + cn);
}
} catch (Exception e) {
System.out.println("FAIL " + host + ":" + port + " " + e.getMessage());
}
}
}编译运行:
javac SSLTest.java
java SSLTest id.vk.com:443SSLSocketFactory.getDefault() 底层读的就是 JDK 的 cacerts,和 RestTemplate 的 SSL 路径完全一致。OK 代表 RestTemplate 也能通,FAIL 则说明不信任。
在 JDK 8u211 上跑这个——❌ FAIL,正好复现了线上的 SSL 异常。
4.7 时间线总结
2023 年底之前
└─ vk.com 用旧证书链(旧根签发)
└─ JDK 8u211 → ✅ 正常
2023-12-13(WR1 签发)
└─ vk.com 切换到 Google Trust Services 新链
└─ 旧证书到期,不再提供服务
切换后
└─ JDK 8u211 → ❌ SSLHandshakeException
└─ 新链走 GTS Root R1,8u211 不认识
└─ 交叉签名到 GlobalSign Root CA 的路径构建失败五、JDK 8u211 之后:证书库的重大变更
JDK 8u211(2019 年 1 月)是一个分水岭版本。从它开始,Oracle JDK 切换到新的发布节奏,证书库管理也发生了质变。
5.1 两个关键变化
变化一:从"删除"到"禁用"
8u211 之前的 JDK 更新,过期的根证书会被直接删除。如果某个老应用依赖一个已过期的根证书,JDK 升级后它就断了。
8u211 之后,过期的根证书不再删除,而是被标记为 disabled。它们仍然存在于 cacerts 中,但 SSL 握手时不会使用。这样既保证了安全,又避免"突然消失"导致的老应用兼容性问题。
# 8u201 及之前:物理删除
keytool -list -> 没有这个证书了
# 8u211 及之后:标记禁用
keytool -list -> 能看到,但带 [disabled] 标记变化二:定期更新机制正式化
从 8u211 开始,Oracle 在每次 CPU(Critical Patch Update)中都会同步更新 cacerts,包括:
- 添加新进入市场的公共根 CA
- 禁用即将过期的根证书
- 更新证书指纹和元数据
5.2 各版本证书变更一览
| JDK 版本 | 发布日期 | 关键证书变更 |
|---|---|---|
| 8u201 | 2019-01 | 旧模式——物理删除过期根 |
| 8u211 🔑 | 2019-01 | 启用 disabled 机制,不删除只标记,发布节奏转为季度 CPU |
| 8u231 | 2019-10 | 新增多个 CA 证书,调整有效期 |
| 8u251 | 2020-04 | 更新 GlobalSign、DigiCert 等根证书 |
| 8u261 | 2020-07 | 补充 Entrust、SSL.com 等 |
| 8u271 | 2020-10 | 更新 Sectigo(原 Comodo)根 |
| 8u281 | 2021-01 | 更新 Amazon Root CA 等 |
| 8u291 | 2021-04 | 新增更多 CA 证书 |
| 8u301 🔑 | 2021-07 | 引入 ISRG Root X1(Let's Encrypt 新根)——解决 DST Root CA X3 过期后的兼容问题 |
| 8u311 | 2021-10 | 证书微调 |
| 8u321 | 2022-01 | 更新 Google Trust Services 根 |
| 8u331 | 2022-04 | 新增更多根证书 |
| ... | ... | ... |
| 8u481 🎯 | 2024-10 | 最新版本,证书覆盖最全 |
5.3 实操:查看 JDK 中证书的 disabled 状态
# 列出所有禁用状态的证书
keytool -list -v -keystore /usr/lib/jvm/jdk-1.8.0_481-oracle-x64/jre/lib/security/cacerts \
-storepass changeit 2>/dev/null | grep -B2 "disabled"
# 查看特定证书详情
keytool -list -v -alias globalsignrootca \
-keystore /usr/lib/jvm/jdk-1.8.0_481-oracle-x64/jre/lib/security/cacerts \
-storepass changeit 2>/dev/null六、修复方案
方案一:升级 JDK(推荐 ✅)
| JDK 版本 | 能否通 | 原因 |
|---|---|---|
| 8u211(2019-01) | ❌ | 无 GTS Root R1,路径构建失败 |
| 8u301(2021-07) | ✅ | 引入 ISRG Root X1,更新 Google Trust Services 支持 |
| 8u321(2022-01) | ✅ | 新增 Google Trust Services 根 |
| 8u481(2024-10) | ✅ | 证书覆盖最全 |
最优解:升级到 8u301+,一步解决所有证书问题。
方案二:手动导入证书
# 下载 GTS Root R1 证书
curl -O https://pki.goog/repo/certs/gtsr1.pem
# 导入到 JDK 的 cacerts
keytool -import -trustcacerts -alias gts-root-r1 \
-file gtsr1.pem \
-keystore /usr/lib/jvm/jdk-1.8.0_211-oracle-x64/jre/lib/security/cacerts \
-storepass changeit确认导入成功:
keytool -list -keystore /usr/lib/jvm/jdk-1.8.0_211-oracle-x64/jre/lib/security/cacerts \
-storepass changeit 2>/dev/null | grep -i gts
# ✅ 输出 gts-root-r1方案三:启动时指定信任库
java -Djavax.net.ssl.trustStore=/etc/pki/java/cacerts -jar app.jar前提是 /etc/pki/java/cacerts 中已包含 GTS Root R1。
七、升级实战:从 JDK 8u211 到 8u481
如果你的生产线还在 8u211,想升级到 8u481(或至少 8u301)来解决证书问题,以下是完整的注意事项和验证步骤。
7.1 兼容性检查:7 年跨度
| 关注点 | 说明 | 风险 |
|---|---|---|
| JRE 行为变更 | 某些过时的加密算法可能被禁用(如 3DES、RC4) | 🔴 |
| TLS 版本默认值 | 8u211 可能默认 TLSv1.0,8u481 已默认禁用 TLSv1.0/1.1 | 🟡 |
| 证书禁用 | 8u481 中部分旧根证书被标记 disabled | 🟡 |
| 时间戳 / OCSP 行为 | 证书验证策略收紧 | 🟢 |
| API 删除 | JDK 8 生命周期内未删除公共 API | 🟢 |
7.2 升级步骤
第一步:下载并解压
# 下载 JDK 8u481(Oracle 官网或内部仓库)
# 解压到目标目录
tar -xzf jdk-8u481-linux-x64.tar.gz -C /usr/lib/jvm/
# 或通过 rpm 安装(如果已注册 Oracle 源)
# rpm -ivh jdk-8u481-linux-x64.rpm第二步:切换默认 JDK
# 方案 A:直接修改 alternatives
alternatives --install /usr/bin/java java /usr/lib/jvm/jdk-1.8.0_481-oracle-x64/jre/bin/java 2
alternatives --config java # 选择 8u481
# 方案 B:修改 /etc/profile 或应用启动脚本
export JAVA_HOME=/usr/lib/jvm/jdk-1.8.0_481-oracle-x64
export PATH=$JAVA_HOME/bin:$PATH第三步:验证切换
java -version
# 预期输出:
# java version "1.8.0_481"
# Java(TM) SE Runtime Environment (build 1.8.0_481-bxx)
# Java HotSpot(TM) 64-Bit Server VM (build 25.481-bxx, mixed mode)
javac -version
# javac 1.8.0_481第四步:验证 cacerts
# 确认 GTS Root R1 已存在
keytool -list -keystore /usr/lib/jvm/jdk-1.8.0_481-oracle-x64/jre/lib/security/cacerts \
-storepass changeit 2>/dev/null | grep -i "gts\|google"
# ✅ 应输出类似:gtsrootr1, Jul 14 2025, trustedCertEntry
# 确认 GlobalSign Root CA 未被禁用
keytool -list -v -alias globalsignrootca \
-keystore /usr/lib/jvm/jdk-1.8.0_481-oracle-x64/jre/lib/security/cacerts \
-storepass changeit 2>/dev/null | grep -i "disabled"
# ✅ 不应输出,或如果有输出需确认是否影响业务第五步:SSL 握手验证(关键 ✅)
# 用 SSLTest.java 验证目标域名
javac SSLTest.java
# 验证之前不通的域名
java SSLTest id.vk.com:443
# ✅ 预期输出:OK id.vk.com:443 subject=CN=*.vk.com
# 顺便验证其他关键业务域名
java SSLTest api.weixin.qq.com:443 api.alipay.com:443第六步:应用验证
# 在预发环境先用新 JDK 启动应用
java -jar yourapp.jar &
# 验证业务接口
curl -I http://localhost:8080/health
# 触发 RestTemplate 调用 id.vk.com 的接口
curl http://localhost:8080/api/vk/test
# 查看日志,确认无 SSL 相关异常
tail -f logs/spring.log | grep -i "ssl\|certificate\|handshake"7.3 回滚方案
# 备份当前 JDK 路径和配置
cp /etc/profile /etc/profile.bak.$(date +%Y%m%d)
echo $JAVA_HOME > /tmp/old_java_home.txt
# 回滚时
export JAVA_HOME=$(cat /tmp/old_java_home.txt)
# 或切回 alternatives 旧版本
alternatives --config java7.4 生产建议
| 阶段 | 动作 | 耗时预估 |
|---|---|---|
| 兼容性评估 | 检查加密算法、TLS 版本依赖 | 半天 |
| 预发验证 | 部署新 JDK,跑全量回归 | 1 天 |
| 灰度发布 | 先升级 10% 的实例,观察 1~2 天 | 2 天 |
| 全量升级 | 分批替换剩余实例 | 1 天 |
| 观察期 | 持续监控 SSL 相关指标和日志 | 1 周 |
最佳实践:先在预发环境验证,再灰度 10% 的生产流量,确认稳定后再全量升级。
八、证书过期后的续期机制
8.1 对方证书过期(叶证书到期)
服务端在证书到期前重新申请签发,部署新证书。客户端无需任何操作,只要根 CA 还在信任库中,新证书自动握手成功。
8.2 中间 CA 过期 / 被替换
CA 机构会更换中间证书。如果新链的根 CA 没变,客户端也无需操作。但如果更换了根 CA(例如从老根迁移到新根),就看 JDK 的 cacerts 里有没有新根——这就是本次案例的根因。
8.3 根 CA 过期——这才是隐患
有些根证书本身也有过期时间。例如:
| 根证书 | 过期时间 | 状态 |
|---|---|---|
GlobalSign Root CA | 2028-01 | 仍在有效期 |
DST Root CA X3(Let's Encrypt 老根) | 2021-09 | 已过期 |
如果一个老旧 JDK 没有 ISRG Root X1(Let's Encrypt 新根),而 DST Root CA X3 又已过期——所有 Let's Encrypt 签发的 https 域名都会握手失败。
解决方案:
- 升级 JDK(最推荐)
- 手动导入新根证书:
curl -O https://letsencrypt.org/certs/isrgrootx1.pem
keytool -import -trustcacerts -alias isrg-root-x1 \
-file isrgrootx1.pem \
-keystore /usr/lib/jvm/jdk-1.8.0_xxx/jre/lib/security/cacerts \
-storepass changeit- 同步系统证书到 JDK:
keytool -importkeystore \
-srckeystore /etc/pki/java/cacerts \
-destkeystore "$JAVA_HOME/jre/lib/security/cacerts" \
-srcstorepass changeit -deststorepass changeit8.4 如何监控证书是否即将过期?
# 查看对方证书有效期
keytool -printcert -sslserver id.vk.com:443 | grep -A2 "Valid from"
# 检查 JDK 中特定根证书的过期时间
keytool -list -v -keystore /usr/lib/jvm/jdk-1.8.0_481-oracle-x64/jre/lib/security/cacerts \
-storepass changeit 2>/dev/null | grep -A5 -i "globalsign"九、三类常见 SSL 故障速查
| 现象 | 根因 | 解决方案 |
|---|---|---|
PKIX path building failed: unable to find valid certification path | JDK cacerts 没有对应的根证书 | 升级 JDK 或导入根证书到 cacerts |
Certificate expired | 服务端证书到期,或 JDK 中的根证书到期 | 升级 JDK 或手动导入新根 |
No subject alternative names matching | 域名和证书 SAN 不匹配 | 确认请求域名正确,或换正确域名 |
十、排查方法论
发现问题
→ SSLHandshakeException on id.vk.com
对比验证
→ 其他 https 域名(如 baidu.com)正常,排除 JDK 整体问题
查看证书链
→ keytool -printcert -sslserver id.vk.com:443
→ 发现新链:GTS Root R1 ← WR1 ← *.vk.com
确认 JDK 版本
→ java -version → 8u211(2019-01)
查 cacerts
→ 确认无 GTS Root R1
确认根因
→ vk.com 2023 年底切换证书链 → JDK 8u211 不支持新链
选择修复
→ 升级 JDK 到 8u301+(推荐 ✅)
→ 或手动导入 GTS Root R1 到 cacerts十一、小结
| 问题 | 答案 |
|---|---|
| RestTemplate HTTPS 用谁的证书? | JDK 的 cacerts,不是 CentOS 系统 |
| 服务器没有 $JAVA_HOME 怎么办? | which java → readlink -f → 找到 jre/lib/security/cacerts |
| 为什么 JDK 8u211 突然连不上 id.vk.com? | vk.com 切换到 Google Trust Services 新链,8u211 不认识 GTS Root R1 |
| 对方证书过期了我需要操作吗? | 叶证书/中间 CA 过期不需要;根 CA 过期需要更新 JDK cacerts |
| JDK 8u211 之后证书管理有什么变化? | 过期根改为 disabled 而非删除,每季度 CPU 同步更新 cacerts |
| 升级 JDK 需要注意什么? | 检查加密算法、TLS 版本、证书禁用状态,先预发验证再灰度 |
| 怎么验证某个域名到底能不能通? | 编译运行 SSLTest.java,看 SSL 握手结果 |
| keytool -printcert 能代替验证吗? | 不能,它只打印证书信息,不检查 JDK 是否信任 |