Deploy de madrugada derrubou a fintech: por que ASG com min=1 e CI/CD sem janela são incompatíveis
Publicado em 22 de abril de 2026
O cliente reportou: 'o app caiu e voltou sozinho'
A fintech acordou com uma notificação às 00:13 BRT: o app havia ficado fora do ar por alguns minutos e voltado sozinho. Sem alarme configurado para 5xx, sem alerta de instância unhealthy — a descoberta foi pelo próprio usuário.
Na investigação da manhã seguinte, a causa ficou clara em menos de 30 minutos cruzando três fontes: CloudWatch ALB, eventos do Auto Scaling Group e o histórico do GitHub Actions.
A timeline exata do incidente
Todos os horários abaixo estão em BRT (UTC-3):
20:34 (10/mar) — Deploy anterior executado sem problema. Nesse horário, o ASG ainda operava com 2 instâncias.
21:00 — Scheduled Action asg-scale-nighttime reduz min capacity de 2 para 1.
21:01 — Uma instância é terminada. ASG passa de 2 para 1 instância (i-abc1234f).
00:02 (11/mar) — Dev faz push na branch main: 'add produtor 50% de comissao'. GitHub Actions inicia o pipeline.
00:11:01 — Pipeline cria nova versão da Launch Template com AMI atualizada.
00:11:09 — Instance Refresh inicia. A única instância existente é removida do target group e substituída.
00:11 — 00:13 — ALB sem targets saudáveis. 92 erros HTTP 5xx retornados: 49 no primeiro minuto, 42 no segundo.
00:13:16 — Nova instância conclui o warmup e fica saudável. Serviço restaurado.
01:06 (11/mar) — Segundo push: 'ajustando emissão de nota fiscal'. Novo deploy — mas dessa vez a instância já havia sido substituída, então o Instance Refresh não causou novo downtime.
Dois deploys na mesma madrugada. O primeiro derrubou. O segundo passou sem impacto — por sorte, não por design.
Por que o MinHealthyPercentage não protegeu
O Instance Refresh estava configurado com MinHealthyPercentage: 100 — o que em teoria deveria garantir que sempre houvesse ao menos 100% das instâncias saudáveis durante a substituição. Na prática, com apenas 1 instância, essa configuração é matematicamente impossível de honrar.
Para substituir uma instância em um ASG com min=1, a AWS precisa terminar a instância existente antes de lançar a nova. Não há como manter 100% de 1 instância durante a troca — é zero ou um, e durante o warmup da nova instância o ALB fica sem targets.
# Configuração do Instance Refresh no momento do incidente
MinHealthyPercentage: 100 # protetivo apenas com 2+ instâncias
InstanceWarmup: 120 # 2 minutos de warmup (o downtime real foi ~2 minutos)
AutoRollback: false # sem rollback automáticoO InstanceWarmup de 120 segundos também confirma o incidente: o downtime durou exatamente o tempo de warmup da nova instância — 2 minutos e 7 segundos, de 00:11:09 a 00:13:16 BRT.
A armadilha da 'otimização de custo noturna'
O Scheduled Action que reduz o ASG para min=1 às 21:00 é uma otimização legítima. Uma instância c6g.2xlarge parada custa dinheiro que ninguém precisa gastar quando o tráfego é baixo. A economia mensal com essa configuração pode chegar a $150-200.
O problema não é a otimização em si. O problema é que o pipeline de CI/CD não tem consciência do estado atual do ASG. Ele não sabe se há 1 ou 4 instâncias em execução. Ele dispara o Instance Refresh de qualquer jeito — a qualquer hora, em qualquer condição.
Custo reduzido + CI/CD sempre ativo = janela de downtime garantida toda noite entre 21:00 e 07:00 BRT. Não é uma possibilidade — é uma certeza matemática.
Evidências nos logs
Para reproduzir o diagnóstico e confirmar a causa em qualquer ASG com esse padrão:
# 1. Verificar eventos do ASG na janela do incidente
aws autoscaling describe-scaling-activities \
--auto-scaling-group-name <nome-do-asg> \
--max-items 20
# 2. Verificar Instance Refreshes recentes
aws autoscaling describe-instance-refreshes \
--auto-scaling-group-name <nome-do-asg> \
--max-records 5
# 3. Verificar 5xx no ALB por minuto
aws cloudwatch get-metric-statistics \
--namespace AWS/ApplicationELB \
--metric-name HTTPCode_ELB_5XX_Count \
--dimensions Name=LoadBalancer,Value=<arn-lb> \
--start-time 2026-03-11T03:00:00Z \
--end-time 2026-03-11T04:00:00Z \
--period 60 --statistics Sum
# 4. Verificar HealthyHostCount no mesmo período
aws cloudwatch get-metric-statistics \
--namespace AWS/ApplicationELB \
--metric-name HealthyHostCount \
--dimensions Name=TargetGroup,Value=<arn-tg> \
Name=LoadBalancer,Value=<arn-lb> \
--start-time 2026-03-11T03:00:00Z \
--end-time 2026-03-11T04:00:00Z \
--period 60 --statistics MinimumOs dados do HealthyHostCount vão mostrar a queda de 1 para 0 hosts no minuto do Instance Refresh — confirmando que o ALB ficou sem targets. O UnhealthyHostCount permanece 0 o tempo todo porque a instância foi removida do target group (não marcada unhealthy), o que torna o monitoramento padrão de 'unhealthy hosts' inútil para detectar esse tipo de incidente.
Duas opções de remediação
Opção 1 (custo zero): bloquear deploys no horário noturno
A abordagem mais simples: adicionar uma condição no workflow do GitHub Actions que aberta o pipeline se o horário atual estiver dentro da janela de risco.
# .github/workflows/ci-cd-main.yml
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- name: Verificar janela de deploy
run: |
HOUR=$(TZ=America/Sao_Paulo date +%H)
# Bloquear entre 21:00 e 07:00 BRT
if [ "$HOUR" -ge 21 ] || [ "$HOUR" -lt 7 ]; then
echo "Deploy bloqueado fora da janela de manutenção (21:00-07:00 BRT)"
echo "ASG opera com min=1 nesse período. Rebase e execute durante o horário comercial."
exit 1
fiEssa opção tem custo zero e força o time a adiar deploys noturnos. O trade-off é que emergências reais podem precisar de um bypass manual — o que é aceitável se houver processo definido para isso.
Opção 2 (proteção completa): verificar DesiredCapacity antes do Instance Refresh
Uma abordagem mais cirúrgica: o pipeline verifica o DesiredCapacity do ASG antes de iniciar o Instance Refresh. Se for 1, aborta ou aguarda o scale-up antes de continuar.
# Step no workflow — verificar capacidade do ASG antes do deploy
- name: Verificar capacidade do ASG
run: |
DESIRED=$(aws autoscaling describe-auto-scaling-groups \
--auto-scaling-group-names <nome-do-asg> \
--query 'AutoScalingGroups[0].DesiredCapacity' \
--output text)
if [ "$DESIRED" -lt 2 ]; then
echo "ERRO: ASG com apenas $DESIRED instância(s). Instance Refresh causaria downtime."
echo "Aguarde o scale-up diurno ou execute o deploy manualmente com 2+ instâncias."
exit 1
fi
echo "ASG com $DESIRED instâncias — deploy seguro para prosseguir."Essa opção é mais robusta porque funciona independente do horário — protege contra qualquer condição em que o ASG esteja com capacidade insuficiente, não apenas no período noturno.
O que ficou de lição
O incidente não foi falha de ninguém. Foi uma combinação de duas decisões razoáveis — reduzir instâncias à noite para economizar, e fazer CI/CD contínuo na main — que criaram uma colisão silenciosa. O ASG não tem como avisar o pipeline. O pipeline não tem como consultar o ASG. Sem um mecanismo explícito conectando os dois, o downtime é questão de tempo.
MinHealthyPercentage: 100 protege quando você tem 2 instâncias. Com 1, é teatro de segurança. A única proteção real é não deixar o deploy acontecer quando não há redundância.
O alarme CloudWatch para HTTPCode_ELB_5XX_Count > 10 também ficou como lição: esse incidente foi descoberto pelo usuário, não por alerta. Com um alarme básico configurado, a equipe teria sido notificada em menos de 1 minuto.
