技术文章

技术实践

Java HTTPS 调用报 PKIX path building failed:一次证书信任链问题排查记录

一次证书信任链问题排查记录

发布于 2026-09-20 0 次阅读 技术实践

Java HTTPS 调用报 PKIX path building failed:一次证书信任链问题排查记录

一、问题现象

最近在 Java 服务调用 HTTPS 接口时突然出现 SSL 证书验证失败。该接口此前一直可以正常调用,近期没有修改相关 Java 调用代码。

Java 异常如下:

Caused by: sun.security.validator.ValidatorException:
PKIX path building failed:
sun.security.provider.certpath.SunCertPathBuilderException:
unable to find valid certification path to requested target

运行环境为 JDK 8。

与此同时,通过 OpenSSL 检查目标服务:

openssl s_client \
  -connect efppmi-test.cet.com:8443 \
  -servername efppmi-test.cet.com \
  -showcerts </dev/null

出现:

Verify return code: 21 (unable to verify the first certificate)

但是使用 curl 显式指定 CA 证书:

curl \
  --cacert /usr/local/share/ca-certificates/ssl.cer \
  https://efppmi-test.cet.com:8443/

却可以正常访问。

因此问题表现为:

Java 默认访问              ❌ PKIX 失败
OpenSSL 默认验证           ❌ Verify return code 21
curl --cacert ssl.cer      ✅ 正常

二、首先理解 PKIX 报错意味着什么

Java 调用 HTTPS 服务时,需要验证服务端证书是否可信。

正常情况下会形成类似这样的信任链:

服务端证书
    ↓
Intermediate CA
    ↓
Root CA
    ↓
Java TrustStore

如果 Java 无法从服务端证书找到最终可信的 Trust Anchor,就可能出现:

PKIX path building failed
unable to find valid certification path to requested target

因此最开始重点怀疑:

  1. 服务端没有提供完整证书链;
  2. Java 没有信任对应 CA;
  3. Java 使用了错误的 TrustStore;
  4. JDK 的 cacerts 中没有正确导入证书;
  5. 服务器近期更换证书导致原来的信任关系失效。

三、为什么 curl 可以,而 Java 不可以

这是排查过程中非常重要的一点。

使用:

curl \
  --cacert /usr/local/share/ca-certificates/ssl.cer \
  https://efppmi-test.cet.com:8443/

相当于明确告诉 curl:

请使用 ssl.cer 作为 CA 信任依据

而 Java 默认不会直接读取:

/usr/local/share/ca-certificates/ssl.cer

Java 通常使用自己的 TrustStore,例如 JDK 8:

$JAVA_HOME/jre/lib/security/cacerts

因此:

curl --cacert ssl.cer
        ↓
显式使用 ssl.cer
        ↓
验证成功

并不能直接证明 Java 的 cacerts 中已经正确包含该证书。


四、确认 Java 是否真的可以使用这张证书

为了排除证书文件本身的问题,可以创建一个临时 TrustStore。

1. 创建测试 TrustStore

$JAVA_HOME/bin/keytool \
  -importcert \
  -trustcacerts \
  -alias test-ca \
  -file /usr/local/share/ca-certificates/ssl.cer \
  -keystore /tmp/test-truststore.jks \
  -storepass changeit \
  -noprompt

查看是否导入成功:

$JAVA_HOME/bin/keytool \
  -list \
  -keystore /tmp/test-truststore.jks \
  -storepass changeit

2. 强制 Java 使用测试 TrustStore

使用 JDK 8 自带的 jrunscript 做一个最小化 HTTPS 测试:

$JAVA_HOME/bin/jrunscript \
  -J-Djavax.net.ssl.trustStore=/tmp/test-truststore.jks \
  -J-Djavax.net.ssl.trustStorePassword=changeit \
  -e '
var url = new java.net.URL("https://efppmi-test.cet.com:8443/");
var conn = url.openConnection();
conn.connect();
print("HTTPS 连接成功,响应码: " + conn.getResponseCode());
'

测试结果:

HTTPS 连接成功

到这里可以得到一个非常重要的结论:

ssl.cer 本身可以被 Java 使用,并且能够完成目标 HTTPS 服务的证书验证。

因此问题不再是“Java 不支持这张证书”,而应该继续调查:

为什么默认 TrustStore 没有正确使用这张证书?

五、确认 Java 实际使用哪个 TrustStore

不能仅根据 $JAVA_HOME 猜测 Java 使用哪个 TrustStore。

开启 Java SSL 调试:

$JAVA_HOME/bin/jrunscript \
  -J-Djavax.net.debug=ssl,trustmanager \
  -e '
var url = new java.net.URL("https://efppmi-test.cet.com:8443/");
var conn = url.openConnection();
conn.connect();
print(conn.getResponseCode());
' 2>&1 | tee /tmp/ssl-debug.log

过滤 TrustStore 相关日志:

grep -iE "trustStore|cacerts|jssecacerts|trusted cert" \
  /tmp/ssl-debug.log

实际得到:

Inaccessible trust store:
/usr/local/openjdk-8/jre/lib/security/jssecacerts

trustStore is:
/usr/local/openjdk-8/jre/lib/security/cacerts

trustStore type is: jks

Reloaded 130 trust certs

其中:

Inaccessible trust store: .../jssecacerts

并不是本次问题。

Java 会优先寻找:

$JAVA_HOME/jre/lib/security/jssecacerts

如果不存在,则使用:

$JAVA_HOME/jre/lib/security/cacerts

日志已经明确证明 Java 最终加载的是:

/usr/local/openjdk-8/jre/lib/security/cacerts

因此可以排除“Java 使用了另一个 cacerts”的猜测。


六、检查 cacerts 中的证书

Dockerfile 中原本已经执行过:

RUN keytool -import \
    -trustcacerts \
    -file /usr/local/share/ca-certificates/ssl.cer \
    -alias your_certificate_alias \
    -keystore $JAVA_HOME/jre/lib/security/cacerts \
    -storepass changeit \
    -noprompt

理论上证书已经进入 Java TrustStore。

通过 alias 查看:

$JAVA_HOME/bin/keytool \
  -list \
  -v \
  -keystore "$JAVA_HOME/jre/lib/security/cacerts" \
  -storepass changeit \
  -alias your_certificate_alias

确实能够找到对应证书。

这时候问题就比较奇怪了:

Java 加载的是正确 cacerts
          ↓
cacerts 中也存在指定 alias
          ↓
但 HTTPS 仍然 PKIX 失败
          ↓
使用临时 test.jks 却成功

下一步的关键就是:

不要只比较 alias,要比较证书本身。


七、通过 SHA256 指纹找到真正原因

首先查看当前证书文件的指纹:

$JAVA_HOME/bin/keytool \
  -printcert \
  -file /usr/local/share/ca-certificates/ssl.cer |
grep -E "SHA1:|SHA256:"

然后查看 Java cacerts 中对应 alias 的证书:

$JAVA_HOME/bin/keytool \
  -list -v \
  -keystore "$JAVA_HOME/jre/lib/security/cacerts" \
  -storepass changeit \
  -alias your_certificate_alias |
grep -E "SHA1:|SHA256:"

最后检查刚才可以正常工作的测试 TrustStore:

$JAVA_HOME/bin/keytool \
  -list -v \
  -keystore /tmp/test-truststore.jks \
  -storepass changeit \
  -alias test-ca |
grep -E "SHA1:|SHA256:"

正常情况下应该满足:

ssl.cer
   =
cacerts 中的证书
   =
test-truststore.jks 中的证书

但是实际检查发现:

ssl.cer SHA256
        =
test-truststore.jks SHA256

但是

cacerts 中 your_certificate_alias SHA256
        ≠
ssl.cer SHA256

至此问题基本定位。


八、最终根因:生产和测试证书使用了相同 alias

继续检查 Dockerfile 后发现,同时安装了生产环境和测试环境的证书,但是两张证书使用了相同 alias:

your_certificate_alias

例如:

# 生产证书
RUN keytool -importcert \
    -trustcacerts \
    -file /cert/prod.cer \
    -alias your_certificate_alias \
    -keystore "$JAVA_HOME/jre/lib/security/cacerts" \
    -storepass changeit \
    -noprompt

# 测试证书
RUN keytool -importcert \
    -trustcacerts \
    -file /cert/test.cer \
    -alias your_certificate_alias \
    -keystore "$JAVA_HOME/jre/lib/security/cacerts" \
    -storepass changeit \
    -noprompt

问题就在这里。

Java KeyStore 中:

alias = 一个证书条目的唯一标识

同一个 KeyStore 中不能用同一个 alias 正常保存两张不同的可信证书。

因此实际结果可能变成:

cacerts
    │
    └── your_certificate_alias
              ↓
          生产环境证书

测试环境证书并没有按照预期成为一个独立的可信条目。

Java 调用测试环境:

测试环境服务端证书
        ↓
寻找可信证书
        ↓
cacerts 中没有正确的测试证书
        ↓
PKIX path building failed

这也解释了为什么把测试证书单独放入:

/tmp/test-truststore.jks

之后 Java 马上可以正常访问。


九、解决方法

生产环境和测试环境证书分别使用不同 alias。

例如:

RUN keytool -importcert \
    -trustcacerts \
    -file /usr/local/share/ca-certificates/prod.cer \
    -alias cet-prod-ca \
    -keystore "$JAVA_HOME/jre/lib/security/cacerts" \
    -storepass changeit \
    -noprompt

RUN keytool -importcert \
    -trustcacerts \
    -file /usr/local/share/ca-certificates/test.cer \
    -alias cet-test-ca \
    -keystore "$JAVA_HOME/jre/lib/security/cacerts" \
    -storepass changeit \
    -noprompt

最终:

cacerts
├── JDK 默认可信 CA
│
├── cet-prod-ca
│     └── 生产环境证书
│
└── cet-test-ca
      └── 测试环境证书

可以通过:

keytool -list \
  -keystore "$JAVA_HOME/jre/lib/security/cacerts" \
  -storepass changeit |
grep -E "cet-prod-ca|cet-test-ca"

确认两张证书均存在。

再分别查看指纹:

keytool -list -v \
  -keystore "$JAVA_HOME/jre/lib/security/cacerts" \
  -storepass changeit \
  -alias cet-prod-ca |
grep -E "SHA1:|SHA256:"
keytool -list -v \
  -keystore "$JAVA_HOME/jre/lib/security/cacerts" \
  -storepass changeit \
  -alias cet-test-ca |
grep -E "SHA1:|SHA256:"

确保与对应证书文件一致。


十、如果需要更新同一个 alias

keytool 没有一个简单的 --overwrite 参数用于直接覆盖已有 alias。

如果确实需要更新同一个 alias,可以先删除:

keytool -delete \
  -alias cet-test-ca \
  -keystore "$JAVA_HOME/jre/lib/security/cacerts" \
  -storepass changeit

再重新导入:

keytool -importcert \
  -trustcacerts \
  -file /usr/local/share/ca-certificates/test.cer \
  -alias cet-test-ca \
  -keystore "$JAVA_HOME/jre/lib/security/cacerts" \
  -storepass changeit \
  -noprompt

Dockerfile 中也可以写成:

RUN keytool -delete \
      -alias cet-test-ca \
      -keystore "$JAVA_HOME/jre/lib/security/cacerts" \
      -storepass changeit 2>/dev/null || true \
    && keytool -importcert \
      -trustcacerts \
      -file /usr/local/share/ca-certificates/test.cer \
      -alias cet-test-ca \
      -keystore "$JAVA_HOME/jre/lib/security/cacerts" \
      -storepass changeit \
      -noprompt

这样可以避免旧 alias 中残留旧证书。


十一、这次排查中最有价值的几个方法

这次问题看起来只是一个 alias 配置错误,但排查过程中有几个方法非常适合以后处理 Java HTTPS 问题。

1. 不要看到 PKIX 就直接导证书

先确认问题属于哪一层:

服务端证书链
      ↓
操作系统 CA
      ↓
Java TrustStore
      ↓
应用自定义 SSLContext

否则很容易反复导入证书却没有解决真正的问题。

2. curl 成功不代表 Java 一定成功

特别是:

curl --cacert xxx.cer

实际上已经人为给 curl 指定了信任来源。

而 Java 默认使用:

cacerts / jssecacerts / javax.net.ssl.trustStore

两者不能直接等价比较。

3. 用临时 TrustStore 做隔离测试非常有效

这次真正把问题范围缩小的是:

默认 cacerts     → 失败
临时 test.jks    → 成功

它直接证明:

证书本身没有问题
Java HTTPS 没有问题
服务端基本可验证

问题集中在默认 TrustStore

比不断修改业务代码有效得多。

4. 不要只看 alias,要比较证书指纹

这是本次最终定位问题的关键。

alias 相同 ≠ 证书相同

最可靠的是比较:

SHA256 Fingerprint

通过指纹最终发现:

证书文件
        ≠
cacerts 中同 alias 的证书

从而继续定位到 Dockerfile 中生产、测试证书 alias 冲突。

5. 不建议通过关闭 SSL 校验解决 PKIX

例如在 Java 中实现一个“信任所有证书”的 TrustManager,虽然可能让接口立即恢复,但本质上绕过了 HTTPS 身份验证。

正确方向应该是:

确认服务端证书链
        ↓
确认可信 CA
        ↓
确认 Java TrustStore
        ↓
确认实际加载的证书

十二、总结

本次问题最终不是 Java HTTPS 调用代码导致的,也不是证书文件本身无法使用,而是:

Docker 镜像同时向 JDK cacerts 导入生产和测试证书时使用了相同 alias,导致 Java TrustStore 中实际保存的证书与当前测试环境需要的证书不一致。

整个排查链路可以总结为:

Java HTTPS 调用
    ↓
PKIX path building failed
    ↓
OpenSSL 验证也存在证书链异常
    ↓
curl --cacert 可以正常访问
    ↓
创建独立 test-truststore.jks
    ↓
Java 强制使用 test.jks 后成功
    ↓
证明证书本身可以正常使用
    ↓
javax.net.debug 检查默认 TrustStore
    ↓
确认 Java 使用正确的 cacerts
    ↓
检查 cacerts 中 alias
    ↓
alias 存在
    ↓
比较 SHA256 指纹
    ↓
发现 cacerts 中证书 ≠ 当前 ssl.cer
    ↓
检查 Dockerfile
    ↓
发现生产/测试证书使用相同 alias
    ↓
分别使用独立 alias
    ↓
问题解决

这次问题也说明,在排查 Java PKIX path building failed 时,最重要的并不是反复执行 keytool -import,而是回答三个问题:

Java 实际使用哪个 TrustStore?

这个 TrustStore 中实际存放的是哪张证书?

这张证书是否真的能够建立目标服务的完整信任链?

把这三个问题确认清楚,大多数 Java HTTPS 证书问题都能快速缩小范围。

← 返回首页