深夜部署导致金融科技宕机:为什么ASG min=1和无限制CI/CD不兼容
发布于 2026年4月22日
客户反映:'应用崩溃了然后自己恢复了'
金融科技公司在凌晨00:13 BRT收到通知:应用宕机了几分钟后自行恢复。没有配置5xx错误告警,没有实例健康检查告警——这次发现完全靠用户自己报告。
第二天早上的调查中,交叉比对三个数据源后,不到30分钟就找到了根本原因:CloudWatch ALB指标、Auto Scaling Group事件记录和GitHub Actions运行历史。
事故精确时间线
以下所有时间均为BRT(UTC-3):
20:34(3月10日) — 上一次部署顺利完成。当时ASG仍以2个实例运行。
21:00 — 计划任务 asg-scale-nighttime 将最小容量从2降至1。
21:01 — 一个实例被终止。ASG从2个实例变为1个(i-abc1234f)。
00:02(3月11日) — 开发者推送到main分支:"添加制作人50%佣金"。GitHub Actions启动流水线。
00:11:01 — 流水线创建带有更新AMI的新版Launch Template。
00:11:09 — Instance Refresh启动。唯一运行中的实例从目标组中移除并被替换。
00:11 — 00:13 — ALB没有健康的目标。返回92个HTTP 5xx错误:第一分钟49个,第二分钟42个。
00:13:16 — 新实例完成预热并变为健康状态。服务恢复。
01:06(3月11日) — 第二次推送:"调整发票开具"。新部署——但这次实例已经被替换过,所以Instance Refresh没有再次造成宕机。
同一个深夜两次部署。第一次导致宕机。第二次没有影响——靠的是运气,不是设计。
为什么MinHealthyPercentage没有起到保护作用
Instance Refresh配置了MinHealthyPercentage: 100——理论上应该确保替换过程中始终有至少100%的实例处于健康状态。但实际上,只有1个实例时,这个配置在数学上是不可能实现的。
在min=1的ASG中替换实例,AWS必须在启动新实例之前先终止现有实例。在交换过程中不可能保持100%的1个实例健康——要么是零要么是一,在新实例预热期间ALB没有任何目标。
# 事故发生时的Instance Refresh配置
MinHealthyPercentage: 100 # 只有2个及以上实例时才有保护作用
InstanceWarmup: 120 # 2分钟预热(实际宕机时长约2分钟)
AutoRollback: false # 无自动回滚120秒的InstanceWarmup也印证了这次事故:宕机时长恰好等于新实例的预热时间——2分07秒,从00:11:09到00:13:16 BRT。
'夜间成本优化'的陷阱
21:00将ASG缩减为min=1的计划任务是合理的优化。在流量低迷时,让一个空闲的c6g.2xlarge实例运行白白浪费钱。这种配置每月可节省$150-200。
问题不在于优化本身。问题在于CI/CD流水线对ASG当前状态毫不知情。它不知道有1个还是4个实例在运行。它在任何时间、任何条件下都会触发Instance Refresh。
降低成本 + 持续CI/CD = 每晚21:00至07:00 BRT之间必然的宕机窗口。这不是可能性——是数学上的必然。
日志中的证据
在任何具有这种模式的ASG上重现诊断并确认根本原因:
# 1. 查看事故时间窗口内的ASG事件
aws autoscaling describe-scaling-activities \
--auto-scaling-group-name <asg名称> \
--max-items 20
# 2. 查看最近的Instance Refresh
aws autoscaling describe-instance-refreshes \
--auto-scaling-group-name <asg名称> \
--max-records 5
# 3. 按分钟查看ALB 5xx错误
aws cloudwatch get-metric-statistics \
--namespace AWS/ApplicationELB \
--metric-name HTTPCode_ELB_5XX_Count \
--dimensions Name=LoadBalancer,Value=<lb-arn> \
--start-time 2026-03-11T03:00:00Z \
--end-time 2026-03-11T04:00:00Z \
--period 60 --statistics Sum
# 4. 查看同一时段的HealthyHostCount
aws cloudwatch get-metric-statistics \
--namespace AWS/ApplicationELB \
--metric-name HealthyHostCount \
--dimensions Name=TargetGroup,Value=<tg-arn> \
Name=LoadBalancer,Value=<lb-arn> \
--start-time 2026-03-11T03:00:00Z \
--end-time 2026-03-11T04:00:00Z \
--period 60 --statistics MinimumHealthyHostCount数据将显示Instance Refresh那一分钟从1个健康主机降至0——证实ALB没有任何目标。UnhealthyHostCount全程保持0,因为实例是从目标组中移除的(而非被标记为不健康),这使得标准的'不健康主机'监控对于检测此类事故毫无用处。
两种修复方案
方案一(零成本):在夜间时段阻止部署
最简单的方法:在GitHub Actions工作流中添加条件,如果当前时间在风险窗口内就中止流水线。
# .github/workflows/ci-cd-main.yml
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- name: 检查部署时间窗口
run: |
HOUR=$(TZ=America/Sao_Paulo date +%H)
# 阻止21:00至07:00 BRT之间的部署
if [ "$HOUR" -ge 21 ] || [ "$HOUR" -lt 7 ]; then
echo "部署已阻止:超出维护窗口(21:00-07:00 BRT)"
echo "ASG在此期间以min=1运行。请在工作时间内重新执行。"
exit 1
fi此方案零成本,强制团队推迟夜间部署。代价是真正的紧急情况可能需要手动绕过——如果有明确的流程,这是可以接受的。
方案二(全面保护):Instance Refresh前检查DesiredCapacity
更精准的方法:流水线在启动Instance Refresh之前检查ASG的DesiredCapacity。如果为1,则中止或等待扩容后再继续。
# 流水线步骤——部署前验证ASG容量
- name: 检查ASG容量
run: |
DESIRED=$(aws autoscaling describe-auto-scaling-groups \
--auto-scaling-group-names <asg名称> \
--query 'AutoScalingGroups[0].DesiredCapacity' \
--output text)
if [ "$DESIRED" -lt 2 ]; then
echo "错误:ASG只有 $DESIRED 个实例。Instance Refresh将导致宕机。"
echo "请等待白天扩容或手动在2个及以上实例时执行部署。"
exit 1
fi
echo "ASG有 $DESIRED 个实例——部署安全,可以继续。"此方案更健壮,因为它不受时间限制——无论何时,只要ASG容量不足就会触发保护,而不仅仅是夜间时段。
这次事故的教训
这次事故没有人的错。这是两个合理决策的碰撞——夜间减少实例以节省成本,以及对main分支持续CI/CD——它们产生了无声的冲突。ASG无法通知流水线。流水线无法查询ASG。没有明确的机制将两者连接起来,宕机只是时间问题。
MinHealthyPercentage: 100在有2个实例时有保护作用。只有1个时,这是安全剧场。唯一真正的保护是在没有冗余的情况下阻止部署发生。
HTTPCode_ELB_5XX_Count > 10的CloudWatch告警也是这次事故的教训:这次事故是由用户发现的,不是告警通知的。配置了基本告警,团队不到1分钟就会收到通知。
