上周有个客户把他们内部平台从 12 个微服务拆成 40 个之后,跨服务调用开始大面积报 x509: certificate signed by unknown authority。他们说这事很奇怪:代码一行没改,配置是从原来的网关抄过来的,测试环境好好的,一上生产就炸。研发查了两天,最后发现是服务之间的 TLS 双向认证证书到期了,而他们续签证书的时候,只签发了叶子证书(工作负载证书),没有把签发它的中间 CA 一起换掉。
于是就出现了这个局面:客户端手里的证书是新的,服务端手里的是新的,但两边各自信任的中间 CA 不是同一张。单向 TLS 的时候问题不大,浏览器会容忍服务端链上少一张中间证书(就是我们上一篇讲的那个坑),但 mTLS 是双向的,客户端要验服务端、服务端也要验客户端,任何一端的信任链断裂,对面立刻就报 unknown authority,而且是握手阶段就失败,连业务请求都发不出去。
如果你也在做服务间加密、正在给 Kubernetes 加 Istio,或者只是想给自己的内部 API 加一层双向认证,这两家的服务器可以先拿来做测试环境:joysol(服务器与主机,云服务器与裸金属多节点部署,适合搭多实例测试集群)、zorocloud(原生 IP 精品线路,CN2 GIA / 9929 / CMIN2 公网线路,公网 IP 固定适合做服务入口)。mTLS 这类功能对网络稳定性和时钟同步都很敏感,机器本身的环境会直接影响排查结论,下面我把这次踩坑的完整过程和后面总结出来的规范写下来。

一、mTLS 和单向 TLS 到底差在哪,别只当是「多了一层」
先把概念说清楚,否则后面排查全是玄学。
单向 TLS(我们平时给网站配的那种 HTTPS)里,客户端验证服务端:我是不是连上了你说的那个服务器?服务端不验证客户端是谁,任何人都能连上来连上之后被认证。所以它防的是中间人和流量被窃听,不防未授权客户端。
mTLS(双向 TLS) 多了反向的一步:服务端在握手时也出示自己的证书,客户端验证完之后还要反过来验证服务端的证书。验证不过直接断开。所以它防的是冒充服务端的假服务和非法接入的内网客户端。
握手过程大概是这个顺序:
客户端发 ClientHello,带上自己支持的证书类型和密码套件
服务端回 ServerHello + Certificate(服务端证书链)+ CertificateRequest(我要验你的证书了)
客户端回 Certificate(客户端证书链)+ ClientKeyExchange
服务端验证客户端证书链 + 客户端验证服务端证书链
双方协商出会话密钥,开始走对称加密
关键点在于证书链这个概念。单张证书是没法自证身份的,它得有个「链」:叶子证书(你的服务用的那张)由中间 CA 签发,中间 CA 由根 CA 签发。验证的时候,对端手上必须有根 CA 的公钥(或者根 CA 签的中间证书),才能一环一环往上验回去。任何一环对不上,就是 unknown authority。
我客户那个 bug 的本质就在这里:他们换了叶子证书和签发它的中间 CA,但没同步换对端信任列表里的根证书。而根证书的有效期通常是 5 到 10 年,运维同学压根不会想到去动它。

二、证书轮转这件事,为什么比想象中难
单张证书只能用 90 天,所以轮转(rotation)是必须做的一件事,不是可选项。很多人以为轮转就是「到期前重新签一张换上」,真做起来才发现里面的坑能踩一整年。
第一个坑:轮转必须双证书并行,不能是替换。正确做法是同时挂两张证书:一张快过期但还有效的旧证书,加上一张刚签发的新证书。客户端握手的策略是优先用自己认可的、有效期最晚的那张,所以新证书一挂上,流量就自然切过去了。等旧证书真正过期再下掉,这个过程可以做到无感。这叫「双证书热轮转」,Istio 里的 SDS 默认就是干这个的。
如果你做的是「删掉旧证书 → 挂新证书」这种一步到位的替换,那必然有一个窗口期是全挂的,而且这个窗口期在你毫无感知的时候就已经开始了因为客户端和服务端各轮各的,你根本不知道对方什么时候换的。
第二个坑:CA 轮转和叶子证书轮转是两件事,而且必须同一个时间窗口做。这是我们踩得最疼的一个。如果你要换中间 CA(做 CA 轮转),必须把「签发新叶子证书」和「信任新中间 CA」这两件事打包在同一个窗口里做,而且顺序必须是:
第一步:给所有服务签发由「新的中间 CA」签的新叶子证书,并挂上(此时双证书并存,旧 CA 签的旧证书还在用)
第二步:把新的中间 CA 加进所有服务的信任列表(此时新证书和旧证书都能被信任)
第三步:等所有客户端都握上了新证书(可以从服务端握手日志或者 mTLS 指标里确认),再把旧的中间 CA 从信任列表里移除
第四步:最后才把旧叶子证书下掉
我客户当时只做了第一步和第二步,跳过了第三步的确认,直接清理了旧 CA,结果有一批服务因为流量没打过来(比如那会儿正好有个定时任务没触发),证书还是旧的,但旧 CA 已经从信任列表里被删了这批服务瞬间全挂,而且挂得毫无规律:因为不同服务的连接池里,长连接和短连接混着,长连接还活着(用的是握手时已建立的会话),短连接一来就失败。这个现象当时把研发整懵了。
第三个坑:轮转的时钟要准。证书的有效期判断依赖系统时钟。机器时间漂了 5 分钟还好说,漂了几天就完蛋机器认为证书「还没生效」而拒绝使用,或者认为「已经过期」而拒绝使用。表现同样是握手失败,但错误信息不同:certificate is not yet valid 和 certificate has expired。所以轮转之前先确认 NTP 同步状态,这是最低成本的排除项。我见过凌晨三点有人手动改时间导致全站握手失败的。

三、真握手失败了,按这个顺序查
我把这套排查顺序固定成了一张清单,丢过人的时候十分钟能定位:
第一步:先把错误信息分个类,别急着手改配置。握手失败的错误码其实分得很细:
certificate signed by unknown authority → 信任链对不上,优先查 CA
certificate has expired → 证书真的过期了,或者时钟不对
certificate is not yet valid → 证书没生效,或者时钟超前
certificate is valid for X, not Y → 域名/SAN 对不上
certificate required / certificate verify failed → 客户端证书没带或验证不过
我客户那个是第一种。很多人一看 unknown authority 就去换叶子证书,换了三次都没用因为问题在 CA 层,不在叶子层。
第二步:把两端的证书链导出来并排比。服务端上执行 openssl s_client -connect 目标地址:443 -showcerts </dev/null,会打印出服务端发过来的完整证书链(从叶子到中间)。客户端侧把信任列表也导出来:
系统信任库:ls -l /etc/ssl/certs/ 加上 grep -r "中间CA名称" /etc/ssl/certs/
自建 CA 场景:直接看你的 CA bundle 文件
Istio 场景:istioctl proxy-config secret <pod名> -n <命名空间>,能直接看到 Envoy 实际加载的证书链
然后比两件事:链条上的中间 CA 是不是同一张(Subject 和 Authority 的指纹都对一遍),以及双方是否都带了完整的中间证书。只要有一方没带中间证书,链路就断了这跟上一篇浏览器那个问题是同一个原理,只是 mTLS 两边都要验,暴露面翻倍。
第三步:验证证书链本身是否自洽。把导出的链条存成一个 PEM,然后:
openssl verify -CAfile 根证书.pem -untrusted 中间证书.pem 叶子证书.pem
这条命令会把问题定位到具体哪一环。如果报 unable to get local issuer certificate,说明中间证书缺失;如果报 certificate signature failure,说明签发关系对不上(典型的 CA 换了一半的情况)。
第四步:确认双方真的在校验。这是一个很容易被忽略的架构级问题:有些服务虽然在配置文件里写了 require_and_verify_client_cert,但客户端库其实没送证书;或者 Istio 的 PeerAuthentication 策略是 PERMISSIVE 而不是 STRICT,证书验不过就降级放行了你以为加密了,其实没验。在 Istio 里查:istioctl proxy-config cluster <pod> --fqdn <服务名> -o json,看 transport_socket 里面有没有 validation_context。
第五步:看轮转窗口的日志。如果你接了 cert-manager,看它的 CertificateRequest 和 Certificate 事件;如果没有,看你自己签发流程的日志。重点是确认新旧证书的签发时间戳和生效时间戳,跟故障时间对得上。

四、几套主流方案的轮转节奏不一样,别混着抄
不同技术栈的默认行为差别很大,配置的时候要清楚自己在哪种模式里:
自签 CA + 自己管。最灵活也最容易出错。叶子的有效期你说了算,但 CA 的生命周期完全靠人记。我建议给根 CA 设一个显式的日历提醒,而不是靠「反正还有五年」这种口头共识。
cert-manager。最省心的方案。给 Certificate 资源指定 renewBefore,它会在到期前自动续签,而且默认就是双证书并存(新证书签好后先挂上,旧的等 renewBefore 窗口过了再删)。关键参数有两个:renewBefore 建议设成有效期的 1/3 到 1/2(90 天的证书设 30 天或 45 天),rotationPolicy 保持默认的 Always。CA 轮转用 CertificateRequest 配合 spec.isCA: true 来做,同样记得走「双 CA 并存」的流程。
Istio / Envoy SDS。网格内的证书由 Istio 的 CA(默认是内置的 istiod CA)签发,Envoy 通过 SDS 动态获取,所以单个 Pod 重启就能拿到新证书,不需要滚动重启整个 Deployment。这是它比手工方案省事的地方。但要注意两点:一是 istiod 自签 CA 的根证书必须分发到所有工作负载的信任列表,否则前面说的那个坑会原样复现;二是开了 istiod 的多副本时,需要保证它们签发的是同一个 CA,否则每个副本签的证书互相不认。我见过 istiod 扩到 3 副本之后随机握手失败的,就是这个原因。
Consul / Vault。Consul 的 Connect 用自己的 CA,Vault 的 PKI 引擎可以按需签发短生命周期证书(甚至几秒一批)。短证书的思路其实很值得学证书活的时间越短,轮转出问题的时间窗就越小。但前提是你的客户端 SDK 支持这种模式,否则每次请求都要拉证书,开销和故障点都会上来。
五、写在最后:给自己加几条硬规矩
这次事故之后,我们内部定了三条规矩,后来再没出过同类问题:
第一,任何 CA 变更必须走「双证书 + 双信任」流程,并且第三步的确认不能省。也就是上面那四步,一步都不能跳。特别是「等所有客户端都握上新证书」这个确认环节我们靠的是服务端握手日志里的证书指纹统计,不是「我觉得差不多了」。
第二,所有机器的时钟必须纳管到 NTP,并且监控时钟偏移。时钟漂移导致的握手失败最难查,因为它看起来和网络问题一模一样。监控上把 offset 单独拉一条曲线,超过 1 分钟就告警。
第三,证书到期时间必须进监控看板,不能靠人记。不是只监控叶子证书的到期时间,根 CA 和中间 CA 的到期时间也要监控。根 CA 通常是 5 到 10 年后的某一天,正是那种「所有人都忘了」的时间点。
另外补充一个容易被忽略的细节:轮转脚本本身要能重复执行。我们有一次半路失败的脚本,手工重跑的时候因为中间状态没清干净,反而把好证书给覆盖了。幂等性在证书这种「一次错就全挂」的场景里特别值钱。

如果你也在做 mTLS 或者服务网格,欢迎在评论区说说你们用的什么方案、轮转多久一次、踩过哪些坑,我看到会补充进来。



下载 
下载
下载
下载 
下载
下载 
下载
下载
下载
下载
下载
下载
喜欢
顶
难过
囧
围观
无聊



