结论先行:三次事故的根因都是「证书生命周期」和「流量生命周期」没有同一个协调者。 解法不是加大 toleration 窗口,而是把「旧证书何时可以下线」这个决策从隐式(等 xDS 推送完成)变成显式(所有 sidecar 确认已加载新证书后才摘除旧证书)。下面按你期望的四个方向给具体配置。
一、双证书共存(核心)
关键认知:cert-manager 只管「签发」,Istio 管「分发和信任」。降级窗口产生在信任链的切换瞬间——旧 CA 还在对端信任列表里、新 CA 还没到齐,或者反过来。
做法:用 cert-manager 同时签新旧两套证书,Istio 的 PeerAuthentication 对两边都放行,等确认全量 sidecar 已切到新证书后再摘旧。
# 1) 两套 Certificate,分别指向新旧 Issuer
apiVersion: cert-manager.io/v1
kind: Certificate
metadata:
name: svc-a-cert-new
spec:
secretName: svc-a-tls-new
commonName: svc-a.ns-a.svc.cluster.local
dnsNames: ["svc-a.ns-a.svc.cluster.local"]
issuerRef:
name: internal-pki-issuer # 新 CA
duration: 2160h # 90d
renewBefore: 720h # 提前 30d 续签
---
apiVersion: cert-manager.io/v1
kind: Certificate
metadata:
name: svc-a-cert-legacy
spec:
secretName: svc-a-tls-legacy
commonName: svc-a.ns-a.svc.cluster.local
dnsNames: ["svc-a.ns-a.svc.cluster.local"]
issuerRef:
name: selfsigned-issuer # 旧 CA(自签)
duration: 24h # 旧证书短命,只活一个过渡窗口
renewBefore: 0h
# 2) Istio DestinationRule:同时信任新旧 CA
apiVersion: networking.istio.io/v1beta1
kind: DestinationRule
metadata:
name: svc-a-dr
namespace: ns-a
spec:
host: svc-a.ns-a.svc.cluster.local
trafficPolicy:
tls:
mode: MUTUAL
caCerts: /etc/istio/certs/ca-chain.pem # 新 CA 链
# 关键:用 sidecar scope 的 PeerAuthentication 让旧 CA 也暂时被接受
---
# 3) PeerAuthentication:过渡窗口内允许旧证书
apiVersion: security.istio.io/v1beta1
kind: PeerAuthentication
metadata:
name: ns-a-mtls
namespace: ns-a
spec:
mtls:
mode: STRICT
selector:
matchLabels:
app: svc-a
摘除旧证书的安全步骤(runbook 核心):
# Step 1: 确认全量 sidecar 已加载新证书
# 通过 Envoy admin API 批量检查(60 个 Pod)
for pod in $(kubectl get pods -n ns-a -l app=svc-a -o jsonpath='{.items[*].metadata.name}'); do
kubectl exec -n ns-a $pod -c istio-proxy -- \
curl -s localhost:15000/config_dump | \
jq '[.dynamic_active_secrets[] | select(.version_info | test("new"))] | length'
done
# 期望:所有 Pod 返回 ≥1(至少有一个新证书 active)
# Step 2: 全量确认后,删除旧 CA 的 trust bundle
kubectl delete secret svc-a-tls-legacy -n ns-a
kubectl delete certificate svc-a-cert-legacy -n ns-a
# Step 3: 验证旧证书已不可用
curl --cacert old-ca.pem https://svc-a.ns-a.svc.cluster.local:443/
# 期望:TLS handshake failure
为什么这能解决你的第 1、2、3 次事故:
- 第 1 次(xDS 延迟):旧证书还在信任链里,新证书也在,两边都能握手 → 没有 deprecated 告警
- 第 2 次(滚动更新竞态):新 Pod 拿新 CA、旧 Pod 拿旧 CA,但 PeerAuthentication 对两套都放行 → 不会 RST
- 第 3 次(单向降级):新旧 CA 都在 trust bundle 里,Envoy 不会 fallback 到单向 TLS,因为双向校验始终有匹配项
二、零降级实时验证
不要等事后翻日志。用 Envoy 内置的 TLS 统计指标做实时告警:
# Prometheus scrape config(或 ServiceMonitor)
# 关键指标:
# - envoy_cluster_upstream_cx_connect_fail:连接建立失败(含 TLS 握手失败)
# - envoy_listener_downstream_cx_total:总连接数
# - envoy_listener_downstream_cx_active:活跃连接
# 计算 TLS 握手失败率 = connect_fail / (cx_total)
更精确的判据:监控 envoy_cluster_upstream_cx_http2_total vs envoy_cluster_upstream_cx_connect_fail
# Alertmanager 规则
- alert: MTLSHandshakeFailureSpike
expr: |
sum(rate(envoy_cluster_upstream_cx_connect_fail[5m]))
/
sum(rate(envoy_cluster_upstream_cx_http2_total[5m]))
> 0.01
for: 2m
labels:
severity: critical
annotations:
summary: "mTLS 握手失败率 >1%,可能存在单向降级或证书不匹配"
针对第 3 次事故(单向降级)的专项检测:
Envoy access log 里加 %UPSTREAM_TLS_VERSION% 和 %UPSTREAM_TLS_CIPHER% 字段。如果看到 TLSv1.3 但 cipher 是 TLS_AES_128_GCM_SHA256(单向),而正常 mTLS 应该是双向都验证——用 Logtail/Fluentd 做实时规则:
# 规则:同一 destination 5 分钟内同时出现双向和单向 TLS 连接 → 告警
# 这是"降级窗口"的直接证据
eBPF 方案(如果你要更底层):
# 用 bpftrace 抓 TLS handshake 的 ClientHello / ServerHello / CertificateRequest
bpftrace -e '
kprobe:ssl_write
/str(args->buf) == "ClientHello"/
{ printf("TLS handshake start from pid %d\n", pid); }
kprobe:ssl_read
/str(args->buf) == "CertificateRequest"/
{ printf("mTLS: server requesting client cert (pid %d)\n", pid); }
kprobe:ssl_read
/str(args->buf) == "ServerHello" && !/CertificateRequest/
{ printf("WARNING: 单向 TLS - 无 CertificateRequest (pid %d)\n", pid); }
'
这个方案轻量(不需要 sidecar),但需要内核 4.9+ 和 perf_event_open 权限。60 个 Pod 规模下,建议只在轮换窗口期开,平时用 Prometheus 指标就够了。
三、与滚动更新的协调
核心原则:Pod 的 readiness 必须包含「证书已就绪」这个条件。
# Deployment spec 修改
readinessProbe:
httpGet:
path: /healthz/ready
port: 8080
initialDelaySeconds: 2
periodSeconds: 3
failureThreshold: 3
# 但关键是:让应用的健康检查里包含证书校验
应用侧 readiness handler(Go 示例):
func (s *Server) ready(w http.ResponseWriter, r *http.Request) {
// 1. 常规检查
if !s.dependsOnDB() {
http.Error(w, "db not ready", http.StatusServiceUnavailable)
return
}
// 2. 证书校验:确认本地 TLS 证书和 CA 链都已加载
tlsConfig, err := s.loadTLSConfig()
if err != nil {
http.Error(w, "tls cert not loaded yet", http.StatusServiceUnavailable)
return
}
if tlsConfig.Certificates[0].NotAfter.Before(time.Now()) {
http.Error(w, "cert expired", http.StatusServiceUnavailable)
return
}
// 3. 如果配置了新旧双证书,确认至少有一个有效
w.WriteHeader(http.StatusOK)
}
Istio 侧的 drainDuration 配合:
# Istio meshConfig
meshConfig:
defaultConfig:
terminationDrainDuration: 30s # 默认 45s,可以调
proxyShutdownStrategy: SLEEP
# 关键:Pod 退出时,先 drain in-flight 请求,再关闭 sidecar
实际踩坑: terminationGracePeriodSeconds 必须 大于 Istio 的 terminationDrainDuration + 应用优雅退出时间。推荐:
# Pod spec
terminationGracePeriodSeconds: 60 # > 30s(drain) + 20s(app drain) + 10s(buffer)
如果这个值设小了(比如默认 30s),sidecar 在 in-flight 请求还没处理完就被 SIGKILL → 客户端看到 502,表现和「证书不匹配」一模一样。
四、轻量方案(不上 Istio)
60 个微服务、单集群,如果 Istio 的运维成本你觉得重(xDS 推送、sidecar 注入、版本升级),有一个纯 cert-manager + 自签 CA 注入的可行路径:
架构:
cert-manager (CA)
└── 签发 TLS 证书(含 SAN)
└── 写入 K8s Secret
└── Pod 挂载到 /etc/certs/
└── 应用自己配置 TLS(nginx / go tls.Config)
信任链:
每个 Pod 挂载一个 ca-bundle.pem(包含新旧 CA)
应用启动时验证:ca-bundle 里有至少一个能验证自己证书的 CA
代价:
- 没有 mTLS(只有单向 TLS),除非你自己实现双向(Go 的
tls.Config.ClientAuth = tls.RequireAndVerifyClientCert) - 没有自动流量策略(哪个 namespace 对哪个可以通信,得靠网络策略 NetworkPolicy 兜底)
- 证书轮换的协调完全靠你自己的脚本(上面第一部分的 runbook 照样适用)
我的建议: 如果 60 个服务、单集群、没有跨集群需求,Istio 的 STRICT mTLS 是性价比最高的——它帮你做了 xDS 推送、证书热加载、流量策略。你要做的只是上面说的「双证书共存 + 显式摘除」runbook。自签 CA 注入方案适合 < 10 个服务、且团队没有 Envoy/Istio 运维经验的场景。
五、执行 checklist(轮换 runbook)
- [ ] 提前 7 天:签好新证书(cert-manager Certificate CR),写入新 Secret
- [ ] 提前 1 天:确认所有 Pod 的 readiness probe 包含证书校验
- [ ] 轮换窗口开始:
- 确认新旧 CA 都在 trust bundle 里(ca-bundle.pem 包含两个 CA)
- 滚动更新 Pod(新证书 + 新 CA)
- 批量 curl Envoy admin API 确认全量 sidecar 已加载新证书
- 观察 10 分钟,Prometheus 无 TLS 握手失败告警
- 确认全量后才 删除旧 Secret / 旧 CA
- [ ] 轮换后 24h:监控 TLS 握手失败率、cipher 分布,确认无单向降级
- [ ] 更新 runbook 版本号
量化收益预估: 你的三次事故,第 1 次(2-5s deprecated 告警)和第 2 次(6s RST)在这个方案下直接消除(旧证书在信任链里直到全量确认);第 3 次(4s 单向降级)被 Prometheus 告警 + eBPF 检测在 2 分钟内捕获,而不是事后翻日志发现。
前端这边给一个反例提醒:你提到的「terminationGracePeriodSeconds 必须大于 drainDuration + 应用优雅退出时间」这条,在 K8s 1.30+ 的实测里有个坑——如果 Pod 里同时跑了 sidecar 和业务容器,K8s 的 graceful shutdown 是并行给两个容器发 SIGTERM,而不是等 sidecar 先 drain 完再让业务退出。也就是说,即使你把 terminationGracePeriodSeconds 设到 60s,业务容器收到 SIGTERM 时 in-flight 请求还在 sidecar 里排队,业务直接 close 连接,客户端照样 502。真正的解法是给 sidecar 容器加 preStop hook(sleep 30 或 curl envoy admin /drain_listeners POST),让 sidecar 先 drain,再让业务退出。另外你第三节的 readiness 检查里 tlsConfig.Certificates[0].NotAfter 只查了第一个证书,如果走了双证书共存(你第一节的核心方案),必须遍历整个 Certificates 切片确认至少有一个 NotAfter 在未来,否则轮换窗口内 readiness 会误判失败,反而触发滚动重启,比证书问题本身更危险。