先讲个真实场景:早上刚到工位,组里同事就甩过来一条报错,说 Go 写的内部工具连不上新部署的服务,日志里就这么一句话:
verify certificate: x509: certificate relies on legacy Common Name field, use SANs instead这条报错现在越来越常见,尤其当你用 Go 1.15 之后的版本,去访问一些“历史遗留”的 HTTPS 服务,或者自签证书没配好 SAN 的内部系统。它的大白话意思是:你证书里的域名信息只写在 Common Name(CN)字段里,而 Go 现在默认只看 Subject Alternative Name(SAN,主题备用名称),找不到就直接拒绝连接。
这篇文章我打算把这个错误彻底讲透:为什么 Go 突然翻脸、怎么确认证书里到底有没有 SAN、四种修复方案怎么选,以及我在实际排障中踩过的坑和总结的排查套路。不管你是维护老系统的运维、写 Go 服务端的开发,还是刚接触自签证书的新手,这篇文章都能帮你少走弯路。
1. 这个报错的来龙去脉:Go 1.15 为什么和 CN 过不去
1.1 CN 与 SAN 到底差在哪
要理解这个报错,先得搞清楚 X.509 证书里两个字段的分工。Common Name 是证书里的“常用名”,在 SSL/TLS 证书里通常填域名,比如CN=example.com。而 Subject Alternative Name 是证书的扩展字段,里面可以列一堆备用名称,包括域名(DNS)、IP 地址(IP)、邮箱(email)等。
你可能会想:既然 CN 都写了域名,为什么还要 SAN?问题就出在“CN 能写的东西太不严谨”。一个 CN 只能填一个值,虽然老浏览器也支持通过通配符匹配,但它本质上是个“给人看的名称”,不是专门给机器做域名校验用的。SAN 才是标准体系里真正用来做“主机名验证”的扩展字段,一个证书可以同时列DNS:example.com、DNS:www.example.com、IP:127.0.0.1,清清楚楚。
打个比方:CN 像快递单上“收件人”一栏,SAN 像“收件地址列表”。以前快递员只看收件人名字就敢送,现在规则改了,必须核对地址列表里的详细地址,地址列表为空就拒收。这个变化在浏览器圈早就完成了,但到了 2020 年才轮到 Go。
1.2 浏览器和 Go 的“分道扬镳”
早期的 SSL 证书标准里,CN 是校验主机名的主要依据,RFC 6125 等标准出来后,正式把主机名校验挪到了 SAN。浏览器厂商跟进得比较早,主流浏览器从 2017 年左右就开始强制要求证书必须包含 SAN,否则直接提示不安全。
Go 语言则是在 1.15 版本(2020 年 8 月发布)做出了一个破坏性变更:x509 包在校验证书时不再回退到 CN 字段,如果证书里没有 SAN,就返回x509: certificate relies on legacy Common Name field, use SANs instead这个错误。
这个变更影响面很大,因为很多内部系统的自签证书、老一代 CA 签发的证书都是只填 CN 不填 SAN 的。更麻烦的是,你写好的 Go 程序在 Go 1.14 及更早版本跑得好好的,一升级到 1.15+,连不上旧服务,而且报错信息对新手很不友好。我当时就遇到过客户环境里 Docker Registry 连不上旧仓库证书的问题,原因就是 Docker 的 Go 运行时升级后,对证书的要求变严了。
1.3 你会踩到它的真实场景
这个报错最常出现在几类场景:
- Kubernetes 集群里组件之间通过 HTTPS 通信,证书只用了 CN 没配 SAN,kubelet 升级后就报错。
- Docker 配置了私有镜像仓库,仓库证书是自签的且没有 SAN,pull/push 镜像时失败。
- Go 微服务调用内部 API,对方用的是老 CA 签发的证书,只写了 Common Name。
- 各种调用外部 HTTPS 接口的 Go CLI 工具,比如
grpcurl、go-resty类客户端。 - 内部监控系统(Prometheus、Grafana)抓取 HTTPS 端点时发现证书不合法。
这类报错的特点就是:你从浏览器访问这个服务可能一切正常,因为浏览器要求的 SAN 校验规则早已普及;但换成 Go 写的程序一访问就挂。原因就是 Go 的运行时策略跟浏览器不完全一样,而且 Go 1.15 之后变得异常严格。
2. 先诊断再动手:如何确认证书里有没有 SAN
2.1 一条命令看清证书全貌
收到报错先别急着改代码,第一步肯定是确认服务端证书到底长什么样。用 OpenSSL 看证书详细信息:
openssl x509 -in server.crt -noout -text重点看输出里的这两段:
Subject: C=CN, O=Internal CA, CN=myserver.example.com ... X509v3 extensions: X509v3 Subject Alternative Name: DNS:myserver.example.com, DNS:www.myserver.example.com如果X509v3 extensions下面没有X509v3 Subject Alternative Name这一段,那就说明证书里确实没有 SAN,报错必然会出现。
如果只想快速看 SAN,可以精简成一行:
openssl x509 -in server.crt -noout -ext subjectAltName输出类似:
X509v3 Subject Alternative Name: DNS:myserver.example.com, IP Address:127.0.0.1有输出说明证书带 SAN,没输出说明证书没带。这是排查这类问题的“第一板斧”,建议牢牢记住。
2.2 pem、crt、key 文件怎么区分
排查时经常被文件后缀搞晕,这里顺带说清楚。.pem、.crt、.cer通常都是 PEM 格式的证书文件,里面是 Base64 编码的证书内容,开头长这样:
-----BEGIN CERTIFICATE-----.key是私钥文件,开头通常是:
-----BEGIN PRIVATE KEY-----有的老系统会把证书和私钥放在同一个.pem文件里,中间用不同的BEGIN标记区分。查看这类文件时,用openssl x509会报错,因为 OpenSSL 默认只解析第一个BEGIN CERTIFICATE块。可以用awk先拆分:
awk 'BEGIN {c=0} /BEGIN CERTIFICATE/{c++} {print > ("cert_" c ".pem")}' combined.pem不过实际排查中,更常用的手法是用openssl s_client直接连接远程服务,把线上证书拉下来看:
openssl s_client -connect myserver.example.com:443 -servername myserver.example.com 2>/dev/null | openssl x509 -noout -text这里-servername参数会带上 SNI,确保拿到的是对应域名的证书。这个方法在排查线上问题时非常有用,不用登录服务器就能看到对方证书的 SAN 情况。
2.3 确认“谁的证书”出了问题
有一点容易混淆:报错可能来自客户端校验服务端证书,也可能来自服务端校验客户端证书(双向 TLS)。判断方法看报错出现在哪一侧。
- 客户端日志报
x509: certificate relies on legacy Common Name field,说明是客户端不信任服务端证书,查服务端的证书。 - 服务端日志报这个错,说明是服务端不信任客户端证书,查客户端证书。
- 代理或网关(nginx、Envoy)报类似错误,则要看代理和后端之间的 TLS 配置。
另外,Go 报错信息里的verify certificate:前缀一般来自 gRPC 的 TLS 握手,或者 、http.Client的 HTTPS 请求。这时候先确认你访问的域名和证书里的 SAN 是否匹配,因为即使证书有 SAN,只要 SAN 里没有你要访问的域名,Go 也会拒绝。
3. 四种修复方案:从最快到最彻底
3.1 方案一:OpenSSL 重新生成带 SAN 的证书
如果你能控制服务器证书,最推荐的做法是直接用 OpenSSL 重新生成一份带 SAN 的证书。自签证书一条命令就能搞定:
openssl req -x509 -newkey rsa:2048 -nodes \ -keyout server.key -out server.crt \ -days 365 \ -subj "/CN=myserver.example.com" \ -addext "subjectAltName=DNS:myserver.example.com,DNS:www.myserver.example.com,IP:127.0.0.1,IP:192.168.1.10"解释一下参数:
-x509表示直接生成自签证书,而不是生成证书签名请求。-newkey rsa:2048生成新的 2048 位 RSA 私钥,-nodes表示私钥不加密。-subj "/CN=myserver.example.com"设置主题信息,这里 CN 可以先填一个主域名。-addext "subjectAltName=..."是 OpenSSL 1.1.1 及以上版本支持的直接添加 SAN 扩展的参数,非常方便。
生成后可以再次用第一板斧确认:
openssl x509 -in server.crt -noout -ext subjectAltName如果你用的是旧版 OpenSSL 或者需要通过 CSR 交给 CA 签发,那么需要准备一个 OpenSSL 配置文件。比如server.cnf:
[req] distinguished_name = req_distinguished_name req_extensions = v3_req prompt = no [req_distinguished_name] C = CN O = Internal CN = myserver.example.com [v3_req] subjectAltName = @alt_names [alt_names] DNS.1 = myserver.example.com DNS.2 = www.myserver.example.com IP.1 = 127.0.0.1 IP.2 = 192.168.1.10然后生成 CSR 并让 CA 签名:
openssl req -new -key server.key -out server.csr -config server.cnf openssl x509 -req -in server.csr -CA ca.crt -CAkey ca.key -CAcreateserial \ -out server.crt -days 365 -extensions v3_req -extfile server.cnf这里最容忽略的是签名时也要带上-extensions v3_req -extfile server.cnf,否则即便 CSR 里带了 SAN,最终签出的证书也未必会保留 SAN。我见过不少人栽在这个细节上。
3.2 方案二:临时用 GODEBUG 绕过(只适合调试)
如果证书暂时改不了,又急着联调,Go 留了一个后门。Go 1.15 引入变更的同时,也提供了环境变量开关:
GODEBUG=x509ignoreCN=0 ./your_appx509ignoreCN=0表示恢复旧的证书校验行为,即当证书没有 SAN 时,回退到用 CN 字段匹配主机名。这个开关仅对 Go 1.15+ 有效,到 Go 1.17 时虽然仍可设置,但官方明确说后续版本会移除。
注意,这个方案仅适合本地调试或临时应急,千万不要在正式环境长期依赖它。原因很简单:CN 匹配不如 SAN 严格,容易遭受中间人攻击的变体。而且如果服务端证书属于“没有 SAN 的旧证书”,长期依赖GODEBUG开关等于把整个服务的证书安全策略拖回十年前。
如果程序是编译好的二进制,也可以用环境变量方式临时设置:
GODEBUG=x509ignoreCN=0 docker run ...在容器编排和 systemd 服务里也可以注入这个环境变量。但再强调一次,这只是应急止血,修复证书才是正路。
3.3 方案三:更新程序里的 CA 信任链
有些情况下证书本身没问题,而是程序里指定的根证书或中间证书不对,导致校验链不完整。这时报错信息可能混着其他错误,比如x509: certificate signed by unknown authority。
排查思路分三步:
第一步,确认你发起 HTTPS 请求的客户端是否指定了自定义 CA 文件。Go 的http.Client默认使用系统根证书,如果你的服务端证书是由内部 CA 签发的,且这个 CA 没有被加入系统信任库,就需要在客户端代码里显式加载:
pool := x509.NewCertPool() caCert, err := os.ReadFile("internal-ca.crt") if err != nil { log.Fatal(err) } pool.AppendCertsFromPEM(caCert) client := &http.Client{ Transport: &http.Transport{ TLSClientConfig: &tls.Config{RootCAs: pool}, }, }第二步,确认服务端是否把完整的证书链发给了客户端。检查方式用:
openssl s_client -connect myserver.example.com:443 -showcerts正常情况应该能看到“Certificate chain”里有至少两三个证书,最后一个是根证书。如果只有服务器证书一张,说明中间证书没有配置完整,需要在服务器上把链补齐。
第三步,确认证书过期时间。检查证书有效期最快捷的方式:
openssl x509 -in server.crt -noout -dates输出:
notBefore=Sep 26 00:00:00 2023 GMT notAfter=Sep 25 23:59:59 2026 GMT如果证书已经过期,Go 会报x509: certificate has expired or is not yet valid,这种就老老实实重新签发。
3.4 方案四:用 mkcert 一站式解决本地证书
如果你是本地开发环境需要自签证书,强烈推荐mkcert这个工具。它会自动生成本地根证书,并把它安装到系统信任库,然后一条命令生成带 SAN 的服务器证书。
安装方式(macOS 上用 Homebrew):
brew install mkcert初始化并安装本地根证书:
mkcert -install生成证书:
mkcert myserver.example.com localhost 127.0.0.1执行后会生成myserver.example.com+2.pem和myserver.example.com+2-key.pem两个文件,前者是证书,后者是私钥。这个证书天然带 SAN,既包含域名又包含 IP,完美解决 Go 1.15 的校验要求。
mkcert 在我们团队内部用得很多,尤其是给前端本地起 HTTPS、给 gRPC 服务联调、给 Docker 私有仓库配本地证书,省掉了手工维护 OpenSSL 命令的麻烦。不过它也有局限:生成的根证书只在当前机器受信任,如果要分发给其他同事或服务器,需要把根证书导出并在目标机器上安装信任。
4. 现场实录:那些年被证书坑过的场景
4.1 curl 访问自签名站点报 SSL 错误
网上经常搜到这种求助,形如:
curl https://esign.example.edu.cn:9100/ curl: (60) SSL certificate problem: self-signed certificate这类报错其实不带 Go 的 SAN 提示,因为它来自 curl/OpenSSL 的校验逻辑。但排障思路很像。如果你只是临时测接口,可以用-k跳过校验:
curl -k https://myserver.example.com:9100/但长期方案依然是让服务端配上受信任的证书,或者把自签根证书放进本机信任库。在 macOS/Linux 上可以用:
# macOS sudo security add-trusted-cert -d -r trustRoot -k /Library/Keychains/System.keychain ca.crt # Linux (Debian/Ubuntu) sudo cp ca.crt /usr/local/share/ca-certificates/ sudo update-ca-certificates配完再用 curl 访问,就能正常校验通过。另外一个小技巧:用curl -v可以看到完整握手过程,里面会输出“SSL certificate verify result”,对定位“unable to get local issuer certificate”帮助很大。
4.2 nginx 双向认证时报 no required ssl certificate was sent
还有一类常见报错是服务端开了双向 TLS(mTLS),但客户端没带证书,nginx 会报:
no required ssl certificate was sent这时你需要在 nginx 配置里加上:
ssl_verify_client on; ssl_client_certificate /etc/nginx/certs/ca.crt;客户端访问时需要带上证书和私钥:
curl --cert client.crt --key client.key https://myserver.example.com/如果客户端证书也出现 SAN 缺失的问题,服务端 Go 程序在解析时同样可能报x509: certificate relies on legacy Common Name field。处理方式和前面一样,要么重新签发客户端证书,要么在服务端临时开GODEBUG=x509ignoreCN=0。
这里有个容易被忽略的点:ssl_client_certificate填的是签发客户端证书的 CA,而不是客户端证书本身。很多新手配错,拿客户端证书去配,结果握手永远失败。
4.3 openssl verify 提示 unable to get local issuer certificate
这个错误是 OpenSSL 在校验证书链时找不到根证书导致的。如果你本地没有安装签发该证书的根 CA,那么即使证书本身没错,也过不了openssl verify校验。
测试方式:
openssl verify -CAfile ca.crt server.crt如果只写:
openssl verify server.crt默认用的是系统信任库,看不到内部 CA 就会报unable to get local issuer certificate。处理方式很简单:把内部 CA 加入系统信任库。
另外有个细节,OpenSSL 校验链时,对server.crt文件里包含的证书顺序有要求:先从叶子证书开始,再到中间证书,最后到根证书。如果顺序倒了,OpenSSL 也会报错。可以把证书链按顺序合并成一个文件:
cat leaf.crt intermediate.crt root.crt > fullchain.crt然后配置到 nginx 的ssl_certificate指令里。这个“fullchain”习惯要养成,很多线上事故就是只配了叶子证书。
4.4 TLS 握手 fatal alert: bad_certificate
还有一个比较吓人的报错:
fatal alert: bad_certificate - a corrupt or unusable certificate was received这个错误通常出现在握手阶段,原因多种多样:证书格式不对、证书链不完整、证书私钥不匹配、客户端和服务端支持的加密套件不一致。
我的排查顺序是:
- 确认证书私钥是否匹配:
openssl x509 -in server.crt -noout -pubkey | openssl md5 openssl pkey -in server.key -pubout | openssl md5两个 MD5 值一致才是同一对,否则证书和私钥不匹配。
- 确认证书链顺序:
openssl s_client -connect myserver.example.com:443 -showcerts看服务端是否把中间证书也发下来了。
- 确认证书格式是否正确,nginx 通常要求 PEM 格式,个别系统要求 PFX/P12。
如果服务端是 Java 系的,还可能遇到 keystore 里证书过期而 JVM 信任库没更新的情况,报错形态五花八门。遇到bad_certificate别慌张,按上面顺序排查基本能定位到问题。
5. 避坑清单与我的实操心得
5.1 证书生命周期的三个坑
第一个坑是证书有效期太短。内部系统经常签发一年期甚至更短的自签证书,导致一年后服务突然连不上。建议在 Go 服务里做证书过期监控,或者在 CI 里周期性地用脚本检查证书有效期:
openssl x509 -in server.crt -noout -enddate第二个坑是通配符证书的 SAN 写法。*.example.com只能匹配a.example.com,不能匹配a.b.example.com。需要多级泛域名时,SAN 里要写两个:
DNS.1 = *.example.com DNS.2 = *.api.example.com第三个坑是多域名证书重启服务时只挂了一张老证书。有的生产环境里 Nginx 配置和证书文件分离,更新证书时忘了重启或 reload,导致服务实际用的还是旧证书。排查时注意看服务进程的运行时间,别只看磁盘上的文件。
5.2 我总结的排查口诀
踩过几次坑之后,我给自己总结了一套排查流程,遇到证书相关报错基本能十分钟内定位。
第一句:先看报错来自哪一层。Go 报x509:是 Go 运行时校验失败;curl 报(60)是 OpenSSL 校验失败;浏览器报“您的连接不是私密连接”是浏览器内置校验失败。不同层级的报错信息差别很大,但都能用 OpenSSL 的s_client和x509 -text来检查服务端证书。
第二句:确认证书 SAN 齐不齐。用openssl x509 -in server.crt -noout -text | grep -A1 "Subject Alternative Name"看有没有、对不对。没有就是老的 CN-only 证书,直接重签;有但是域名对不上,说明匹配的是其他域名,检查访问地址。
第三句:确认链和信任库。openssl s_client -connect host:port -showcerts看服务端发了几张证书,openssl verify -CAfile ca.crt server.crt看本地能否校验证书链。
第四句:确认是不是版本问题。如果代码在旧环境能跑、新环境不行,重点看 Go 版本。Go 1.15 前和 1.15 后的证书行为变化很大,这个版本分界线值得每个 Go 开发者记住。
第五句:实在急用,GODEBUG=x509ignoreCN=0先顶着,但记录工单、限期换证书,别让这个开关上生产长期运行。
我个人在实际操作中,现在生成任何自签证书都会条件反射地加上 SAN,甚至在 CSR 模板里默认就写好subjectAltName。因为这已经不是“可选项”,而是 Go、浏览器、以及越来越多现代工具的硬性要求。多写一行 SAN,省掉的是一个团队一下午的排障时间,这笔账怎么算都划算。