Python requests SSL证书验证失败:自签名证书排查与解决方案

发布时间:2026/10/22 11:27:24
Python requests SSL证书验证失败:自签名证书排查与解决方案 1. 报错现场还原先读懂这串英文在说什么前阵子接到一个非常典型的报障内网一台调度机上的数据同步脚本突然挂了自动告警把堆栈贴过来第一行是requests.exceptions.SSLError: HTTPSConnectionPool(hostrsda-sds.sd.dch.com, port443)。乍一看很容易以为是接口服务没起来或者网络不通但真正让人摸不着头脑的是——用 curl 验证这个接口返回正常浏览器也能打开偏偏 Python requests 一到握手阶段就翻车而且反复重试都是同一个结果。1.1 报错文本拆解先别急着搜解决方案把报错拆开看信息量其实很大。完整报错大致长这样requests.exceptions.SSLError: HTTPSConnectionPool(hostrsda-sds.sd.dch.com, port443): Max retries exceeded with url: /api/sync/data (Caused by SSLCertVerificationError(1, [SSL: CERTIFICATE_VERIFY_FAILED] certificate verify failed: self-signed certificate (_ssl.c:1129)))拆开看每一段的含义HTTPSConnectionPool(hostrsda-sds.sd.dch.com, port443)requests 已经成功解析到这个内部系统域名并且准备向 443 端口建立连接。注意这里没有 DNS 解析错误、没有连接超时说明网络层是通的。Max retries exceeded不是根因只是 requests 默认会重试几次每次都卡在 TLS 握手阶段所以最后统一报了个重试都失败。SSLCertVerificationError真正的元凶。这是 OpenSSL 在验证服务端返回的数字证书时给出的失败原因。括号里最后那句self-signed certificate (_ssl.c:1129)则具体指明了证书验证失败的类型服务端给了一张自签名证书而客户端的信任库里没有对应的根证书。所以这个报错翻译成大白话就是Python 在跟服务器建立 HTTPS 连接时服务器递过来一张身份证Python 发现这张身份证不是自己认识的那几个权威机构签发的于是拒绝继续握手。跟服务器本身挂没挂、代码逻辑对不对没有半点关系。1.2 不同报错尾巴对应不同病因同样是CERTIFICATE_VERIFY_FAILED括号里的细节不同病因可能完全不同。我在实际排查中遇到过好几种变体报错尾巴里的关键字实际含义self-signed certificate服务端使用自签名证书信任链根本无法建立unable to get local issuer certificate证书链里缺少中间证书或根证书不在客户端信任库certificate has expired证书已经过了有效期hostname mismatch证书上签发的域名/IP 和访问地址对不上IP address mismatch你用 IP 访问但证书 SAN 里只写了域名看到报错的第一时间先看最后那对括号而不是看开头的HTTPSConnectionPool。这是整条排查链路里成本最低、收益最高的一步。2. 为什么浏览器能打开requests 却死活不认账这是这类问题里最让人困惑的一点。我在当时的现场也先问了同事一句你浏览器访问有问题吗他说没有内网系统天天都在用。于是问题来了同一台机器同一个域名浏览器和 curl 都正常为什么 Python 就不行2.1 TLS 握手阶段的三道关卡要解释这个问题得先简单捋一下 HTTPS 握手时客户端会做什么。当 requests 通过 OpenSSL 与服务器建立 TLS 连接时客户端拿到服务器下发的数字证书后会依次做三件事信任链校验这张证书是不是由我信任的某个根 CA 直接或间接签发的服务端有没有把中间证书一起带上有效期校验当前时间是否在证书的notBefore和notAfter之间。域名校验证书的 Subject Alternative NameSAN里是否包含我访问的域名或 IP。三项全部通过TLS 握手才会继续任何一项不通过直接抛出CERTIFICATE_VERIFY_FAILED。浏览器能打开只说明浏览器认为这三关都过了。但浏览器认为和Python 认为用的标准是不一样的。2.2 Python 与浏览器/curl 的信任库差异关键差异在于信任库从哪来。浏览器尤其 Windows 上的 Chrome、Edge使用的是一套系统内置的根证书库同时企业 IT 如果往系统证书库里装过内部 CA浏览器也会一并信任。curl 在 Windows 上也默认走系统证书库。而 Python 的 requests 不读系统证书库它使用certifi这个包自带的证书包。这个包只包含公开的公共 CA 证书——它不认识你们公司的内部根 CA也不认识任何自签名证书。所以只要内网系统用的是公司自建 CA 签发的证书或者干脆就是一张没花钱的自签名证书就会出现一个非常普遍的诡异场景浏览器正常因为系统证书库里早就装了公司根证书。curlWindows 上正常读系统证书库。Python requests报SSLError因为它的信任库里压根没有你公司的根证书。这就是同一个系统只有 Python 报错的底层原因不是玄学只是信任库的查找路径不同。2.3 内网系统的证书现状另外内网系统用自签名证书的比例远比大家想象的高。我见过的几种典型情况系统部署时临时生成了一张自签名证书后面一直没人换。公司有自建 PKI 体系签发了内部证书但只下发到了 Windows 域机器Linux 机器和容器里完全没有。证书本来是正规渠道签发的但只覆盖了某个域名访问时用了 IP 或另一个别名导致 hostname mismatch。证书中途过期了运维没盯住。你只有先搞清楚内网系统到底属于哪种情况才能选对后面的解决方案。这也是为什么我强烈建议不要一上来就verifyFalse先把根因定位了后面才走得稳。3. 排查三步走用 openssl 把根因钉死为了不靠猜我习惯性地先跑三条命令把服务端证书的真实情况摸清楚。这个过程其实很快五分钟之内就能判断出大概方向。3.1 第一步抓取服务端证书链在出问题的机器上直接连一次服务端把证书链抓出来看openssl s_client -connect rsda-sds.sd.dch.com:443 -servername rsda-sds.sd.dch.com -showcerts /dev/null这里有两个参数值得说-servername用来指定 SNIServer Name Indication。如果服务端是虚拟主机不带这个参数可能让对方不知道该返回哪个站点导致证书内容不对。-showcerts会把服务端下发的整条证书链都显示出来而不只是最外层那张。输出的内容会很长重点看几个字段subjectCN rsda-sds.sd.dch.com issuerCN My Internal CA notBefore... notAfter... Verify return code: 21 (unable to verify the first certificate)如果issuer显示的不是某个公开 CA比如常见的那几家机构而是什么Internal CA、Self-Signed之类的名字基本可以判断服务端用的就是内部证书或自签名证书问题定性为信任链缺失。3.2 第二步核对有效期与域名清单接着单独把证书摘要打出来echo | openssl s_client -connect rsda-sds.sd.dch.com:443 -servername rsda-sds.sd.dch.com 2/dev/null | \ openssl x509 -noout -subject -issuer -dates -ext subjectAltName看到的结果类似subjectCN rsda-sds.sd.dch.com issuerCN My Internal CA notBeforeJan 1 00:00:00 2022 GMT notAfterJan 1 00:00:00 2024 GMT X509v3 Subject Alternative Name: DNS:rsda-sds.sd.dch.com这一步主要确认两件事一是证书过没过期二是 SAN 里有没有你要访问的域名。如果访问地址是 IP而 SAN 里没有这个 IP那后面怎么改 CA 文件都没用必须解决域名匹配问题。3.3 第三步交叉验证 curl 与验签命令再回到问题本身用 curl 做个对照curl -vI https://rsda-sds.sd.dch.com 21 | grep -i ssl certificatecurl 大多数情况下走系统信任库。对照结果有两个分支curl 正常说明系统层信任没问题问题基本锁死在 Python 的 certifi 信任库上。curl 也报错说明服务端证书本身就有问题比如过期、签发机构不可信等这时要往服务端去排查。为了进一步验证加了自己的 CA 文件能不能通过还可以用 curl 指定 CAcurl --cacert internal-ca.pem -I https://rsda-sds.sd.dch.com如果这条命令通了说明证书链本身没断只是客户端缺了中间那一环。3.4 排查结论对照表整个定位过程走完基本可以汇总出下面的对应关系你观察到的现象可能的根因后续方向curl 正常requests 报 self-signedPython 信任库没有公司根证书给 requests 配 CA 文件或把内部 CA 加进 certifi证书 notAfter 早于当天证书过期找运维换证书SAN 里没有访问域名/IP域名不匹配改用证书上的域名访问或让运维重签openssl 的 Verify return code 为 20找不到根证书签发者补齐根证书和中间证书证书链里只有叶子证书没有中间证书服务端配置漏了中间证书让运维把 chain 配置全定位到这一步后面的解决方案就非常清晰了。4. 解决方案分五档从最应急到最规范针对requests 不信任内网证书这个问题网上能搜到很多答案但大部分只给出其中一两种而且没有说清楚适用场景。我把常见的做法从应急到规范分成了五档你可以按自己的处境选。4.1 应急档verifyFalse 临时绕过这是最省事、但也是最危险的一种import requests import urllib3 # 仅限本地临时调试禁止提交到生产代码 urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning) resp requests.get(https://rsda-sds.sd.dch.com/api/sync/data, verifyFalse, timeout10) print(resp.status_code)把verifyFalse传进去requests 就不再校验证书了握手会直接通过。如果你不写urllib3.disable_warnings控制台会刷一堆InsecureRequestWarning警告虽然不影响运行但非常吵。这个方案的定位是五分钟内确认代码本身没问题。比如你想快速验证接口参数、JSON 格式、业务逻辑是否正常可以用它绕开证书这个变量。但它等于把 HTTPS 的加密验证完全关掉存在中间人攻击的风险。我的红线是只允许出现在本地临时脚本里提交代码前必须改成正规做法不然 code review 不会放行。4.2 标准档verify 指向内部 CA 文件既然问题是客户端不认识这个 CA那就把它介绍给客户端。做法是把公司内部根证书或完整证书链存成一个 PEM 文件然后用verify参数指过去import requests resp requests.get( https://rsda-sds.sd.dch.com/api/sync/data, verify/data/certs/internal-ca.pem, timeout10 ) print(resp.status_code)这个 CA 文件从哪来一般有两条路找运维或 IT 要公司内部根证书通常是一个.crt或.pem文件。如果给的是.cer或.der二进制 DER 格式记得先转成 PEMopenssl x509 -inform der -in internal-ca.cer -out internal-ca.pem用 openssl 直接从服务端抓完整链openssl s_client -connect rsda-sds.sd.dch.com:443 -servername rsda-sds.sd.dch.com -showcerts /dev/null full_chain.pem抓下来的文件里可能包含多张证书把以-----BEGIN CERTIFICATE-----开头的块全部保留requests 背后的 OpenSSL 会自己读懂这个证书链文件。这是我最推荐的单项目方案改动集中、不污染全局环境、风险可控。缺点是每个项目都要配一份 CA 文件项目多了管理起来稍微麻烦。4.3 全局档环境变量统一指定 CA如果公司内部环境里跑着一堆 Python 脚本一个个改 verify 参数不现实。此时可以用环境变量统一搞定export REQUESTS_CA_BUNDLE/data/certs/internal-ca.pem export SSL_CERT_FILE/data/certs/internal-ca.pemREQUESTS_CA_BUNDLErequests/urllib3 会优先读取这个变量指向的 CA 文件。SSL_CERT_FILEOpenSSL 全局的证书文件变量对 Python 标准库里的ssl模块同样有效。两条建议都写上覆盖面最全。设置之后这台机器上所有 Python 脚本、所有用 requests 或标准库发 HTTPS 请求的进程都会自动信任这个 CA。这个方案适合运维统一管理比如写进/etc/profile.d/company-cert.sh或者放进 CI 环境的全局配置里。代码一行都不用改缺点是机器级别的全局影响如果文件路径写错或者证书更新不及时会影响整台机器。4.4 合并档把内部 CA 追加进 certifi还有一种思路是直接改掉 Python 的默认信任库——把公司内部 CA 追加到 certifi 的证书包尾部。先找到 certifi 证书包的路径import certifi print(certifi.where())然后用一条命令追加cat internal-ca.pem $(python -c import certifi; print(certifi.where()))追加之后所有 requests 请求默认就能识别内部系统了不需要给每个业务代码加verify参数。这个方案我把它定位为快速全局生效但埋了个雷一旦certifi重新安装或升级证书包会被覆盖你追加的内容就没了报错会莫名其妙地复发。所以如果要用它建议写成一个自动执行的初始化脚本在环境准备阶段重新追加一次。否则还是 4.3 的环境变量方案更稳妥。4.5 根治档换正式证书 统一分发上面几档都是从客户端适配服务端的角度解决但真正意义上的根治是让内网服务端换上一张客户端本来就会信任的证书如果系统是面向公网的让运维申请权威 CA 签发的正式证书装上之后所有客户端天然信任。如果是纯内网系统建议建立公司统一的内部 PKI由统一的内部根 CA 签发证书并把该根 CA 通过标准机制下发到所有开发机、测试机、生产服务器和容器镜像里。这一档通常不是一个人能推动的需要运维和基础架构团队配合。但如果你在项目里有一定话语权长期来看这是性价比最高的选择——它能消灭一整类间歇性 SSL 证书报错而不是每个人每次遇到都各自打一个补丁。4.6 方案对比表把五档放一起看各自适用场景就很清楚了方案改动量安全性维护成本适用场景verifyFalse一行代码低极低临时调试、快速定位业务问题verify 指定 CA 文件几行代码高中单个项目推荐优先使用环境变量指定 CA无代码改动高低整机、CI、运维统一配置追加进 certifi一次性脚本中高中快速全项目生效注意升级覆盖换上正式证书依赖运维最高最低长期根治降复杂度首选5. 验证与收尾确认修复而不是碰巧成功改完代码之后一定要做一轮系统性的验证别只看这次跑通了就收工。我习惯写一个小的对照脚本把几种 verify 方式放一起测一遍。5.1 三组对照测试脚本import requests URL https://rsda-sds.sd.dch.com/api/health BUNDLE /data/certs/internal-ca.pem cases { certifi默认: True, 内部CA文件: BUNDLE, verifyFalse: False, } for name, verify in cases.items(): try: resp requests.get(URL, verifyverify, timeout10) print(f{name}: HTTP {resp.status_code}) except Exception as exc: print(f{name}: FAIL - {type(exc).__name__}: {exc})在我的排查实例里输出是这样的certifi默认: FAIL - SSLError: [SSL: CERTIFICATE_VERIFY_FAILED] self-signed certificate 内部CA文件: HTTP 200 verifyFalse: HTTP 200看到这个结果就说明问题确实出在信任库而不是服务本身指定内部 CA 文件后证书校验真正通过了。有一个细节要确认跑内部CA文件那个 case 时控制台不应该出现InsecureRequestWarning。如果出现了说明你的代码路径里还有地方在用verifyFalse需要回头找出来。5.2 换环境的回归注意修好不等于一劳永逸。项目可能换机器部署、拉进 CI、打进 Docker 镜像这些环境未必有你本机的 CA 文件。我当时就踩过一次本地测完下班第二天发现在另一台机器上依然报错因为那台机器的/data/certs/internal-ca.pem根本不存在。如果项目用 Docker记得把 CA 文件包含进去并用环境变量全局生效COPY certs/internal-ca.pem /etc/ssl/company/internal-ca.pem ENV REQUESTS_CA_BUNDLE/etc/ssl/company/internal-ca.pem ENV SSL_CERT_FILE/etc/ssl/company/internal-ca.pem在 CI 流水线里则在构建或执行阶段提前设置好环境变量。这样所有环节都能对同一份证书配置保持一致。5.3 证书链终验最后用 openssl 对整理好的 CA 文件和证书链做一次终验确保证书链是完整的openssl verify -CAfile /data/certs/internal-ca.pem server.pem返回OK说明验证通过如果返回unable to get issuer certificate说明你拿到的 server.pem 缺中间证书还需要把中间证书拼进去。6. 容易复发的五个隐蔽场景证书类问题有个特点修好一次之后可能几个月后又冒出来而且每次原因都不太一样。我总结了一下最容易复发的几个场景都藏在代码和环境的细节里。6.1 只导了叶子证书中间证书断链很多人从浏览器里导出证书时默认只导出了站点那张叶子证书没有导出中间证书和根证书。如果服务端又没有把中间证书配置全客户端验证到一半就断了报unable to get local issuer certificate。这类问题最隐蔽因为浏览器通常会自己补齐中间证书看起来完全正常。正确做法是从服务端抓完整链或者找运维要根 中间 叶子整套然后拼接成一个 PEM 文件而不是只拿一张叶子证书。6.2 IP 访问与 SAN 不匹配内网系统经常被人用 IP 直连比如写成https://10.20.30.40。证书 SAN 里通常只写了域名于是报hostname mismatch。这类问题跟信任库无关你换再多的 CA 文件都没用。要么改用域名访问要么让运维把 IP 加进证书的subjectAltName。6.3 出口安全设备替换了证书还有一种情况目标服务器本身用的是正规证书但公司出口或服务器机房前置的安全设备对 HTTPS 做了统一解密把服务端证书替换成了设备自己的证书。你拿 openssl 一看issuer 可能不是目标网站的 CA而是某个安全设备品牌的内部 CA。这种情况解决路径完全不同要去联系负责出口安全设备的团队申请放行或安装对应的信任证书。6.4 verifyFalse 混进生产代码很多人临时调试时写了verifyFalse后来忘了删直接一起提交。代码在本地开发环境跑没问题因为本地可能没什么人盯着流量但生产环境就默默放弃了证书校验安全上的隐患非常大。我在做代码评审时会把所有verifyFalse当做一个必须说明理由的审查点业务代码里不应该出现这种写法。如果确实有内部系统需要特殊处理应该用 4.2 的verify参数指向 CA 文件而不是关闭校验。6.5 环境重装后 certifi 被重置使用虚拟环境比如 venv、conda切换环境、或者重新安装 Python 后certifi 被重新安装之前追加证书的方案就会失效。我自己的习惯是把证书路径和 CA 文件纳入项目的配置文件管理而不是去改全局证书包。这样即使环境重装只要配置文件在代码仍然能够正常找到验证证书。最后说点个人的习惯。我现在处理这类 SSLError第一步永远是跑openssl s_client拿证书链先搞清楚服务端给了什么证书、谁签的、过没过期而不是直接往代码里塞verifyFalse。定位清楚了之后单项目优先选择verify指向内部 CA 文件机器级场景用环境变量只有临时调试脚本才允许关闭校验而且会在旁边留一行明确的注释提醒自己走之前改回来。这套流程走下来绝大多数证书问题都能在半小时内解决并且不会再以同样的姿势复发。

关于本文作者

来自尧图内容编辑团队

尧图内容编辑团队 内容团队

尧图内容编辑团队

本文由尧图网络内容编辑团队执笔。团队由资深项目经理、前端工程师与设计师组成,所有内容均来自亲手交付的真实项目,先讲清问题、再给出可落地的解法。尧图深耕北京网站建设十年,服务过京华建材集团、智造科技等各行业客户,把一线经验沉淀为可复用的行业观察。

  • 十年建站经验,覆盖建材、制造、服务、文创等
  • 项目经理把关选题与事实准确性
  • 工程师与设计师联合撰写专业细节
  • 统一编辑规范,保证文风与排版一致
  • 每月复盘转化数据,迭代选题方向

延伸阅读

相关资讯与近期热门内容

深度阅读推荐

建站决策前值得细读的三篇

网站改版的5个关键决策
2024-08-12

网站改版的5个关键决策

什么时候该改版、改到什么程度、如何避免流量掉光,京华建材集团改版复盘给出答案。

获取专属建站方案

看完文章,把您的行业与预算告诉我们,免费获取一份量身定制的官网建设方案与报价。

立即免费咨询