Buzeli
buzeliSoluções Digitais
Incidentes

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.

Copiar
# 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ático

O 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:

Copiar
# 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 Minimum

Os 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.

Copiar
# .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
          fi

Essa 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.

Copiar
# 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.