ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

Python SSL证书验证失败?用certifi解决CERTIFICATE_VERIFY_FAILED

Python SSL证书验证失败?用certifi解决CERTIFICATE_VERIFY_FAILED 写 Python 的人早晚都会遇到这么一条报错SSLCertVerificationError: [SSL: CERTIFICATE_VERIFY_FAILED] certificate verify failed: unable to get local issuer certificate。我头一回撞上它是在一个爬虫项目里requests.get()明明什么都没写错偏偏在连某个 https 站点的时候当场翻车。盯着命令行里那一长串英文看了半天心里只有一个念头证书验证我连证书长什么样都没见过它凭什么拦我的请求后来我才搞清楚这恰恰是 Python 在替你执行安全证书验证而它手里缺少一份可信的证书清单来做比对。这个缺口就是 certifi 的用武之地。certifi 在 Python 生态里存在感极低但地位极其特殊。它不负责加密不负责握手只是把 Mozilla 维护的全套根证书CA 根证书打包成一个 pem 文件让 requests、urllib3、httpx 这些库在做 HTTPS 安全证书验证时有一份权威的可信名单可以对照。说白了你的程序向服务器发起 HTTPS 请求时服务器出示的那张“身份证”到底值不值得信Python 要照着 certifi 给的那份名单来判断。这篇博文我会从证书验证的原理讲起把 certifi 的工作方式、接入方法、常见报错和避坑经验完整说一遍。不管你是刚入门的 Python 新手还是长期跟爬虫、接口打交道的开发把这一个小库吃透能帮你省掉大把排查 SSL 报错的时间。1. 先搞清楚 certifi 到底在解决什么问题1.1 一次 HTTPS 报错背后的完整逻辑上面说的那个CERTIFICATE_VERIFY_FAILED核心含义是客户端和服务器在建立 HTTPS 连接时服务器把自己的数字证书发给客户端客户端想沿着证书链找到签发它的一级 CA再找到根 CA最终确认这张证书确实被一家可信机构背书过。这一步就是安全证书验证的核心动作。如果客户端手里没有根 CA 的权威名单它就无法判断服务器给出的证书是不是伪造的。这时候 Python 的处理方式非常坚决宁可连接失败也不肯把请求数据发到一个身份不明的服务器上。很多人不理解为什么浏览器能打开、Python 打不开其实就是浏览器自带了一套很全的证书库而 Python 环境里缺少对应的可信名单。用一个生活化的类比你去银行办业务柜员要核实你的身份证是不是真的她手里得有一份“发证机关可信名单”。certifi 就是 Python 世界里的那份名单。有人说“证书验证是 HTTPS 的一部分不是多此一举吗”这里要分清HTTPS 加密保证了传输过程中别人看不懂但如果不验证证书你根本不知道自己在跟谁加密通信。中间人完全可以伪装成目标服务器跟你建立加密通道再跟真正的服务器建立另一个加密通道把你所有的输入原封不动地转手。安全证书验证就是在入口处拦住这种偷梁换柱。1.2 certifi 的核心职责究竟是什么certifi 本身不参与 SSL/TLS 握手的加密运算它更像一本被 Python 社区广泛接受的《可信 CA 黄页》。你唯一经常要调用的接口是certifi.where()它返回一个路径指向一个存放全套根证书的 pem 文件。requests、urllib3、httpx 在初始化 SSL 上下文时如果没有特殊指定就会默认去找这个文件。这个设计听起来简单但它是 Python 生态里一个非常聪明的折中。早期 Python 做 HTTPS 请求证书来源完全依赖操作系统Windows 有系统证书库macOS 有钥匙串Linux 各家发行版把证书放在不同目录格式和更新方式五花八门。同样一段代码在 Windows 开发机上跑得好好的部署到一台 CentOS 服务器上就突然报证书错误。certifi 的釜底抽薪思路是不管什么操作系统只要你 pip install certifi就能拿到同一份打包好的根证书文件。环境差异被这一个库抹平了。另外要澄清一个常见误解certifi 不是 Python 官方提供的证书库而是社区维护的一个包。它承载的数据来自 Mozilla但它本身的更新节奏、发布周期由自己的维护者控制。所以你不能假设它永远是最新的需要在合适的时候主动升级。1.3 为什么 requests、urllib3、httpx 都默认依赖它requests 底层用的是 urllib3而 urllib3 在做 HTTPS 请求时需要一个可靠的根证书来源。urllib3 早期也尝试过直接读系统证书但系统证书在不同平台上的差异实在令人头疼维护成本很高。certifi 出现之后urllib3 很快把它作为默认的证书源集成进来。requests 因为依赖 urllib3所以也顺理成章地跟着用 certifi。httpx 是近几年很火的异步优先 HTTP 客户端它也默认依赖 certifi。aiohttp 的情况稍有不同它没有内置默认的 certifi 连接需要你手动构造 SSLContext。但只要你愿意一样可以轻松让它使用 certifi 的证书包。我在后面第三部分会具体展示这三种库的接入差异。还有一点值得提除了 certifi社区里也有system-store、truststore这类库能把操作系统的证书库直接迁移成 Python 可用的格式。它们各有适用场景但默认约定就是 certifi。因为 certifi 最省事、跨平台一致、装完就能用。除非你有非常特殊的合规要求否则项目里没必要替换。2. 证书验证原理信任链是怎么一环扣一环的2.1 HTTPS 握手时验证环节到底在做什么SSL/TLS 握手里客户端和服务器先协商加密套件然后服务器出示证书。接下来的验证动作可以拆成三步先看证书有效期再看证书域名是否匹配当前访问的域名最关键的是沿证书链回溯——服务器证书由中间 CA 签发中间 CA 的证书又由根 CA 签发客户端要用根证书里的公钥去验证链条上每一层签名。只要断了一环或者某个环节的证书已经过期、被撤销验证就会失败。在证书验证里有些细节特别容易让新手误解。比如“证书有效期”不是只看服务器证书还要看链条上每一层证书的有效期。很多根证书的有效期非常长但中间 CA 证书可能只有几年如果中间 CA 的证书过期了整条链也不可信。再比如“域名匹配”证书里会写明它适用于哪些域名存在 Subject Alternative Name 字段里。你用 IP 直连、或者用了和证书上完全不同的域名去访问匹配也会失败。更绕的是“信任锚”这个概念。客户端验证链条时必须有一个起点这个起点就是根证书。certifi 提供的 cacert.pem 文件里装的正是一批被称为“信任锚”的根证书。客户端不需要验证这些根证书本身因为它们是“被直接信任”的。链条就这么从根证书一路展开直到服务器证书。2.2 certifi 和 Mozilla 的关系以及为什么可信certifi 里的根证书不是 Python 社区自己攒的而是直接从 Mozilla 的 CA 根证书数据库同步过来的。Mozilla 维护这个数据库最初是为了 Firefox 浏览器能验证 HTTPS 站点。它有公开的审核流程哪个 CA 能进名单、哪个 CA 因为违规被移出名单都有明确规则。这套规则很多年下来积累了相当高的公信力成了跨语言、跨平台项目普遍认可的标准。为什么选 Mozilla 而不是直接选 Windows 或 macOS 自带的证书库原因在于跨平台性和可访问性。Windows 的证书库虽然全但它和操作系统绑定不是一份可以直接分发到任何 Python 环境里的独立文件。Mozilla 的数据库是公开的、可下载的、不绑定某个商业公司所以非常适合做成一个跨平台的 Python 包。certifi 通过 pip 安装后本质上是某个时间点的数据库快照。它不会实时更新需要你偶尔执行pip install --upgrade certifi。这意味着如果你环境里的 certifi 版本太老而某张服务器证书链路中引用的根证书在后来被移除了或者新增的根证书在老包里根本不存在验证就可能失败。2.3 证书过期、撤销、信任链断裂的三种典型表现我把实际遇到过的失败类型归纳成三种。第一种服务器证书真的过期了这属于对方站点运维没做好你只能等对方修复自己没法绕过去。第二种服务器只把自己的叶子证书发下来没有把中间 CA 证书一并发下来客户端本地也没缓存这条链链条断在中间报错信息经常是unable to get local issuer certificate。这种情况根源多半在服务器配置但客户端可以通过预先缓存中间证书来缓解一部分问题。第三种容易被忽略你本地系统时间不对。证书验证里有个“当前时间必须在证书有效期范围内”的规则本地时间差几个小时就可能让一张本来有效的证书被判定为“尚未生效”或“已过期”。很多年前我在一台虚拟机里排查了很久最后发现宿主机时间被手动改成了几年前的一个日期整个环境的 HTTPS 请求全部失败。浏览器能打开、requests 打不开的经典案例优先级最高的怀疑对象就是证书库的差异。浏览器用的证书库和 Python 用的 certifi 不是同一份前者会跟着系统自动更新后者是个待升级的 Python 包。所以别第一反应就怀疑服务器挂了先看看 certifi 版本和系统时间。3. 实操落地四个把 certifi 用好的关键姿势3.1 安装、定位证书路径以及为什么别写死路径安装 certifi 只需要一行命令pip install certifi。之后在代码里随时可以拿到证书文件的绝对位置import certifi print(certifi.where())输出通常是一个到site-packages/certifi/cacert.pem的路径。你只要一直调用certifi.where()就不用担心换环境后路径漂移。我见过有人图省事把某台机器上的绝对路径硬编码进配置文件结果换一台电脑直接炸。这是一个完全不必要的坑。如果你在用虚拟环境记得先激活当前环境再安装。项目里如果用 Poetry、pipenv那就按各自的 lock 文件走但核心逻辑一样保证安装后的 certifi 在当前 Python 解释器可见。还要注意 Python 版本兼容性certifi 对 Python 版本要求不高但太老的 Python 环境可能会装到旧版本。安装完成后可以顺手确认一下版本号pip show certifi看版本和安装路径确认当前环境用的是不是你想要的那份。3.2 在 requests、httpx、aiohttp 里显式接上 certifirequests 默认就用 certifi所以大多数情况下你不需要额外做什么。如果你想显式锁死行为让团队里的任何人都能一眼看出“当前请求用的是 certifi 证书包”可以这样写import requests import certifi resp requests.get(https://example.com, verifycertifi.where())httpx 也默认依赖 certifi但同样支持显式指定import httpx import certifi resp httpx.get(https://example.com, verifycertifi.where())aiohttp 最特别它没有默认帮你连 certifi需要主动构造一个加载了 certifi 证书文件的 SSLContextimport asyncio import ssl import certifi from aiohttp import ClientSession, TCPConnector ssl_ctx ssl.create_default_context(cafilecertifi.where()) connector TCPConnector(sslssl_ctx) async def fetch(): async with ClientSession(connectorconnector) as session: async with session.get(https://example.com) as resp: print(resp.status) asyncio.run(fetch())注意这三个库对 verify 参数的处理不完全一样。requests 和 httpx 的 verify 可以传布尔值或证书路径aiohttp 没有 verify 参数它只认你传入的 SSLContext。想用 certifi 就按上面的写法别把它们混为一谈。3.3 企业内部 CA、自签证书怎么优雅地接入遇到企业内网接口正规处理方式不是关闭验证而是把企业 CA 证书追加到一份自定义证书链文件里。我推荐先把 certifi 的 cacert.pem 复制一份再往里面追加企业根证书import shutil import certifi combined_pem /path/to/combined.pem shutil.copy(certifi.where(), combined_pem) # 用文本编辑器或命令行把企业根证书内容追加到 combined_pem 末尾之后请求时把 verify 参数指向这份合并文件requests.post( https://internal-api.example.com, verify/path/to/combined.pem, json{key: value} )这样做的好处是certifi 升级后你的自定义文件不受影响也不会污染系统证书库。自签证书则要看场景——如果是调试本地服务临时指定验证路径就好对外提供服务则应该走正规 CA不要拿自签证书当长期方案。注意追加文件时要小心格式。pem 文件里每一段证书以-----BEGIN CERTIFICATE-----开头以-----END CERTIFICATE-----结尾。追加之前最好检查一下企业证书是不是完整的 pem 格式避免文件损坏导致整条链验证失败。3.4 更新 certifi 与全局环境变量的配置更新 certifi 的标准动作pip install --upgrade certifi有些项目想全局接管系统程序对证书的读取可以设置环境变量SSL_CERT_FILE把它指向certifi.where()返回的文件export SSL_CERT_FILE$(python -c import certifi; print(certifi.where()))这样设置之后很多使用 OpenSSL 默认配置读取证书的程序也会优先读取这份列表。在 Docker 容器里这个姿势尤其有用——基础镜像精简版系统可能没有任何 CA 证书设置好SSL_CERT_FILE再装好 certifi整个容器的 HTTPS 请求能省掉一堆莫名的证书问题。我自己的经验是Dockerfile 里先把 certifi 装上然后在运行时设置环境变量。如果基础镜像也装了系统级 ca-certificates那就双保险如果没装也不要慌certifi 这份随包走的证书文件足够撑起绝大多数场景。4. 常见报错与排查实录SSL 问题定位指南4.1 按优先级排查 CERTIFICATE_VERIFY_FAILED这类报错我建议按下面的顺序查不要上来就关验证。第一步升级 certifipip install --upgrade certifi。很多时候版本一升级问题自己就消失了因为根证书名单可能已经更新。第二步查系统时间。执行date看一下时区和日期不对都会有连锁问题。第三步查 Python 环境的 OpenSSL 版本。太老的 OpenSSL 可能不认识新证书的签名算法报错里常带着outdated encryption之类的字样。该升级 Python 就升级该升级底层依赖就升级。第四步抓服务器证书链。用openssl s_client -connect example.com:443 -showcerts看看服务器有没有把中间证书完整发下来。要是只发了叶子证书那是对方站点的配置问题你只能反馈给站点维护方。实际工作中我遇到最多的是第一种和第三种老环境里的 certifi 和 OpenSSL 双双跟不上时代。把它们一起升级问题往往迎刃而解。还有一种容易被忽略的情况.pem文件被某个程序截断或覆盖了。如果你曾经手动改过 certifi 目录比如把自定义证书写进去后来又没有维护好文件损坏也会导致同样的报错。4.2 千万别把 verifyFalse 当万能解药我知道网上很多爬虫教程一遇到 SSL 报错就教你加verifyFalse理由通常是“反爬太严重”或者“证书有问题”。这话在本地临时跑通一个 Demo 的时候勉强说得通但一旦放到正式环境等于把 HTTPS 的安全验证完全关闭。此时数据虽然仍是加密传输但你对“信息发送给谁”这件事不做任何确认中间人可以替换证书伪装成目标服务器把你发的账号密码、接口参数全部截获。如果你实在需要临时关闭务必在代码里留下明确注释并且只限于本机调试不能进测试环境长期跑。正规做法永远是补齐证书链、更新证书库、把可信 CA 加进验证名单而不是绕开验证。我在评审代码时看到verifyFalse基本默认先打回。这不是保守而是这个参数让整个安全体系形同虚设。4.3 浏览器能开、Python 打不开这个经典问题怎么破这种问题掉进心里的第一反应应该是浏览器和 Python 用的不是同一份根证书库。浏览器自己维护、自动更新Python 环境里的 certifi 是挂了某个时间戳的快照。你可以从两个地方对比着看浏览器里查看站点证书链记录根证书的名字Python 里用 certifi 查这份根证书是否在 cacert.pem 里。cacert.pem 是文本文件每一段以BEGIN CERTIFICATE开头你可以直接搜索关键字确认。还要留意一种隐蔽场景企业办公电脑装了 SSL 检测网关或上网行为管理设备它们会在 TLS 握手流程中插入自己的证书来审计流量。浏览器因为安装了设备下发的根证书所以正常访问Python 环境没有于是 requests 一直报错。正确处理办法是把设备根证书追加到自定义证书链文件就像前面 3.3 节做的那样而不是关闭验证。4.4 跨平台差异与容器环境的证书坑Windows、macOS、Linux 的系统证书机制差别很大。Windows 有专门的证书管理器macOS 用钥匙串Linux 有/etc/ssl/certs但发行版之间也有细节差异。没有 certifi 时同样的 requests 代码在开发机好好的部署到 Linux 服务器就报证书错误这种事情我遇到过不止一次。用 certifi 之后跨平台行为统一了少烦很多。Docker 和 CI 环境里更容易出问题很多基础镜像是精简版根本没有系统证书包。这时候有两件必做事项一是把 certifi 装进镜像二是设置环境变量SSL_CERT_FILE指向certifi.where()的文件。对 aiohttp、httpx 这类能显式构造 SSLContext 的库直接加载 certifi 证书包最稳妥对 requests 这种默认集成良好的只要 certifi 在通常也不会有大问题。为了帮你快速定位问题我整理了一张速查表报错信息常见原因建议动作unable to get local issuer certificate根证书缺失或版本太老升级 certifi检查 SSL_CERT_FILEcertificate has expired本地时间不准或证书过期用 date 查看时间核对服务器证书有效期self-signed certificate站点用了自签证书调试期临时用自定义 CA 文件生产换正规证书hostname mismatch证书域名与访问地址不一致确认 URL 的域名是否和证书 SAN 匹配outdated encryptionOpenSSL 版本太旧升级 Python / OpenSSL / 底层依赖这张表里的前三行是我日常看到最多的。到现在我已经养成了脚本开工前先确认证书环境的习惯省下的排查时间非常可观。5. 延伸certifi 之外的几个证书管理细节5.1 SSL_CERT_FILE 与系统证书双轨并行有些项目需要在系统证书和 certifi 之间做切换尤其企业内部同时存在外网服务和内网服务。我的做法是外网请求保持默认内网独有 CA 用自定义合并文件全局默认证书由SSL_CERT_FILE统一指定。这样互不干扰排查时逻辑也清晰。要注意的是SSL_CERT_FILE这个环境变量会影响到很多走 OpenSSL 默认路径的程序不只是 Python。如果你在一台共享服务器上设置了它可能会影响其他应用读取证书的行为所以设置前最好确认这台机器的用途。容器环境相对隔离随便设共享开发机上就要谨慎一点。5.2 把 certifi 合进打包产物时的注意事项如果你用 PyInstaller 或 Nuitka 打包 Python 程序certifi 的 cacert.pem 是否被正确一并打包进去是个容易被忽略的问题。PyInstaller 默认会收集包内数据文件但如果你用了复杂的目录结构或自定义选项最好在打包后实际跑一次 HTTPS 请求验证。避免出现“开发环境正常、exe 里报证书错误”的尴尬。我见过一个同事打包出来的工具在某些网络环境下连外网接口都开不了查了半天发现就是证书数据没打进去。后来我们在测试流程里加了一步启动打包后的程序主动访问一个已知 https 站点验证 SSL 握手成功。这一步成本很低但能挡住一个非常大的坑。5.3 证书轮换与月度更新习惯证书领域有个不成文的习惯定期检查依赖库的更新。我一般每两三个月主动跑一次pip list --outdated看到 certifi 有新版就顺手升掉。别等到线上报 SSL 错误才开始处理那样往往是被动且紧张的。给团队写的接口测试模板里我也会在初始化阶段自动打印certifi.where()路径和版本让新人一眼能看出当前环境用的证书来自哪里。另外如果你负责的服务会调用大量外部 HTTPS API建议把证书监控纳入日常巡检。不是说每张证书都要人工看而是至少要确保环境里的根证书包保持更新。证书的轮换和依赖库升级一样属于“平时不觉得重要、出事才后悔没做”的事。我个人对这个库的体会是certifi 属于“存在感越低越舒服”的依赖。它不写代码不画界面就默默把一个文件放在那里帮无数 Python 程序在建立 HTTPS 连接时守住信任的底线。踩过几次 SSL 报错的坑之后我养成了一个习惯任何涉及爬虫、接口对接的项目第一行先import certifi然后用certifi.where()确认路径再决定怎么传 verify 参数。这个习惯帮我省下的排查时间远比初学时期死记硬背各种报错含义来得划算。如果你也经常被证书验证的问题困扰不妨先把这个不起眼的库里里外外搞明白很多让人抓狂的 SSL 问题会突然变得清晰起来。
返回列表