先给结论:轮换不出事故的核心不是"双跑多久",而是把"谁还在用旧凭据"从人肉确认变成可观测指标。我们去年做过 150+ 服务的凭据治理,以下是经过两轮实战后沉淀的落地方案。
1. 轮换窗口设计(按凭据类型差异化)
| 凭据类型 | 双跑时长 | 下线判据 | 回滚窗口 | |---|---|---|---| | 第三方 API Key | ≥14 天(覆盖完整周循环 + 月末对账) | 旧 Key 在出口代理上连续 7 天调用量 = 0 | 旧 Key 保留 30 天可恢复 | | 数据库账号 | ≥30 天(覆盖月末/季末对账 + 定时批跑) | 旧账号在 ProxySQL 上连续 14 天零新连接 | 旧账号 DROP 前保留 60 天 | | 内部静态 token | ≥7 天(覆盖完整发版周期) | 网关/mesh 侧旧 token 前缀连续 5 天零命中 | 旧 token 保留 14 天可恢复 |
回滚预案统一原则:旧凭据不是"删"而是"降级"——从"可用"状态改为"仅管理员可手动恢复",保留在 Vault/KMS 里 60-90 天。这样回滚不需要重新申请第三方 Key(那要等 2-3 天)。
2. 识别残留使用方:在"使用端"而非"拉取端"埋点
你提到 Vault/K8s Secret 日志只能看到"谁拉取过"——这是对的,因为拉取是启动时行为,真正的使用是运行时行为。我们的做法:
(a) 第三方 Key:出口代理 + Wasm 插件按 Key 维度计数
所有出流量走一个 Envoy sidecar(或集中式出口网关),加一个 Wasm 插件:
# envoy wasm plugin config (简化)
wasm:
plugin_name: "credential-tracer"
configuration:
# 匹配第三方 API 的认证 header
match_headers:
- name: "Authorization" # Bearer xxx
- name: "X-Api-Key" # 对象存储/短信网关
- name: "X-Auth-Token" # 支付回调签名
# 提取 key 的"指纹"(前 8 位 + 后 4 位),不记全量避免泄露
fingerprint_mode: "prefix8_suffix4"
# 按服务名 + 目标域名 + key 指纹聚合
metrics:
- name: "cred_use_total"
type: counter
tags: [service, target_host, key_fp]
这样你在 Prometheus 里一条查询就能看:
# 哪些服务还在用旧 Key(key_fp = "abc12345...xyz")
sum by (service) (cred_use_total{key_fp="abc12345__xyz"})
连续 7 天 = 0 才是安全下线判据。
(b) 数据库共享账号:ProxySQL 连接级审计
在 MySQL 前面加一层 ProxySQL(或 MariaDB MaxScale),开启连接审计:
-- ProxySQL admin
mysql> UPDATE mysql_users SET
username = 'svc_order', -- 新独立账号
password = '*NEW_HASH',
default_hostgroup = 10;
-- 开启查询日志(只记连接建立,不记 SQL 内容)
mysql> UPDATE global_variables SET
variable_value = '1' WHERE variable_name = 'monitor_connect_interval';
-- 查"谁还在用旧账号"
mysql> SELECT
src, -- 客户端 IP(对应 K8s Pod IP)
username, -- 旧账号名
COUNT(*) AS connections,
MAX(idle_time) AS last_seen
FROM stats_processlist
WHERE username = 'shared_super'
GROUP BY src, username
ORDER BY last_seen DESC;
更精确的做法:在 ProxySQL 里配一个 hostgroup 给旧账号,专门用来"接住"还在用旧账号的连接,然后按 src(Pod IP)反查是哪个服务:
# 拿到 Pod IP 后反查
kubectl get pods -A -o wide | grep 10.244.3.17
(c) 内部静态 token:给新 token 加可识别前缀 + 网关拦截
最轻量、成本最低的方案——给每个新 token 加一个带时间戳和序号的前缀:
旧 token: eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOi...(随机,无法区分新旧)
新 token: rot-2026Q4-svc042-eyJhbGciOiJIUzI1NiJ9...
在网关(或 Linkerd/Envoy mesh)侧加一条路由规则:
# Envoy route filter
headers:
- name: "Authorization"
present_match: true
# 在 access log 里记录 token 前缀
access_log_format: |
{"service":"%REQ(X-B3-NAME)%","token_prefix":"%REQ(AUTHORIZATION)%%PREFIX3%%","status":"%RESPONSE_CODE%"}
然后在 Prometheus/Grafana 上做一个 dashboard:按 token_prefix 分组统计调用量。前缀 = 旧格式的调用量降到 0,就可以安全下线旧 token 了。
3. 第三方 Key vs 内部 token 的差异化策略
| 维度 | 第三方 Key | 内部 token | |---|---|---| | 优先级 | 最高(泄露 = 资金/数据风险) | 最低(内网隔离) | | 轮换粒度 | 一 Key 一服务,不允许共用 | 可接受短期共用,逐步拆 | | 验证方式 | 调一次 API 看 200 即确认新 Key 可用 | 网关侧看 token 前缀命中即可 | | 下线风险 | 高(第三方不支持双 Key 并行时,旧 Key 一关新 Key 没生效就断) | 低(自己控制验证逻辑) | | 建议顺序 | 先做(第 1 个季度),每个 Key 单独灰度 | 后做(第 2 个季度),批量切 |
第三方 Key 的灰度顺序:按"影响面从小到大"排——先切只被 1 个服务用的 Key(约 40 个),再切被 2-3 个服务用的(约 15 个),最后切被 5+ 服务共用的(约 5 个)。每批之间留 3 天观察期。
4. 踩坑实录("以为下线了、其实还有人用")
坑 1:异步消费者的"幽灵调用" 我们有一个短信服务,主链路已经切到新 Key,但下游的"发送失败重试队列"(RabbitMQ)里还有 3 条老消息,消费者用旧 Key 重试时打到了第三方。结果:旧 Key 在"下线"后第 11 天又出现了一次调用。 教训:下线前必须 flush 所有异步队列(RabbitMQ / Kafka consumer group),或者给重试队列设一个硬 TTL。
坑 2:cron job / 定时任务
一个对账脚本跑在 crontab 里,配置写死在 /etc/cron.d/reconcile,不在 K8s 里。凭据盘点只看 K8s Secret,漏了它。
教训:盘点范围必须覆盖"所有能出网/连 DB 的进程",包括 sidecar、init container、裸金属 crontab、CI/CD pipeline。
坑 3:staging / 开发环境 生产切完了,staging 还在用旧 Key。某天 staging 的 Key 泄露被安全扫描器标记,才发现 staging 没切。 教训:轮换 checklist 里必须有"环境"维度(prod / staging / dev),每个环境独立验证。
坑 4:SDK 内部缓存
Java 的 HTTP client(OkHttp / Apache HttpClient)连接池里缓存了旧 Key 的 TLS session。切换 Key 后,复用旧连接的请求仍然带着旧 Key。
教训:切 Key 后必须重启应用(或至少 invalidate 连接池),不能只改环境变量然后 kubectl rollout restart——要确认 Pod 真的重建了(kubectl get pod -o wide 看 AGE)。
落地成本估算(给你算笔账)
以 200 个服务、60 个第三方 Key 为例:
- 出口代理 + Wasm 插件部署:2 人天(一次性)
- ProxySQL 部署 + 配置:1 人天(一次性)
- 每个第三方 Key 的轮换(申请新 Key → 部署 → 验证 → 观察 → 下线):0.5 人天
- 60 个 Key 串行做:30 人天;并行 3 个服务线同时做:约 10 人天
- 数据库拆分(假设 20 个共享账号):每个 1 人天 = 20 人天
- 内部 token(假设 100 个):每个 0.3 人天 = 30 人天
- 总计约 60-70 人天,分摊到两个季度,每周 2-3 人天的节奏,完全可控
核心原则:不要试图一次性切完,按"影响面从小到大 + 环境从 staging 到 prod"的顺序,每次只切一个凭据、验证一个凭据、下线一个凭据。 慢一点,但每一步都可观测、可回滚、可审计。
从 DBA 侧补一个你「坑 4」里没提但更常见的坑:共享账号拆独立账号时,旧账号不要走 DROP,而是先 REVOKE 全部权限、保留 GRANT 记录 30 天。原因:很多业务方不会立刻切代码,而是「重启后跑一次再发现 500」——这种情况下你 DROP 了就没法应急恢复;而 REVOKE 后连接还能建立、但一执行 SQL 就报 access denied,报错信息里直接带 username + host,业务侧定位比「账号不存在」快一个量级。第二个建议:ProxySQL 的 stats_processlist 只能看「连接建立时的 src IP」,但 K8s 里 Pod IP 是动态的,跨发版就对不上了。更稳的口径是在 ProxySQL 里给旧账号单独建一个 hostgroup + 独立线程池,把 max_connections 设成 1,这样旧账号一有残余调用就会立刻打满、直接拒绝,而不是慢慢消耗——配合一个 stats_mysql_connection_metrics 的告警,比按 IP 反查 Pod 可靠得多。第三个:你提到「DROP 前保留 60 天」,我的建议是保留 90 天 + 每月跑一次 SELECT user, host, COUNT(*) FROM performance_schema.host_cache 之类的兜底脚本,确认零流量再清。
「坑 4」SDK 缓存那段我补一个更隐蔽的变体:如果你们跑 service mesh(Istio/Linkerd),sidecar 的 outbound cluster 连接池会独立缓存 TLS session 和 HTTP/2 多路复用连接。kubectl rollout restart 只重建了 app 容器,sidecar 的 warm connection 还在——新 Key 的 Authorization header 变了,但 H2 stream 复用旧连接时,某些中间件(尤其是自研网关)会从连接级 session 里取认证信息,而不是每次从 header 读。我们线上踩了这个坑:重启后 200 正常,但 30 分钟后流量切回旧连接时 401 爆发。解法是在 mesh 侧配 drain + close_idle_connections 的 startup 策略,或者给 app 加一个 startupProbe 验证新凭据可用后才标 ready。
你这篇凭据轮换的 runbook 非常扎实,我从"流程管理"侧补一个你坑 1-4 都没覆盖的组织性风险:
"连续 7 天零调用"这个判据,对低频任务会系统性漏判。 我们去年切一个第三方支付 Key 时,出口代理上连续 14 天调用量为 0,我们据此下线了旧 Key。结果第 19 天,一个"季度对账补偿任务"(每 90 天跑一次,跑在独立 crontab 里,不在 K8s 内)被触发,旧 Key 已经没了,对账直接失败,财务数据断了一周。你的坑 2 提到了 crontab,但你的判据是"连续 7 天"——如果调用周期 > 7 天,这个判据形同虚设。
我的建议:判据不能是固定天数,而是 "≥ 该凭据历史最长调用间隔的 1.5 倍"。具体做法:在 Wasm 插件里不只记"最近 7 天调用量",而是记"该 key_fp 的历史最长间隔"(max(timestamp_diff)),然后下线判据 = max_gap × 1.5。如果一个 Key 历史上最长间隔是 45 天(月对账),那观察期至少要 67 天。
另外,你提到的"owner 离职"问题(虽然你没明说,但 60 个 Key 分属 200 个服务,必然有 owner 流动)——这和我自己写的 feature flag 退役是同一个根因:创建凭据的成本是 5 分钟,退役凭据的成本是 14 天 + 全链路验证,成本不对称导致"没人主动退役"。解法一样:把退役判据量化成可断言的指标(你的 Wasm 插件已经做到了),让"敢下线"变成"指标说能下线",而不是靠"我记得没人用了"。
顾若衡的"使用端埋点"思路非常对,我从自动化测试验收角度补两个落地建议:
-
旧凭据"零流量"判据需要区分"真零"和"采样零"。你提到出口代理 Wasm 插件按 key_fp 计数,这个方案有个盲区:如果某个服务的流量经过了服务网格的 mTLS 层(Istio sidecar),它的出站请求可能被 sidecar 代管,Wasm 插件只看到 sidecar 的出口而不看到原始业务 Key。我的建议是加一层"反向验证":在旧凭据下线前 24 小时,主动发一次带旧 Key 的探测请求(打到第三方沙箱端点),如果第三方返回 401/403 说明旧 Key 确实已失效;如果返回 200,说明还有存活路径你没覆盖到。
-
凭据轮换的验收测试应该是"破坏性测试"。建议把每次轮换拆成 5 个阶段,每阶段都有明确的 pass/fail 断言:(a) 新 Key 部署成功 → 断言:新 Key 调 API 返回 200;(b) 旧 Key 标记为 deprecated → 断言:旧 Key 调用仍成功但日志有 warn;(c) 观察期结束 → 断言:cred_use_total{key_fp=旧} 连续 N 天 = 0;(d) 旧 Key 下线 → 断言:旧 Key 调用返回 401;(e) 回滚演练 → 断言:从 Vault 恢复旧 Key 后 30s 内业务恢复。这 5 步写成 CI pipeline 的 stage,每次轮换自动跑,比人工 checklist 可靠得多。