这个报错我处理得实在太多了,见得多到有点腻:SSL certificate problem: unable to get local issuer certificate。第一次遇到的人基本都会懵——这句话到底在说什么?“local issuer”指的是谁?证书又是谁签的?我先把这句话翻译成人话:SSL 库在验证服务端证书时,需要在你本地信任库中找到“给服务器证书签字的那张上级证书”,结果没找到。这篇内容就是围绕这个错误做一次完整复盘:从它常出现的现场,到逐步排查的思路,再到 Git、curl、Python、Node、Java 这些日常工具的修复方案,最后聊聊为什么我强烈建议你不要一上来就关闭证书验证。适合被这个报错卡住的开发同学、做接口联调的测试朋友,以及维护内部系统的运维同事。
1. 这行报错到底在说什么:先搞懂证书链和“本地信任库”
1.1 HTTPS 不是在验证一张证书,而是在验证一条担保链
HTTPS 握手时,服务器会把自己的证书发给客户端,比如一张服务于example.com的叶子证书。客户端要做的事不是单纯确认这张叶子证书真实存在,而是要顺着它的签名往上找:这张证书是谁签发的?那张签发证书又是谁签发的?最终要一路追到一张客户端本来就信任的根证书(CA Root)。
完整的证书链通常包含三层:叶子证书(服务于具体域名)、中间 CA 证书(给叶子证书签发)、根 CA 证书(给中间 CA 签发)。服务器正常情况下会把叶子证书和中间 CA 证书一起发给客户端,但根 CA 证书通常不随链下发,因为根证书早就预置在你的电脑或软件里了。
这就是unable to get local issuer certificate的真相:客户端需要在本地信任库里找到“叶子证书的签发者”,也就是中间证书或根证书,却在本地没找到。可能是服务器只发了一张叶子证书,中间环节断掉了;也可能是中间证书发了,但最上面的根不在本地。无论哪种,都表现为:无法构建一条完整的信任链。
生活化类比:你拿着一张某公司分公司开的介绍信去办事,对方验证的不只是介绍信本身,还要看开介绍信的分公司章是否在总公司备案过。如果对方没有总公司的印章样本,他只能说“我无法核实这份介绍信的签发资格”。TLS 的本地信任库,就是客户端保存的“总公司印章样本”。
1.2 “本地”并不是单指操作系统
这是很多人理解偏掉的地方。“本地”指当前程序运行机器上的一个信任库,但它不是单一的存在。有些程序直接读操作系统的证书库(Windows 证书存储、macOS 钥匙串、Linux 的/etc/ssl/certs);有些程序自带一份 CA 列表,比如 Python 的 requests 库内置cacert.pem、Node.js 内置根证书列表、JDK 自带cacerts、Git for Windows 自带ca-bundle.crt。
所以同一个证书,浏览器访问没问题,不代表 curl 和程序也没问题;反过来也是一样。这个错误最常出现在“浏览器能打开,但命令行工具报错”的场景,因为浏览器用的是系统或浏览器自己的信任库,而命令行工具可能用它自己携带的那份。想明白这一点,就不会再把它和网络不通混为一谈。
2. 报错出现之前的现场还原:五种高频场景对号入座
2.1 Git 拉取或推送仓库时
最常见的就是git clone https://git.xxx.internal/team/project.git或git push时,终端直接弹出一段红字:
fatal: unable to access '...': SSL certificate problem: unable to get local issuer certificate这类情况多数是企业内部 Git 服务器使用了私有 CA 签发的证书,Git 自己携带的 CA 列表里没有对应根证书。有的同事图省事,直接执行git config --global http.sslVerify false,然后整个 Git 就进入“裸奔”状态了。这个坑后面我会展开讲。
2.2 curl 调试接口返回 60
比如我随手敲curl -v https://api.internal.example.com/health,很快就看到:
curl: (60) SSL certificate problem: unable to get local issuer certificatecurl 的机制也一样,除非用--cacert显式指定,否则它默认依赖系统 CA 文件,而系统 CA 文件里没有企业私有根证书时,就会报 60 号错。
2.3 Python 脚本请求 HTTPS 接口
requests.get('https://...')直接抛异常时,报错文本会长一些:
requests.exceptions.SSLError: HTTPSConnectionPool(host='...', port=443): Max retries exceeded ... Caused by SSLError(SSLCertVerificationError(...) certificate verify failed ...)注意,Python 这里不同环境、不同版本措辞有差异,有段时间 macOS 上 Python 还容易报 “Python is not configured to use TLS/SSL” 之类,但底层很多时候就是同一个问题:在 requests 内置的证书库中找不到那张签发者证书。
2.4 Java 联调接口提示 PKIX path building failed
Java 的报错经常长这样:
sun.security.provider.certpath.SunCertPathBuilderException: unable to find valid certification path to requested target很多初学者觉得这跟“SSL certificate problem”是两码事,其实底子一致:JDK 的cacerts信任库里缺少对应的根证书或中间证书。只是 JDK 用了另一套证书校验框架,所以错误文案完全不同。
2.5 公司代理与抓包工具介入时
公司为了安全审计,在网络出口做 TLS 解密;或者你在开发时挂了 Charles、Fiddler 之类的代理抓包。这些工具会为每个 HTTPS 连接动态签发证书,相当于一个“中间人”。如果你要调试的脚本或命令行工具不信任这个代理的根证书,一样会报本文标题这个错。
这些场景我都实操遇过不止一次,也踩过“直接关闭验证”的坑。下面分享我认为最值得遵循的排查顺序。
3. 定位断点三步走:服务端没发全,还是本地不认账?
3.1 从 openssl 输出的证书链找答案
遇到报错第一件事,我不会去翻配置文件,而是先问服务端:你到底把证书链完整发过来了吗?用这条命令看:
openssl s_client -connect api.example.com:443 -servername api.example.com -showcerts </dev/null 2>/dev/null | grep -E 's:|i:'输出大概长这样:
s:/C=US/CN=api.example.com i:/C=US/O=Example Intermediate CA s:/C=US/O=Example Intermediate CA i:/C=US/O=Example Root CA-servername是触发 SNI 用的,很多多域名服务器不写它会返回默认证书。grep里s:是当前证书的 Subject,i:是它的 Issuer,也就是签发者。
如果输出里只有一行,比如s:是api.example.com,i:是某个 CA,那就说明服务端只发了叶子证书,没有把中间证书一起发过来。这是很典型的服务端证书链配置不完整,Nginx 需要把网站证书和中间证书按顺序串成一个文件后填到ssl_certificate,Apache 也有类似配置。这种问题你在客户端改任何配置都没用,只能让运维去修服务端。
如果输出里出现了多行,也就是叶子证书和中间证书都在,那服务端这边基本没问题,可以进入下一步。
3.2 对照本地信任库挨个排查
服务端链完整,报错还出现,问题就在本地信任库。这时候要找出“链顶层那张根证书”在本地是否存在。
Linux 上,直接看:
ls -l /etc/ssl/certs/ca-certificates.crt或者检索:
grep -A 20 "Example Root CA" /etc/ssl/certs/ca-certificates.crtWindows 上,按Win + R输入certlm.msc,打开本地计算机证书,在“受信任的根证书颁发机构”里找。macOS 则用“钥匙串访问”,查看“系统”分类下的证书。
如果工具用的是自带信任库,就要单独查。Git 的 CA 文件位置可以通过git config --global --get http.sslCAInfo看;Python 的 requests 用 certifi 包,直接检查site-packages/certifi/cacert.pem里面有没有对应根证书;JDK 可以执行:
keytool -list -keystore "$JAVA_HOME/lib/security/cacerts" -storepass changeit | grep -i "example"找到缺失项之后,再把根证书补进去。这一步的核心结论是:别只盯着一种信任库,要看你报错的那个工具到底读的是哪一份。
3.3 系统时间必须看一眼
这个点最容易忽略。TLS 证书的有效期判断依赖本地时间,本机时间如果比真实时间慢或快超过容忍范围,很多证书会被判定为“尚未生效”或“已过期”,同样会触发验证失败。我见过一台内网服务器因为主板电池失效,时间停在两年前,项目组排查了整个下午,最后发现只是时间同步没做。
在 Linux 上先date看一眼,再timedatectl status确认;Windows 上如果时间明显不对,先手动同步。时间对了之后,证书错误可能直接就消失了。
3.4 为什么不能跳过排查直接关验证
有些人看到报错就开始改配置:Git 关sslVerify、curl 加-k、Python 加verify=False。这样做确实能绕过去,但你永远不知道链到底断在哪。这次侥幸连上了,下次换一台机器、换一个仓库,同样的报错又会冒出来。
正确路线是先确认服务端链完整吗、本地缺哪一张、时间对吗,然后再进入下一节的按工具修复。
4. 分工具落地修复:Git、curl、Python、Node、Java 一套配齐
4.1 Git:指定 CA Bundle,而不是关掉验证
Git for Windows 可能使用 OpenSSL 后端,也可能用 Windows 的 schannel 后端,取决于安装时的选项。最稳妥的做法是显式指定一个 CA 文件:
git config --global http.sslCAInfo "/path/to/your-root-ca.pem"如果你的私有 CA 有多张根证书,可以拼成一个 PEM 文件给 Git。如果确定 Git 走的是 schannel 后端,且根证书已经导入 Windows 系统信任库,也可以试试:
git config --global http.schannelUseSSLCAInfo true无论哪种后端,都建议确认一下验证没有被关掉:
git config --global --get http.sslVerify输出应该是true。如果有人之前把它设成了false,立刻改回来:
git config --global http.sslVerify true4.2 curl:命令行与系统环境变量两条路
临时调试可以用:
curl --cacert /path/to/ca.pem https://api.example.com/health希望全局生效,可以设置环境变量:
export CURL_CA_BUNDLE=/path/to/ca.pem如果是在 Windows 的 PowerShell 里,就用$env:CURL_CA_BUNDLE="C:\path\ca.pem"。生产环境的脚本我更推荐在命令行级指定--cacert,避免环境变量影响其他无关命令。
需要提醒一句:curl -k是应急手段,不是修复方案。它会把证书验证完全关闭,局域网里任何人伪造服务器证书,curl 都会照单全收。
4.3 Python/requests:verify 参数与 CA 环境变量
requests 的信任库来源于 certifi 包。企业内部接私有 CA 最直接的方式是每次请求指定:
import requests resp = requests.get( "https://api.internal.example.com/health", verify="/path/to/ca.pem", ) print(resp.status_code)如果希望整个项目都生效,可以用环境变量:
export REQUESTS_CA_BUNDLE=/path/to/ca.pem export SSL_CERT_FILE=/path/to/ca.pemrequests 优先读取REQUESTS_CA_BUNDLE,而更底层的 urllib 原生调用会看SSL_CERT_FILE,所以两个变量都设到同一份 bundle,覆盖面更广。
这里有个细节:verify=False确实能立刻“解决问题”,但同时会产生InsecureRequestWarning警告。这个警告本身就是告诉你:当前处在不可信连接中。调试时可以临时用一次,长期脚本里出现它,我会建议直接拉黑。
4.4 Node.js:NODE_EXTRA_CA_CERTS 与 npm cafile
Node 程序访问 HTTPS 时,可以追加证书:
export NODE_EXTRA_CA_CERTS=/path/to/ca.pem这个环境变量会把证书追加到默认信任库之后,不会覆盖 Node 内置根证书。注意它只对 Node 的 TLS 模块生效,对 npm 安装依赖时的请求不生效。
npm 访问私有 registry 时,正确配置是:
npm config set cafile /path/to/ca.pem不是strict-ssl false。网上很多旧教程让人设strict-ssl=false,这是一颗定时炸弹,尤其是 registry 地址被劫持时,npm 将没有防护能力。cafile才是正确的姿势,它告诉 npm 使用哪张根证书去校验证书链。
4.5 Java:导入 cacerts 与自定义 TrustStore
JDK 的信任库默认位置在$JAVA_HOME/lib/security/cacerts(JDK 8),JDK 9 之后是$JAVA_HOME/conf/security/cacerts。导入命令:
keytool -importcert \ -alias my-internal-ca \ -file /path/to/root-ca.pem \ -keystore "$JAVA_HOME/lib/security/cacerts" \ -storepass changeit \ -noprompt导入后对使用该 JVM 发起的新 HTTPS 连接立即生效,不需要重启操作系统,但有些服务会缓存 SSLContext,需要重启进程。
如果不想动全局 JDK,也可以在应用启动时指定:
java -Djavax.net.ssl.trustStore=/path/to/truststore.jks \ -Djavax.net.ssl.trustStorePassword=changeit \ -jar yourapp.jar还可以把私有 CA 打包进自己的truststore.jks,然后交给代码里的SSLContext。对绝大多数联调场景,直接改cacerts是最省事的。
下面这张表基本覆盖了常见的信任库位置和配置入口,可以直接收藏:
| 工具/环境 | 默认信任库 | 推荐的配置入口 |
|---|---|---|
| Git | 系统库或自带 ca-bundle | http.sslCAInfo/schannelUseSSLCAInfo |
| curl | 系统 CA 文件 | --cacert/CURL_CA_BUNDLE |
| Python requests | certifi 内置 cacert.pem | verify/REQUESTS_CA_BUNDLE/SSL_CERT_FILE |
| Node.js | 内置根证书 | NODE_EXTRA_CA_CERTS |
| npm | 内置 CA 列表 | npm config set cafile |
| Java/JVM | $JAVA_HOME/lib/security/cacerts | keytool -importcert/trustStore启动参数 |
5. 自签证书、企业私有 CA 与抓包工具的特殊处置
5.1 先核实根证书指纹再放入信任库
如果是企业内部统一 CA,建议找网络管理员从企业 CA 门户下载根证书,不要自己在浏览器里“导出”。如果是某台服务器自签证书,也要从管理员或服务器本机导出。拿到证书文件后,先核对指纹:
openssl x509 -in root-ca.pem -noout -subject -fingerprint -sha256输出里会有一段形如SHA256 Fingerprint=...的字符串。把这个指纹和管理员给你的值比对,一致了再信任。这个步骤虽然多花十秒钟,但能防止证书在下载链路上被替换,属于值得坚持的安全习惯。
5.2 把证书导入操作系统信任库,而不是每个软件手工配
Linux 上,把root-ca.pem复制到/usr/local/share/ca-certificates/目录,扩展名必须是.crt,然后执行:
sudo cp root-ca.pem /usr/local/share/ca-certificates/root-ca.crt sudo update-ca-certificatesWindows 上用certlm.msc打开本地计算机证书,进入“受信任的根证书颁发机构”导入;macOS 需要双击证书文件,在钥匙串访问中把它加入“系统”并设置为“始终信任”。
操作系统层面导入后,浏览器和很多原生工具都能识别。但请记住第 1 章说的:Git、Python 虚拟环境、JDK、npm 等仍可能有自己的证书库,所以还是需要按第 4 章的方案配置。两者不是替代关系,而是互补关系。
5.3 抓包代理为什么也会触发这个错
Charles、Fiddler 这类工具的本质是 TLS 代理:它们本地生成一个自己的根证书,并为每次 HTTPS 连接临时签发一张域名证书。你的程序连上代理后,拿到的其实是代理签发的证书,不是服务器原本的证书。程序只要不信任代理根证书,就会报无法获取本地签发者证书。
解决办法不是关验证,而是把代理工具的根证书安装到目标信任库。Charles 的菜单里有 “Install Charles Root Certificate”,可以导出.pem文件;Fiddler 也有类似入口。导出后按第 4 章的方案,配置到对应工具或 JDK 里。
有一个经常踩的坑:代理工具重装或升级后,根证书可能重新生成,之前导入的信任关系全部失效,需要重新导入。所以别在出问题时才想起“我上个月明明配好了”,要习惯性检查证书生成时间。
5.4 不要让临时调试习惯变成生产隐患
办公网络如果强制走审计代理,全局跳过验证相当于把公司的安全审计也跳过了。更合理的做法是第一时间找网络部门确认代理根证书的分发方式,把它纳入系统信任库。这既解决了报错,也维持了安全边界。我在实际项目里见过有人把-k写进定时任务脚本,半年后安全扫描才发现,代价远大于当初配置证书的十分钟。
6. 禁用证书验证是下策:应急开关与根治思路的区别
6.1 关闭验证关闭的到底是什么
不管你是执行git config --global http.sslVerify false,还是给 curl 加-k,亦或是给 requests 传verify=False,关闭的都是“证书链信任验证”。HTTPS 加密本身还在,但客户端不再核实对面服务器是不是它声称的那台。于是中间人攻击变得异常简单:攻击者只要在局域网内部署一台伪造证书的服务器,客户端就会毫无戒备地把账号、Token 发过去。
有些事情只有测试环境能忍,生产环境绝对不能。即使只在联调时“临时用一次”,也很容易被人复制进生产脚本,变成长期隐患。这是所有证书类报错里最需要警惕的处理方式。
6.2 真正需要应急时,我怎么做
我的习惯是:在隔离的调试虚拟机里,且仅针对某个一次性的请求,可以允许自己用一次curl -k,但同一时间记下目标服务证书的 fingerprint 用于核对。Git 则用单次开关,而不是改全局配置:
git -c http.sslVerify=false clone https://git.xxx.internal/team/project.gitPython 里临时调试可以用verify=False,但代码里要留 TODO,并在调试结束后立刻删掉。核心原则是:让“跳过验证”这个状态是显式的、一次性的、可见的,而不是悄悄埋进全局配置里。
6.3 长期来看,让私有 CA 成为可信根
真正该做的事,是让私有 CA 成为组织内公认的信任源。具体几条经验:
- 公司内部系统统一由一套内部 CA 签发,避免每台服务器都是自签证书、每台开发机各自导入不同的东西;
- 通过自动化配置管理工具或组策略,把内部根证书分发到所有开发机和服务器;
- 服务端必须把证书链配置完整,叶子证书、中间证书按顺序合并在一个文件里;
- 定期更新工具和系统证书库,比如 Linux 上的
ca-certificates包、Java 的cacerts,避免因为旧 CA 列表缺少新根证书而报错。
我自己现在的习惯是:遇到SSL certificate problem: unable to get local issuer certificate,第一反应永远是先跑一次openssl看链,再检查信任库,最后才考虑临时开关。排查五分钟后把证书配好,比无脑跳过验证要可靠得多。毕竟这一行报错真正要告诉你的是“信任断了”,而不是“验证慢了”。