bigywater.tech
微服务 · 87

mTLS:证书签发与轮换

服务网格里的 mTLS 证书怎么轮换才不断流?证书过期之后会发生什么?换 CA 的时候先动哪一边?这篇用 Envoy 1.39.3 的文件型 SDS、自己签发的小 CA 和 Python 客户端,把握手失败的各种错误信息和轮换的窗口逐项量出来。

0 / 199
服务端证书原子轮换期间的握手失败数(30 ms 内生效)
16
证书过期后到轮换完成之间失败的握手数(客户端每 200 ms 握手一次)
5 种
CA 轮换的信任包与证书组合里,服务端先换或客户端先去旧 CA 的两种会失败

握手为什么失败

实测 · 本机执行输出
  客户端                | 结果 | 详情                                                                                                                                       | 耗时(ms)
  -------------------+----+------------------------------------------------------------------------------------------------------------------------------------------+-------
  (a)CA1 签发的有效客户端证书  | 成功 | HTTP 200,服务端证书序列号尾号 …469362                                                                                                              | 5     
  (b)不带客户端证书         | 失败 | SSLError: [SSL: TLSV13_ALERT_CERTIFICATE_REQUIRED] tlsv13 alert certificate required (_ssl.c:2713)                                       | 1     
  (c)CA2 签发的有效客户端证书  | 成功 | HTTP 200,服务端证书序列号尾号 …469362                                                                                                              | 2     
  (d)未知 CA3 签发的客户端证书 | 失败 | SSLError: [SSL: TLSV1_ALERT_UNKNOWN_CA] tlsv1 alert unknown ca (_ssl.c:2713)                                                             | 1     
  (e)已经过期的客户端证书      | 失败 | SSLError: [SSL: SSLV3_ALERT_CERTIFICATE_EXPIRED] ssl/tls alert certificate expired (_ssl.c:2713)                                         | 2     
  (f)客户端不信任服务端的 CA   | 失败 | SSLCertVerificationError: [SSL: CERTIFICATE_VERIFY_FAILED] certificate verify failed: unable to get local issuer certificate (_ssl.c:108 | 1     
  上面的错误信息来自客户端 Python ssl 抛出的异常(握手和第一次读写都在 hs() 里统一捕获);服务端拒绝客户端证书时,客户端看到的是 TLS alert,不是 HTTP 响应

不带证书、未知 CA、过期证书、客户端不信任服务端的 CA,各有各的 TLS alert,而且都是立即失败,不是超时。

证书轮换

实测 · 本机执行输出
  共 199 次握手,失败 0 次;服务端证书序列号变化:旧 …782540 → 新 …507228;替换 SDS 文件的时刻 2.00 s,客户端第一次看到新证书的时刻 2.03 s(相差 30 ms);新证书出现之后又看到旧证书的次数 0
  原子替换 SDS 文件之后,新的握手很快(见上一行)用上新证书,期间没有握手失败

用 SDS 文件原子替换服务端证书,新握手很快用上新证书,本次测量没有握手失败。

证书过期

实测 · 本机执行输出
  旧证书(有效期 5 s)的握手在 0.0~4.5 s 成功(22 次);失败 16 次,时间范围 4.7~7.8 s;错误信息:SSLCertVerificationError: [SSL: CERTIFICATE_VERIFY_FAILED] certificate verify failed: certificate has expired (_ssl.c:1082);第 8.0 s 轮换,第一次用新证书成功在 8.0 s
  Envoy 在证书过期之后仍然把它提供给客户端(它没有因为过期而撤下证书),是客户端验证失败拒绝的;所以过期的窗口从证书到期一直延续到轮换完成,期间所有新握手都失败——证书要在到期之前很久轮换(例如有效期的一半),而不是到期时

Envoy 不会自己撤下过期证书,客户端拒绝握手,所有新连接失败,直到轮换完成。要在有效期过半时就轮换。

CA 轮换顺序

实测 · 本机执行输出
  组合                                 | 结果 | 详情                                                                                                                                      
  -----------------------------------+----+-----------------------------------------------------------------------------------------------------------------------------------------
  (a)信任 CA1,服务端证书 CA1                | 成功 | HTTP 200                                                                                                                                
  (b)信任 CA1,服务端证书 CA2(顺序错:服务端先换)     | 失败 | SSLCertVerificationError: [SSL: CERTIFICATE_VERIFY_FAILED] certificate verify failed: unable to get local issuer certificate (_ssl.c:108
  (c)信任 CA1+CA2,服务端证书 CA2(正确:先分发信任包) | 成功 | HTTP 200                                                                                                                                
  (d)信任 CA2,服务端证书 CA1(客户端先去掉了 CA1)   | 失败 | SSLCertVerificationError: [SSL: CERTIFICATE_VERIFY_FAILED] certificate verify failed: unable to get local issuer certificate (_ssl.c:108
  (e)信任 CA2,服务端证书 CA2(最终状态)          | 成功 | HTTP 200                                                                                                                                
  信任包要先于证书发出去:服务端证书先换成新 CA 而客户端还不信任它,握手立刻失败;客户端过早去掉旧 CA 同理。所以 CA 轮换分三步:分发包含新旧两个 CA 的信任包 → 换签发者 → 去掉旧 CA,每一步要等上一步在所有实例上完成
ERRORS {}

先分发新旧两个 CA 的信任包,再换签发者,最后去掉旧 CA;每一步等上一步在所有实例上完成。

分步演示

mTLS 证书轮换
本步的对象

    本文用到的工具与边界

    • 本机 macOS 27 arm64 / Python 3.14.8 / Go / nginx / Envoy / etcd / Consul / Nacos / OpenTelemetry / Prometheus(按各篇注明用到哪些及版本);所有服务都是本机自己起的进程,端口 9150 以上,只监听 127.0.0.1,不连任何外部服务,不碰本机已有的实例;故障用 kill -9 进程、让连接断开或在代码里注入延迟与错误来制造;计时用 time.perf_counter,数字每次运行会有小幅抖动。Envoy 1.39.3,监听器要求客户端证书,服务端证书和信任的 CA 通过文件型 SDS(path_config_source)下发;证书由 Python cryptography 签发(ECDSA P-256,URI SAN 为 spiffe 风格);客户端是 Python ssl;各节只跑了一次,毫秒数每次运行略有不同(例如生效延迟在几次运行里是 6 ms 到 36 ms)
    • 没有验证:已经建立的长连接在轮换之后用的是旧证书还是新证书;客户端证书(上游一侧)的轮换;Envoy 对 SAN 的校验(match_typed_subject_alt_names)和 SPIFFE 身份授权;证书吊销(CRL/OCSP);Istio、cert-manager 等真实证书签发系统的轮换流程(本篇是自己写的小 CA);多实例分批轮换时新旧证书混用的窗口
    京ICP备2026067502号-1京公网安备11010502063487号