Chaos Engineering 实战一次 Zone 故障演练暴露出 3 个隐藏的单点我用这套预案 15 分钟恢复说实话我们之前一直觉得自己挺高可用的。多可用区部署、数据库主从、K8s 副本数 3、告警链路到企业微信。纸面上一看该有的都有。直到上个月我们第一次认真跑了一次 Zone 级故障演练才发现“理论上能扛”和“真断了还能扛”之间差了不止三条街。演练场景很简单把 us-east-1b 这个可用区里的所有业务 Pod 全部干掉模拟一次 zone failure。结果核心链路在 3 分钟内开始异常客服群里陆续有人反馈“页面点不动”。更离谱的是我们花了 15 分钟才完全恢复——而且不是因为业务复杂是因为三个隐藏的单点没被发现。这篇文章就把这次演练的过程、暴露的问题、以及我现在沉淀的预案和 checklist 完整记下来。不是纯理论所有命令和配置都可以直接拿去跑。背景为什么要做 Zone 级演练我们是一套典型的云原生架构K8s 托管集群、RDS 多可用区、ElastiCache 主从、对象存储。业务流量走 Nginx Ingress → 业务服务 → 消息队列 → 结算系统。看似每个组件都有冗余但我们之前从没验证过“一个可用区整体挂掉”这种场景。导火索是隔壁团队的一次真实事故他们某个可用区因为运营商链路问题整体失联结果一个看似无关的“配置中心”实例刚好全在那个区导致全链路服务发现异常。那次事故让我意识到高可用不能只靠架构图要靠演练图。于是我们定了一个目标每季度做一次 Zone 级故障演练目标不是“证明系统没问题”而是“主动找出问题”。演练设计怎么模拟一个可用区挂掉我们没有直接物理断网而是用了 Chaos Mesh 的ZoneChaos配合节点标签来做模拟。核心思路是把某个可用区标签下的 Pod 全部删除并且让调度器在一段时间内禁止往这个区调度新 Pod。先用标签把节点按可用区打好标kubectl label nodes--alltopology.kubernetes.io/zone\--overwritekubectl label nodes$(kubectl get nodes-lzone1b-oname)\topology.kubernetes.io/zoneus-east-1b--overwrite然后写一个 Chaos Mesh 实验apiVersion:chaos-mesh.org/v1alpha1kind:PodChaosmetadata:name:zone-1b-failurenamespace:chaos-testingspec:action:pod-killmode:allduration:10mselector:namespaces:-productionnodeSelectors:topology.kubernetes.io/zone:us-east-1blabelSelectors:app:order-servicescheduler:cron:every 30s这个实验会每 30 秒把 us-east-1b 里 production 命名空间下、带apporder-service标签的 Pod 杀一遍。持续 10 分钟。足够暴露问题也不会真的把集群搞崩。提醒演练前一定要在非生产环境先跑三遍。我们第一次是在 staging 跑的结果把 staging 的 ZooKeeper 选主搞出了脑裂花了一个小时才恢复。血的教训。演练中暴露的 3 个隐藏单点单点一配置中心只在一个可用区有副本我们用的是自建的 Nacos 做配置中心。部署文档上写的是三节点但仔细一看三个 Pod 全在 us-east-1b。原因很简单当初部署的时候集群只有 1b 有足够的资源后来扩了 1a 和 1c但 Nacos 的 StatefulSet 没加反亲和性约束副本一直原地不动。演练开始后1b 被干掉Nacos 三个节点全挂。业务服务启动新 Pod 时读不到配置直接 CrashLoopBackOff。老 Pod 还能靠本地缓存撑一会儿但任何需要重启或扩容的场景都会炸。修复方案是给 StatefulSet 加上强反亲和affinity:podAntiAffinity:requiredDuringSchedulingIgnoredDuringExecution:-labelSelector:matchExpressions:-key:appoperator:Invalues:-nacostopologyKey:topology.kubernetes.io/zone然后滚动迁移强制把副本打散到三个可用区。单点二第三方 webhook 的 endpoint 写死了单一可用区入口我们的支付回调依赖一个第三方服务商。他们的回调地址是区域化的但我们在文档里抄了一个 us-east-1b 的入口写死在代码里。1b 一挂webhook 发不过来支付状态一直卡在“处理中”。这个问题特别隐蔽因为日常完全没问题。只有整个可用区失联时才会暴露。修复方案是把这个 endpoint 改成多可用区域名 健康检查importrandomfromurllib.parseimporturlparse WEBHOOK_ENDPOINTS[https://pay.us-east-1a.example.com/webhook,https://pay.us-east-1b.example.com/webhook,https://pay.us-east-1c.example.com/webhook,]defget_webhook_endpoint():# 实际生产环境建议用 DNS 权重 健康探测而不是随机returnrandom.choice(WEBHOOK_ENDPOINTS)更稳妥的做法是接对方的全局域名让 DNS 和任播来处理可用区切换。但这件事必须去供应商侧确认不能自己脑补。单点三有状态会话缓存没有跨区复制用户登录态存在 ElastiCache Redis 里。Redis 是主从结构主节点在 1b从节点在 1a。我们以为主从自动切换就够了但演练时切换花了 40 多秒期间大量用户被登出客服工单直接刷屏。更坑的是有些服务在 Redis 不可用时没有降级逻辑直接 500。等于说 Redis 主从切换虽然发生了但业务损伤已经造成。修复分两步把 Redis 主从改成 Cluster Mode让客户端能自动感知故障转移给所有读缓存的地方加降级缓存读不到就走数据库宁可慢不能挂。defget_user_session(user_id):try:returnredis.get(fsession:{user_id})exceptredis.ConnectionError:# 降级直接读数据库同时打标监控metrics.counter(cache_fallback_db).inc()returndb.query_session(user_id)15 分钟恢复的时间线演练当天我们的 on-call 同学按下面这个时间线恢复了业务T0:00Chaos 实验注入1b 业务 Pod 开始被批量删除。T1:30监控大盘出现订单成功率下降告警触发。T2:00on-call 确认是演练导致未触发回滚继续观察。T3:30客服开始反馈登录掉线、支付卡住。确认 Redis 主从切换进行中。T5:00Redis 完成自动切换但第三方 webhook 还在丢消息支付状态不一致。T7:00临时切换 webhook 到备用 endpoint支付回调恢复。T10:00Nacos 迁移完成新 Pod 可以正常启动。T15:00全链路指标回到基线演练结束。坦白讲这 15 分钟里至少有 8 分钟是在定位“到底是哪个单点”。如果问题事先都知道恢复能压缩到 5 分钟以内。所以混沌工程最大的价值不是训练恢复速度而是把未知风险变成已知风险。我沉淀下来的演练 checklist现在我们每次演练前都会过一遍这个 checklist演练后补一条复盘记录。演练前确认演练范围是 zone、node、还是单 Pod确认停止条件成功率降到多少立即终止通知客服和运营避免“误报警”变成真事故。在 staging 跑通至少一次完整实验。备份数据库和关键配置确保可以回滚。演练中指定一个指挥员其他人只记录不操作。每 30 秒记录一次核心指标订单成功率、P99 延迟、错误率、登录态异常数。发现超时或失败立即暂停实验先止血再复盘。演练后输出《单点清单》和《修复计划》。把修复项排进下一个 sprint责任人到天。更新 runbook把新发现的问题写成标准化恢复步骤。把有修复的项再跑一次演练验证是否真的解决了。写在最后混沌工程不是“没事找事”。在真实事故里那 15 分钟可能就是几十万甚至几百万的损失。演练把这 15 分钟“预支”到一个可控的周末让我们能笑着把问题挖出来。如果你也在做云原生架构我建议先别急着上更复杂的容灾方案先跑一次 Zone 级故障演练。你可能会发现你最担心的那个组件其实没问题真正脆弱的是那些你从来没想过会挂的东西。现在我们的演练已经从“季度一次”改成“月度一次”。不是因为我喜欢折腾而是因为每次都能找到新的坑。高可用这件事本来就是没有尽头的。参考资料Chaos Mesh 官方文档https://chaos-mesh.org/AWS Well-Architected 可靠性支柱https://docs.aws.amazon.com/wellarchitected/latest/reliability-pillar/welcome.htmlGoogle SRE BookChaos Engineering 章节