支付系统里的 ECS 混沌工程:别让故障注入变成真实事故

2026-09-08 31 预计阅读时间: 1 分钟
来源: infoq.com AI 摘要 Original link

Disclaimer: This article is an AI-assisted summary. Read it together with the original source when precision matters. The summary may omit context, version differences, or edge cases and is not official documentation.

预计阅读时间:11 分钟

传统混沌工程常隐含三个前提:实验可以随时干净停止、爆炸半径能够提前算清、生产环境适合直接验证。金融支付系统往往同时打破这三个前提。一次看似简单的 ECS 任务终止,可能经过 DNS 缓存、客户端重试、数据库连接池和跨可用区调度,演变成持续时间更长、范围更大的连锁反应。

企业 ECS 部署中的几个数字很能说明问题:60 秒 DNS TTL 最终对应了 93 秒故障转移;重试逻辑让数据库负载放大到 2.4 倍;可用区重新平衡还可能形成通用混沌工具观察不到的循环。对支付平台来说,实验设计的核心因此不是“能不能杀掉一个容器”,而是“能不能证明资金链路在故障扩散时仍然保持正确”。

DNS TTL 不是故障恢复倒计时

将服务发现记录的 TTL 设置为 60 秒,并不意味着所有调用方都会在第 60 秒切换到健康实例。一次完整的故障转移还可能受到以下环节影响:

  • DNS 解析器、运行时或代理各自维护缓存;
  • 已建立的 HTTP、gRPC 或数据库连接不会因 DNS 记录变化立即关闭;
  • 健康检查需要先确认故障,再更新注册记录;
  • 客户端可能仍在对旧地址执行退避重试;
  • 新 ECS 任务还要经过启动、注册和预热。

因此,93 秒故障转移并不是“60 秒 TTL 配错了”这么简单。更合理的测量方式,是分别记录故障发生、健康检查失败、DNS 记录更新、客户端首次解析新地址以及业务成功率恢复的时间点。

支付接口尤其需要观察业务层恢复,而不能只看 ECS 的 runningCount。容器处于 RUNNING 状态,不代表它已经加载密钥、建立数据库连接并能够安全处理扣款请求。

重试可能把局部故障放大成数据库事故

当上游请求超时,最直觉的补救是重试。但如果多个 ECS 任务同时失败,所有调用方采用相同间隔重试,就会形成同步重试风暴。来源案例中的数据库负载达到正常水平的 2.4 倍,说明实验影响已经越过被注入故障的服务,进入共享数据层。

支付链路中的重试至少应具备四个约束:

  1. 写操作必须携带幂等键。 网络超时无法证明交易没有成功,不能把第二次扣款当成新交易。
  2. 限制最大尝试次数和总时间预算。 不要让三层服务各重试三次,最终把一次请求放大成数十次内部调用。
  3. 使用指数退避和随机抖动。 避免所有客户端在同一毫秒重新冲击数据库。
  4. 设置重试预算。 当系统整体错误率升高时,应减少而不是增加重试流量。

实验看板不能只放 HTTP 错误率,还应同时显示数据库 QPS、活跃连接、锁等待、重试次数和幂等冲突数。否则,前端成功率短暂恢复可能掩盖后端正在被重试耗尽。

ECS 的跨可用区反馈循环

通用故障注入工具通常能看到“某个任务被终止”,却不一定理解 ECS 调度器、负载均衡器和可用区容量之间的反馈关系。如果任务被持续放到容量不足或健康检查不稳定的可用区,服务可能不断经历启动、摘除、替换和再次平衡。

这类循环需要从控制面观察,而不是只盯应用指标:

  • 每个可用区的运行任务数;
  • ECS deployment 数量和 rollout 状态;
  • 任务启动、停止及停止原因;
  • 负载均衡目标注册与摘除频率;
  • 容量提供者的可用资源和扩缩容活动;
  • 同一服务在可用区之间的迁移次数。

实验终止条件也不能只是“注入时间到”。如果控制面已经进入重新平衡循环,停止故障注入不会自动恢复稳定状态。真正的结束条件应是任务分布重新稳定、目标组健康、积压清空,并且支付成功率和重复交易指标回到基线。

可以这样实践:带硬保护的单任务实验

下面是一个可改造的 Bash 示例。它默认只允许在 staging 环境运行,要求服务至少有两个任务、当前没有滚动部署、关键 CloudWatch 告警均为 OK。除非显式设置 RUN_CHAOS=true,脚本只会执行 dry run。

运行前需要安装并配置 AWS CLI 和 jq。请将集群、服务、区域及告警名称替换为自己的非生产资源:

#!/usr/bin/env bash
set -euo pipefail

: "${CLUSTER:?Set CLUSTER}"
: "${SERVICE:?Set SERVICE}"
: "${ALARM_NAMES:?Set comma-separated ALARM_NAMES}"

AWS_REGION="${AWS_REGION:-us-east-1}"
ENVIRONMENT="${ENVIRONMENT:-unset}"
RUN_CHAOS="${RUN_CHAOS:-false}"

if [[ "$ENVIRONMENT" != "staging" ]]; then
  echo "Refusing to run outside staging" >&2
  exit 1
fi

service_json=$(aws ecs describe-services \
  --region "$AWS_REGION" \
  --cluster "$CLUSTER" \
  --services "$SERVICE" \
  --output json)

desired=$(jq -r '.services[0].desiredCount' <<<"$service_json")
running=$(jq -r '.services[0].runningCount' <<<"$service_json")
deployments=$(jq -r '.services[0].deployments | length' <<<"$service_json")
rollout=$(jq -r '.services[0].deployments[0].rolloutState // "UNKNOWN"' <<<"$service_json")

if (( desired < 2 )) || (( running != desired )); then
  echo "Service lacks safe headroom: desired=$desired running=$running" >&2
  exit 1
fi

if (( deployments != 1 )) || [[ "$rollout" != "COMPLETED" ]]; then
  echo "Service is not in a stable deployment state" >&2
  exit 1
fi

IFS=',' read -ra alarms <<<"$ALARM_NAMES"
for alarm in "${alarms[@]}"; do
  state=$(aws cloudwatch describe-alarms \
    --region "$AWS_REGION" \
    --alarm-names "$alarm" \
    --query 'MetricAlarms[0].StateValue' \
    --output text)

  if [[ "$state" != "OK" ]]; then
    echo "Alarm $alarm is $state; experiment blocked" >&2
    exit 1
  fi
done

task=$(aws ecs list-tasks \
  --region "$AWS_REGION" \
  --cluster "$CLUSTER" \
  --service-name "$SERVICE" \
  --desired-status RUNNING \
  --query 'taskArns[0]' \
  --output text)

if [[ -z "$task" || "$task" == "None" ]]; then
  echo "No running task found" >&2
  exit 1
fi

echo "Selected task: $task"

if [[ "$RUN_CHAOS" != "true" ]]; then
  echo "Dry run only. Set RUN_CHAOS=true to stop this staging task."
  exit 0
fi

aws ecs stop-task \
  --region "$AWS_REGION" \
  --cluster "$CLUSTER" \
  --task "$task" \
  --reason 'bounded staging chaos experiment' \
  >/dev/null

echo "Task stopped. Watch business SLOs, retry load, DNS timing, and AZ distribution."

例如先执行 dry run:

CLUSTER=payments-staging \
SERVICE=payment-api \
AWS_REGION=ap-southeast-1 \
ENVIRONMENT=staging \
ALARM_NAMES=PaymentErrorRate,DatabaseConnections \
./ecs-chaos.sh

确认选中的任务和所有保护条件后,才添加 RUN_CHAOS=true。这个脚本只是最小示例:在真实平台中,还应把“停止测试流量”做成独立且经过验证的紧急开关,并通过自动化系统持续检查告警,而不是依赖工程师手动盯屏。

上生产前,先回答这些问题

支付系统采用混沌工程时,可以用下面的清单审查实验:

  • 故障是否先在压测、回放或隔离的 staging 环境验证?
  • 爆炸半径是否按任务数、交易量、商户范围和可用区同时限制?
  • 写请求是否具备经过验证的幂等语义?
  • 是否测量实际 DNS 切换时间,而不是直接采用 TTL 数值?
  • 是否为重试设置总预算,并监控数据库负载放大倍数?
  • 停止注入后,如何确认 ECS 调度和可用区分布真正收敛?
  • 告警触发时,能否立即停止测试流量,而不仅是停止故障动作?
  • 对账、重复扣款检测和人工处置流程是否参与演练?

成熟的支付混沌工程不是更大胆地破坏生产,而是更精确地定义边界、观测传播路径并验证资金正确性。ECS 任务恢复只是开始;只有 DNS、重试、数据库、可用区调度和交易账务全部回到可证明的稳定状态,实验才算真正结束。


相关推荐