RDS, ElastiCache e Estratégias de Dados na AWS

[305] RDS, ElastiCache e Estratégias de Dados na AWS

A camada mais crítica e mais difícil de recuperar quando algo dá errado: RDS em Multi-AZ e read replicas, o RDS Proxy para pools de conexão, cluster Redis no ElastiCache com os padrões cache-aside e write-through, os objetivos de RPO e RTO, a hierarquia de backup e o teste de recuperação.
DevOps

26 min de leitura

Em qualquer sistema de produção, a camada de dados é simultaneamente a mais crítica e a mais difícil de recuperar quando algo dá errado. Um servidor de aplicação com problema pode ser substituído em segundos — um banco de dados com dados corrompidos ou perdidos pode representar danos irreversíveis ao negócio.

A AWS oferece serviços gerenciados que transferem para a infraestrutura da nuvem as responsabilidades mais complexas do gerenciamento de dados: backups automáticos, replicação entre zonas de disponibilidade, failover automático, patching de segurança e scaling de storage. O time de engenharia mantém o controle sobre a configuração, os dados e o schema — a AWS cuida do hardware, do sistema operacional e do software de banco de dados.

Este artigo aprofunda o RDS para bancos de dados relacionais, o ElastiCache para caching distribuído e as estratégias de backup e recuperação de desastre que garantem durabilidade e disponibilidade dos dados em produção.

RDS em Alta Disponibilidade

Multi-AZ: Failover Automático Transparente

O modo Multi-AZ do RDS mantém uma réplica síncrona da instância primária em uma zona de disponibilidade diferente. Em caso de falha da instância primária — hardware, rede, zona de disponibilidade inteira — o RDS promove a réplica automaticamente em 60 a 120 segundos, sem intervenção manual e sem alteração no endpoint de conexão.

A réplica Multi-AZ não serve leituras em condições normais — é exclusivamente uma réplica de standby para failover. Para escalar leituras, são usadas Read Replicas.

# rds-producao.tf

resource "aws_db_instance" "principal" {
  identifier = "${var.project_name}-${var.environment}-primary"

  # Motor e versão
  engine               = "postgres"
  engine_version       = "16.1"
  instance_class       = "db.r6g.xlarge"

  # Storage com auto-scaling
  allocated_storage     = 100
  max_allocated_storage = 1000
  storage_type          = "gp3"
  storage_encrypted     = true
  kms_key_id            = aws_kms_key.rds.arn

  # Credenciais gerenciadas via Secrets Manager
  manage_master_user_password   = true
  master_username               = "postgres_admin"
  master_user_secret_kms_key_id = aws_kms_key.rds.arn

  # Alta disponibilidade
  multi_az               = true
  db_subnet_group_name   = aws_db_subnet_group.principal.name
  vpc_security_group_ids = [aws_security_group.rds.id]
  publicly_accessible    = false

  # Parâmetros de banco
  db_name              = var.db_name
  parameter_group_name = aws_db_parameter_group.postgres16.name
  option_group_name    = aws_db_option_group.postgres16.name

  # Backups e manutenção
  backup_retention_period         = 14
  backup_window                   = "02:00-03:00"
  maintenance_window              = "Mon:03:00-Mon:04:00"
  auto_minor_version_upgrade      = true
  allow_major_version_upgrade     = false
  copy_tags_to_snapshot           = true
  deletion_protection             = true
  skip_final_snapshot             = false
  final_snapshot_identifier       = "${var.project_name}-${var.environment}-final"
  delete_automated_backups        = false

  # Monitoramento avançado
  monitoring_interval             = 60
  monitoring_role_arn             = aws_iam_role.rds_monitoring.arn
  performance_insights_enabled    = true
  performance_insights_retention_period = 7
  performance_insights_kms_key_id = aws_kms_key.rds.arn
  enabled_cloudwatch_logs_exports = ["postgresql", "upgrade"]

  tags = local.tags_comuns
}

# Read Replica para leituras intensivas
resource "aws_db_instance" "replica_leitura" {
  identifier             = "${var.project_name}-${var.environment}-replica"
  replicate_source_db    = aws_db_instance.principal.identifier
  instance_class         = "db.r6g.large"
  publicly_accessible    = false
  vpc_security_group_ids = [aws_security_group.rds.id]
  parameter_group_name   = aws_db_parameter_group.postgres16.name

  # Réplicas não têm backups próprios — são recuperadas da primária
  backup_retention_period = 0
  skip_final_snapshot     = true
  deletion_protection     = false

  # Promoção automática em caso de falha da primária
  # (requer configuração adicional na aplicação)
  auto_minor_version_upgrade = true

  performance_insights_enabled = true

  tags = merge(local.tags_comuns, {
    Role = "read-replica"
  })
}

# Grupo de parâmetros otimizados para produção
resource "aws_db_parameter_group" "postgres16" {
  name   = "${var.project_name}-${var.environment}-pg16"
  family = "postgres16"

  # Configurações de performance
  parameter {
    name  = "shared_buffers"
    value = "{DBInstanceClassMemory/4096}"
  }

  parameter {
    name  = "effective_cache_size"
    value = "{DBInstanceClassMemory*3/4096}"
  }

  parameter {
    name  = "work_mem"
    value = "16384"  # 16MB por operação de sort
  }

  parameter {
    name  = "maintenance_work_mem"
    value = "524288"  # 512MB para operações de vacuum
  }

  # Configurações de logging
  parameter {
    name  = "log_min_duration_statement"
    value = "1000"  # loga queries > 1s
  }

  parameter {
    name  = "log_checkpoints"
    value = "1"
  }

  parameter {
    name  = "log_lock_waits"
    value = "1"
  }

  parameter {
    name  = "log_temp_files"
    value = "0"  # loga todos os arquivos temporários
  }

  # Configurações de conexão
  parameter {
    name  = "max_connections"
    value = "200"
  }

  # WAL e replicação
  parameter {
    name  = "wal_level"
    value = "replica"
    apply_method = "pending-reboot"
  }

  parameter {
    name  = "max_wal_senders"
    value = "5"
    apply_method = "pending-reboot"
  }

  lifecycle {
    create_before_destroy = true
  }
}

# Chave KMS para criptografia do RDS
resource "aws_kms_key" "rds" {
  description             = "Chave KMS para criptografia do RDS"
  deletion_window_in_days = 7
  enable_key_rotation     = true

  tags = local.tags_comuns
}

resource "aws_kms_alias" "rds" {
  name          = "alias/${var.project_name}-${var.environment}-rds"
  target_key_id = aws_kms_key.rds.key_id
}

RDS Proxy: Gerenciamento de Conexões para Aplicações de Alta Escala

Bancos de dados PostgreSQL têm um limite prático de conexões simultâneas — cada conexão consome memória significativa. Aplicações com muitas instâncias ou funções Lambda que abrem conexões novas a cada invocação podem rapidamente esgotar o pool de conexões disponíveis.

O RDS Proxy é um proxy gerenciado que mantém um pool de conexões persistentes ao banco e distribui as conexões das aplicações entre elas. Para funções Lambda especialmente, o Proxy é praticamente obrigatório em produção:

# RDS Proxy para gerenciamento de pool de conexões
resource "aws_db_proxy" "principal" {
  name                   = "${var.project_name}-${var.environment}-proxy"
  debug_logging          = false
  engine_family          = "POSTGRESQL"
  idle_client_timeout    = 1800
  require_tls            = true
  role_arn               = aws_iam_role.rds_proxy.arn
  vpc_security_group_ids = [aws_security_group.rds_proxy.id]
  vpc_subnet_ids         = module.vpc.ids_subnets_privadas

  auth {
    auth_scheme               = "SECRETS"
    description               = "Credenciais do banco de dados"
    iam_auth                  = "REQUIRED"
    secret_arn                = aws_db_instance.principal.master_user_secret[0].secret_arn
  }

  tags = local.tags_comuns
}

resource "aws_db_proxy_default_target_group" "principal" {
  db_proxy_name = aws_db_proxy.principal.name

  connection_pool_config {
    connection_borrow_timeout    = 120
    max_connections_percent      = 90
    max_idle_connections_percent = 50
  }
}

resource "aws_db_proxy_target" "principal" {
  db_instance_identifier = aws_db_instance.principal.identifier
  db_proxy_name          = aws_db_proxy.principal.name
  target_group_name      = aws_db_proxy_default_target_group.principal.name
}

ElastiCache: Caching Distribuído com Redis

O ElastiCache oferece Redis e Memcached como serviços gerenciados. Redis é a escolha dominante por sua versatilidade — suporta estruturas de dados complexas, pub/sub, streams, scripting Lua e persistência opcional.

Cluster Redis com Replicação

# elasticache.tf

# Subnet group para o ElastiCache
resource "aws_elasticache_subnet_group" "redis" {
  name       = "${var.project_name}-${var.environment}-redis-subnet"
  subnet_ids = module.vpc.ids_subnets_privadas

  tags = local.tags_comuns
}

# Grupo de parâmetros Redis
resource "aws_elasticache_parameter_group" "redis7" {
  name   = "${var.project_name}-${var.environment}-redis7"
  family = "redis7"

  # Habilita notificações de keyspace — útil para implementar
  # invalidação de cache orientada a eventos
  parameter {
    name  = "notify-keyspace-events"
    value = "Ex"  # E=keyspace events, x=expired events
  }

  # Desabilita comandos perigosos em produção
  parameter {
    name  = "rename-commands"
    value = "FLUSHALL banned FLUSHDB banned CONFIG banned"
  }

  tags = local.tags_comuns
}

# Cluster Redis com replicação e failover automático
resource "aws_elasticache_replication_group" "redis" {
  replication_group_id = "${var.project_name}-${var.environment}-redis"
  description          = "Cluster Redis para ${var.project_name} ${var.environment}"

  node_type            = var.redis_node_type
  num_cache_clusters   = var.environment == "production" ? 3 : 1
  port                 = 6379
  parameter_group_name = aws_elasticache_parameter_group.redis7.name
  subnet_group_name    = aws_elasticache_subnet_group.redis.name
  security_group_ids   = [aws_security_group.redis.id]

  # Alta disponibilidade
  automatic_failover_enabled  = var.environment == "production"
  multi_az_enabled            = var.environment == "production"
  at_rest_encryption_enabled  = true
  transit_encryption_enabled  = true
  auth_token                  = var.redis_auth_token
  auth_token_update_strategy  = "ROTATE"

  # Versão Redis
  engine_version = "7.1"

  # Manutenção e backups
  maintenance_window         = "tue:03:00-tue:04:00"
  snapshot_window            = "02:00-03:00"
  snapshot_retention_limit   = 7
  auto_minor_version_upgrade = true

  # Logs para CloudWatch
  log_delivery_configuration {
    destination      = aws_cloudwatch_log_group.redis_slow.name
    destination_type = "cloudwatch-logs"
    log_format       = "json"
    log_type         = "slow-log"
  }

  log_delivery_configuration {
    destination      = aws_cloudwatch_log_group.redis_engine.name
    destination_type = "cloudwatch-logs"
    log_format       = "json"
    log_type         = "engine-log"
  }

  tags = local.tags_comuns
}

resource "aws_cloudwatch_log_group" "redis_slow" {
  name              = "/elasticache/${var.project_name}-${var.environment}/slow-log"
  retention_in_days = 14
}

resource "aws_cloudwatch_log_group" "redis_engine" {
  name              = "/elasticache/${var.project_name}-${var.environment}/engine-log"
  retention_in_days = 7
}

Padrões de Caching na Aplicação

Com a infraestrutura do Redis provisionada, a aplicação implementa os padrões de caching que reduzem a carga no banco de dados:

// src/cache/redis.client.js
const { createClient } = require('redis');

let client = null;

async function getClient() {
  if (client?.isReady) return client;

  client = createClient({
    url: process.env.REDIS_URL,
    socket: {
      tls: true,
      rejectUnauthorized: true,
      connectTimeout: 5000,
    },
    password: process.env.REDIS_AUTH_TOKEN,
  });

  client.on('error', (err) => {
    console.error(JSON.stringify({ level: 'error', msg: 'Redis error', err: err.message }));
  });

  client.on('reconnecting', () => {
    console.warn(JSON.stringify({ level: 'warn', msg: 'Redis reconnecting' }));
  });

  await client.connect();
  return client;
}

module.exports = { getClient };
// src/cache/cache.service.js
const { getClient } = require('./redis.client');

const PREFIXO = process.env.SERVICE_NAME || 'api';
const TTL_PADRAO = 300; // 5 minutos

class CacheService {
  // ── Cache-Aside (Lazy Loading) ────────────────────
  // Busca no cache; se não encontrar, busca na fonte e armazena
  async getOuBuscar(chave, funcaoBusca, ttl = TTL_PADRAO) {
    const redis = await getClient();
    const chaveCompleta = `${PREFIXO}:${chave}`;

    // Tenta buscar no cache
    const cached = await redis.get(chaveCompleta);
    if (cached) {
      return JSON.parse(cached);
    }

    // Cache miss — busca na fonte de dados
    const dados = await funcaoBusca();

    // Armazena no cache para próximas requisições
    await redis.setEx(
      chaveCompleta,
      ttl,
      JSON.stringify(dados)
    );

    return dados;
  }

  // ── Invalidação por chave específica ──────────────
  async invalidar(chave) {
    const redis = await getClient();
    await redis.del(`${PREFIXO}:${chave}`);
  }

  // ── Invalidação por padrão ────────────────────────
  // Útil para invalidar todos os caches relacionados a uma entidade
  async invalidarPadrao(padrao) {
    const redis = await getClient();
    const chaves = await redis.keys(`${PREFIXO}:${padrao}`);

    if (chaves.length > 0) {
      // Usa pipeline para deletar múltiplas chaves atomicamente
      const pipeline = redis.multi();
      chaves.forEach(chave => pipeline.del(chave));
      await pipeline.exec();
    }

    return chaves.length;
  }

  // ── Write-Through Cache ───────────────────────────
  // Atualiza cache e banco de dados simultaneamente
  async escreverComCache(chave, dados, funcaoEscrita, ttl = TTL_PADRAO) {
    const redis = await getClient();
    const chaveCompleta = `${PREFIXO}:${chave}`;

    // Executa as operações em paralelo
    await Promise.all([
      funcaoEscrita(dados),
      redis.setEx(chaveCompleta, ttl, JSON.stringify(dados))
    ]);

    return dados;
  }

  // ── Rate Limiting com Redis ───────────────────────
  async verificarRateLimit(identificador, limite, janelaSegundos) {
    const redis = await getClient();
    const chave = `${PREFIXO}:rate:${identificador}`;

    // Script Lua garante atomicidade
    const script = `
      local atual = redis.call('INCR', KEYS[1])
      if atual == 1 then
        redis.call('EXPIRE', KEYS[1], ARGV[2])
      end
      return { atual, redis.call('TTL', KEYS[1]) }
    `;

    const [contagem, ttl] = await redis.eval(
      script,
      { keys: [chave], arguments: [limite.toString(), janelaSegundos.toString()] }
    );

    return {
      permitido: contagem <= limite,
      contagem: parseInt(contagem),
      limite,
      resetEm: parseInt(ttl),
    };
  }

  // ── Distributed Lock ─────────────────────────────
  // Evita processamento duplicado em sistemas distribuídos
  async adquirirLock(recurso, ttlMs = 30000) {
    const redis = await getClient();
    const chave = `${PREFIXO}:lock:${recurso}`;
    const token = `${Date.now()}-${Math.random()}`;

    // SET NX (only if Not eXists) com expiração
    const adquirido = await redis.set(chave, token, {
      NX: true,
      PX: ttlMs,
    });

    if (!adquirido) {
      return null;  // Lock não adquirido
    }

    // Retorna função para liberar o lock
    return async () => {
      // Script Lua garante que apenas quem adquiriu pode liberar
      const scriptLiberar = `
        if redis.call('GET', KEYS[1]) == ARGV[1] then
          return redis.call('DEL', KEYS[1])
        else
          return 0
        end
      `;
      await redis.eval(scriptLiberar, { keys: [chave], arguments: [token] });
    };
  }
}

module.exports = new CacheService();

Uso do serviço de cache na camada de repositório:

// src/repositories/produto.repository.js
const cache = require('../cache/cache.service');
const db = require('../database/db');

class ProdutoRepository {
  async buscarPorId(id) {
    return cache.getOuBuscar(
      `produto:${id}`,
      () => db.query('SELECT * FROM produtos WHERE id = $1', [id])
        .then(result => result.rows[0]),
      600  // 10 minutos
    );
  }

  async buscarCategoria(categoriaId) {
    return cache.getOuBuscar(
      `categoria:${categoriaId}:produtos`,
      () => db.query(
        'SELECT * FROM produtos WHERE categoria_id = $1 AND ativo = true ORDER BY nome',
        [categoriaId]
      ).then(result => result.rows),
      300  // 5 minutos
    );
  }

  async atualizar(id, dados) {
    const produto = await db.query(
      'UPDATE produtos SET nome = $1, preco = $2, updated_at = NOW() WHERE id = $3 RETURNING *',
      [dados.nome, dados.preco, id]
    ).then(result => result.rows[0]);

    // Invalida o cache do produto e da categoria
    await Promise.all([
      cache.invalidar(`produto:${id}`),
      cache.invalidarPadrao(`categoria:${produto.categoria_id}:*`),
    ]);

    return produto;
  }
}

module.exports = new ProdutoRepository();

Estratégias de Backup e Recuperação de Desastre

Os Objetivos de Recuperação: RPO e RTO

Toda estratégia de dados começa com dois números que definem o que é aceitável perder e quanto tempo de indisponibilidade é tolerável:

RPO — Recovery Point Objective — o máximo de dados que pode ser perdido em caso de desastre. Se o RPO é de 1 hora, a organização aceita perder até 1 hora de transações. Se o RPO é de zero, nenhuma transação pode ser perdida — o que exige replicação síncrona.

RTO — Recovery Time Objective — o máximo de tempo que o sistema pode ficar indisponível antes de ser restaurado. Se o RTO é de 15 minutos, o sistema deve estar operacional em no máximo 15 minutos após uma falha.

RPO e RTO mais agressivos custam mais — em infraestrutura, em complexidade operacional e em custos de engenharia. A definição desses objetivos é uma decisão de negócio, não apenas técnica.

Hierarquia de Backup do RDS

O RDS oferece três níveis de proteção de dados que trabalham em camadas:

Backups automáticos — o RDS faz snapshots diários do volume de storage e retém os logs de transação (WAL) para permitir recuperação point-in-time. A janela de retenção é configurável de 1 a 35 dias. Permitem restaurar para qualquer segundo dentro da janela de retenção.

Snapshots manuais — criados explicitamente e retidos indefinidamente até serem deletados manualmente. Adequados para pontos de checkpoint antes de operações arriscadas — migrations, atualizações de versão, mudanças de schema.

Backups cross-region — réplicas de snapshots em outra região para proteção contra falha regional completa. Essencial para RPO baixo em cenários de disaster recovery.

# Operações de backup e restauração via AWS CLI

# Cria snapshot manual antes de uma migration
aws rds create-db-snapshot \
  --db-instance-identifier minha-api-production-db \
  --db-snapshot-identifier pre-migration-$(date +%Y%m%d-%H%M%S) \
  --tags Key=Motivo,Value=pre-migration Key=Autor,Value=joao

# Lista snapshots disponíveis
aws rds describe-db-snapshots \
  --db-instance-identifier minha-api-production-db \
  --query 'DBSnapshots[*].{ID:DBSnapshotIdentifier,Status:Status,Criado:SnapshotCreateTime}' \
  --output table

# Restaura para um ponto específico no tempo (point-in-time recovery)
aws rds restore-db-instance-to-point-in-time \
  --source-db-instance-identifier minha-api-production-db \
  --target-db-instance-identifier minha-api-recovery-test \
  --restore-time 2025-03-10T14:30:00Z \
  --db-instance-class db.r6g.large \
  --no-multi-az \
  --db-subnet-group-name minha-api-staging-db-subnet-group \
  --vpc-security-group-ids sg-0abc123

# Copia snapshot para outra região (cross-region backup)
aws rds copy-db-snapshot \
  --source-db-snapshot-identifier \
    arn:aws:rds:us-east-1:123456789:snapshot:pre-migration-20250310 \
  --target-db-snapshot-identifier pre-migration-20250310-dr \
  --source-region us-east-1 \
  --region us-west-2 \
  --kms-key-id arn:aws:kms:us-west-2:123456789:key/abc-def

Automatizando Backups com AWS Backup

O AWS Backup é um serviço centralizado que gerencia backups de múltiplos serviços AWS — RDS, EBS, EFS, DynamoDB, S3 — com uma única política:

# aws-backup.tf

resource "aws_backup_vault" "principal" {
  name        = "${var.project_name}-${var.environment}-vault"
  kms_key_arn = aws_kms_key.backup.arn

  tags = local.tags_comuns
}

# Vault de destino para cross-region replication
resource "aws_backup_vault" "dr_region" {
  provider    = aws.dr_region
  name        = "${var.project_name}-${var.environment}-vault-dr"
  kms_key_arn = aws_kms_key.backup_dr.arn
}

resource "aws_backup_plan" "producao" {
  name = "${var.project_name}-${var.environment}-backup-plan"

  rule {
    rule_name         = "backup-diario"
    target_vault_name = aws_backup_vault.principal.name
    schedule          = "cron(0 2 * * ? *)"  # Todo dia às 2h UTC

    start_window_minutes      = 60
    completion_window_minutes = 180
    recovery_point_tags       = local.tags_comuns

    lifecycle {
      cold_storage_after = 30   # Move para Glacier após 30 dias
      delete_after       = 90   # Deleta após 90 dias
    }

    # Replica backup para outra região
    copy_action {
      destination_vault_arn = aws_backup_vault.dr_region.arn

      lifecycle {
        delete_after = 30  # Mantém DR por 30 dias
      }
    }
  }

  rule {
    rule_name         = "backup-semanal"
    target_vault_name = aws_backup_vault.principal.name
    schedule          = "cron(0 3 ? * SUN *)"  # Todo domingo às 3h UTC

    lifecycle {
      delete_after = 365  # Retém backups semanais por 1 ano
    }
  }

  tags = local.tags_comuns
}

# Associa o plano de backup ao RDS e outros recursos
resource "aws_backup_selection" "banco_dados" {
  name         = "banco-dados-producao"
  iam_role_arn = aws_iam_role.backup.arn
  plan_id      = aws_backup_plan.producao.id

  resources = [
    aws_db_instance.principal.arn,
    aws_elasticache_replication_group.redis.arn,
  ]

  condition {
    string_equals {
      key   = "aws:ResourceTag/Environment"
      value = "production"
    }
  }
}

Testando a Recuperação

Uma estratégia de backup sem testes regulares de recuperação é uma falsa segurança. O processo de testar a recuperação deve ser automatizado e executado regularmente:

#!/bin/bash
# scripts/testar-recuperacao.sh
# Testa a capacidade de recuperação do banco de dados a partir de um snapshot
# Deve ser executado mensalmente via pipeline de CI/CD

set -euo pipefail

SNAPSHOT_ID="$1"
AMBIENTE_TESTE="recovery-test-$(date +%Y%m%d)"
REGIAO="us-east-1"

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

log "=== Iniciando teste de recuperação ==="
log "Snapshot: $SNAPSHOT_ID"
log "Ambiente de teste: $AMBIENTE_TESTE"

# Restaura o banco de dados a partir do snapshot
log "Restaurando banco de dados..."
aws rds restore-db-instance-from-db-snapshot \
  --db-instance-identifier "$AMBIENTE_TESTE" \
  --db-snapshot-identifier "$SNAPSHOT_ID" \
  --db-instance-class "db.t3.micro" \
  --no-multi-az \
  --no-publicly-accessible \
  --db-subnet-group-name "minha-api-staging-db-subnet-group" \
  --vpc-security-group-ids "sg-0abc123" \
  --region "$REGIAO"

# Aguarda o banco ficar disponível
log "Aguardando banco ficar disponível..."
aws rds wait db-instance-available \
  --db-instance-identifier "$AMBIENTE_TESTE" \
  --region "$REGIAO"

log "Banco disponível. Executando verificações..."

# Obtém o endpoint do banco restaurado
ENDPOINT=$(aws rds describe-db-instances \
  --db-instance-identifier "$AMBIENTE_TESTE" \
  --query 'DBInstances[0].Endpoint.Address' \
  --output text \
  --region "$REGIAO")

# Executa verificações de integridade
RESULTADO=$(psql \
  "postgresql://postgres_admin:$DB_PASSWORD@$ENDPOINT:5432/producao" \
  -c "SELECT COUNT(*) FROM usuarios;" \
  -t -A)

if [ -z "$RESULTADO" ] || [ "$RESULTADO" -eq 0 ]; then
  erro "Verificação falhou: tabela usuarios vazia ou inacessível"
fi

log "Verificação bem-sucedida: $RESULTADO usuários encontrados"

# Verifica integridade referencial
psql "postgresql://postgres_admin:$DB_PASSWORD@$ENDPOINT:5432/producao" \
  -c "SELECT COUNT(*) FROM pedidos p LEFT JOIN usuarios u ON p.usuario_id = u.id WHERE u.id IS NULL;" \
  -t -A | {
    read ORFAOS
    if [ "$ORFAOS" -gt 0 ]; then
      log "AVISO: $ORFAOS pedidos órfãos encontrados — possível problema de integridade"
    else
      log "Integridade referencial: OK"
    fi
  }

# Registra o resultado do teste
aws cloudwatch put-metric-data \
  --namespace "BackupTests" \
  --metric-name "RecoveryTestSuccess" \
  --value 1 \
  --dimensions "Environment=production" \
  --region "$REGIAO"

log "=== Teste de recuperação concluído com sucesso ==="

# Limpa o banco de teste
log "Removendo banco de teste..."
aws rds delete-db-instance \
  --db-instance-identifier "$AMBIENTE_TESTE" \
  --skip-final-snapshot \
  --region "$REGIAO"

log "Banco de teste removido."

Monitoramento da Camada de Dados

Alertas Essenciais para RDS e ElastiCache

# observabilidade/prometheus/rules/alertas-dados.yml
groups:
  - name: rds
    rules:
      - alert: RDSAltaCPU
        expr: aws_rds_cpuutilization_average > 80
        for: 10m
        labels:
          severity: warning
        annotations:
          summary: "CPU do RDS acima de 80%"
          description: |
            Uso de CPU: {{ $value }}%
            Considerar: otimização de queries, upgrade de instância
            ou adicionar read replica para distribuir carga.

      - alert: RDSConexoesProximasDoLimite
        expr: |
          aws_rds_database_connections_average
          /
          aws_rds_database_connections_average offset 0s * 0 + 200
          > 0.85
        for: 5m
        labels:
          severity: critical
        annotations:
          summary: "Conexões RDS acima de 85% do máximo"
          runbook_url: "https://wiki.empresa.com/runbooks/conexoes-rds"

      - alert: RDSStorageAbaixo20Percent
        expr: |
          aws_rds_free_storage_space_average
          /
          aws_rds_allocated_storage_average
          < 0.20
        for: 30m
        labels:
          severity: warning
        annotations:
          summary: "Storage do RDS com menos de 20% livre"
          description: "Storage livre: {{ $value | humanizePercentage }}"

  - name: elasticache
    rules:
      - alert: RedisBaixaMemoriaDisponivel
        expr: |
          aws_elasticache_freeable_memory_average
          /
          aws_elasticache_bytes_used_for_cache_average
          < 0.10
        for: 5m
        labels:
          severity: critical
        annotations:
          summary: "Redis com menos de 10% de memória livre"
          description: |
            O Redis está próximo da capacidade máxima.
            Risco de eviction de chaves e degradação de performance.

      - alert: RedisCacheHitRateBaixa
        expr: |
          aws_elasticache_cache_hits_average
          /
          (aws_elasticache_cache_hits_average + aws_elasticache_cache_misses_average)
          < 0.80
        for: 15m
        labels:
          severity: warning
        annotations:
          summary: "Cache hit rate do Redis abaixo de 80%"
          description: |
            Cache hit rate atual: {{ $value | humanizePercentage }}
            Hit rate baixo aumenta a carga no banco de dados.

O Que Vem a Seguir

O próximo artigo completa o que vimos com os serviços de rede e entrega de conteúdo da AWS — Route53 para DNS, CloudFront para CDN e ACM para certificados SSL/TLS — os componentes que ficam entre os usuários e a aplicação.

Referências para Aprofundamento

Documentação oficial AWS

Performance e otimização

Disaster Recovery

Exercícios

Exercício 1

Um time quer aliviar a carga do banco e propõe: "já temos multi_az = true, então vamos apontar as queries de relatório para a réplica que já está lá parada". Por que isso não funciona? Qual recurso do arquivo rds-producao.tf resolve o problema, e o que muda na aplicação para usá-lo?

Ver resposta

✓ Resposta: Não funciona porque a réplica Multi-AZ não serve leituras — ela é exclusivamente um standby síncrono para failover. Não existe endpoint para acessá-la: o endpoint de conexão é um só, e continua apontando para a primária mesmo depois de um failover (é justamente isso que torna o failover transparente para a aplicação). A réplica está "parada" por design.

O recurso que resolve é o aws_db_instance.replica_leitura — uma Read Replica, com replicação assíncrona e endpoint próprio. Na aplicação isso não é gratuito: é preciso separar as conexões de leitura das de escrita, direcionando cada query ao endpoint certo.

E há uma consequência a assumir: como a replicação é assíncrona, existe replication lag. Uma escrita seguida de leitura imediata na réplica pode não enxergar o dado recém-gravado. Por isso relatórios e agregações são bons candidatos; ler de volta o registro que o usuário acabou de salvar, não.

Exercício 2

No connection_pool_config do RDS Proxy, max_connections_percent = 90. Combinando com o aws_db_parameter_group.postgres16 do mesmo artigo, quantas conexões o Proxy abre no máximo contra o banco? E por que sobrou margem?

Ver resposta

✓ Resposta: 180 conexões. O parameter group define max_connections = 200, e max_connections_percent = 90 é uma porcentagem desse limite: 90% de 200 = 180.

Os 20 slots restantes são a margem que mantém o banco administrável. Se o Proxy pudesse consumir 100% das conexões, um pico de tráfego deixaria o banco inacessível justamente durante o incidente — sem conexão sobrando para um psql de diagnóstico, para o agente de monitoramento ou para matar a query travada que causou o problema. A reserva existe para o operador, não para a aplicação.

max_idle_connections_percent = 50 é outro eixo: dessas conexões, metade pode ficar ociosa no pool esperando reuso — é o que faz o Proxy valer a pena para Lambda, onde cada invocação abriria uma conexão nova e o custo de handshake (mais TLS) dominaria o tempo de execução.

Exercício 3

O método invalidarPadrao do CacheService funciona perfeitamente em desenvolvimento e derruba a latência da API em produção, com picos de segundos em requisições que não têm nada a ver com aquela entidade. Onde está o problema e como corrigir?

async invalidarPadrao(padrao) {
  const redis = await getClient();
  const chaves = await redis.keys(`${PREFIXO}:${padrao}`);
  // ...
}
Ver resposta

✓ Resposta: O problema é o comando KEYS. O Redis é single-threaded: enquanto executa um comando, nenhum outro roda. E KEYS percorre o keyspace inteiro — todas as chaves do banco, não apenas as que casam com o padrão. Com poucas centenas de chaves em desenvolvimento isso é instantâneo; com milhões em produção, o Redis fica bloqueado por segundos, e toda requisição que dependa do cache trava junto. Daí os picos em endpoints não relacionados: a fila é global.

A correção é trocar KEYS por SCAN, que é iterativo — devolve um cursor e um lote pequeno por vez, liberando o Redis entre as chamadas:

async invalidarPadrao(padrao) {
  const redis = await getClient();
  let cursor = '0';
  let total = 0;

  do {
    const res = await redis.scan(cursor, {
      MATCH: `${PREFIXO}:${padrao}`,
      COUNT: 100,
    });
    cursor = res.cursor;

    if (res.keys.length > 0) {
      await redis.unlink(res.keys);  // UNLINK libera em background
      total += res.keys.length;
    }
  } while (cursor !== '0');

  return total;
}

Duas observações: SCAN só garante que chaves presentes do início ao fim da iteração serão retornadas — pode devolver duplicatas e não enxergar chaves criadas durante o percurso, o que para invalidação de cache é aceitável. E UNLINK é preferível a DEL em lotes grandes, porque devolve o controle imediatamente e libera a memória em uma thread de fundo.

Vale notar que o rename-commands do parameter group já bane FLUSHALL, FLUSHDB e CONFIG — a mesma lógica de proteção se aplicaria a KEYS em produção.

Exercício 4

No script Lua de verificarRateLimit, o EXPIRE só é chamado quando atual == 1. Um cliente com limite de 100 requisições por 60 segundos dispara 100 requisições no segundo 0 e mais 100 no segundo 59. Quantas passam, e quantas passariam se o EXPIRE fosse chamado a cada incremento?

Ver resposta

✓ Resposta: Como está, passam 100: a chave é criada no segundo 0 com TTL de 60s, o contador chega a 100 e as 100 requisições do segundo 59 recebem contagens de 101 a 200 — todas com permitido: false, porque a chave ainda não expirou. A partir do segundo 60 a chave some e a contagem recomeça.

Se o EXPIRE fosse chamado a cada incremento, o TTL seria renovado a cada requisição e a janela nunca fecharia enquanto houvesse tráfego: um cliente que mantivesse requisições constantes ficaria bloqueado indefinidamente, mesmo muito depois dos 60 segundos originais. O if atual == 1 é o que fixa a janela no primeiro acesso — é o padrão de fixed window.

Vale registrar o efeito de borda dessa estratégia: nada impede 100 requisições no segundo 59 e mais 100 no segundo 61 — 200 em dois segundos, respeitando formalmente o limite de 100/minuto. Quando isso importa, a saída é sliding window (com ZADD/ZREMRANGEBYSCORE sobre timestamps) ou token bucket.

Repare ainda que o script recebe ARGV[1] (o limite) e não o usa — a decisão de permitir ou barrar acontece em JavaScript, com contagem <= limite. Funciona, mas o argumento é morto: o limite poderia entrar no script para que a decisão fosse atômica junto com o incremento.

Exercício 5

Uma migration rodou às 14:32 UTC e corrompeu a tabela pedidos. O problema só foi percebido às 16:10, com o sistema recebendo escritas normalmente nesse intervalo. Com a configuração de rds-producao.tf, qual dos três níveis de proteção você usa, e por que o comando de restauração não coloca o sistema de volta no ar sozinho?

Ver resposta

✓ Resposta: Usa-se o point-in-time recovery, apoiado nos backups automáticos — o backup_retention_period = 14 mantém snapshots diários mais os logs de transação (WAL), o que permite restaurar para qualquer segundo dos últimos 14 dias. O alvo é 14:31, imediatamente antes da migration:

aws rds restore-db-instance-to-point-in-time \
  --source-db-instance-identifier minha-api-production-db \
  --target-db-instance-identifier minha-api-recovery-test \
  --restore-time 2025-03-10T14:31:00Z

O snapshot manual pre-migration-* também serviria, mas é menos preciso: recupera o instante em que o snapshot foi tirado, não o segundo anterior ao dano.

O comando não coloca o sistema de volta porque restore-db-instance-to-point-in-time cria uma instância nova — é o que diz o --target-db-instance-identifier, distinto do --source. A instância corrompida continua no ar, servindo tráfego. Restaurar é só o primeiro passo; ainda é preciso validar os dados na instância nova e então redirecionar a aplicação (mudança de endpoint ou renomeação das instâncias), com a janela de indisponibilidade que isso implica.

E há um custo inevitável no cenário: voltar para 14:31 descarta as escritas legítimas entre 14:32 e 16:10. Essas quase duas horas são exatamente o RPO real do incidente — e é por isso que a recuperação costuma ser cirúrgica (restaurar em paralelo e migrar só a tabela pedidos) em vez de uma troca completa da instância.

Comentários

Mais em DevOps

Deploy Automático para Servidores com GitHub Actions
Deploy Automático para Servidores com GitHub Actions

Os três modelos de deploy — push, pull e por agente — e quando cada um se…

Variáveis de Ambiente e Secrets em Pipelines
Variáveis de Ambiente e Secrets em Pipelines

A distinção entre configuração e segredo, e o que fazer com cada um: os três…

Cultura DevOps: Maturidade, Postmortems e Melhoria Contínua
Cultura DevOps: Maturidade, Postmortems e Melhoria Contínua

O que separa uma organização DevOps de alto desempenho de uma medíocre depois…