AIGC标识 Spring Boot 2 调用第三方 HTTPS 的证书问题:复现、排查、修复与持续监控

本文由 AI 协助整理,基于实际测试记录撰写。

Spring Boot 2 调用第三方 HTTPS 的证书问题:复现、排查、修复与持续监控

实测时间:2026-11-03(北京时间)。测试目标:https://id.vk.com/。服务端证书、跳转地址及网络环境会变化,以下结果反映本次现场,不代表之后所有请求的结果。

一、问题发现与影响

Spring Boot 2 应用使用 RestTemplate 调用第三方 HTTPS 接口时,可能出现以下异常:

javax.net.ssl.SSLHandshakeException:
  sun.security.validator.ValidatorException:
    PKIX path building failed:
      sun.security.provider.certpath.SunCertPathBuilderException:
        unable to find valid certification path to requested target

该错误表示 Java 在验证对端证书时,未能构建到受信任证书的有效路径。异常可能由信任库缺少合适的信任锚、服务端未提供完整中间证书、连接实际经过了替换证书的代理等多种情况引起;不能仅凭异常文本断定唯一根因。

这类问题可以在应用代码没有变化时出现:服务端证书或 CA、DNS 解析结果、代理或出口环境、Java 和信任库版本都可能发生变化。本次现场观察到的服务端证书链与历史记录不同,应以出问题时实际访问到的链为准。

二、本次实测结论

对照 Java 与信任库 访问 https://id.vk.com/ 的结果
老容器 Oracle 8u211 + 默认旧信任库 失败,出现 SSLHandshakeException / PKIX path building failed
老容器 Oracle 8u211 + 临时指定的新版本信任库副本 成功,证书与主机名验证通过,收到 HTTP 302
新容器 Oracle 8u481 + 默认新信任库 成功,证书与主机名验证通过,收到 HTTP 302

这组对照说明:对于本次实际收到的证书链,信任库差异足以解释验证结果。 本次测试是 JDK HttpsURLConnection 的默认 TLS 配置,尚不能直接替代使用业务实际 RestTemplate、自定义客户端和生产网络路径的最终验收。

2.1 镜像与容器

项目 老环境 新环境
镜像 registry.example.com/team/java8-old:v1 registry.example.com/team/java8-new:v1
本次容器 vk-cert-old vk-cert-new
本次 Java 安装后为 Oracle 8u211 Oracle 8u481
状态 测试后保留运行 测试后保留运行

原始老镜像没有 JVM,本次按现场构建步骤在 vk-cert-old 容器中安装了 Java 和 jq;原始镜像并未被修改。yum install java 本次从已启用的内部源解析到 8u211,结果取决于启用的仓库及其内容,不能保证之后或在其他环境执行时版本相同。

本文中的镜像仓库地址和镜像名称均为脱敏后的示例,不是可直接拉取的实际地址。实证图片由本次命令的真实标准输出、标准错误和退出码脱敏后生成,是证据页面截图,并非原生终端截图;异常截图仅展示日志前段。

两个测试容器的状态

查看脱敏日志:容器状态

三、原理:RestTemplate 使用哪里的证书?

HTTPS 服务端发送证书链,客户端通过信任库中的受信任证书验证该链,并检查访问域名是否与证书匹配。

RestTemplate 使用底层 HTTP 客户端发起请求。在使用 JVM 默认 TLS 配置的情况下,Java 信任管理器通常按以下顺序查找信任库:

  1. 系统属性 javax.net.ssl.trustStore 指定的文件;
  2. ${java.home}/lib/security/jssecacerts,如果存在;
  3. ${java.home}/lib/security/cacerts。

java.home 是应用进程实际运行的 Java 路径,不一定与环境变量 JAVA_HOME 相同。如果显式指定的信任库路径或类型错误,可能导致信任验证失败。完整规则参见 Oracle Java 8 JSSE 参考文档。

本次检查到的默认信任库:

老:/usr/java/jdk1.8.0_211-amd64/jre/lib/security/cacerts
新:/usr/lib/jvm/jdk-1.8.0_481-oracle-x64/jre/lib/security/cacerts

Spring Boot 2.7 的 RestTemplateBuilder 可以依据 classpath 选择 Apache HttpClient、OkHttp 或 JDK 客户端;项目还可能自定义 SSLContext、TrustManager 或信任库。因此应核实业务实际实例而不是只看 Java 版本。Spring Boot 2.7 官方说明

操作系统的 CA 证书库、curl 使用的信任库和 JVM 的 cacerts 不一定相同。curl 的结果可以辅助判断网络与系统 CA 问题,但不能单独证明 Java 验证结果。客户端在双向 TLS 中使用的自身证书和私钥通常由 keyStore 管理;验证服务端的 trustStore 是另一项配置。

四、问题复现

4.1 确认两个容器

在 Windows PowerShell 执行:

docker ps --filter "name=vk-cert-"
docker exec vk-cert-old java -version
docker exec vk-cert-new java -version

进入容器检查:

docker exec -it vk-cert-old bash
docker exec -it vk-cert-new bash

输入 exit 退出终端,容器继续运行。截图和下列容器内测试文件是本次环境的现场材料,容器删除或挂载目录清理后可能不可用。

4.2 老容器安装 Java 的实际步骤

在老容器内执行了:

rm -f /etc/yum.repos.d/MooseFS.repo /etc/yum.repos.d/*Base* \
  && curl -fsSL \
       -o /etc/yum.repos.d/CentOS-Base.repo \
       http://mirrors.cloud.tencent.com/repo/centos7_base.repo \
  && yum clean all \
  && yum install -y java jq

腾讯源地址当前由 HTTP 跳转到 HTTPS,故使用 -L 跟随跳转并使用 -f 在下载失败时返回错误。以上命令会删除容器内匹配的 yum 源文件并安装软件,只适合经授权的隔离测试环境。生产环境应通过版本化的镜像构建流程管理。

安装后实际为 1.8.0_211;复现时要另外确认 java -version、包来源及版本,而不能只依赖 yum install java。

准备容器内的测试程序

先将 4.6 节源码保存到本机当前目录的 CheckVkHttps.java,在 Windows PowerShell 中使用本机 JDK 8 编译,再将 class 放进两个已有测试容器(不要求容器内具备编译器):

javac -version
javac .\CheckVkHttps.java
if ($LASTEXITCODE -ne 0) { throw "Java 编译失败,请检查 JDK 版本" }

docker exec vk-cert-old mkdir -p /tmp/vk-cert-check
docker exec vk-cert-new mkdir -p /tmp/vk-cert-check
docker cp .\CheckVkHttps.class vk-cert-old:/tmp/vk-cert-check/
docker cp .\CheckVkHttps.class vk-cert-new:/tmp/vk-cert-check/

确认 javac -version 是 1.8,或使用支持 --release 8 的新版编译器生成 Java 8 兼容字节码。镜像地址已脱敏为示例,复现前须使用自己有权访问的实际镜像,确认两个容器分别运行相应 Java 版本。

4.3 老 Java + 默认信任库

docker exec vk-cert-old java -cp /tmp/vk-cert-check CheckVkHttps https://id.vk.com/
echo "退出码: $LASTEXITCODE"

本次实测退出码为 1,关键输出:

java.version=1.8.0_211
javax.net.ssl.SSLHandshakeException: ... PKIX path building failed

老 Java 默认信任库验证失败

查看脱敏日志:完整异常堆栈与退出码

4.4 新 Java + 默认信任库

docker exec vk-cert-new java -cp /diag CheckVkHttps https://id.vk.com/
echo "退出码: $LASTEXITCODE"

截图来自本次现场命令:当时测试程序通过只读挂载提供在新容器的 /diag。如果按上文 docker cp 准备了全新容器,改用 /tmp/vk-cert-check:

docker exec vk-cert-new java -cp /tmp/vk-cert-check CheckVkHttps https://id.vk.com/
echo "退出码: $LASTEXITCODE"

本次实测退出码为 0,关键输出:

java.version=1.8.0_481
TLS certificate and hostname verification: OK
HTTP status=302

收到 HTTP 302 表示对该次请求目标的 TLS 握手和主机名验证已通过;302 是服务端跳转,业务使用的后续跳转域名仍应逐一验证。

新 Java 默认信任库验证通过

查看脱敏日志:新 Java 验证结果

4.5 老 Java + 新信任库副本的控制实验

本次在老容器中放置了新 Java 信任库的独立副本,没有覆盖旧 Java 的默认 cacerts。下面演示如何将新容器信任库复制到宿主机临时目录,再复制到老容器的测试目录:

# 当前案例的实际 JDK 路径,仅用于独立对照实验。
$comparisonStore = Join-Path $env:TEMP 'vk-comparison-cacerts'
docker cp vk-cert-new:/usr/lib/jvm/jdk-1.8.0_481-oracle-x64/jre/lib/security/cacerts $comparisonStore
docker cp $comparisonStore vk-cert-old:/tmp/vk-cert-check/new-jdk-cacerts

路径会随镜像、JDK 厂商和版本变化,须先检查新容器运行时 java.home 和实际信任库配置;如果新容器使用了自定义 trustStore 或 jssecacerts,上面的 cacerts 不一定是其实际使用的文件。只复制到独立实验路径,不要覆盖两个容器的默认信任库。随后在老容器内为当前测试进程临时指定该副本:

docker exec vk-cert-old java `
  '-Djavax.net.ssl.trustStore=/tmp/vk-cert-check/new-jdk-cacerts' `
  -cp /tmp/vk-cert-check `
  CheckVkHttps https://id.vk.com/
echo "退出码: $LASTEXITCODE"

实测 Java 版本仍是 8u211,但证书和主机名验证成功,退出码为 0。

老 Java 临时指定新信任库后验证通过

查看脱敏日志:信任库对照实验

这是诊断实验,不等同于生产环境可以未经审核复制、覆盖或长期固定整个 cacerts。容器中的测试文件和对照信任库都是临时资产,容器删除或相关文件清理后不保证继续存在;该对照库未作为文档附件保存。

4.6 可独立运行的测试程序

将下列代码保存为 CheckVkHttps.java。程序使用 JDK HttpsURLConnection 发起请求,保留默认的证书和主机名校验,不自动跟随跳转。它用于隔离 JVM 默认 TLS 行为,不能代替业务实际 RestTemplate 的验证。

import java.net.URL;
import javax.net.ssl.HttpsURLConnection;

public class CheckVkHttps {
  public static void main(String[] args) throws Exception {
    String target = args.length == 0 ? "https://id.vk.com/" : args[0];
    System.out.println("java.version=" + System.getProperty("java.version"));
    System.out.println("java.vendor=" + System.getProperty("java.vendor"));
    System.out.println("java.home=" + System.getProperty("java.home"));
    System.out.println("target=" + target);
    HttpsURLConnection connection = (HttpsURLConnection) new URL(target).openConnection();
    connection.setConnectTimeout(15000);
    connection.setReadTimeout(15000);
    connection.setInstanceFollowRedirects(false);
    try {
      int status = connection.getResponseCode();
      System.out.println("TLS certificate and hostname verification: OK");
      System.out.println("HTTP status=" + status);
      String location = connection.getHeaderField("Location");
      if (location != null) System.out.println("Location=" + location);
    } finally {
      connection.disconnect();
    }
  }
}

在有 JDK 8 的环境中执行:

javac CheckVkHttps.java
java CheckVkHttps https://id.vk.com/

如果用更高版本 JDK 编译后复制到 Java 8 环境运行,需要确保生成的字节码兼容 Java 8。实验中两个容器的测试文件和临时信任库都是现场准备,容器删除或挂载目录清理后不保证仍然存在。

五、定位流程

5.1 收集应用和网络信息

记录应用镜像 digest、运行实例、JDK 供应商和版本、实际 java.home、目标域名、异常完整 cause、故障发生时间,以及 DNS、代理和出网区域。必要时从多个实际出口分别测试,因为服务端节点和返回证书链可能不同。

应用内部可以记录以下非密码信息:

log.info("java.version={}", System.getProperty("java.version"));
log.info("java.vendor={}", System.getProperty("java.vendor"));
log.info("java.home={}", System.getProperty("java.home"));
log.info("trustStore={}", System.getProperty("javax.net.ssl.trustStore"));
log.info("requestFactory={}", restTemplate.getRequestFactory().getClass().getName());

trustStore=null 仅表示没有通过该系统属性显式指定路径,不代表没有默认信任库。请求工厂可能是拦截器包装类,需继续追踪底层客户端。不要记录信任库密码、代理密码或业务凭据。

检查项目中的 RestTemplateBuilder、RestTemplateCustomizer、ClientHttpRequestFactory、HttpClient、SSLContext、TrustManager 及启动参数。若设置了自定义信任库,应确认其实际位置、类型、更新机制和覆盖范围。

5.2 查看服务端提供的证书信息

keytool -printcert -sslserver id.vk.com:443

检查证书的主体、签发者、有效期及服务端提供的证书链。keytool -printcert 用于查看证书,不能代替应用的信任链和主机名验证。Oracle keytool 文档

本次服务端返回的链包含 SSL.com 证书。老库中未发现名称包含 SSL.com 的信任证书;新库包含与当前链中根证书匹配的条目。实际控制实验也表明,改用新库后老 JVM 可完成当前请求。证书链可能随时间和网络出口变化,不能将此次链条当作固定事实。

5.3 临时打开 JVM 证书调试

docker exec vk-cert-old java `
  '-Djavax.net.debug=ssl,handshake,trustmanager' `
  -cp /tmp/vk-cert-check `
  CheckVkHttps https://id.vk.com/

在输出中检查 trustStore is:、握手过程和失败原因。生产排查应在受控环境或受控时间开启并保护日志;该日志量可能较大,完成后关闭。

5.4 区分故障类型

现象 应检查的方向
UnknownHostException DNS、域名配置
连接超时或拒绝 网络、代理、端口、目标可用性
PKIX path building failed 实际信任库、证书链、代理签发证书
证书过期 服务端证书有效期、系统时间
主机名不匹配 请求域名、证书 SAN、SNI、代理
TLS 协商失败 协议、加密套件、JDK 安全策略
HTTP 401、403、5xx 在 HTTP 层区分认证、授权和服务端故障

RestTemplate 默认将 4xx 和 5xx 视为 HTTP 错误并可能抛出异常;这与 TLS 握手失败应分别统计。Spring 默认响应错误处理器

5.5 使用业务 RestTemplate 验证

在预发环境使用业务实际的 RestTemplate、相同的信任库、代理和出网路径,调用第三方允许且无副作用的地址:

try {
    ResponseEntity<Void> response = restTemplate.exchange(
            "https://id.vk.com/", HttpMethod.GET, HttpEntity.EMPTY, Void.class);
    log.info("HTTP status={}", response.getStatusCodeValue());
} catch (HttpStatusCodeException ex) {
    log.warn("HTTP status={}", ex.getRawStatusCode());
} catch (ResourceAccessException ex) {
    log.error("HTTPS request failed", ex);
}

示例只用于定位;异常可能是 DNS、超时或 TLS,应检查完整 cause。设置连接和读取超时,确保不会输出凭据,并分别验证业务实际经过的跳转域名。

六、修复与上线

6.1 优先升级维护中的基础镜像和 JDK

本次新镜像 Java 8u481 对当前目标及实际测试过的跳转目标通过验证。建议构建新的应用镜像,在预发验证后灰度发布:

锁定候选基础镜像及 digest → 构建应用镜像 → 功能与 HTTPS 回归
→ 预发验证 → 小流量灰度 → 观察错误率 → 逐步全量

上线前核对实际 Java 版本、运行时信任库、内部 CA、关键第三方 HTTPS、TLS 协商、业务功能和性能。如果应用显式指定了旧 trustStore,升级 JDK 也可能继续使用旧库,必须核实运行时配置。

6.2 自定义信任库的适用情况

内部 CA、专线服务或暂时无法升级的环境,可评估受控的独立信任库。应从可信来源获取 CA、通过独立渠道核对指纹,记录来源、用途、责任人和更新时间,并先完成所有依赖该库的接口回归。

-Djavax.net.ssl.trustStore=... 通常会让默认信任管理器使用指定信任库,不会自动与 JDK 默认库合并。只导入内部 CA 可能使公共 HTTPS 失败;还需维护公共 CA 更新。不要未经审核将本次对照实验中的信任库副本直接当作长期生产方案。

如果实际原因是服务端证书过期、域名不匹配或中间证书未正确配置,应推动服务端修复。不要使用信任所有证书、关闭主机名校验或 curl -k 作为生产修复。

6.3 真实 RestTemplate 验收

测试程序只是 JVM 默认 TLS 的控制实验。生产前应使用业务实际 RestTemplate、信任库、代理、DNS 和出口,调用第三方允许且没有副作用的地址,分别记录 TLS、HTTP 和业务结果;验证必要的跳转目标,并设置连接与读取超时。

若收到 HTTP 4xx/5xx,RestTemplate 可能抛 HTTP 状态异常,应与 ResourceAccessException 下的握手、DNS 或超时等异常区分。不得打印 token 或其他敏感数据。

七、后续监控

7.1 实际 HTTPS 合成探测

从与生产一致或具有代表性的出口,使用相同镜像/JDK、HTTP 客户端、SSLContext、信任库和代理,对第三方允许的无副作用地址发起 HTTPS 请求。建议初始频率每 5 分钟一次,连续 2~3 次 TLS 失败告警;根据 SLA 和第三方限流调整。

分别记录 DNS、网络连接、TLS、证书链、主机名、HTTP 状态和业务校验结果。完整 URL、异常全文、token 不应作为高基数指标标签。对多区域、多出口或多业务关键域名分别覆盖。

7.2 证书有效期与证书变化

每天检查服务端发送的叶证书及证书链有效期;可按剩余 30、14、7 天分级提醒,并将 CA 或证书指纹变化作为待核查通知。证书未过期仍可能因链缺失、不受信任、域名不匹配或 TLS 协商失败而不可用,因此到期监控不能替代实际握手探测。

7.3 业务异常与监控自身

按目标服务统计调用总量、失败率、SSLHandshakeException、证书路径验证失败、超时和响应时间。对探测器宕机或长时间无数据单独告警,避免无数据被误判为健康。告警按业务、环境、目标和出口去重。

外部服务不可用一般不宜直接作为应用 liveness 失败的唯一条件,否则可能触发持续重启并扩大故障。

八、镜像与 JDK 升级检测

8.1 版本台账与定期检查

记录应用镜像和基础镜像 digest、JDK 版本与供应商、默认或自定义 trustStore、内部 CA、关键目标域名、最近验证时间和负责人。

生产固定镜像 digest 能提升可复现性,但固定后不会自动得到安全更新。建议每周检查基础镜像新版本或 digest,每月评估 JDK、基础镜像和信任库更新;紧急安全更新或证书变化走加急流程。频率按组织安全要求调整。Docker 构建实践

8.2 CI/CD 和灰度检查

发现更新 → 记录新旧 digest/JDK/信任库 → 构建候选镜像
→ 应用和业务测试 → 关键域名的实际 HTTPS 验证
→ 内部 CA 和跳转目标验证 → 预发 → 灰度 → 全量

对网络依赖测试应区分证书验证失败、CI 环境没有出网、第三方暂时不可用和业务认证失败;不能把环境不通直接判定为证书不兼容。必要时以可控测试 CA、过期证书和错误域名覆盖失败路径,再用实际生产代表性出口做线上目标验证。

8.3 回滚

保留旧应用镜像 digest、配置与配套信任库,设定灰度观察和回滚负责人。回滚前确认旧版本仍能完成关键连接:本次旧 Java 默认信任库会重新出现该问题,因此直接回滚到旧环境可能恢复故障。

九、实施清单

优先级 工作 验收标准
P0 预发使用真实 RestTemplate 测试新镜像 证书、主机名和关键业务请求通过
P0 检查真实 SSLContext 与信任库 确认无意外沿用旧自定义库
P0 发布与回滚方案 新旧镜像 digest、配置和负责人明确
P1 关键外部 HTTPS 探测 能区分 TLS、网络与业务失败并告警
P1 证书到期与链变化监控 有阈值、通知人和响应流程
P1 镜像升级回归 覆盖关键域名、内部 CA 和必要跳转
P2 资产台账与定期升级 版本、来源、负责人和验证记录可追溯

参考资料


首发于 keep simple。

posted @ 2026-11-03 17:38  amyaxy  阅读(3)  评论(0)    收藏  举报