Resiliência e Chaos Engineering

[313] Resiliência e Chaos Engineering

Sistemas que falham de forma previsível em vez de nunca falhar: os padrões circuit breaker, retry com backoff exponencial e jitter, e bulkhead para isolar recursos, experimentos controlados com o AWS Fault Injection Simulator, Game Days com o time e o graceful shutdown que evita derrubar requisições.
DevOps

26 min de leitura

Todo engenheiro que opera sistemas em produção por tempo suficiente aprende uma lição inevitável: sistemas complexos falham. Não é uma questão de se, mas de quando e como. A questão relevante não é construir sistemas que nunca falhem — isso é impossível em qualquer escala significativa — mas construir sistemas que falhem de maneira previsível, controlada e recuperável.

A diferença entre um sistema resiliente e um frágil não está na ausência de falhas — está em como o sistema se comporta quando as falhas ocorrem. Um sistema resiliente degrada graciosamente: quando um componente falha, os demais continuam funcionando com capacidade reduzida, sem colapso em cascata. Um sistema frágil, ao contrário, amplifica falhas locais em interrupções sistêmicas.

O Chaos Engineering é a disciplina de experimentar intencionalmente falhas em sistemas de produção para descobrir fraquezas antes que elas causem incidentes reais. A premissa é contraintuitiva mas poderosa: se o sistema vai falhar de qualquer forma, é melhor que a primeira falha aconteça em condições controladas, com o time de plantão disponível, do que às 3 da manhã durante um pico de tráfego.

Os Padrões de Resiliência Fundamentais

Antes de experimentar caos, é necessário implementar os padrões que tornam um sistema capaz de tolerar falhas. Esses padrões são os blocos de construção de qualquer arquitetura resiliente.

Circuit Breaker

O Circuit Breaker protege um serviço de continuar tentando chamar um serviço dependente que está falhando. Funciona como um disjuntor elétrico: quando o número de falhas ultrapassa um limiar, o circuito "abre" e todas as chamadas subsequentes são rejeitadas imediatamente sem tentar o serviço downstream. Após um período de espera, o circuito entra em estado "half-open" e permite uma chamada de teste — se bem-sucedida, o circuito fecha; se não, continua aberto.

// src/resilience/circuit-breaker.js
class CircuitBreaker {
  constructor(options = {}) {
    this.nome = options.nome || 'circuit-breaker';
    this.limiarFalhas = options.limiarFalhas || 5;
    this.timeoutAbertura = options.timeoutAbertura || 60000;   // 60s
    this.timeoutRequisicao = options.timeoutRequisicao || 5000; // 5s

    // Estados: FECHADO (normal) | ABERTO (rejeitando) | HALF_OPEN (testando)
    this.estado = 'FECHADO';
    this.contagemFalhas = 0;
    this.ultimaFalha = null;
    this.contagemSuccessos = 0;

    // Métricas para o Prometheus
    this._inicializarMetricas();
  }

  _inicializarMetricas() {
    const { Counter, Gauge } = require('prom-client');

    this.metricaEstado = new Gauge({
      name: `circuit_breaker_state`,
      help: 'Estado do circuit breaker (0=fechado, 1=half-open, 2=aberto)',
      labelNames: ['nome'],
    });

    this.metricaChamadas = new Counter({
      name: `circuit_breaker_calls_total`,
      help: 'Total de chamadas pelo circuit breaker',
      labelNames: ['nome', 'resultado'],
    });
  }

  async executar(funcao, fallback = null) {
    // Circuito aberto — rejeita imediatamente
    if (this.estado === 'ABERTO') {
      const agora = Date.now();
      if (agora - this.ultimaFalha < this.timeoutAbertura) {
        this.metricaChamadas.inc({ nome: this.nome, resultado: 'rejeitado' });

        if (fallback) return fallback();
        throw new Error(
          `Circuit breaker ${this.nome} aberto — aguardando recuperação`
        );
      }
      // Timeout expirou — tenta half-open
      this._transicionarPara('HALF_OPEN');
    }

    try {
      // Executa com timeout
      const resultado = await Promise.race([
        funcao(),
        new Promise((_, reject) =>
          setTimeout(
            () => reject(new Error('Timeout da requisição')),
            this.timeoutRequisicao
          )
        ),
      ]);

      this._registrarSuccesso();
      this.metricaChamadas.inc({ nome: this.nome, resultado: 'sucesso' });
      return resultado;

    } catch (erro) {
      this._registrarFalha(erro);
      this.metricaChamadas.inc({ nome: this.nome, resultado: 'falha' });

      if (fallback) return fallback();
      throw erro;
    }
  }

  _registrarSuccesso() {
    if (this.estado === 'HALF_OPEN') {
      this.contagemSuccessos++;
      // Requer 2 sucessos consecutivos para fechar
      if (this.contagemSuccessos >= 2) {
        this._transicionarPara('FECHADO');
      }
    }
    this.contagemFalhas = 0;
  }

  _registrarFalha(erro) {
    this.ultimaFalha = Date.now();
    this.contagemFalhas++;
    this.contagemSuccessos = 0;

    console.warn(JSON.stringify({
      level: 'warn',
      msg: `Circuit breaker ${this.nome}: falha registrada`,
      contagemFalhas: this.contagemFalhas,
      limiar: this.limiarFalhas,
      erro: erro.message,
    }));

    if (
      this.estado !== 'ABERTO' &&
      this.contagemFalhas >= this.limiarFalhas
    ) {
      this._transicionarPara('ABERTO');
    }
  }

  _transicionarPara(novoEstado) {
    const estadoAnterior = this.estado;
    this.estado = novoEstado;

    const estadoNumerico = { FECHADO: 0, HALF_OPEN: 1, ABERTO: 2 };
    this.metricaEstado.set(
      { nome: this.nome },
      estadoNumerico[novoEstado]
    );

    if (novoEstado === 'FECHADO') {
      this.contagemFalhas = 0;
      this.contagemSuccessos = 0;
    }

    console.info(JSON.stringify({
      level: 'info',
      msg: `Circuit breaker ${this.nome}: transição de estado`,
      de: estadoAnterior,
      para: novoEstado,
    }));
  }

  obterEstado() {
    return {
      nome: this.nome,
      estado: this.estado,
      contagemFalhas: this.contagemFalhas,
      ultimaFalha: this.ultimaFalha
        ? new Date(this.ultimaFalha).toISOString()
        : null,
    };
  }
}

module.exports = { CircuitBreaker };

Retry com Backoff Exponencial e Jitter

Tentar novamente uma operação que falhou é razoável — mas tentar novamente imediatamente, com todos os clientes ao mesmo tempo, pode transformar uma falha temporária em uma cascata de sobrecarga. O backoff exponencial aumenta o intervalo entre tentativas exponencialmente. O jitter adiciona aleatoriedade para evitar que múltiplos clientes tentem ao mesmo tempo (problema do "thundering herd"):

// src/resilience/retry.js

async function comRetry(funcao, opcoes = {}) {
  const {
    tentativasMaximas = 3,
    delayBase = 1000,        // 1s
    delayMaximo = 30000,     // 30s
    multiplicador = 2,
    jitter = true,
    retryableErros = null,   // null = tenta qualquer erro
    onRetry = null,          // callback antes de cada retry
  } = opcoes;

  let tentativa = 0;

  while (true) {
    try {
      return await funcao();
    } catch (erro) {
      tentativa++;

      // Verifica se o erro é recuperável
      if (retryableErros && !retryableErros.some(e => erro instanceof e)) {
        throw erro;
      }

      // Verifica se deve tentar status HTTP específicos
      if (erro.status && ![429, 500, 502, 503, 504].includes(erro.status)) {
        throw erro;
      }

      if (tentativa >= tentativasMaximas) {
        console.error(JSON.stringify({
          level: 'error',
          msg: 'Todas as tentativas esgotadas',
          tentativas: tentativa,
          erro: erro.message,
        }));
        throw erro;
      }

      // Calcula delay com backoff exponencial
      const delayExponencial = Math.min(
        delayBase * Math.pow(multiplicador, tentativa - 1),
        delayMaximo
      );

      // Adiciona jitter para evitar thundering herd
      // Estratégia "full jitter": delay aleatório entre 0 e o delay calculado
      const delay = jitter
        ? Math.random() * delayExponencial
        : delayExponencial;

      console.warn(JSON.stringify({
        level: 'warn',
        msg: 'Tentativa falhou, tentando novamente',
        tentativa,
        tentativasMaximas,
        proximaTentativaMs: Math.round(delay),
        erro: erro.message,
      }));

      if (onRetry) {
        await onRetry({ tentativa, erro, delay });
      }

      await new Promise(resolve => setTimeout(resolve, delay));
    }
  }
}

// Uso combinado com circuit breaker
class ClienteHTTPResilient {
  constructor(baseUrl, opcoes = {}) {
    this.baseUrl = baseUrl;
    this.circuitBreaker = new CircuitBreaker({
      nome: opcoes.nome || new URL(baseUrl).hostname,
      limiarFalhas: opcoes.limiarFalhas || 5,
      timeoutAbertura: opcoes.timeoutAbertura || 30000,
    });
  }

  async get(caminho, opcoes = {}) {
    return this.circuitBreaker.executar(
      () => comRetry(
        () => fetch(`${this.baseUrl}${caminho}`, {
          method: 'GET',
          signal: AbortSignal.timeout(5000),
          ...opcoes,
        }).then(async res => {
          if (!res.ok) {
            const erro = new Error(`HTTP ${res.status}`);
            erro.status = res.status;
            throw erro;
          }
          return res.json();
        }),
        { tentativasMaximas: 3, delayBase: 500 }
      ),
      opcoes.fallback
    );
  }
}

module.exports = { comRetry, ClienteHTTPResilient };

Bulkhead: Isolamento de Recursos

O padrão Bulkhead — referência às divisórias estanques de um navio — isola recursos entre diferentes partes do sistema para que a sobrecarga em uma parte não afete as demais. Na prática, significa ter pools de threads, conexões ou workers separados por funcionalidade:

// src/resilience/bulkhead.js
class Bulkhead {
  constructor(opcoes = {}) {
    this.nome = opcoes.nome;
    this.concorrenciaMaxima = opcoes.concorrenciaMaxima || 10;
    this.tamanhoFila = opcoes.tamanhoFila || 20;
    this.timeoutFila = opcoes.timeoutFila || 5000;

    this.executando = 0;
    this.fila = [];

    const { Gauge, Counter } = require('prom-client');

    this.metricaExecutando = new Gauge({
      name: 'bulkhead_executing',
      help: 'Chamadas em execução no bulkhead',
      labelNames: ['nome'],
    });

    this.metricaRejeitadas = new Counter({
      name: 'bulkhead_rejected_total',
      help: 'Chamadas rejeitadas pelo bulkhead',
      labelNames: ['nome'],
    });
  }

  async executar(funcao) {
    // Verifica se pode executar imediatamente
    if (this.executando < this.concorrenciaMaxima) {
      return this._executarAgora(funcao);
    }

    // Verifica se a fila tem espaço
    if (this.fila.length >= this.tamanhoFila) {
      this.metricaRejeitadas.inc({ nome: this.nome });
      throw new Error(
        `Bulkhead ${this.nome} saturado: fila cheia (${this.tamanhoFila})`
      );
    }

    // Adiciona à fila e aguarda
    return new Promise((resolve, reject) => {
      const timeout = setTimeout(() => {
        const index = this.fila.findIndex(item => item.resolve === resolve);
        if (index !== -1) this.fila.splice(index, 1);
        reject(new Error(`Timeout de fila no bulkhead ${this.nome}`));
      }, this.timeoutFila);

      this.fila.push({
        funcao,
        resolve,
        reject,
        timeout,
      });
    });
  }

  async _executarAgora(funcao) {
    this.executando++;
    this.metricaExecutando.set({ nome: this.nome }, this.executando);

    try {
      return await funcao();
    } finally {
      this.executando--;
      this.metricaExecutando.set({ nome: this.nome }, this.executando);
      this._processarFila();
    }
  }

  _processarFila() {
    if (this.fila.length === 0) return;
    if (this.executando >= this.concorrenciaMaxima) return;

    const proximo = this.fila.shift();
    clearTimeout(proximo.timeout);

    this._executarAgora(proximo.funcao)
      .then(proximo.resolve)
      .catch(proximo.reject);
  }
}

module.exports = { Bulkhead };

AWS Fault Injection Simulator

O AWS FIS — Fault Injection Simulator — é o serviço gerenciado da AWS para Chaos Engineering. Permite injetar falhas reais em recursos AWS — terminar instâncias EC2, introduzir latência em chamadas de rede, falhar chamadas de API do SSM, degradar bancos de dados RDS — de forma controlada e com mecanismos de parada de emergência.

Experimento: Terminação de Instâncias EC2

# fis.tf

# IAM Role para o FIS executar ações nos recursos
resource "aws_iam_role" "fis" {
  name = "${var.project_name}-${var.environment}-fis-role"

  assume_role_policy = jsonencode({
    Version = "2012-10-17"
    Statement = [{
      Effect    = "Allow"
      Principal = { Service = "fis.amazonaws.com" }
      Action    = "sts:AssumeRole"
    }]
  })
}

resource "aws_iam_role_policy" "fis" {
  role = aws_iam_role.fis.name

  policy = jsonencode({
    Version = "2012-10-17"
    Statement = [
      {
        Effect = "Allow"
        Action = [
          "ec2:TerminateInstances",
          "ec2:StopInstances",
          "ec2:DescribeInstances",
          "ecs:StopTask",
          "ecs:DescribeTasks",
          "rds:RebootDBInstance",
          "rds:FailoverDBCluster",
          "elasticache:RebootCacheCluster",
          "ssm:SendCommand",
          "ssm:GetCommandInvocation",
        ]
        Resource = "*"
        Condition = {
          StringEquals = {
            "aws:ResourceTag/Environment" = var.environment
            "aws:ResourceTag/ChaosElegible" = "true"
          }
        }
      },
      {
        Effect   = "Allow"
        Action   = ["cloudwatch:DescribeAlarms"]
        Resource = "*"
      }
    ]
  })
}

# Experimento 1: Termina 33% das instâncias do Auto Scaling Group
resource "aws_fis_experiment_template" "terminar_instancias" {
  description = "Termina 1/3 das instâncias EC2 para testar recuperação do ASG"
  role_arn    = aws_iam_role.fis.arn

  # Stop condition — aborta o experimento se o alarme disparar
  stop_condition {
    source = "aws:cloudwatch:alarm"
    value  = aws_cloudwatch_alarm.disponibilidade_critica.arn
  }

  # Alvo: instâncias EC2 com tag Environment e ChaosElegible
  target {
    name           = "instancias-app"
    resource_type  = "aws:ec2:instance"
    selection_mode = "PERCENT(33)"  # Termina 33%

    resource_tag {
      key   = "Environment"
      value = var.environment
    }

    resource_tag {
      key   = "ChaosElegible"
      value = "true"
    }
  }

  action {
    name      = "terminar-instancias"
    action_id = "aws:ec2:terminate-instances"

    target {
      key   = "Instances"
      value = "instancias-app"
    }
  }

  tags = local.tags_comuns
}

# Experimento 2: Introduz latência de rede via SSM
resource "aws_fis_experiment_template" "latencia_rede" {
  description = "Introduz 200ms de latência nas chamadas de rede para testar timeouts"
  role_arn    = aws_iam_role.fis.arn

  stop_condition {
    source = "aws:cloudwatch:alarm"
    value  = aws_cloudwatch_alarm.latencia_critica.arn
  }

  target {
    name           = "instancias-app"
    resource_type  = "aws:ec2:instance"
    selection_mode = "PERCENT(50)"

    resource_tag {
      key   = "ChaosElegible"
      value = "true"
    }
  }

  action {
    name      = "injetar-latencia"
    action_id = "aws:ssm:send-command"

    parameter {
      key   = "documentArn"
      value = "arn:aws:ssm:us-east-1::document/AWSFIS-Run-Network-Latency"
    }

    parameter {
      key   = "documentParameters"
      value = jsonencode({
        Interface       = "eth0"
        DelayMilliseconds = "200"
        JitterMilliseconds = "50"
        DurationSeconds = "120"
        InstallDependencies = "True"
      })
    }

    parameter {
      key   = "duration"
      value = "PT3M"  # Duração máxima do experimento: 3 minutos
    }

    target {
      key   = "Instances"
      value = "instancias-app"
    }
  }

  tags = local.tags_comuns
}

# Experimento 3: Falha no banco de dados RDS Multi-AZ
resource "aws_fis_experiment_template" "failover_rds" {
  description = "Força failover do RDS Multi-AZ para testar tempo de recuperação"
  role_arn    = aws_iam_role.fis.arn

  stop_condition {
    source = "none"
  }

  target {
    name          = "banco-dados"
    resource_type = "aws:rds:db"
    selection_mode = "ALL"

    resource_tag {
      key   = "Environment"
      value = var.environment
    }

    resource_tag {
      key   = "ChaosElegible"
      value = "true"
    }
  }

  action {
    name      = "failover-rds"
    action_id = "aws:rds:reboot-db-instances"

    parameter {
      key   = "forceFailover"
      value = "true"
    }

    target {
      key   = "DBInstances"
      value = "banco-dados"
    }
  }

  tags = local.tags_comuns
}

# Alarme de parada de emergência
resource "aws_cloudwatch_alarm" "disponibilidade_critica" {
  alarm_name          = "${var.project_name}-${var.environment}-disponibilidade-critica"
  comparison_operator = "LessThanThreshold"
  evaluation_periods  = 2
  metric_name         = "HealthyHostCount"
  namespace           = "AWS/ApplicationELB"
  period              = 60
  statistic           = "Average"
  threshold           = 1

  dimensions = {
    LoadBalancer = aws_lb.aplicacao.arn_suffix
    TargetGroup  = aws_lb_target_group.aplicacao.arn_suffix
  }

  alarm_description = "Para o experimento FIS se não houver hosts saudáveis"
  tags              = local.tags_comuns
}

Executando Experimentos com a CLI

#!/bin/bash
# scripts/executar-experimento-chaos.sh
# Executa um experimento de Chaos Engineering com observação em tempo real

set -euo pipefail

TEMPLATE_ID="${1:?Uso: $0 <experiment-template-id>}"
REGIAO="us-east-1"

log() { echo "[$(date -u +%H:%M:%S)] $*"; }
erro() { echo "[$(date -u +%H:%M:%S)] ERRO: $*" >&2; exit 1; }

log "=== Iniciando Experimento de Chaos Engineering ==="
log "Template: $TEMPLATE_ID"

# Verifica que os sistemas estão saudáveis antes de começar
log "Verificando saúde dos sistemas antes do experimento..."
HOSTS_SAUDAVEIS=$(aws elbv2 describe-target-health \
  --target-group-arn "$TARGET_GROUP_ARN" \
  --query 'TargetHealthDescriptions[?TargetHealth.State==`healthy`] | length(@)' \
  --output text)

if [ "$HOSTS_SAUDAVEIS" -lt 2 ]; then
  erro "Sistema não está saudável o suficiente para chaos (${HOSTS_SAUDAVEIS} hosts). Abortando."
fi

log "Sistema saudável: $HOSTS_SAUDAVEIS hosts antes do experimento"

# Registra o início para correlacionar com métricas
INICIO_EXPERIMENTO=$(date -u +%Y-%m-%dT%H:%M:%SZ)

# Inicia o experimento
log "Iniciando experimento..."
EXPERIMENTO_ID=$(aws fis start-experiment \
  --experiment-template-id "$TEMPLATE_ID" \
  --region "$REGIAO" \
  --query 'experiment.id' \
  --output text)

log "Experimento iniciado: $EXPERIMENTO_ID"

# Monitora o experimento
log "Monitorando estado do experimento..."
while true; do
  STATUS=$(aws fis get-experiment \
    --id "$EXPERIMENTO_ID" \
    --region "$REGIAO" \
    --query 'experiment.state.status' \
    --output text)

  case "$STATUS" in
    "running")
      HOSTS_AGORA=$(aws elbv2 describe-target-health \
        --target-group-arn "$TARGET_GROUP_ARN" \
        --query 'TargetHealthDescriptions[?TargetHealth.State==`healthy`] | length(@)' \
        --output text 2>/dev/null || echo "N/A")
      log "Status: $STATUS | Hosts saudáveis: $HOSTS_AGORA"
      sleep 15
      ;;
    "completed")
      log "Experimento concluído com sucesso!"
      break
      ;;
    "stopped")
      log "AVISO: Experimento parado (stop condition ativada)"
      break
      ;;
    "failed")
      erro "Experimento falhou. Verificar logs do FIS."
      ;;
    *)
      log "Status inesperado: $STATUS"
      sleep 5
      ;;
  esac
done

# Aguarda recuperação e verifica estado final
log "Aguardando recuperação completa do sistema..."
sleep 60

HOSTS_FINAL=$(aws elbv2 describe-target-health \
  --target-group-arn "$TARGET_GROUP_ARN" \
  --query 'TargetHealthDescriptions[?TargetHealth.State==`healthy`] | length(@)' \
  --output text)

log "Estado final: $HOSTS_FINAL hosts saudáveis"
log "Início: $INICIO_EXPERIMENTO"
log "Fim: $(date -u +%Y-%m-%dT%H:%M:%SZ)"

if [ "$HOSTS_FINAL" -ge "$HOSTS_SAUDAVEIS" ]; then
  log "✅ Sistema se recuperou completamente"
else
  log "⚠️  Sistema recuperou parcialmente: ${HOSTS_FINAL}/${HOSTS_SAUDAVEIS} hosts"
fi

Game Days: Praticando a Falha

O Game Day é a prática de simular falhas e incidentes em ambiente controlado com o time completo presente — uma forma de treino operacional que combina o Chaos Engineering técnico com o treinamento de resposta a incidentes.

Um Game Day típico segue a estrutura:

Pré-Game Day — definição do cenário, hipóteses a testar, métricas de sucesso e plano de rollback. O time documenta o comportamento esperado do sistema.

Execução — um facilitador conduz os experimentos enquanto o time observa métricas, toma decisões e responde como faria em um incidente real.

Revisão — análise do que aconteceu versus o que foi esperado, identificação de fraquezas e definição de ações corretivas.

## Game Day — Plano de Execução

**Data:** 2025-03-15
**Duração:** 4 horas (09h–13h)
**Facilitador:** Ana Costa
**Participantes:** Time de Plataforma + On-call

### Cenário 1: Falha de uma AZ inteira (09h00–10h00)
**Hipótese:** O sistema continua funcionando com latência < 500ms
se a AZ us-east-1b ficar indisponível.

**Ação:** Adicionar regra de NACL bloqueando todo o tráfego para a
subnet privada em us-east-1b.

**Métricas observadas:**
- Taxa de erro HTTP 5xx (alvo: < 0.1%)
- Latência p99 (alvo: < 500ms)
- Tempo de detecção do health check
- Tempo até rebalanceamento do ASG

**Rollback:** Remover a regra de NACL

---

### Cenário 2: Esgotamento do pool de conexões RDS (10h15–11h15)
**Hipótese:** A aplicação degrada graciosamente e retorna HTTP 503
ao invés de travar quando o pool de conexões é esgotado.

**Ação:** Injetar carga de 10x via k6 apontando para endpoint
que abre transações longas no banco.

**Métricas observadas:**
- Comportamento do circuit breaker
- Resposta HTTP da aplicação (503 vs travamento)
- DatabaseConnections no CloudWatch RDS
- Tempo de recuperação após a carga diminuir

---

### Cenário 3: Rollback de deploy com bug crítico (11h30–12h30)
**Hipótese:** O time consegue detectar e reverter um deploy
problemático em menos de 5 minutos.

**Ação:** Deploy de versão com bug que aumenta latência p99 em 10x.
Time precisa detectar, decidir reverter e executar.

**Métricas observadas:**
- MTTD (tempo até detecção via alerta)
- MTTR (tempo até rollback completo)
- Eficácia dos runbooks

Healthchecks e Graceful Shutdown

Resiliência não é apenas sobre sobreviver a falhas externas — é também sobre falhar graciosamente quando o próprio processo precisa ser encerrado:

// src/server.js — servidor com healthchecks e graceful shutdown completos

const express = require('express');
const app = express();

// Estado de prontidão do servidor
const estado = {
  pronto: false,        // readiness: aceita tráfego?
  vivo: true,           // liveness: processo está OK?
  encerrandoGrace: false,
};

// Endpoint de liveness — monitora se o processo está vivo
// Falha apenas em casos extremos: deadlock, memória corrompida
app.get('/health/live', (req, res) => {
  if (!estado.vivo) {
    return res.status(503).json({ status: 'unhealthy', motivo: 'processo-degradado' });
  }
  res.json({ status: 'healthy', uptime: process.uptime() });
});

// Endpoint de readiness — monitora se está pronto para receber tráfego
// Falha durante inicialização, encerramento ou quando dependências estão indisponíveis
app.get('/health/ready', async (req, res) => {
  if (estado.encerrandoGrace) {
    return res.status(503).json({
      status: 'not-ready',
      motivo: 'encerrando',
    });
  }

  if (!estado.pronto) {
    return res.status(503).json({
      status: 'not-ready',
      motivo: 'inicializando',
    });
  }

  // Verifica dependências críticas
  try {
    await Promise.all([
      verificarBancoDados(),
      verificarRedis(),
    ]);
    res.json({ status: 'ready' });
  } catch (erro) {
    res.status(503).json({
      status: 'not-ready',
      motivo: 'dependencia-indisponivel',
      detalhe: erro.message,
    });
  }
});

// Graceful shutdown — garante que requisições em andamento completam
function configurarGracefulShutdown(servidor) {
  const TIMEOUT_GRACE = 30000; // 30 segundos

  async function encerrar(sinal) {
    console.info(JSON.stringify({
      level: 'info',
      msg: `Recebido sinal ${sinal} — iniciando encerramento gracioso`,
    }));

    // 1. Marca como não-pronto — load balancer para de enviar tráfego
    estado.encerrandoGrace = true;

    // 2. Aguarda o load balancer detectar o unhealthy (geralmente 10-30s)
    await new Promise(resolve => setTimeout(resolve, 10000));

    // 3. Para de aceitar novas conexões
    servidor.close(async () => {
      console.info(JSON.stringify({
        level: 'info',
        msg: 'Servidor HTTP fechado — encerrando conexões com banco',
      }));

      // 4. Fecha conexões com banco e cache graciosamente
      try {
        await pool.end();
        await redisClient.quit();
        console.info(JSON.stringify({
          level: 'info',
          msg: 'Encerramento gracioso concluído',
        }));
        process.exit(0);
      } catch (erro) {
        console.error(JSON.stringify({
          level: 'error',
          msg: 'Erro no encerramento gracioso',
          erro: erro.message,
        }));
        process.exit(1);
      }
    });

    // Timeout forçado — se demorar mais que TIMEOUT_GRACE, força encerramento
    setTimeout(() => {
      console.error(JSON.stringify({
        level: 'error',
        msg: 'Timeout de encerramento gracioso — forçando saída',
      }));
      process.exit(1);
    }, TIMEOUT_GRACE);
  }

  process.on('SIGTERM', () => encerrar('SIGTERM'));
  process.on('SIGINT', () => encerrar('SIGINT'));
}

// Inicialização com verificação de dependências
async function inicializar() {
  console.info('Inicializando servidor...');

  // Aguarda dependências ficarem disponíveis
  await aguardarBancoDados();
  await aguardarRedis();

  // Executa migrations pendentes
  await executarMigrations();

  // Marca como pronto para receber tráfego
  estado.pronto = true;
  console.info('Servidor pronto para receber tráfego');
}

const servidor = app.listen(process.env.PORT || 3000, async () => {
  await inicializar();
});

configurarGracefulShutdown(servidor);

O Que Vem a Seguir

O próximo artigo aborda Platform Engineering — a disciplina emergente que consolida as práticas de DevOps em plataformas internas que permitem que times de desenvolvimento sejam autônomos sem precisar ser especialistas em infraestrutura. Serão cobertos o conceito de Internal Developer Platform, o Backstage da Spotify como portal de desenvolvedores e os princípios de "paved roads" que equilibram autonomia e padronização.

Referências para Aprofundamento

Chaos Engineering

Resiliência

Exercícios

Exercício 1

Siga o estado do CircuitBreaker neste cenário: o limiar é 5 e o circuito está FECHADO. Chegam 4 falhas seguidas, depois 1 sucesso, depois mais 4 falhas. O circuito abre? E se as 9 chamadas tivessem sido 4 falhas, 4 falhas e 1 sucesso ao final?

Ver resposta

✓ Resposta: No primeiro cenário o circuito não abre. A chave está em _registrarSuccesso(), que termina com this.contagemFalhas = 0: um único sucesso zera o contador. As 4 primeiras falhas levam a contagem a 4, o sucesso devolve a zero, e as 4 seguintes chegam de novo só a 4. O limiar de 5 nunca é alcançado, ainda que 8 das 9 chamadas tenham falhado — uma taxa de erro de 89%.

No segundo cenário o circuito abre na oitava chamada: as 8 falhas são consecutivas, a contagem chega a 5 na quinta e _registrarFalha dispara a transição para ABERTO. O sucesso final nem chega a ser tentado — o circuito já está rejeitando.

A diferença entre os dois cenários é apenas a ordem das mesmas 9 chamadas. Isso expõe a limitação do contador consecutivo: ele detecta bem a falha total de uma dependência, e é praticamente cego para a degradação parcial — o caso em que o serviço responde a uma fração das chamadas, que é como falhas reais costumam se manifestar. Basta um sucesso esporádico para segurar o circuito fechado indefinidamente.

A correção é trocar contagem consecutiva por taxa de erro em janela deslizante: abrir quando, digamos, mais de 50% das chamadas dos últimos 30 segundos falharem, exigindo um volume mínimo de chamadas para que a taxa seja significativa (sem isso, 1 falha em 1 chamada é 100% de erro). É assim que bibliotecas maduras como Hystrix, Resilience4j e o opossum implementam o padrão — e é a razão para preferi-las a uma implementação própria em produção.

Exercício 2

Observe a composição em ClienteHTTPResilient.get(): o circuitBreaker.executar() envolve o comRetry(), que por sua vez faz o fetch. Por que essa ordem — e não a inversa, com o retry por fora do circuit breaker?

Ver resposta

✓ Resposta: Porque a ordem inversa anularia o circuit breaker justamente quando ele é necessário.

Com o retry por fora, cada tentativa seria uma chamada nova ao circuit breaker. Com o circuito ABERTO, ele rejeita imediatamente lançando erro — e o retry, vendo um erro, faria exatamente o que foi programado para fazer: tentar de novo, esperar o backoff, tentar de novo. O resultado é o retry insistindo contra um circuito aberto, gastando o tempo do usuário para nada. Pior: a rejeição rápida do circuito faz as tentativas se esgotarem em milissegundos, sem nem o alívio do backoff.

Na ordem correta — circuit breaker por fora, retry por dentro —, cada elemento faz o seu papel na camada certa: o retry absorve a falha transitória (um 503 isolado, um timeout esporádico) e o circuit breaker enxerga o resultado final daquela operação, contando como uma única falha o conjunto das 3 tentativas. Quando a dependência cai de vez, o circuito abre e corta todo o ciclo de retry de uma vez.

Há um efeito colateral a dimensionar: os tempos se multiplicam. Com tentativasMaximas: 3 e timeoutRequisicao: 5000 no circuit breaker, três tentativas de 5s mais os backoffs ultrapassam facilmente o timeout do circuito, e o Promise.race aborta a operação inteira no meio do ciclo. O timeout externo precisa ser maior que a soma do que ele envolve — ou os dois brigam entre si.

Repare ainda que o fetch tem seu próprio AbortSignal.timeout(5000), idêntico ao do circuit breaker. Vale notar que o Promise.race do circuit breaker não cancela a função perdedora: quando o timeout vence, a requisição HTTP continua em andamento, consumindo conexão — é o AbortSignal que efetivamente a interrompe.

Exercício 3

O Bulkhead tem concorrenciaMaxima: 10 e tamanhoFila: 20. Chegam 40 chamadas simultâneas, cada uma levando 2 segundos. Descreva o destino das 40. Por que rejeitar as excedentes é melhor do que simplesmente enfileirar todas?

Ver resposta

✓ Resposta: 10 executam de imediato, 20 entram na fila, e as 10 restantes são rejeitadas na hora com Bulkhead saturado: fila cheia. Das 20 enfileiradas, ainda há uma segunda peneira: o timeoutFila: 5000. Como cada chamada leva 2 s e a vazão é de 10 por vez, as 10 primeiras da fila começam por volta dos 2 s e as 10 seguintes por volta dos 4 s — ou seja, todas conseguem entrar antes dos 5 s, mas com pouquíssima margem. Um pico um pouco maior faria a cauda da fila estourar por timeout.

Rejeitar é melhor que enfileirar tudo por causa do fail fast. Uma fila ilimitada não aumenta a capacidade do sistema — ela apenas converte "recusa imediata" em "espera longa seguida de erro". O cliente que aguardaria 40 segundos por uma resposta já terá desistido, mas o servidor continua ocupando memória com o pedido dele e ainda vai processá-lo, gastando recurso em trabalho que ninguém mais quer. É a fila da morte: o sistema passa a trabalhar exclusivamente em requisições já expiradas e nunca se recupera, mesmo depois que a carga cai.

Com um limite explícito, o excedente vira um erro rápido e barato, sobre o qual dá para agir: devolver 503 com Retry-After, servir uma resposta degradada de cache, ou acionar o fallback do circuit breaker. E as métricas bulkhead_executing e bulkhead_rejected_total tornam a saturação visível — um contador de rejeições subindo é um sinal claro para escalar, enquanto uma fila crescendo em silêncio só aparece como "o site está lento".

O nome vem das divisórias estanques de um navio, e a metáfora explica o propósito maior: com pools separados por funcionalidade, o endpoint de relatórios que satura seu bulkhead não consome as conexões do endpoint de checkout. O compartimento alaga, o navio segue.

Exercício 4

Compare as stop_condition dos três experimentos do fis.tf. O terceiro, failover_rds, declara source = "none". O que isso significa na prática, e por que é especialmente arriscado neste experimento?

Ver resposta

✓ Resposta: source = "none" significa nenhuma parada de emergência: o experimento roda até o fim, independentemente do que aconteça com o sistema. Os outros dois se protegem com alarmes do CloudWatch — disponibilidade_critica e latencia_critica — que abortam o experimento automaticamente se a situação sair do controle.

É especialmente arriscado aqui porque o failover de RDS é, dos três, o de recuperação menos elástica. Terminar instâncias EC2 é reversível em minutos — o Auto Scaling Group repõe sozinho, e é justamente isso que o experimento quer medir. Latência de rede via SSM é temporária e tem DurationSeconds: 120 embutido. Já a promoção do standby leva de 60 a 120 segundos durante os quais o banco não aceita escrita, e nada disso é acelerável: não há como "cancelar" um failover em andamento. Toda conexão do pool cai e precisa ser restabelecida; aplicações que não tratam a reconexão ficam fora do ar bem depois do banco já ter voltado.

Some-se o alvo: selection_mode = "ALL", ou seja, todas as instâncias RDS com as tags — não uma amostra. Os outros experimentos usam PERCENT(33) e PERCENT(50), contendo o raio de alcance. O experimento sem freio é o único que atinge 100% do alvo.

Isso contraria o princípio central do Chaos Engineering: minimizar o raio de alcance e ter sempre como abortar. Um experimento que não pode ser interrompido não é um experimento controlado — é uma aposta. Vale notar que a proteção real aqui não está só no arquivo do FIS: a condição no aws_iam_role_policy exige as tags Environment e ChaosElegible = "true", o que impede que o experimento alcance qualquer recurso que não tenha sido explicitamente marcado como elegível. É a diferença entre escolher onde o caos pode agir e descobrir depois onde ele agiu.

Um detalhe do mesmo arquivo, no nível de rigor que compliance costuma cobrar: a tag está escrita ChaosElegibleEligible em inglês, ou Elegível em português. Como o nome precisa bater exatamente entre a política IAM, o resource_tag do experimento e a tag aplicada ao recurso, um erro assim funciona enquanto for repetido igual em todos os lugares, e falha silenciosamente no dia em que alguém escrever certo.

Exercício 5

No graceful shutdown do server.js, o passo 2 é um setTimeout de 10 segundos antes de o servidor parar de aceitar conexões. Por que esperar em vez de fechar imediatamente? E o que acontece se o orquestrador conceder menos tempo do que os 30 s do TIMEOUT_GRACE?

Ver resposta

✓ Resposta: A espera existe para dar tempo ao load balancer perceber que este processo saiu de circulação. A sequência é deliberada: o passo 1 marca encerrandoGrace = true, o que faz /health/ready passar a responder 503; o load balancer só descobre isso no próximo ciclo de health check, e ainda leva alguns segundos para tirar o alvo da rotação. Os 10 segundos cobrem essa janela.

Fechar imediatamente produziria o erro clássico de deploy: o processo recusa conexões enquanto o balanceador ainda está mandando tráfego, e o usuário recebe 502. É o mesmo problema que o preStop: sleep 5 do Kubernetes resolve — a diferença é que ali a espera é externa ao processo, e aqui está dentro dele.

Sobre o tempo concedido, há uma armadilha concreta. Em Kubernetes, o terminationGracePeriodSeconds padrão é 30 segundos, contados a partir do SIGTERM — o mesmo valor do TIMEOUT_GRACE. Só que este código gasta 10 desses 30 segundos apenas esperando, deixando 20 para fechar conexões e encerrar o pool. Quando o grace period se esgota, vem o SIGKILL: o setTimeout de 30 s nunca chega a disparar, o pool.end() pode ser interrompido no meio, e transações em andamento morrem sem rollback limpo — o banco só as libera quando o próprio timeout de conexão dele expirar.

A regra é que o tempo total do processo seja menor que o do orquestrador, com folga: 10 s de espera mais um TIMEOUT_GRACE de 15 s cabem confortavelmente em 30 s de grace period. E o inverso também precisa valer — se as requisições mais longas da aplicação levam 25 s, nenhum desses números funciona, e é o grace period que tem de subir.

Repare que o /health/live foi escrito corretamente para não verificar dependências, ao contrário do /health/ready, que checa banco e Redis. É a separação exata que evita o efeito cascata: uma queda do banco tira os pods de rotação (readiness) sem que o kubelet os reinicie em massa (liveness).

Comentários

Mais em DevOps

GitLab CI/CD: A Alternativa Enterprise ao GitHub Actions
GitLab CI/CD: A Alternativa Enterprise ao GitHub Actions

A plataforma que nasceu DevOps completa, e não como hospedagem de código que…

Capstone: Implementando os Microsserviços
Capstone: Implementando os Microsserviços

O que separa código de demonstração de código de produção não é a lógica de…

Seus Primeiros Recursos na AWS com Terraform
Seus Primeiros Recursos na AWS com Terraform

Uma infraestrutura AWS completa provisionada inteiramente por código: a…