一次 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 一个正常的验证过程

客户端拿到服务端证书后,会尝试做这样一件事:

  1. 检查 leaf certificate 是谁签的。
  2. 找到对应的 intermediate certificate。
  3. 再检查 intermediate 是谁签的。
  4. 一路往上追到本地信任库中的 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

unable to get local issuer certificate 的典型断点

一句话收束这一段:

客户端不是不信任根证书,而是没法从叶子证书走到根证书。


五、这类错误最常见的根因,其实就那么几种

别被错误字符串吓住。真到工程里,常见原因没那么多,来来回回就那几样。

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 mismatch
  • certificate has expired
  • self signed certificate

方向错了,越查越远。

第二步:用 openssl s_client -showcerts 看服务端返回链

如果缺 intermediate,就先记账:服务端有嫌疑。

第三步:在出问题的运行环境里检查 CA bundle

尤其是容器、CI、k8s Job 这种环境。别拿你笔记本的结果替容器做证词。

第四步:用最小 Python 脚本复现

不要上来就从整套业务 SDK 开始翻。先用 20 行脚本确认是不是纯 TLS 问题,省得把自己绕进更深的一层。

第五步:优先改服务端 full chain,其次再补客户端 CA

原因很简单:

服务端把链发完整,是对所有客户端都友好的修复。

如果只在某个 Python 客户端里单独绕过去,那更像止疼片,不像根治。


九、完整脚本在示例仓库

这篇文章写到这里,其实已经够把 "问题是什么、锅大概率在哪一侧、修复优先级怎么排" 讲清楚了。

再往下继续塞脚本和命令,文章就变味了——越看越像 README,不像博客。

所以我把完整脚本和可复现实验单独整理成了一个公开示例项目:

里面现在有三类内容:

  1. inspect_cert_chain.sh
    专门看服务端到底发了哪些证书,适合快速确认有没有漏发 intermediate。

  2. tls_probe.py
    从 Python 运行时视角检查 CA trust store、环境变量覆盖、requests / certifi 差异。

  3. unable-local-issuer-repro/
    一个完整的可运行复现实验,能稳定演示:

  4. 失败场景:服务端只发 leaf cert,客户端即使信任 root CA 也会失败
  5. 成功场景:服务端改成发送 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) 是否能顺着链走到本地信任的 root
  • subjectAltName 里有没有目标域名
  • 证书是否过期
  • leaf 是否 CA:FALSE,intermediate 是否 CA:TRUE
  • 本机和容器是不是在用同一套 trust store
  • REQUESTS_CA_BUNDLESSL_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,程序还是报错。根是认识的,可中间那位 "介绍人" 没来,链子还是接不上。

对这类问题,我更推荐一个朴素的判断顺序:

  1. 先看服务端发没发 full chain,用 openssl s_client -showcerts 看返回证书里有没有 intermediate;如果只有 leaf cert,没有中间证书,那锅多半先在服务端。
  2. 再看客户端 trust store 里到底装了什么,在出问题的环境里打印 ssl.get_default_verify_paths(),顺手核对 REQUESTS_CA_BUNDLE 这类变量;别你本机信了,容器里其实根本没装那份 CA。
  3. 最后才考虑运行时、库版本、平台差异。前两步都看过了,再去怀疑 Python、requestscertifi 或镜像差异,才不容易跑偏。 别倒过来。倒过来查,容易把一件本来两小时能解决的事,拖成两天。

总结思维导图

@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

Python HTTPS 证书报错排查思维导图

给明天就能做的 5 条建议

  • 碰到这类错误,先抓住 unable to get local issuer certificate 这一层,别被 Max retries exceeded 带偏。
  • 立刻用 openssl s_client -showcerts 看目标服务到底发了几张证书。
  • 在出问题的运行环境里打印 CA bundle 路径和内容来源,别拿本机结果替线上环境发言。
  • 如果你能改服务端,优先上 full chain,这比在单个客户端里打补丁更稳。
  • verify=False 可以拿来做一次性验证,但不要把它混进正式代码。

扩展阅读


本作品采用知识共享署名-非商业性使用-禁止演绎 4.0 国际许可协议进行许可。