排查 Python 版本升级后的 SSL 问题:从 Python 3.8 到 3.11 的配置变化
2025-02-06

升级 Python 版本本来是个常规操作——直到你的 SSL 连接突然全部挂掉。

我在把项目从 Python 3.8 升到 3.11 的时候就遇到了这个问题。之前跑了一年多的代码,升级后直接报 certificate verify failed。排查了半天,发现不是证书坏了,而是 Python 3.10 开始悄悄收紧了 SSL 的默认配置。

这篇文章记录整个排查过程,以及我理解到的根本原因和几种修复思路。

现象:升级后 SSL 直接炸了

项目用 aiohttp 做 HTTPS 请求,SSL 上下文是这样配的:

import ssl

context = ssl.create_default_context(ssl.Purpose.CLIENT_AUTH)
context.load_cert_chain(certfile="mycertfile", keyfile="mykeyfile")

Python 3.8 下跑了一年多,没任何问题。升到 3.11 后,所有 HTTPS 请求都报错:

aiohttp.client_exceptions.ClientConnectorCertificateError:
  Cannot connect to host example.com:443 ssl:True
  [SSLCertVerificationError: certificate verify failed:
   unable to get local issuer certificate]

第一反应是证书过期了?检查了一下,没有。证书文件一模一样,服务器也没动过。那问题只可能出在客户端——Python 本身。

到底改了什么?

花了点时间翻 Python changelog 和 ssl 模块文档,总算搞清楚了。核心变化发生在 Python 3.10(PEP 644),3.11 继承了这些变化。

Python SSL 默认配置变化:3.8 vs 3.10+

变化一:默认协议更严格

Python 3.8 的 ssl.create_default_context() 用的是 PROTOCOL_TLS,这个协议比较宽松,客户端和服务端都能用。

Python 3.10+ 改成了 PROTOCOL_TLS_CLIENT,这个协议专门用于客户端连接,并且强制开启证书验证

变化二:check_hostname 默认开启

在 3.8 中,如果你用 ssl.Purpose.CLIENT_AUTH 创建上下文,check_hostname 实际上是 False——它不会验证服务器证书的主机名是否匹配。

3.10+ 里,不管你用什么 Purpose,check_hostname 默认都是 True。如果证书的 CN(Common Name)或 SAN(Subject Alternative Name)跟你连接的主机名不一致,直接报错。

变化三:verify_mode 默认 CERT_REQUIRED

3.8 用 CLIENT_AUTH 时,verify_modeCERT_NONE——压根不验证服务器证书。这意味着即使证书链不完整、根证书缺失,连接照样能建立。

3.10+ 改成了 CERT_REQUIRED,要求完整的证书链验证。如果 Python 找不到对应的 CA 根证书,就会报 unable to get local issuer certificate

一句话总结

Python 3.8 默认信任一切,Python 3.10+ 默认不信任任何东西。 你之前的代码"能跑"只是因为验证被关掉了,不是因为证书配置正确。

排查步骤

在知道原因之前,我是这样一步步定位的。如果你遇到类似问题,可以按这个顺序排查:

第一步:确认是 Python 的问题,不是证书的问题

openssl 直接检查服务器证书:

openssl s_client -connect example.com:443 -showcerts

如果输出里有 Verify return code: 0 (ok),说明证书本身没问题,问题在 Python 这边。

第二步:检查 Python 用的 CA 路径

import ssl
print(ssl.get_default_verify_paths())

输出类似:

DefaultVerifyPaths(
  cafile='/opt/homebrew/etc/openssl@3/cert.pem',
  capath='/opt/homebrew/etc/openssl@3/certs',
  ...
)

确认这些路径下确实有 CA 证书文件。macOS 上特别容易踩坑——系统 OpenSSL 和 Homebrew 装的 OpenSSL 用的是不同的证书路径。

第三步:对比 3.8 和 3.11 的 SSLContext 默认值

import ssl

# 看看你的上下文到底长什么样
ctx = ssl.create_default_context(ssl.Purpose.CLIENT_AUTH)
print(f"protocol: {ctx.protocol}")
print(f"check_hostname: {ctx.check_hostname}")
print(f"verify_mode: {ctx.verify_mode}")

在 Python 3.8 和 3.11 分别跑一遍,你就能看到差异。

三种修复方案

方案一:修复证书链(推荐)

这是最正确的做法。把完整的证书链配好,让验证正常通过:

import ssl
import certifi

context = ssl.create_default_context(cafile=certifi.where())
context.load_cert_chain(certfile="mycertfile", keyfile="mykeyfile")

certifi 是一个维护良好的 CA 证书包,pip 可以直接装。这样做的好处是不依赖系统的 CA 路径,跨平台一致。

pip install certifi

方案二:手动配置 SSLContext

如果你需要更细粒度的控制,可以手动创建 SSLContext:

import ssl

context = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT)
context.load_verify_locations("/path/to/ca-bundle.crt")
context.load_cert_chain(certfile="mycertfile", keyfile="mykeyfile")

# 用于 aiohttp
import aiohttp
connector = aiohttp.TCPConnector(ssl=context)
async with aiohttp.ClientSession(connector=connector) as session:
    async with session.get("https://example.com") as resp:
        print(await resp.text())

方案三:禁用验证(仅限调试)

如果你只是想在本地快速调试,临时禁用验证:

import ssl

context = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT)
context.check_hostname = False
context.verify_mode = ssl.CERT_NONE
context.load_cert_chain(certfile="mycertfile", keyfile="mykeyfile")

注意: 这种做法会让连接暴露在中间人攻击风险下。只在本地调试时使用,绝对不要用在生产环境。你的 code review 流程应该能拦住这种代码。

一个容易忽略的坑:macOS 上的 CA 路径

macOS 用户特别需要注意:如果你用 Homebrew 装了 Python,它链接的 OpenSSL 可能跟系统自带的不一样。这意味着 Python 可能找不到系统 Keychain 里的 CA 证书。

最简单的解决办法就是用 certifi

import certifi
print(certifi.where())
# 输出类似: /path/to/python/site-packages/certifi/cacert.pem

然后把这个路径传给 ssl.create_default_context(cafile=certifi.where())

总结

Python 3.10+ 收紧 SSL 默认配置是好事——它强制你正确处理证书验证,而不是假装问题不存在。但如果你从 3.8 直接升上来,这个变化会很突然。

关键 takeaway:

  1. ssl.create_default_context() 的行为在 3.10 变了 —— check_hostnameverify_mode 默认开启
  2. 先确认是客户端问题还是证书问题 —— 用 openssl s_client 排除服务端因素
  3. 优先修复证书链,而不是禁用验证 —— certifi 包是最省事的方案
  4. macOS 上注意 CA 路径 —— Homebrew Python 和系统 Python 用不同的证书路径

参考文档