证书明明没过期,安卓和 curl 却报 chain verify failed:一次中间证书丢失复盘
一次 TLS 故障复盘:证书未到期、浏览器正常,但部分安卓客户端和命令行工具报证书链验证失败。根因是服务器只发了叶子证书、漏了中间证书。记录排查过程与预防方法。
本文目录

故障发生在周五下午,症状很典型:监控告警说某个域名的 TLS 握手失败率上升,但你用浏览器打开,一切正常,证书有效期到三个月之后。值班同事第一反应是"误报吧",毕竟浏览器都好好的。
半小时后客服转来用户反馈:某旧型号安卓手机上的 App 连不上,后台日志里也有 curl 报 unable to get local issuer certificate。浏览器好、部分客户端坏——这是证书链问题的经典症状。
根因:服务器只发了一张证书
TLS 握手时,服务器应该把证书链一并发给客户端:叶子证书加上必要的中间证书(根证书一般不发,客户端信任库里有)。这次自动化续期脚本替换证书时,Nginx 的 ssl_certificate 指向了只含叶子证书的文件,而不是带中间证书的 fullchain。
大多数桌面浏览器为什么没事?它们会做 AIA Fetching:证书里带了签发者的下载地址,浏览器自己去把中间证书拉下来补全链条,还会缓存。而很多安卓系统组件、curl、以及不少编程语言默认的 TLS 栈不追这个地址,链条缺一环就直接报错。

所以"浏览器能开"从来不是 TLS 配置正确的证据,它只能证明浏览器很能忍。
排查路径
先复现,别猜。用 OpenSSL 直接看服务器到底发了什么:
openssl s_client -connect example.com:443 -servername example.com输出里关注 Certificate chain 一段:如果只列了编号 0 的一张证书(叶子),没有后续编号的中间证书,且下面有 Verify return code: 21 (unable to verify the first certificate),基本就坐实了。

也可以拿一个不做 AIA Fetching 的客户端测,比如干净的容器里跑 curl——容器镜像不带缓存,最能还原"第一次见你"的客户端视角。
修复很直接:确认签发方提供的 fullchain 文件,把 ssl_certificate 指过去,重载 Nginx,再用上面的命令复测,确认链条完整、verify return code 为 0。
预防上真正有用的几件事
第一,监控要模拟"弱客户端"。只探活或者只查证书有效期不够,探测端应该用不做 AIA Fetching 的 TLS 栈做完整链验证,最好再从多个网络位置探。
第二,续期流程统一产出 fullchain,并把"续期后必须重载服务"写进流程里。手工替换证书文件是这类故障的高发姿势——人总会记得叶子,忘记中间。

第三,变更后加一道链验证的回归检查。哪怕只是一个 cron 里跑的 openssl s_client 加 grep,也能把这类问题从"用户先发现"变成"自己先发现"。
在 CertbotX 中实践
用 ACME 自动签发时,CertbotX 走 DNS-01 验证(支持 CNAME 委托,不用把主域 DNS 权限交出去),签发产物包含完整证书链,续期在到期前自动完成,不需要人工拷文件。签好后可以自动部署到 Agent、EdgeOne 或 CDN,部署动作会留下记录可审计——排查时能看到"哪次部署换了哪份证书",比翻 shell 历史靠谱得多。