一次 HTTPS 证书报错排查:为什么会出现 `unable to get local issuer certificate`
Posted on 三 22 4月 2026 in Journal
| Abstract | 一次 HTTPS 证书报错排查:为什么会出现 unable to get local issuer certificate |
|---|---|
| Authors | Walter Fan |
| Category | Journal |
| Status | v1.0 |
| Updated | 2026-04-22 |
| License | CC-BY-NC-ND 4.0 |
一次 HTTPS 证书报错排查:为什么会出现 unable to get local issuer certificate
短大纲
- 先把这句报错翻成人话
- 借
tls-cert-debugger这个公开示例,看看链子到底断在哪儿 - 分清服务端的锅,还是客户端的锅
- 给一套够用的排查顺序和修法
- 最后收一份明天就能照着做的 CheckList
一、最让人火大的 SSL 报错,往往不是最难的
有些错误,一看就知道该往哪儿使劲。
比如 "连接超时",多半先查网络。比如 "401 Unauthorized",多半先看 token。可 certificate verify failed: unable to get local issuer certificate 这类报错,就很会摆架子。它字很多,语气也很严肃,第一眼看过去,好像整条 HTTPS 栈都坏了。
其实很多时候,事情没那么玄。
一句话说,这类错误大多不是 "Python 不会做 HTTPS",也不是 "requests 坏了",而是:
客户端在校验证书时,没能拼出一条从服务器证书一直通到受信任根证书的完整链路。
我写了一个例子 tls-cert-debugger,里面把这个问题演得很标准:证书链明明是 root CA -> intermediate CA -> server cert,客户端也信任根证书,但服务端偏偏只发了叶子证书。结果呢?链子就在中间断了。
这事很像认亲。
你认识爷爷,不代表见到孙子就能立刻确认是一家人。中间那个 "爹" 要是没到场,场面就会有点尴尬。
我就碰到过这类问题, 一开始有点晕, 静下心来分析, 其实就是 TLS 证书验证链路的问题。
二、先把错误脱敏,再看真正的信号
把敏感域名、内部路径和产品名拿掉以后,这类错误大概长这样:
HTTPSConnectionPool(host='internal.example.com', port=443): Max retries exceeded with url: /api/v1/secret
(Caused by SSLError(SSLCertVerificationError(
1,
'[SSL: CERTIFICATE_VERIFY_FAILED] certificate verify failed:
unable to get local issuer certificate (_ssl.c:998)'
)))
这里真正有用的,不是最外面那层 Max retries exceeded。那只是 urllib3 / requests 告诉你 "我重试过了,还是不行"。
真正要盯住的是里面这句:
SSLCertVerificationError: unable to get local issuer certificate
它已经把方向指得很清楚了:证书验证失败,而且失败点在 issuer certificate,也就是签发者证书这一层。
换成人话就是:
- 客户端拿到了服务端证书;
- 它想继续往上找 "这张证书是谁签的";
- 结果没找到,或者找得不完整;
- 所以它不敢信。
这和下面几类错误不是一回事:
hostname mismatch:主机名对不上certificate has expired:证书过期self signed certificate:自签名证书不被信任
它更像是:链路里缺了一节。
三、拿公开示例开刀,这个错误是怎么被 "故意造" 出来的
我看了那个公开示例 tls-cert-debugger 里的复现实验,觉得这个小实验做得挺老实。它没堆太多花活,就是把生产里常见的坑缩小成一个你本机就能跑起来的玩具模型。
它干了三件事:
1. 生成一条三段式证书链
证书关系是:
root CA -> intermediate CA -> server cert
也就是说,真正给服务器证书签名的,不是 root CA,而是 intermediate CA。
2. 启动一个 HTTPS 服务,但默认只挂叶子证书
服务端默认加载的是 server.pem,也就是叶子证书本体,不带中间证书。
这正是问题的根。
3. 客户端信任 root CA,但访问时仍然失败
客户端代码里,requests.get(..., verify=root_ca.pem) 这一点也没有偷懒。可它还是报了:
unable to get local issuer certificate
这说明一个很重要、也很容易被忽略的事实:
"客户端信任 root CA" 这件事,本身并不保证链路一定能验通。
如果服务端没有把 intermediate certificate 一起发出来,客户端只知道:
- 我看见了 leaf cert;
- 我本地信 root CA;
- 可 leaf cert 的直接签发者是 intermediate;
- intermediate 这张证书我手里没有;
- 那我就没法从 leaf 一步步走到 root。
于是握手失败。
四、证书链到底断在哪儿
这一段值得讲透。很多人一提 TLS,就觉得那是玄学。其实它比不少业务逻辑还老实,你给它什么,它就验什么,不会陪你打马虎眼。
4.1 一个正常的验证过程
客户端拿到服务端证书后,会尝试做这样一件事:
- 检查 leaf certificate 是谁签的。
- 找到对应的 intermediate certificate。
- 再检查 intermediate 是谁签的。
- 一路往上追到本地信任库中的 root CA。
只要这条链完整,而且每一节都对得上,验证就通过。
4.2 报这个错时,通常发生了什么
最常见的情况是:
- 服务端只发了 leaf cert;
- 客户端信任库里只有 root CA;
- 中间证书没有随握手发下来;
- OpenSSL / Python / requests 无法自动脑补;
- 验证失败。
这个公开示例里的复现实验,基本就是在演这个过程。
4.3 一张图看明白
@startuml
title `unable to get local issuer certificate` 的典型断点
skinparam backgroundColor white
skinparam shadowing false
skinparam roundcorner 12
skinparam defaultTextAlignment center
actor "Python Client" as Client
rectangle "HTTPS Server" as Server
database "Local Trust Store" as Store
Client -> Server : TLS handshake
Server --> Client : leaf certificate only
Client -> Store : 查找 issuer 链
Store --> Client : 只有 root CA
Client -> Client : 缺少 intermediate CA\n无法拼出完整 trust chain
Client --> Server : certificate verify failed
@enduml

一句话收束这一段:
客户端不是不信任根证书,而是没法从叶子证书走到根证书。
五、这类错误最常见的根因,其实就那么几种
别被错误字符串吓住。真到工程里,常见原因没那么多,来来回回就那几样。
1. 服务端没有发送完整证书链
这是最常见、也最容易被误判成 "客户端问题" 的一种。
比如 Web Server、Ingress、Gateway、Load Balancer 上配置的是:
server.pem
而不是:
fullchain.pem
结果就是服务端只把 leaf cert 发给客户端,中间证书没带上。
这也是 tls-cert-debugger 里故意 "挖坑" 的方式。
2. 客户端 CA bundle 配错了
你以为自己传的是 "公司 CA 包",实际上可能是:
- 路径写错了;
- 文件格式不对;
- 只放了一个不够用的证书;
- 运行环境和你本地 shell 用的 CA 包根本不是同一份。
这种情况也会表现成 issuer 找不到。
3. 运行环境的信任库和你想的不一样
很多人本机命令行能通,部署到容器里就不通,心态一下子就有点崩。
原因通常不是容器讨厌你,而是:
- 本机装了企业根证书;
- 容器镜像里没装;
requests用的是certifi默认 CA;- 你的内部 CA 根本不在那份 trust store 里。
这就会出现一个很经典的场面:
浏览器能开,Python 不行。
本机能跑,容器不行。
看着邪门,其实一点也不邪门。
4. 证书链里本来就缺文件
有时候不是服务端忘了配 full chain,而是证书发放流程本身就只给了 leaf cert,或者中间证书没跟着归档。
这种问题不查证书文件,很容易永远陷在 "是不是 Python 版本太新" 这种歪路上。
六、怎么判断锅在服务端,还是在客户端
我通常把这事拆成两头查。这样不容易一上来就瞎改代码,更不容易还没看清证书链,就先去翻 SDK 源码。
6.1 先查服务端:它到底发了什么证书
最直接的办法,是用 openssl s_client 看服务端实际发出来的证书链:
openssl s_client \
-connect internal.example.com:443 \
-servername internal.example.com \
-showcerts
你要看的是:
- 输出里是不是只有一张 leaf cert
- 有没有 intermediate cert
Verify return code是多少
如果只看到 leaf,没有 intermediate,这事八成就不是客户端乱来,而是服务端链没配全。
6.2 再查客户端:它到底信了谁
如果你想从 Python 这头再确认一遍,也不必一上来就搬整套业务代码。用 ssl.create_default_context(cafile=...) 连一下目标服务,往往就够了。
一个最小化示例可以写成这样:
import socket
import ssl
host = "internal.example.com"
context = ssl.create_default_context(cafile="/path/to/ca-bundle.pem")
with socket.create_connection((host, 443), timeout=5) as sock:
with context.wrap_socket(sock, server_hostname=host) as tls_sock:
print("verified:", tls_sock.version())
print("peer cert:", tls_sock.getpeercert())
如果这里都失败,而且错误仍然是 unable to get local issuer certificate,那就说明链路问题还在。
6.3 别被 verify=False 骗了
很多人一着急,先来一句:
requests.get(url, verify=False)
代码一跑,居然通了。于是大家长舒一口气,仿佛问题已经解决了一半。
其实没有。
这只能证明一件事:
你把证书验证关掉以后,请求当然能发出去。
这就像门锁坏了,你把门拆了,确实也能进屋。可这不叫修门,这叫撤防。
verify=False 只适合本地临时复现,或者做一次性验证。真进生产,这就是给自己埋雷。
七、解决方案怎么选,取决于你能改哪一侧
方案一:优先修服务端,发送 full chain
这是首选,也是最像样的修法。
在 tls-cert-debugger 的复现实验里,README 给出的修复方式就很直白:把服务端证书从 leaf-only 的 server.pem,换成带中间证书的 server-fullchain.pem。然后客户端就能通过。
服务端代码层面,核心差别往往只是一行:
context.load_cert_chain(
certfile="server-fullchain.pem",
keyfile="server.key",
)
而不是:
context.load_cert_chain(
certfile="server.pem",
keyfile="server.key",
)
如果你用的是 Nginx、Apache、Envoy、Ingress Controller、云上 LB,思路也一样:挂 full chain,不要只挂 leaf cert。
方案二:客户端显式指定正确的 CA bundle
如果短期内你改不了服务端,客户端当然可以先自保。
比如在 requests 里传入你自己的 CA bundle:
import requests
response = requests.get(
"https://internal.example.com/api/v1/secret",
verify="/etc/ssl/custom-ca-bundle.pem",
timeout=5,
)
这里的 custom-ca-bundle.pem 最好是:
- 来源明确
- 内容可审计
- 和部署环境一起发版
不要今天从同事电脑拷一份,明天再从另一个容器里捞一份。证书文件如果靠口口相传,事故多半也会靠口口相传。
方案三:把内部根证书装进运行环境的 trust store
如果你的服务长期要访问公司内网 HTTPS 服务,单靠每个程序自己传 verify=...,维护成本会越来越高。
更稳妥的办法是:
- 在容器镜像里安装企业根证书
- 明确系统 trust store 的更新方式
- 统一 Python 运行时对 CA 的读取策略
这样 "本地能通、线上不通" 的戏码会少很多。
方案四:把证书链检查放进发布流程
这是更进一步的做法。
每次证书变更、网关变更、LB 迁移、域名切换时,都自动跑一遍:
openssl s_client- Python TLS 校验脚本
- 证书到期检查
很多 SSL 事故本来都不复杂,只是没人提前验,等真炸了才想起来补作业。
八、一个更贴近工程现场的排查顺序
如果这类错误出现在真实服务里,我通常按这个顺序查:
第一步:先确认不是别的 SSL 错误
看清楚是不是:
unable to get local issuer certificate
而不是:
hostname mismatchcertificate has expiredself signed certificate
方向错了,越查越远。
第二步:用 openssl s_client -showcerts 看服务端返回链
如果缺 intermediate,就先记账:服务端有嫌疑。
第三步:在出问题的运行环境里检查 CA bundle
尤其是容器、CI、k8s Job 这种环境。别拿你笔记本的结果替容器做证词。
第四步:用最小 Python 脚本复现
不要上来就从整套业务 SDK 开始翻。先用 20 行脚本确认是不是纯 TLS 问题,省得把自己绕进更深的一层。
第五步:优先改服务端 full chain,其次再补客户端 CA
原因很简单:
服务端把链发完整,是对所有客户端都友好的修复。
如果只在某个 Python 客户端里单独绕过去,那更像止疼片,不像根治。
九、完整脚本在示例仓库
这篇文章写到这里,其实已经够把 "问题是什么、锅大概率在哪一侧、修复优先级怎么排" 讲清楚了。
再往下继续塞脚本和命令,文章就变味了——越看越像 README,不像博客。
所以我把完整脚本和可复现实验单独整理成了一个公开示例项目:
tls-cert-debugger: https://github.com/walterfan/security-handbook/tree/master/example/tls-cert-debugger
里面现在有三类内容:
-
inspect_cert_chain.sh
专门看服务端到底发了哪些证书,适合快速确认有没有漏发 intermediate。 -
tls_probe.py
从 Python 运行时视角检查 CA trust store、环境变量覆盖、requests/certifi差异。 -
unable-local-issuer-repro/
一个完整的可运行复现实验,能稳定演示: - 失败场景:服务端只发 leaf cert,客户端即使信任 root CA 也会失败
- 成功场景:服务端改成发送 full chain,同一客户端立刻恢复正常
9.1 正文里我建议你至少记住这 4 组命令
第一组,先看服务端发了什么:
openssl s_client \
-connect internal.example.com:443 \
-servername internal.example.com \
-showcerts
第二组,手工验证链本身能不能成立:
openssl verify \
-CAfile root-ca.pem \
-untrusted intermediate-ca.pem \
server.pem
第三组,用 curl 做一次交叉验证:
curl -vI "https://internal.example.com/api/v1/secret"
第四组,在 Python 里看当前 CA 路径:
python - <<'PY'
import os, ssl
print(ssl.get_default_verify_paths())
for name in ["SSL_CERT_FILE", "SSL_CERT_DIR", "REQUESTS_CA_BUNDLE", "CURL_CA_BUNDLE"]:
print(f"{name}={os.environ.get(name)}")
PY
这四组命令,已经够你把大多数问题先定性了。
9.2 排查时真正该盯住的检查点
命令不在多,在于看点要对。
我自己会盯下面这些:
- 服务端有没有发 intermediate cert
issuer(cert-1)是否能顺着链走到本地信任的 rootsubjectAltName里有没有目标域名- 证书是否过期
- leaf 是否
CA:FALSE,intermediate 是否CA:TRUE - 本机和容器是不是在用同一套 trust store
REQUESTS_CA_BUNDLE、SSL_CERT_FILE有没有偷偷覆盖默认配置
如果这些点你都看了,问题通常就跑不远了。
9.3 什么时候去看完整脚本
如果你只是临时排一个线上问题,正文里这套思路和上面那几条命令,通常已经够用。
如果你想:
- 系统化地看服务端证书链详情
- 从 Python 侧把 CA /
requests/certifi一次性打平 - 给同事演示 "为什么信任 root CA 仍然可能失败"
- 在培训或文档里放一个可运行的 failure vs success 对比实验
那就直接去看上面的 tls-cert-debugger 示例。
那些内容放在仓库里,比塞在正文里更合适,也更容易维护。
十、这类问题里,最不该做的三件事
1. 把 verify=False 当正式修复
这不是修复,这是撤防。
2. 在日志里把内部域名、敏感路径、证书内容全打出来
排障归排障,脱敏还是要做。日志是拿来帮你定位问题的,不是拿来把内部信息顺手送出去的。
3. 一听到 SSL 就开始怀疑 Python 版本
有时候版本确实会影响行为,可这类报错,十次里有八次还是证书链或 CA store 的事。别一上来就拿运行时当替罪羊。
总结
如果只记一句话,我希望是这一句:
unable to get local issuer certificate 往往不是 "客户端不信任根证书",而是 "客户端没拿到或没找到把 leaf 证书接到根证书所需的那一段中间证书"。
这也是为什么你明明配了 root CA,程序还是报错。根是认识的,可中间那位 "介绍人" 没来,链子还是接不上。
对这类问题,我更推荐一个朴素的判断顺序:
- 先看服务端发没发 full chain,用
openssl s_client -showcerts看返回证书里有没有 intermediate;如果只有 leaf cert,没有中间证书,那锅多半先在服务端。 - 再看客户端 trust store 里到底装了什么,在出问题的环境里打印
ssl.get_default_verify_paths(),顺手核对REQUESTS_CA_BUNDLE这类变量;别你本机信了,容器里其实根本没装那份 CA。 - 最后才考虑运行时、库版本、平台差异。前两步都看过了,再去怀疑 Python、
requests、certifi或镜像差异,才不容易跑偏。 别倒过来。倒过来查,容易把一件本来两小时能解决的事,拖成两天。
总结思维导图
@startmindmap
* Python HTTPS 证书报错排查
** 错误表象
*** CERTIFICATE_VERIFY_FAILED
*** unable to get local issuer certificate
*** Max retries exceeded 只是外层噪音
** 真正含义
*** 证书链不完整
*** issuer 证书没找到
*** 客户端无法从 leaf 走到 root
** 常见根因
*** 服务端没发 intermediate
*** 客户端 CA bundle 配错
*** 容器与本机 trust store 不一致
*** 证书归档本身不完整
** 排查步骤
*** openssl s_client -showcerts
*** Python 最小脚本复现
*** 核对 CA bundle
*** 区分 hostname/expiry/self-signed
** 修复策略
*** 服务端发送 full chain
*** 客户端指定正确 CA bundle
*** 运行环境统一 trust store
*** 把证书检查纳入发布流程
** 不该做
*** verify=False 当正式修复
*** 日志暴露敏感信息
*** 一上来怀疑 Python 版本
@endmindmap

给明天就能做的 5 条建议
- 碰到这类错误,先抓住
unable to get local issuer certificate这一层,别被Max retries exceeded带偏。 - 立刻用
openssl s_client -showcerts看目标服务到底发了几张证书。 - 在出问题的运行环境里打印 CA bundle 路径和内容来源,别拿本机结果替线上环境发言。
- 如果你能改服务端,优先上 full chain,这比在单个客户端里打补丁更稳。
verify=False可以拿来做一次性验证,但不要把它混进正式代码。
扩展阅读
- 完整脚本与复现实验:
tls-cert-debugger - Python 官方文档:
sslmodule - Requests 官方文档:
SSL Cert Verification - urllib3 官方文档:
Custom TLS Certificates - OpenSSL 文档:
openssl s_client
本作品采用知识共享署名-非商业性使用-禁止演绎 4.0 国际许可协议进行许可。