适用场景
本文适用于网站、API 网关或反向代理已经配置 HTTPS,但出现“浏览器能访问,部分 Java、Android、容器或命令行客户端却握手失败”的场景。典型报错包括 unable to get local issuer certificate、PKIX path building failed、certificate verify failed,或者客户端只返回笼统的 TLS handshake error。
这类故障往往不是服务器证书过期,也不是私钥错误,而是服务端只发送了站点证书,没有发送签发它所需的中间证书。桌面浏览器可能从缓存或系统机制补齐证书链,因此问题容易被误判为客户端兼容性故障。
现象描述
一次常见故障具有以下特征:
- 主流桌面浏览器访问正常;
- 新容器、精简 Linux 镜像、Java 服务或移动端偶发失败;
- 服务器证书的域名、有效期和公钥都正确;
- 客户端更新 CA 根证书后仍失败;
- 同一域名切换到不同负载均衡节点时,成功率不一致。
如果只有一部分请求失败,还要考虑多节点配置漂移:某些节点使用完整证书链,另一些节点只部署了叶子证书。
证书链为什么会缺失
TLS 证书链通常包含三类证书:
- 叶子证书:签发给具体域名,由服务端必须发送;
- 中间 CA 证书:连接叶子证书与受信任根 CA,通常也应由服务端发送;
- 根 CA 证书:通常已预置在客户端信任库中,服务端一般不需要发送。
客户端会尝试从叶子证书逐级验证到本地信任的根证书。服务端漏发中间证书时,拥有缓存的客户端可能仍能完成验证,而全新环境无法建立完整路径。不要把根证书盲目拼进服务端链文件;它只会增加握手报文,不能替代客户端信任。
第一步:直接查看服务端实际发送的证书
不要只检查服务器磁盘上的证书文件。客户端验证的是 TLS 端点实际发送的内容,应先从外部执行:
openssl s_client \
-connect api.example.com:443 \
-servername api.example.com \
-showcerts </dev/null
关键参数:
-connect指定真实连接地址和端口;-servername发送 SNI,避免多域名站点返回默认证书;-showcerts展示服务端发送的全部证书,而不是客户端自行补齐后的链;</dev/null让命令在握手完成后退出,便于脚本化采集。
重点观察 Certificate chain 段。正常情况下,第 0 个证书是站点证书,后续至少包含必要的中间 CA。若只看到一个证书,且输出末尾出现下面的验证错误,基本可以确认链不完整:
Verify return code: 20 (unable to get local issuer certificate)
Verify return code: 0 (ok) 代表当前测试机能够验证成功,但不一定证明服务端发送完整。测试机本地可能已缓存或安装了中间证书,所以仍要结合 -showcerts 的证书数量和颁发者关系判断。
第二步:核对每一级证书的主体与颁发者
把服务端返回的证书分别保存为 leaf.pem、intermediate.pem 后,查看身份信息:
openssl x509 -in leaf.pem -noout \
-subject -issuer -serial -dates -ext subjectAltName
openssl x509 -in intermediate.pem -noout \
-subject -issuer -serial -dates
检查以下关系:
- 叶子证书的
issuer应与中间证书的subject对应; - 域名必须出现在叶子证书的
subjectAltName中; - 每一级证书都应处于有效期内;
- 不要仅凭文件名判断证书角色,文件可能在续期或复制时被覆盖。
还可以用 CA 提供的中间证书做离线验证:
openssl verify \
-CAfile root-ca.pem \
-untrusted intermediate.pem \
leaf.pem
成功时输出 leaf.pem: OK。这里 -CAfile 表示信任锚,-untrusted 提供构建路径所需但不直接信任的中间证书。生产环境不必保存私有的根证书;此命令只用于说明和验证链路关系,应使用证书机构公开提供且经过校验的证书文件。
第三步:排除域名、协议和多节点干扰
证书链问题容易与其他 TLS 问题混淆,至少要排除以下情况。
SNI 返回了错误证书
如果遗漏 -servername,共享 IP 的服务器可能返回默认证书。始终同时执行带 SNI 的测试,并确认 SAN 中包含目标域名。
IPv4 与 IPv6 指向不同配置
curl -4Iv https://api.example.com/
curl -6Iv https://api.example.com/
若只有一个地址族失败,分别检查 A、AAAA 记录对应的入口和证书配置。-I 只请求响应头,-v 会打印 TLS 诊断信息;输出可能包含内部地址和请求头,采集日志时应先脱敏。
负载均衡节点配置不一致
已知后端 IP 时,可绕过 DNS 逐个验证,同时保留正确的域名和 SNI:
curl -Iv \
--resolve api.example.com:443:192.0.2.10 \
https://api.example.com/
对每个入口 IP 重复执行并记录证书序列号、颁发者和验证结果。--resolve 只在本次请求中覆盖解析,不会修改系统 hosts。示例地址 192.0.2.10 属于文档保留网段,使用时替换为真实入口地址。
客户端信任库确实过旧
如果服务端发送的链完整,而只有陈旧系统失败,再检查客户端 CA 包、Java truststore 和 TLS 版本。不要通过关闭校验、设置全局“不验证证书”来恢复业务;这会把可见故障变成中间人攻击风险。
Nginx 修复方案
Nginx 的 ssl_certificate 应指向按正确顺序拼接的完整链文件:叶子证书在前,中间证书随后。多数证书机构或 ACME 客户端会生成可直接使用的 fullchain.pem。
server {
listen 443 ssl;
server_name api.example.com;
ssl_certificate /etc/nginx/tls/api.example.com/fullchain.pem;
ssl_certificate_key /etc/nginx/tls/api.example.com/privkey.pem;
ssl_protocols TLSv1.2 TLSv1.3;
}
不要把 cert.pem 与 fullchain.pem 混用。ssl_certificate_key 必须继续指向与叶子证书匹配的私钥,不能把私钥拼进证书链文件。
变更前先验证配置:
sudo nginx -t
只有语法和文件加载检查成功后才平滑重载:
sudo nginx -s reload
如果由 systemd 管理,也可以使用发行版约定的 reload 命令。不要先停止 Nginx 再启动,否则会把证书修复变成额外的业务中断。
确认证书与私钥匹配
链修复时若误用了其他域名的私钥,Nginx 配置检查通常会失败。也可以在发布前比较公钥摘要:
openssl x509 -in leaf.pem -pubkey -noout \
| openssl pkey -pubin -outform DER \
| openssl sha256
openssl pkey -in privkey.pem -pubout -outform DER \
| openssl sha256
两条命令的 SHA-256 值必须相同。该方法适用于 RSA 和椭圆曲线密钥,比只比较 modulus 更通用。私钥文件应限制读取权限,命令输出中不得打印私钥内容。
发布后的验证
修复后重新从服务外部验证,避免仅在 Nginx 主机本地测试:
openssl s_client \
-connect api.example.com:443 \
-servername api.example.com \
-verify_return_error </dev/null
预期看到:
Verify return code: 0 (ok)
然后用至少两类客户端验证,例如 OpenSSL/curl 与真实 Java 或移动端调用方。若有多个入口 IP,应逐一测试。还要确认当前证书序列号和有效期,避免请求仍落到未更新节点:
echo | openssl s_client \
-connect api.example.com:443 \
-servername api.example.com 2>/dev/null \
| openssl x509 -noout -serial -issuer -dates
自动化续期中的常见陷阱
证书链问题经常在续期后复发,原因不是签发失败,而是部署流程只复制了叶子证书。
推荐把续期流程拆成以下可验证步骤:
- 生成或获取新的叶子证书、完整链和私钥;
- 校验证书域名、有效期、公钥匹配和链关系;
- 原子替换目标文件或符号链接,避免读取到半写入文件;
- 执行
nginx -t,失败则不重载; - 平滑重载并从外部验证每个入口;
- 记录证书序列号、到期时间、部署版本和节点,便于审计与回滚。
如果由 Kubernetes Ingress、云负载均衡或 CDN 终止 TLS,证书应更新到真正终止握手的组件,而不是只修改后端 Pod。先用网络拓扑确认 TLS 边界,再决定变更位置。
预防措施
- 监控证书到期时间的同时,定期从外部验证证书链;
- 在发布流水线中检查叶子证书与私钥公钥摘要是否一致;
- 对所有负载均衡入口逐个采样证书序列号,发现配置漂移;
- 明确证书文件语义,统一使用
fullchain.pem等稳定命名; - 续期钩子必须先校验配置,再平滑重载,最后进行外部探测;
- 保留上一版本证书链和配置,出现兼容性问题时可快速回滚;
- 禁止在应用代码、curl 参数或 Java 配置中长期关闭证书校验;
- 日志只记录必要的域名、证书序列号和错误类别,不记录私钥、Cookie 或 Authorization。
总结
“浏览器正常、部分客户端失败”是证书链不完整的典型信号。排查时应以 TLS 端点实际发送的证书为准:先用 openssl s_client -showcerts 查看服务端链,再核对叶子证书与中间证书的颁发关系,并逐个验证 IPv4、IPv6 和负载均衡入口。
修复的核心是让 TLS 终止组件发送顺序正确的完整链,同时保持私钥匹配。Nginx 场景应让 ssl_certificate 指向 fullchain.pem,通过配置检查后平滑重载,并从外部确认 Verify return code: 0 (ok)。把链验证、公钥匹配和多节点探测加入续期流水线,才能避免下一次换证时再次出现隐蔽的兼容性故障。
Discussion
评论