我把 chaos 注入到发布流程:一次网络分区演练暴露的 3 个隐藏依赖

我把 chaos 注入到发布流程:一次网络分区演练暴露的 3 个隐藏依赖

说实话,我这两年对"发布成功"这四个字是越来越不敢掉以轻心了。

经历过太多次:代码上线没问题,监控全绿,结果到凌晨流量一涨,某个依赖服务突然抽风,整个链路连锁雪崩。最烦人的还不是故障本身,而是排查到一半发现:这服务我们根本没想到它会被调用。换句话说,它是个隐藏依赖。

所以这次我们换了个思路:与其等生产环境教育我们,不如在发布流程里主动搞 chaos。这篇文章记录一次网络分区演练,以及它暴露出的 3 个隐藏依赖。

为什么不是 CI 过了就发版

以前我们的发布流程是:

  1. 单元测试/集成测试通过
  2. 灰度到 5% 流量
  3. 观察 30 分钟,没有告警就全量

听起来合理,但问题在于:测试环境太干净了。所有服务都在同一个 VPC,网络延迟稳定,没有丢包,更没有某个中间件被运维重启。我们的集成测试只是验证了"功能正常",没有验证"系统在真实混沌环境下的表现"。

灰度只能发现已经知道的问题,但隐藏依赖的问题是:你根本不知道要观察什么指标。

所以我们决定,在灰度之前加一道 chaos gate:模拟真实故障,看系统能不能扛住。如果扛不住,这个版本不准进全量。

演练设计:只切 30 秒网络

这次目标是一个新上线的订单服务,它背后是 7 个下游调用。我们只模拟一个最简单的场景:订单服务到 payment 服务之间的网络分区,持续 30 秒。

工具用的是 Chaos Mesh,因为它已经接入了 K8s,并且支持网络延迟/丢包/分区。核心脚本就这几行:

apiVersion:chaos-mesh.org/v1alpha1kind:NetworkChaosmetadata:name:order-to-payment-partitionnamespace:chaos-testingspec:action:partitionmode:oneselector:namespaces:-order-servicelabelSelectors:app:order-apidirection:totarget:selector:namespaces:-payment-servicelabelSelectors:app:payment-apimode:allduration:30s

演练前,团队普遍认为:订单服务调用 payment 服务,失败了就应该直接熔断,返回"支付处理中",用户可以稍后查询。这个设计我们 review 过,看起来没问题。

结果一跑,三分钟之内,三个隐藏依赖全跳出来了。

演练时间线:30 秒分区里的连锁反应

我把关键时间点列出来,方便你理解故障是怎么一步步放大的:

  • T+0s:Chaos Mesh 注入网络分区,订单服务 → payment 服务不可达
  • T+3s:订单服务开始报错,错误率从 0.1% 跳到 12%
  • T+8s:错误率继续飙升到 47%,线程池占用率从 30% 涨到 89%
  • T+15s:gRPC 连接池耗尽,订单服务开始拒绝新请求
  • T+22s:上游调用订单服务的入口也开始出现超时,影响面扩散
  • T+30s:网络分区结束,但订单服务花了 4 分钟才恢复正常

30 秒的网络抖动,让订单服务在 4 分多钟内无法正常处理请求。如果没有演练,这个问题可能出现在某个大促凌晨。

隐藏依赖 1:被忽略的同步重试风暴

payment 服务被隔离后,订单服务开始大量报错。我们本来以为熔断器会打开,但 grafana 上显示的是:请求量暴增,而不是下降。

一查代码,发现某个历史模块在 RPC 调用失败时,自己写了一个retry 3的同步重试,并且没有退避。每次用户下单,失败后会立刻再发 3 次请求。30 秒的分区时间里,上游不断重试,下游永远不可达,请求全部堆积在订单服务的线程池里。

更坑的是,这个重试逻辑不在任何我们维护的 SDK 里,而是三年前某个业务组自己封装的组件。如果不是这次演练,它会被继续埋在那里。

修复方案:

  • 统一接入 resilience4j,重试必须带指数退避
  • 禁止业务代码自行写同步重试
  • 熔断触发后,失败请求直接返回降级结果,不再继续消耗资源
RetryConfigconfig=RetryConfig.custom().maxAttempts(3).waitDuration(Duration.ofMillis(100)).multiplier(2).retryExceptions(IOException.class,TimeoutException.class).build();Retryretry=Retry.of("payment-call",config);Supplier<String>decorated=Retry.decorateSupplier(retry,()->paymentClient.charge(order));

这里的关键不是"加重试",而是"重试必须有退避和熔断上下文"。

隐藏依赖 2:分布式锁的租约被 GC 拉长

第二个问题更隐蔽。订单服务里有一步扣减库存,用了 Redis 分布式锁。网络分区期间,锁已经拿到了,但业务还没执行完,锁就过期了。结果导致两个并发请求同时扣同一份库存。

根因不是 Redis 配置,而是我们默认把锁的租约设成了 5 秒,但那个版本的 JVM 在高压下偶尔出现 200ms 的 GC pause。5 秒减去 GC、网络往返、RPC 处理,实际有效时间只剩 2 秒左右。分区一发生,请求等待时间变长,锁就过期了。

这个问题平时不会触发,因为测试环境没有足够压力,GC 也不会触发。只有真实演练里的 30 秒网络抖动,把这个问题放大到了脸上。

修复方案:

  • 锁租约从固定值改为动态续期(看门狗模式)
  • 引入锁执行超时兜底,超过 80% 租约时间直接放弃
  • 库存扣改用数据库乐观锁做二次校验
RLocklock=redissonClient.getLock("stock:deduct:"+skuId);lock.lock(10,TimeUnit.SECONDS);// 看门狗自动续期try{intupdated=jdbcTemplate.update("UPDATE stock SET count = count - ? WHERE sku_id = ? AND count >= ?",qty,skuId,qty);if(updated==0){thrownewInsufficientStockException(skuId);}}finally{lock.unlock();}

隐藏依赖 3:配置中心在分区后不可达

第三个发现让我有点意外。网络分区结束后,payment 服务恢复,但订单服务依然报错。错误日志显示:

Caused by: com.netflix.config.ConfigurationException: ... unable to reach config server

原来订单服务在每次启动时都会从配置中心拉取一次最新的熔断阈值,但我们给配置中心设了强一致性读取。配置中心的主节点在另一个可用区,网络分区期间,订单服务到配置中心的连接也受到影响,无法完成启动。

这就意味着:如果网络分区期间需要扩容订单服务,新 Pod 会直接起不起来。而这一点在之前的架构评审里完全没人提到。

修复方案:

  • 配置中心改为本地缓存优先 + 异步刷新
  • 新 Pod 启动时允许使用上一次缓存配置,30 秒内再同步
  • 配置中心部署双活,避免单可用区依赖
spring:cloud:config:fail-fast:falseretry:initial-interval:1000max-attempts:6request-connect-timeout:3000request-read-timeout:5000

演练之后,我们改了三件事

这次 chaos 演练不是作秀,而是直接推动了三个落地改动:

1. 发布流程新增 chaos gate

每个新版本进入灰度之前,必须跑完一组基础 chaos 用例:网络分区、Pod 级宕机、CPU 限流。用例通过才能进入全量。这些用例跑下来大概 10 分钟,但能拦截掉 80% 的隐藏依赖问题。

# .gitlab-ci.yml 片段chaos-gate:stage:chaosimage:bitnami/kubectl:latestscript:-kubectl apply-f ci/chaos/network-partition.yaml-sleep 35-./ci/chaos/verify-health.sh order-api-kubectl delete-f ci/chaos/network-partition.yamlonly:-merge_requests

2. 建立依赖拓扑图并定期演练

我们用 chaos 实验反推,把系统真正的调用关系画了出来。结果发现,文档里写的依赖图和真实运行图差了 11%。很多"应该已经下线"的服务其实还在被调用。

3. 把演练脚本纳入日常 CI

chaos 用例不再只在发布前跑,而是每晚在非生产环境跑一次。这样新代码一旦引入隐藏依赖,第二天就能看到。

#!/bin/bash# chaos-gate.shset-ekubectl apply-fchaos/network-partition.yamlsleep35kubectl run chaos-check--rm-i--image=curlimages/curl --\curl-sfhttp://order-api/health kubectl delete-fchaos/network-partition.yaml

你也可以直接上手的 checklist

如果你准备在自己的系统里做第一次网络分区演练,我建议按这个顺序来:

  1. 先选一个非关键链路的小服务,不要一上来就搞支付或订单。
  2. 只切一个下游,持续 30 秒,不要同时切多个,避免无法定位根因。
  3. 观察四个指标:错误率、P99 延迟、线程池/连接池使用率、Pod 重启成功率。
  4. 记录每个故障的 T+ 时间线,这比最终结论更有价值。
  5. 修复后再跑一次,确认同一故障不再扩散。

写在最后

混沌工程不是为了让系统"更脆弱",而是为了让脆弱点尽早暴露在你能控制的时间和环境里。

这次 30 秒的网络分区,帮我们省掉了未来可能半夜发生的 3 次 P0。比起事故后复盘,我更喜欢在发布流程里把问题提前炸出来。

如果你也想试试,建议别一开始就做大规模演练。从 30 秒的网络分区开始,只打一个下游,观察三个指标:错误率、重试次数、扩容成功率。通常你会在第一时间发现几个"原来它还会调用这个"的惊喜。

把 chaos 注入发布流程,不是增加负担,而是花钱买安眠药。挺划算的。


标签:chaos engineering、稳定性、SRE、发布流程、故障演练、微服务