适用场景

本文适用于 Nginx、负载均衡器、API 网关或 CDN 回源启用 HTTPS 后,出现“浏览器访问正常,但 Java、Go、容器、Webhook 或旧设备报证书错误”的场景。典型报错包括 unable to get local issuer certificate、certificate verify failed、PKIX path building failed 和 x509: certificate signed by unknown authority。

这里讨论的重点不是把客户端改成跳过校验,而是确认服务端到底发送了哪些证书,区分证书链缺失、SNI 命中错误、客户端信任库过旧和主机名不匹配,并用独立信任库完成修复验收。

为什么会出现“有的客户端正常,有的失败”

TLS 服务端通常发送站点证书和一个或多个中间证书,客户端再用本地信任库中的根证书完成路径验证。根证书本来就应由客户端独立信任,通常不需要由服务端发送;中间证书却不能想当然地省略。

一些浏览器曾经从其他站点缓存过同一张中间证书,或者能借助平台能力补全路径,所以访问看起来正常。全新的容器、精简系统、服务端 SDK 和部分嵌入式设备没有这些缓存,便会直接失败。此时更新客户端信任库可能暂时掩盖现象,却没有修复服务端链路。

RFC 8446 对证书列表给出的顺序是:发送方证书必须位于第一项,后续证书应直接签发前一项。对网站而言,常见顺序就是:

站点叶子证书 -> 中间 CA 证书 -> 更上级中间 CA 证书

不要把私钥拼进证书链文件,也不要把根证书随意塞进链尾。多发无关证书会增加握手体积,还可能让兼容性问题更难定位。

第一步:带上 SNI 观察服务端实际发送内容

先从与故障客户端相近的网络环境执行:

openssl s_client \
  -connect api.example.com:443 \
  -servername api.example.com \
  -showcerts \
  -verify_return_error \
  -verify_hostname api.example.com \
  </dev/null

关键参数不能省略:

  • -servername 发送 SNI。一个 IP 承载多个域名时,不带它可能拿到默认证书,得到完全错误的结论;
  • -showcerts 展示服务端实际发来的证书列表,它不会自动替服务端补链;
  • -verify_return_error 让验证错误真正中止。s_client 是诊断工具,默认可能在打印验证错误后继续握手,不能只看命令是否连通;
  • -verify_hostname 单独校验目标主机名,避免把链完整和域名匹配混为一谈。

成功结果至少应包含:

Verification: OK
Verified peername: api.example.com
Verify return code: 0 (ok)

若看到 Verify return code: 20 (unable to get local issuer certificate),表示当前客户端无法从服务端提供的证书和本地信任锚构建完整路径。它常见于缺少中间证书,但也可能是客户端没有相应根证书,不能只凭错误码直接改配置。

第二步:拆出证书并核对链关系

把 -showcerts 输出中的每段 BEGIN CERTIFICATE 到 END CERTIFICATE 分别保存为 cert-0.pem、cert-1.pem 等文件。然后逐张查看主体、签发者、有效期和 CA 约束:

for cert in cert-*.pem; do
  echo "===== ${cert} ====="
  openssl x509 -in "${cert}" -noout \
    -subject -issuer -serial -dates \
    -ext basicConstraints \
    -ext subjectAltName
done

判断时按以下规则推进:

  1. cert-0.pem 应是 api.example.com 的站点证书,subjectAltName 必须覆盖目标域名;
  2. cert-0.pem 的 issuer 应对应 cert-1.pem 的 subject;
  3. 中间证书的 basicConstraints 应包含 CA:TRUE;
  4. 每张证书都要检查 notBefore 和 notAfter,链中任何一张过期都会失败;
  5. 若只返回一张站点证书,而它不是自签名证书,通常就是服务端没有发送中间证书。

subject 和 issuer 文本相同并不足以完成密码学验证;正式检查要用 openssl verify。假设已从证书颁发机构取得可信根证书 root-ca.pem,服务端应发送的中间证书保存为 intermediate-ca.pem:

openssl verify \
  -purpose sslserver \
  -show_chain \
  -CAfile root-ca.pem \
  -untrusted intermediate-ca.pem \
  cert-0.pem

预期结果是 cert-0.pem: OK,并显示从叶子证书到信任根的路径。-untrusted 表示中间证书只是建链材料,不是信任锚;不要为了让命令通过而把中间证书直接当作 -CAfile 中的根信任。

第三步:排除四类相似故障

1. SNI 或虚拟主机命中错误

对比带和不带 SNI 的结果:

openssl s_client -connect 203.0.113.10:443 \
  -servername api.example.com -brief </dev/null

openssl s_client -connect 203.0.113.10:443 \
  -noservername -brief </dev/null

如果两次拿到不同证书,说明入口存在多个 TLS 虚拟主机。应用必须用域名连接;健康检查若只能访问 IP,应明确配置 SNI 和主机名,而不是关闭证书验证。

2. 证书链完整但主机名不匹配

Verify return code: 0 并不天然证明目标域名正确,因为旧版或不同参数组合的 s_client 可能只验证链。始终保留 -verify_hostname,并检查 SAN,不要只看已经逐步弃用的 Common Name。

3. 客户端信任库过旧

如果服务端发送了完整中间链,现代环境验证成功,只有某一台旧设备失败,应检查其根证书库、系统时间和支持的签名算法。可显式指定该设备实际使用的 CA 文件复现:

openssl s_client \
  -connect api.example.com:443 \
  -servername api.example.com \
  -verify_return_error \
  -verify_hostname api.example.com \
  -CAfile client-ca-bundle.pem \
  </dev/null

只有使用相同信任库,才能判断是服务端链错误还是客户端根信任缺失。

4. 实际入口不止一处

同一域名可能经 DNS 轮询命中多台负载均衡器,IPv4 与 IPv6 也可能指向不同配置。分别验证每个地址,但仍要发送域名 SNI:

for ip in 192.0.2.10 192.0.2.11; do
  echo "===== ${ip} ====="
  openssl s_client \
    -connect "${ip}:443" \
    -servername api.example.com \
    -verify_return_error \
    -verify_hostname api.example.com \
    -brief </dev/null
done

若 IPv6 也对外提供服务,应单独检查 AAAA 记录对应入口。一次成功不能证明整个后端池都已更新。

Nginx 的正确修复方式

Nginx 的 ssl_certificate 文件应先放站点证书,再放中间证书:

cat site-cert.pem intermediate-ca.pem > fullchain.pem

配置示例:

server {
    listen 443 ssl;
    server_name api.example.com;

    ssl_certificate     /etc/nginx/tls/fullchain.pem;
    ssl_certificate_key /etc/nginx/tls/site-key.pem;

    # 用于 OCSP stapling 等受信任 CA 校验,不替代发给客户端的 fullchain。
    ssl_trusted_certificate /etc/nginx/tls/ca-chain.pem;
}

ssl_trusted_certificate 与 ssl_certificate 作用不同:前者中的证书不会自动作为握手证书链发送给客户端。只把中间证书放进 ssl_trusted_certificate,外部客户端仍可能缺链。

上线前先做配置检查,再平滑加载:

nginx -t
nginx -s reload

如果证书由 Kubernetes Secret、云负载均衡器或 CDN 托管,应在真正终止 TLS 的那一层更新完整链。修改后端 Nginx 对“CDN 边缘证书缺链”没有作用;反之,边缘正常也不能证明 CDN 到源站的双向或回源 TLS 正常。

修复后的独立验收

不要在同一个已缓存中间证书的浏览器中刷新后就结束。至少完成以下验证:

openssl s_client \
  -connect api.example.com:443 \
  -servername api.example.com \
  -verify_return_error \
  -verify_hostname api.example.com \
  -CAfile clean-root-bundle.pem \
  -showcerts </dev/null

验收清单包括:

  • Verify return code 为 0,且 Verified peername 是目标域名;
  • 服务端返回的第一张证书是站点证书,后续中间证书顺序正确;
  • 使用全新容器或干净信任库仍能验证,不依赖浏览器缓存;
  • 域名的每个 IPv4、IPv6 和负载均衡后端都返回相同版本的链;
  • Java、Go 或实际故障客户端的最小请求恢复,但代码没有新增“跳过 TLS 校验”;
  • 监控记录证书剩余有效期、握手失败率和各入口证书指纹,避免轮换时漏更节点。

证书轮换应采用“先分发文件、再原子切换引用、配置校验、平滑加载、逐入口复核”的顺序。日志记录 server_name、remote_endpoint、tls_version、verify_code、certificate_serial 和 certificate_not_after 即可,不要记录私钥、会话票据或完整请求头。

总结

“浏览器能打开”不是 TLS 配置正确的证据。排查证书链问题时,先带 SNI 获取服务端真实证书列表,再用 -verify_return_error 和 -verify_hostname 同时验证信任路径与主机名;随后逐张核对签发关系,并用独立根信任和 -untrusted 中间证书复现建链。

服务端修复的核心是让 TLS 终止层发送按“叶子证书到中间证书”排列的完整链。修复后必须覆盖干净信任库、全部地址、IPv4/IPv6 和真实客户端,才能避免证书轮换或节点扩容后再次出现“部分调用方失败”。

参考资料