1. 这个报错到底在说什么ssl_client_socket_impl.cc(992)] handshake failed; returned -1, SSL error code 1, net_error -100这串东西第一次见的人基本都会被吓一跳——文件名带.cc行号精确到 992还夹着net_error -100看起来像是某个底层库崩了。实际上它跟你的 Python 代码逻辑几乎没关系它是 Chromium 网络栈在 TLS 握手阶段失败后吐出的一条日志只不过这条日志被 Python 侧的某个库最常见的是 Selenium、Playwright、pyppeteer 这类驱动浏览器的工具或者 requests 某些代理中间件转发到了控制台。先把这句话拆开看。ssl_client_socket_impl.cc是 Chromium 里负责客户端 SSL socket 实现的源文件(992)是打印日志的行号不同版本行号会变所以别死记这个数字。handshake failed是结论TLS 握手没谈成。returned -1表示底层调用返回失败。SSL error code 1对应 OpenSSL 的SSL_ERROR_SSL属于协议层或证书层的硬错误。net_error -100是 Chromium 自己的错误码体系里的ERR_CONNECTION_CLOSED意思是连接在对端还没完成握手就被关掉了。合起来翻译成人话你的程序发起了 TLS 连接但在证书校验、协议版本协商、加密套件匹配这几个环节中的某一环没对上对端直接把连接掐了。它不是一个Python 语法错误而是一个网络信任链没建立起来的问题。适合谁来参考凡是写过爬虫、做过自动化测试、跑过浏览器自动化、或者在公司内网调过 HTTPS 接口的人基本都会撞上它。下面我按自己踩坑的顺序把这个报错从根因到修法完整捋一遍。2. 报错背后的核心机制拆解2.1 TLS 握手到底在谈什么要修这个错得先知道握手在干什么。TLS 握手本质上是双方在建立信任前的对暗号过程大致分四步客户端发 ClientHello我支持哪些协议版本、哪些加密套件、我带的 SNI 是哪个域名服务端回 ServerHello 并出示证书链客户端验证证书链是否可信、域名是否匹配、是否过期双方协商出会话密钥。任何一步不满足握手就断。SSL error code 1通常出现在第三步——证书验证失败。而net_error -100连接被关闭往往出现在第一步之后——服务端看到客户端的 ClientHello 里协议版本太低、或者 SNI 不对、或者干脆认为这个客户端不该来直接 RST。这两个错误码经常一起出现因为证书验证失败后客户端主动断开日志里就同时留下了两条。2.2 为什么 Python 会吐出 Chromium 的日志这是很多人困惑的点我写的是 Python怎么冒出个.cc文件原因在于 Python 只是个遥控器真正干活的是被它驱动的浏览器内核。Selenium 启动 Chrome、Playwright 启动 Chromium这些浏览器进程的 stderr 会被父进程捕获并打印。所以这条日志的作者是浏览器不是 Python 解释器。这也解释了为什么你在纯requests请求里很少见到它——requests 用的是 Python 自带的 ssl 模块报错格式完全不同会是SSLError加一段 OpenSSL 描述。理解这一点很关键修这个错的方向不在 Python 代码里而在浏览器启动参数、证书环境、网络出口这三处。我见过太多人对着 Python 代码反复改verifyFalse结果一点用没有因为验证逻辑根本不在 Python 层。2.3 错误码对照先定位再动手不同错误码指向完全不同的根因盲目试参数只会浪费时间。下面这张表是我这些年整理出来的对照关系遇到报错先查表定位再决定往哪个方向修。错误码组合大概率根因优先排查方向SSL error code 1 net_error -100证书链不被信任 / 对端主动断开证书、SNI、协议版本SSL error code 1 net_error -107证书域名与访问域名不匹配访问地址是否用了 IP 直连net_error -202证书颁发机构不受信任系统根证书库、自签证书net_error -201证书已过期服务器证书有效期net_error -101连接被重置中间设备拦截、出口限制net_error -105域名解析失败DNS 配置表格里前两行是最常见的。特别注意-107很多人调试时图省事直接用 IP 访问而证书是绑域名的域名对不上立刻握手失败。这种错改回域名访问就没了跟证书本身没关系。2.4 方案选型的取舍逻辑修这类问题有三条路关掉验证、补信任链、改协议参数。三条路的适用场景完全不同选错了要么修不好要么留下安全隐患。关掉验证verifyFalse、--ignore-certificate-errors最快但它只是让客户端闭眼握手该失败还是失败——因为对端可能压根不跟你谈。它只对证书本身有问题但服务端愿意继续的场景有效。补信任链是正解把自签 CA 或企业根证书装进信任库让验证真正通过这是生产环境唯一可接受的做法。改协议参数指定 TLS 版本、加密套件针对的是老旧服务端它们可能只支持 TLS 1.0/1.1而新版客户端默认最低 TLS 1.2谈不拢就断。我的原则是先补信任链补不了再降协议最后才考虑关验证且关验证只允许出现在本地调试。这个顺序能保证你修的是根因而不是把问题藏起来。3. 逐条排查与实操修复3.1 第一步确认是不是证书信任问题先做最小化验证把浏览器因素排除掉。用 Python 自带的 ssl 模块直接连目标看它报什么import socket import ssl hostname example.internal context ssl.create_default_context() with socket.create_connection((hostname, 443), timeout10) as sock: with context.wrap_socket(sock, server_hostnamehostname) as ssock: print(协议版本:, ssock.version()) print(加密套件:, ssock.cipher()) print(对端证书:, ssock.getpeercert())如果这段代码报CERTIFICATE_VERIFY_FAILED那根因就锁定了证书链不被信任。如果它顺利通过但浏览器仍然握手失败那问题在浏览器侧启动参数、代理配置、SNI往下看 3.3。如果这段代码报SSLV3_ALERT_HANDSHAKE_FAILURE或协议版本错误那是协议协商问题看 3.4。这一步的价值在于把Python 层和浏览器层切开。我踩过的坑是一开始就在 Selenium 的 options 里加了一堆参数结果真正的问题是公司内网的自签 CA 没装加多少参数都没用。先做这个最小验证能省掉大量瞎试。3.2 第二步补信任链的正确姿势确认是证书信任问题后别急着verifyFalse。正确做法是把 CA 证书装进信任库。分两种场景。场景一企业内网自签 CA。找运维要根证书通常是.crt或.pem然后有两种装法。临时装法是在代码里指定import ssl import requests # 把企业根证书和系统默认证书合并使用 ctx ssl.create_default_context(cafile/path/to/corp-root-ca.pem) resp requests.get(https://example.internal/api, verify/path/to/corp-root-ca.pem)注意requests的verify参数如果只传自定义 CA 文件会替换而不是追加系统证书导致访问公网 HTTPS 反而失败。稳妥做法是用certifi的证书包加上企业证书合并成一个 bundleimport certifi with open(certifi.where(), rb) as f: system_cas f.read() with open(/path/to/corp-root-ca.pem, rb) as f: corp_ca f.read() with open(/tmp/combined-ca.pem, wb) as f: f.write(system_cas b\n corp_ca)然后统一用/tmp/combined-ca.pem。这个合并动作看起来笨但它避免了修好内网、弄坏公网的经典翻车。场景二浏览器自动化场景。Selenium 或 Playwright 驱动的浏览器有自己的信任库跟 Python 的 certifi 不是一回事。Chromium 在 Linux 上读系统 NSS 库在 Windows 上读系统证书存储。所以你得把 CA 装到操作系统层面而不是 Python 层面。Linux 下大致是把证书拷到/usr/local/share/ca-certificates/然后跑update-ca-certificatesWindows 下用证书管理器导入受信任的根证书颁发机构。装完重启浏览器进程才生效。注意不要用--ignore-certificate-errors来解决内网证书问题。这个参数会让浏览器对所有站点关闭验证包括你访问的公网站点等于把整条链的安全都拆了。它只适合本地临时调试且调试完必须删掉。3.3 第三步浏览器启动参数与 SNI 排查如果最小验证通过、但浏览器仍握手失败重点查两处启动参数和 SNI。启动参数方面常见的坑是--headless老模式--headlessold在某些版本里对 TLS 的处理和新模式不一致换成--headlessnew往往就好了。另一个坑是代理参数如果环境里残留了--proxy-server指向一个已经下线的代理浏览器会先连代理代理再连目标握手在代理那一跳就断了日志里照样是handshake failed。排查方法是把代理参数全部去掉跑一次能通就说明是代理的锅。SNI 方面Chromium 在访问 IP 地址时默认不发 SNI因为 SNI 是域名而很多内网服务端是多域名共用一张证书、靠 SNI 分流。用 IP 直连就会拿到默认证书域名对不上握手失败。解决办法是 hosts 里给 IP 绑个域名用域名访问。这个细节非常隐蔽我当初排查了大半天才发现是 IP 直连惹的祸。from selenium import webdriver from selenium.webdriver.chrome.options import Options options Options() options.add_argument(--headlessnew) # 不要在这里加 --ignore-certificate-errors先确认根因 # 如果确实需要指定 CA用下面这个参数指向合并后的证书文件 # options.add_argument(--ignore-certificate-errors-spki-listhash) driver webdriver.Chrome(optionsoptions) driver.get(https://example.internal/)--ignore-certificate-errors-spki-list这个参数值得单独说它只对证书公钥哈希匹配的特定站点关闭验证比全局关闭安全得多。哈希可以用openssl x509 -in cert.pem -pubkey -noout | openssl pkey -pubin -outform der | openssl dgst -sha256 -binary | openssl enc -base64算出来。适合就这一个内网站点证书有问题、其他都要正常验证的场景。3.4 第四步协议版本与加密套件协商老旧服务端只支持 TLS 1.0/1.1 的情况在内网并不少见。新版 Chromium 和 Python 3.10 默认最低 TLS 1.2谈不拢直接断。验证方法是显式指定低版本去连import ssl import socket context ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT) context.minimum_version ssl.TLSVersion.TLSv1 context.check_hostname False context.verify_mode ssl.CERT_NONE with socket.create_connection((legacy.internal, 443), timeout10) as sock: with context.wrap_socket(sock, server_hostnamelegacy.internal) as ssock: print(协商成功版本:, ssock.version())能连上就说明是协议版本问题。但这里要泼盆冷水降协议版本是权宜之计不是解法。TLS 1.0/1.1 早已被主流弃用正确做法是推动服务端升级。如果短期内升不了只能在客户端临时放开并且明确记录这是技术债。Chromium 侧对应的参数是--ssl-version-mintls1但新版 Chromium 已经逐步移除这类开关所以浏览器路线可能走不通得回到 Python 层用requests或httpx自己控制。加密套件不匹配相对少见但某些国产化环境里会遇到。排查时用openssl s_client -connect host:443 -tls1_2看服务端实际支持哪些套件再对照客户端配置。这一步偏底层一般到不了先跳过。3.5 第五步网络出口与中间设备还有一种情况证书、协议、参数全对就是握手失败。这时候要怀疑中间设备。企业网络里常见的 TLS inspection 设备会拦截 HTTPS 流量、用自己的证书重新签一份给客户端。如果客户端不信任这个中间 CA握手就断日志正是SSL error code 1。判断方法用openssl s_client -connect host:443 -showcerts看服务端返回的证书链如果链里出现一个你不认识的中间 CA尤其是名字里带公司名或安全设备厂商名的那就是被拦截了。解法是把这个中间 CA 装进信任库或者让网络管理员给你的出口开白名单。这个环节个人开发者往往无能为力得找运维配合但至少你能准确说出问题在哪而不是对着报错干瞪眼。4. 常见问题速查与避坑心得4.1 高频问题速查表现象根因处理动作加了 verifyFalse 仍报握手失败验证不在 Python 层在浏览器层改浏览器启动参数或装系统证书公网正常、内网报错内网自签 CA 未信任合并 CA bundle 或装系统根证书用 IP 访问报错、域名正常SNI 缺失导致证书不匹配改用域名访问换机器就好、本机不行本机证书库或代理配置差异对比两机的证书存储与代理设置偶发握手失败、重试就好网络抖动或对端限流加重试与退避别当证书问题修升级 Python 后突然报错新版默认 TLS 最低版本提高检查服务端协议版本表格里偶发失败那行特别值得说。有些服务端在并发高时会主动断掉部分握手表现就是间歇性net_error -100。这种不是配置问题加个指数退避重试就能压下去。我见过有人把它当证书问题查了两天最后发现是服务端限流。4.2 三条踩坑心得心得一先分层再动手。这个报错横跨 Python、浏览器、操作系统、网络四层不先分层定位就改参数等于蒙眼修车。我现在的固定动作是先用ssl模块裸连做最小验证确定问题在 Python 层还是浏览器层再往下钻。这一步花五分钟能省几小时。心得二关验证是止痛药不是解药。--ignore-certificate-errors和verifyFalse能让报错消失但握手失败如果是协议或 SNI 问题关了验证照样失败。更糟的是关了验证后你会误以为修好了等到生产环境出安全事件才发现。我的规矩是本地调试可以临时关提交代码前必须删干净CI 里加一条检查禁止这两个写法出现。心得三把证书合并脚本固化下来。内网开发的人几乎都会遇到自定义 CA 和系统 CA 二选一的困境。与其每次手动合并不如写个小脚本放进项目工具目录启动时自动生成合并 bundle。这个动作一次投入、长期受益团队里新人来了也不用再踩一遍。4.3 一个容易被忽略的细节日志里的行号(992)会随 Chromium 版本变化网上搜到的改第 992 行之类的说法全是误导——那是编译期源码行号你根本改不了。真正有用的是错误码组合行号只是定位日志位置的辅助信息。看到别人帖子里行号和你不一样别慌看错误码就行。另外如果你是在 Docker 容器里跑容器基础镜像往往只带了精简的 CA 包比如ca-certificates没装全表现就是公网 HTTPS 也握手失败。这种在Dockerfile里补一句装ca-certificates就解决了跟业务代码无关。容器场景下这个坑出现频率相当高值得单独记一笔。5. 把修复固化成可复用的检查流程修一次不算本事让同类问题不再反复出现才是。我现在的做法是把排查流程固化成一个小脚本新环境部署时先跑一遍输出一份信任链体检报告目标域名能否解析、TLS 版本协商结果、证书链是否完整、根证书是否在信任库、SNI 是否正常发送。任何一项不过脚本直接指出该补哪一环。这个脚本的核心就是 3.1 里那段ssl裸连代码加上证书链解析和信任库比对。写起来不到一百行但能把凭感觉试参数变成按报告修问题。团队里推广之后同类报错的平均处理时间从半天降到十几分钟因为大家不再从零开始猜。最后分享一个我个人的判断习惯看到handshake failed先别读后面的错误码先问自己三个问题——我连的是域名还是 IP我的信任库里有没有对端的根证书我的客户端最低 TLS 版本和服务端最高版本有没有交集这三个问题答完八成的情况根因就浮出来了。剩下的两成才需要动用抓包和中间设备排查这些重手段。