Performance e FinOps: Otimizando Custo e Velocidade na Cloud

[312] Performance e FinOps: Otimizando Custo e Velocidade na Cloud

Custo de cloud tratado como responsabilidade de engenharia: a estratégia de etiquetas para alocação, orçamentos e detecção de anomalias, Savings Plans contra instâncias Spot, rightsizing pelo Compute Optimizer, lifecycle de storage no S3, VPC endpoints e o custo do próprio pipeline.
DevOps

24 min de leitura

Existe uma correlação quase universal entre o crescimento de um sistema em produção e o crescimento de seus custos de infraestrutura. O que começa como algumas instâncias EC2 e um banco de dados RDS evolui, ao longo de meses, para dezenas de serviços, centenas de recursos e uma fatura AWS que surpreende até os mais experientes quando chega ao fim do mês.

A disciplina de FinOps — Financial Operations — trata a gestão de custos de cloud como uma responsabilidade de engenharia, não apenas financeira. O princípio central é que as pessoas que tomam decisões de arquitetura — engenheiros e arquitetos — devem ter visibilidade dos custos que essas decisões geram, e responsabilidade por otimizá-los. O FinOps não é sobre cortar custos a qualquer custo; é sobre maximizar o valor entregue por cada dólar gasto em infraestrutura.

Performance e custo estão intrinsecamente ligados: um sistema mal dimensionado paga pelo excesso de capacidade; um sistema sobrecarregado degrada a experiência do usuário e frequentemente gera custos de incidente superiores ao que seria economizado com dimensionamento correto. Otimizar os dois simultaneamente — entregando a performance que o negócio precisa pelo menor custo possível — é o objetivo desta disciplina.

Visibilidade de Custos com AWS Cost Explorer e Etiquetas

O primeiro passo de qualquer programa FinOps é visibilidade. Sem saber quanto cada parte do sistema custa, não é possível decidir onde otimizar.

Estratégia de Etiquetas para Alocação de Custos

# tags.tf — convenção de etiquetas para rastreamento de custos

locals {
  tags_comuns = {
    # Identificação do recurso
    Project     = var.project_name
    Environment = var.environment
    ManagedBy   = "terraform"

    # Alocação de custos
    CostCenter  = var.cost_center      # ex: "eng-backend", "eng-plataforma"
    Team        = var.team             # ex: "checkout", "catalog", "infra"
    Owner       = var.owner            # email do responsável técnico

    # Lifecycle
    CreatedAt   = timestamp()
    ExpiresAt   = var.expires_at       # para recursos temporários

    # Compliance
    DataClass   = var.data_class       # "publico", "interno", "confidencial"
  }
}

# Política SCP (Service Control Policy) que exige etiquetas obrigatórias
# Aplicada no nível de AWS Organization para todos os recursos
resource "aws_organizations_policy" "exigir_etiquetas" {
  name = "ExigirEtiquetasObrigatorias"
  type = "SERVICE_CONTROL_POLICY"

  content = jsonencode({
    Version = "2012-10-17"
    Statement = [
      {
        Sid    = "ExigirEtiquetaProject"
        Effect = "Deny"
        Action = [
          "ec2:RunInstances",
          "rds:CreateDBInstance",
          "elasticache:CreateCacheCluster",
          "eks:CreateCluster",
        ]
        Resource = "*"
        Condition = {
          "Null" = {
            "aws:RequestedRegion" = "false"
            "aws:ResourceTag/Project" = "true"
          }
        }
      }
    ]
  })
}

Dashboard de Custos com AWS Cost Explorer

#!/bin/bash
# scripts/relatorio-custos.sh
# Gera relatório de custos por serviço e equipe para o mês atual

MES_INICIO=$(date +%Y-%m-01)
MES_FIM=$(date -d "$(date +%Y-%m-01) +1 month" +%Y-%m-%d 2>/dev/null || \
          date -v+1m -v1d +%Y-%m-%d)

echo "=== Relatório de Custos AWS — $(date +%B/%Y) ==="
echo ""

# Custo total do mês
TOTAL=$(aws ce get-cost-and-usage \
  --time-period Start=${MES_INICIO},End=${MES_FIM} \
  --granularity MONTHLY \
  --metrics BlendedCost \
  --query 'ResultsByTime[0].Total.BlendedCost.Amount' \
  --output text)

echo "💰 Custo total do mês: USD ${TOTAL}"
echo ""

# Custo por serviço AWS
echo "📊 Custo por serviço:"
aws ce get-cost-and-usage \
  --time-period Start=${MES_INICIO},End=${MES_FIM} \
  --granularity MONTHLY \
  --metrics BlendedCost \
  --group-by Type=DIMENSION,Key=SERVICE \
  --query 'ResultsByTime[0].Groups[*].{
    Servico: Keys[0],
    Custo: Metrics.BlendedCost.Amount
  }' \
  --output json \
  | jq 'sort_by(.Custo | tonumber) | reverse | .[:10][] |
    "  \(.Servico): USD \(.Custo | tonumber | . * 100 | round / 100)"' \
  -r

echo ""

# Custo por equipe (via tag Team)
echo "👥 Custo por equipe:"
aws ce get-cost-and-usage \
  --time-period Start=${MES_INICIO},End=${MES_FIM} \
  --granularity MONTHLY \
  --metrics BlendedCost \
  --group-by Type=TAG,Key=Team \
  --query 'ResultsByTime[0].Groups[*].{
    Equipe: Keys[0],
    Custo: Metrics.BlendedCost.Amount
  }' \
  --output json \
  | jq '.[] | "  \(.Equipe): USD \(.Custo | tonumber | . * 100 | round / 100)"' \
  -r

echo ""

# Anomalias de custo detectadas
echo "⚠️  Anomalias detectadas:"
aws ce get-anomalies \
  --date-interval StartDate=${MES_INICIO},EndDate=${MES_FIM} \
  --query 'Anomalies[*].{
    Servico: AnomalyScore.CurrentScore,
    ImpactoUSD: Impact.TotalImpact,
    Inicio: AnomalyStartDate
  }' \
  --output json \
  | jq '.[] | select(.ImpactoUSD > 100) |
    "  Impacto USD \(.ImpactoUSD | round) desde \(.Inicio)"' \
  -r

Alertas de Orçamento

# budget.tf

resource "aws_budgets_budget" "mensal" {
  name         = "${var.project_name}-${var.environment}-mensal"
  budget_type  = "COST"
  limit_amount = var.budget_mensal_usd
  limit_unit   = "USD"

  time_unit  = "MONTHLY"
  time_period_start = "2025-01-01_00:00"

  # Alertas em 70%, 90% e 100% do orçamento
  notification {
    comparison_operator        = "GREATER_THAN"
    threshold                  = 70
    threshold_type             = "PERCENTAGE"
    notification_type          = "FORECASTED"
    subscriber_email_addresses = [var.finops_email]
  }

  notification {
    comparison_operator        = "GREATER_THAN"
    threshold                  = 90
    threshold_type             = "PERCENTAGE"
    notification_type          = "FORECASTED"
    subscriber_email_addresses = [var.finops_email]
    subscriber_sns_topic_arns  = [aws_sns_topic.alertas_custo.arn]
  }

  notification {
    comparison_operator        = "GREATER_THAN"
    threshold                  = 100
    threshold_type             = "PERCENTAGE"
    notification_type          = "ACTUAL"
    subscriber_email_addresses = [var.finops_email]
    subscriber_sns_topic_arns  = [aws_sns_topic.alertas_custo.arn]
  }

  cost_filters = {
    TagKeyValue = ["user:Environment$${var.environment}"]
  }
}

# Detecção automática de anomalias de custo
resource "aws_ce_anomaly_monitor" "servicos" {
  name              = "${var.project_name}-monitor-anomalias"
  monitor_type      = "DIMENSIONAL"
  monitor_dimension = "SERVICE"
}

resource "aws_ce_anomaly_subscription" "alertas" {
  name      = "${var.project_name}-alertas-anomalias"
  frequency = "DAILY"

  monitor_arn_list = [aws_ce_anomaly_monitor.servicos.arn]

  subscriber {
    type    = "SNS"
    address = aws_sns_topic.alertas_custo.arn
  }

  # Alerta apenas se o impacto for maior que USD 50
  threshold_expression {
    dimension {
      key           = "ANOMALY_TOTAL_IMPACT_ABSOLUTE"
      values        = ["50"]
      match_options = ["GREATER_THAN_OR_EQUAL"]
    }
  }
}

Otimização de Compute: EC2, ECS e Lambda

Savings Plans e Reserved Instances

Para cargas de trabalho previsíveis — servidores de aplicação que rodam 24/7 — o uso de On-Demand é o tipo mais caro de compute. Duas alternativas oferecem descontos significativos:

Compute Savings Plans — compromisso de uso de uma quantidade de compute (em USD/hora) por 1 ou 3 anos, com desconto de até 66% sobre On-Demand. Aplicam-se automaticamente a EC2, Fargate e Lambda.

Reserved Instances — reserva de instâncias específicas com desconto de até 72% para comprometimento de 3 anos com pagamento antecipado.

# Identifica instâncias candidatas a Savings Plans
# Instâncias que rodam > 80% do tempo no último mês são boas candidatas
aws ce get-reservation-coverage \
  --time-period Start=$(date -d "30 days ago" +%Y-%m-%d),End=$(date +%Y-%m-%d) \
  --granularity MONTHLY \
  --group-by Type=DIMENSION,Key=INSTANCE_TYPE \
  --query 'CoveragesByTime[0].Groups[*].{
    Tipo: Keys[0],
    Cobertura: Coverage.CoverageHours.CoverageHoursPercentage,
    OnDemandCusto: Coverage.CoverageHours.OnDemandHours
  }' \
  --output table

# Recomendações de Savings Plans da AWS
aws ce get-savings-plans-purchase-recommendation \
  --savings-plans-type COMPUTE_SP \
  --term-in-years ONE_YEAR \
  --payment-option NO_UPFRONT \
  --lookback-period-in-days THIRTY_DAYS \
  --query 'SavingsPlansPurchaseRecommendation.{
    PoupancaMensalEstimada: SavingsPlansDetails[0].EstimatedMonthlySavingsAmount,
    CompromissoHorario: SavingsPlansDetails[0].HourlyCommitmentToPurchase
  }'

Instâncias Spot para Cargas de Trabalho Tolerantes a Interrupção

Instâncias Spot oferecem desconto de até 90% sobre On-Demand. São adequadas para cargas que toleram interrupção — jobs de CI/CD, processamento em batch, workers de fila:

# spot-workers.tf

# Auto Scaling Group com mix de On-Demand e Spot
resource "aws_autoscaling_group" "workers" {
  name                = "${var.project_name}-${var.environment}-workers"
  vpc_zone_identifier = module.vpc.ids_subnets_privadas
  min_size            = 1
  max_size            = 20
  desired_capacity    = 2

  mixed_instances_policy {
    instances_distribution {
      # 20% On-Demand para baseline, 80% Spot para escala
      on_demand_base_capacity                  = 1
      on_demand_percentage_above_base_capacity = 20
      spot_allocation_strategy                 = "price-capacity-optimized"
    }

    launch_template {
      launch_template_specification {
        launch_template_id = aws_launch_template.worker.id
        version            = "$Latest"
      }

      # Múltiplos tipos de instância aumentam disponibilidade de Spot
      override {
        instance_type = "m6i.large"
      }
      override {
        instance_type = "m6a.large"
      }
      override {
        instance_type = "m5.large"
      }
      override {
        instance_type = "m5a.large"
      }
      override {
        instance_type = "m7i.large"
      }
    }
  }

  tag {
    key                 = "Name"
    value               = "${var.project_name}-${var.environment}-worker"
    propagate_at_launch = true
  }
}

# Handler de interrupção Spot — drena o worker antes da instância ser terminada
# A AWS notifica 2 minutos antes de interromper uma instância Spot
resource "aws_cloudwatch_event_rule" "spot_interruption" {
  name        = "spot-interruption-${var.environment}"
  description = "Detecta notificações de interrupção de instâncias Spot"

  event_pattern = jsonencode({
    source      = ["aws.ec2"]
    detail-type = ["EC2 Spot Instance Interruption Warning"]
  })
}

resource "aws_cloudwatch_event_target" "spot_interruption_lambda" {
  rule      = aws_cloudwatch_event_rule.spot_interruption.name
  target_id = "DrenaWorker"
  arn       = aws_lambda_function.drena_worker.arn
}

Rightsizing: Dimensionamento Correto de Instâncias

Um dos maiores desperdícios em cloud é o over-provisioning — instâncias dimensionadas para picos que raramente ocorrem. O AWS Compute Optimizer analisa métricas históricas e recomenda o tipo de instância ideal:

#!/bin/bash
# scripts/rightsizing-report.sh
# Gera relatório de recomendações de rightsizing

echo "=== Recomendações de Rightsizing — AWS Compute Optimizer ==="
echo ""

# Recomendações para instâncias EC2
echo "📦 Instâncias EC2 com recomendação de downsizing:"
aws compute-optimizer get-ec2-instance-recommendations \
  --filters Name=Finding,Values=Overprovisioned \
  --query 'instanceRecommendations[*].{
    Instancia: instanceName,
    AtualTipo: currentInstanceType,
    RecomendadoTipo: recommendationOptions[0].instanceType,
    PoupancaMensal: recommendationOptions[0].estimatedMonthlySavings.value,
    Moeda: recommendationOptions[0].estimatedMonthlySavings.currency,
    UsoMaxCPU: utilizationMetrics[?name==`CPU`].value | [0]
  }' \
  --output json \
  | jq '.[] | "\(.Instancia): \(.AtualTipo) → \(.RecomendadoTipo) | CPU max: \(.UsoMaxCPU)% | Economia: \(.Moeda) \(.PoupancaMensal)/mês"' \
  -r

echo ""

# Recomendações para funções Lambda
echo "λ Funções Lambda sub ou sobredimensionadas:"
aws compute-optimizer get-lambda-function-recommendations \
  --filters Name=Finding,Values=Overprovisioned \
  --query 'lambdaFunctionRecommendations[*].{
    Funcao: functionArn,
    MemoriaAtual: memorySizeRecommendationOptions[0].memorySize,
    MemoriaRecomendada: memorySizeRecommendationOptions[0].memorySize,
    Poupanca: memorySizeRecommendationOptions[0].projectedUtilizationMetrics[0].value
  }' \
  --output json \
  | jq '.[] | "\(.Funcao | split(":") | .[-1]): → \(.MemoriaRecomendada)MB"' \
  -r

Otimização de Banco de Dados

Identificando Queries Lentas com Performance Insights

#!/usr/bin/env python3
# scripts/analise-queries-lentas.py
# Identifica as queries mais custosas no RDS via Performance Insights

import boto3
import json
from datetime import datetime, timedelta, timezone

pi = boto3.client('pi', region_name='us-east-1')
rds = boto3.client('rds', region_name='us-east-1')

INSTANCIA = 'minha-api-production-db'
PERIODO_HORAS = 24

# Obtém o ResourceIdentifier da instância RDS
response = rds.describe_db_instances(
    DBInstanceIdentifier=INSTANCIA
)
resource_id = response['DBInstances'][0]['DbiResourceId']

fim = datetime.now(timezone.utc)
inicio = fim - timedelta(hours=PERIODO_HORAS)

# Busca as top 10 queries por tempo total de execução
response = pi.get_resource_metrics(
    ServiceType='RDS',
    Identifier=resource_id,
    StartTime=inicio,
    EndTime=fim,
    PeriodInSeconds=3600,
    MetricQueries=[
        {
            'Metric': 'db.load.avg',
            'GroupBy': {
                'Group': 'db.sql',
                'Dimensions': ['db.sql.statement'],
                'Limit': 10,
            }
        }
    ]
)

print(f"=== Top 10 Queries por Carga — Últimas {PERIODO_HORAS}h ===\n")

for i, key in enumerate(
    response['MetricList'][0].get('Keys', []), 1
):
    statement = key['Dimensions'].get('db.sql.statement', 'N/A')
    # Trunca queries longas para exibição
    if len(statement) > 120:
        statement = statement[:120] + '...'

    # Calcula a carga média
    valores = [
        p['Value'] for p in key.get('DataPoints', [])
        if 'Value' in p
    ]
    carga_media = sum(valores) / len(valores) if valores else 0

    print(f"{i}. Carga média: {carga_media:.2f} AAS")
    print(f"   Query: {statement}")
    print()

print("\n💡 Queries com carga > 1.0 AAS merecem análise de índices.")

Otimizando Leituras com Connection Pooling

// src/database/pool.js — configuração otimizada do pool de conexões
const { Pool } = require('pg');

const pool = new Pool({
  connectionString: process.env.DATABASE_URL,
  ssl: { rejectUnauthorized: true },

  // Dimensionamento do pool baseado na instância RDS
  // Regra geral: min(max_connections / num_pods, 10)
  max: parseInt(process.env.DB_POOL_MAX || '10'),
  min: parseInt(process.env.DB_POOL_MIN || '2'),

  // Tempo de idle antes de fechar conexão extra
  idleTimeoutMillis: 30000,

  // Timeout para obter conexão do pool
  connectionTimeoutMillis: 5000,

  // Timeout para queries individuais
  statement_timeout: 30000,   // 30 segundos
  query_timeout: 30000,

  // Keepalive para conexões de longa duração
  keepAlive: true,
  keepAliveInitialDelayMillis: 10000,
});

// Métricas do pool expostas para o Prometheus
const { Gauge } = require('prom-client');

const poolTotal = new Gauge({
  name: 'db_pool_total_connections',
  help: 'Total de conexões no pool',
});

const poolIdle = new Gauge({
  name: 'db_pool_idle_connections',
  help: 'Conexões ociosas no pool',
});

const poolWaiting = new Gauge({
  name: 'db_pool_waiting_clients',
  help: 'Clientes aguardando conexão disponível',
});

setInterval(() => {
  poolTotal.set(pool.totalCount);
  poolIdle.set(pool.idleCount);
  poolWaiting.set(pool.waitingCount);
}, 5000);

pool.on('error', (err) => {
  console.error(JSON.stringify({
    level: 'error',
    msg: 'Erro inesperado no pool de conexões',
    err: err.message,
  }));
});

module.exports = pool;

Otimização de Storage e Transferência de Dados

S3 Intelligent-Tiering e Lifecycle Policies

O storage é frequentemente o item de custo mais negligenciado — buckets crescem indefinidamente sem políticas de ciclo de vida:

# s3-lifecycle.tf

resource "aws_s3_bucket_lifecycle_configuration" "dados" {
  bucket = aws_s3_bucket.dados.id

  # Logs de aplicação — transitam para Glacier e são deletados
  rule {
    id     = "lifecycle-logs-aplicacao"
    status = "Enabled"

    filter {
      prefix = "logs/"
    }

    transition {
      days          = 30
      storage_class = "STANDARD_IA"  # Acesso infrequente após 30 dias
    }

    transition {
      days          = 90
      storage_class = "GLACIER_IR"   # Glacier Instant Retrieval após 90 dias
    }

    expiration {
      days = 365  # Deleta após 1 ano
    }

    noncurrent_version_expiration {
      noncurrent_days = 30  # Versões antigas deletadas em 30 dias
    }
  }

  # Backups — retidos mais tempo, transitam para Glacier Deep Archive
  rule {
    id     = "lifecycle-backups"
    status = "Enabled"

    filter {
      prefix = "backups/"
    }

    transition {
      days          = 7
      storage_class = "STANDARD_IA"
    }

    transition {
      days          = 30
      storage_class = "GLACIER"
    }

    transition {
      days          = 90
      storage_class = "DEEP_ARCHIVE"  # USD 0.00099/GB — mais barato possível
    }

    expiration {
      days = 2555  # 7 anos para compliance
    }
  }

  # Assets de frontend — Intelligent-Tiering gerencia automaticamente
  rule {
    id     = "lifecycle-assets-frontend"
    status = "Enabled"

    filter {
      prefix = "frontend/"
    }

    transition {
      days          = 0
      storage_class = "INTELLIGENT_TIERING"
    }
  }
}

# Configuração do Intelligent-Tiering para assets
resource "aws_s3_bucket_intelligent_tiering_configuration" "assets" {
  bucket = aws_s3_bucket.dados.id
  name   = "assets-inteligente"

  filter {
    prefix = "frontend/"
  }

  tiering {
    access_tier = "DEEP_ARCHIVE_ACCESS"
    days        = 180
  }

  tiering {
    access_tier = "ARCHIVE_ACCESS"
    days        = 90
  }
}

Reduzindo Custos de Transferência de Dados

Transferência de dados entre regiões AWS e para a internet é uma das fontes de custo mais surpreendentes. Estratégias para reduzir:

# vpc-endpoints.tf — acessa serviços AWS sem tráfego de internet

# Gateway Endpoint para S3 — gratuito, elimina tráfego para internet
resource "aws_vpc_endpoint" "s3" {
  vpc_id       = module.vpc.vpc_id
  service_name = "com.amazonaws.${var.aws_region}.s3"
  vpc_endpoint_type = "Gateway"

  route_table_ids = concat(
    module.vpc.ids_route_tables_privadas,
    module.vpc.ids_route_tables_publicas
  )

  tags = merge(local.tags_comuns, {
    Name = "${var.project_name}-${var.environment}-s3-endpoint"
  })
}

# Interface Endpoint para Secrets Manager
# Evita tráfego de internet — cobra por hora + GB processado
resource "aws_vpc_endpoint" "secrets_manager" {
  vpc_id              = module.vpc.vpc_id
  service_name        = "com.amazonaws.${var.aws_region}.secretsmanager"
  vpc_endpoint_type   = "Interface"
  private_dns_enabled = true
  subnet_ids          = module.vpc.ids_subnets_privadas
  security_group_ids  = [aws_security_group.vpc_endpoints.id]

  tags = merge(local.tags_comuns, {
    Name = "${var.project_name}-${var.environment}-secretsmanager-endpoint"
  })
}

# Interface Endpoint para ECR — acelera pull de imagens em EKS/ECS
resource "aws_vpc_endpoint" "ecr_api" {
  vpc_id              = module.vpc.vpc_id
  service_name        = "com.amazonaws.${var.aws_region}.ecr.api"
  vpc_endpoint_type   = "Interface"
  private_dns_enabled = true
  subnet_ids          = module.vpc.ids_subnets_privadas
  security_group_ids  = [aws_security_group.vpc_endpoints.id]

  tags = local.tags_comuns
}

resource "aws_vpc_endpoint" "ecr_dkr" {
  vpc_id              = module.vpc.vpc_id
  service_name        = "com.amazonaws.${var.aws_region}.ecr.dkr"
  vpc_endpoint_type   = "Interface"
  private_dns_enabled = true
  subnet_ids          = module.vpc.ids_subnets_privadas
  security_group_ids  = [aws_security_group.vpc_endpoints.id]

  tags = local.tags_comuns
}

Dashboard FinOps no Grafana

Um dashboard dedicado a custos conecta métricas técnicas ao impacto financeiro:

{
  "dashboard": {
    "title": "FinOps — Custos e Eficiência",
    "panels": [
      {
        "title": "Custo por Requisição Atendida",
        "type": "stat",
        "description": "USD gasto por cada 1000 requisições processadas — métrica de eficiência de custo",
        "targets": [
          {
            "expr": "(aws_billing_estimated_charges / on()(sum(increase(http_requests_total[24h])) / 1000))",
            "legendFormat": "USD / 1k req"
          }
        ]
      },
      {
        "title": "Custo Estimado do Dia (Projeção Mensal)",
        "type": "timeseries",
        "targets": [
          {
            "expr": "aws_billing_estimated_charges",
            "legendFormat": "Custo acumulado USD"
          }
        ]
      },
      {
        "title": "Utilização de CPU dos Nós EKS",
        "description": "Baixa utilização indica oportunidade de rightsizing ou consolidação",
        "type": "gauge",
        "targets": [
          {
            "expr": "avg(100 - (avg by(node) (irate(node_cpu_seconds_total{mode='idle'}[5m])) * 100))",
            "legendFormat": "CPU média dos nós"
          }
        ],
        "thresholds": {
          "steps": [
            {"color": "red", "value": 0},
            {"color": "yellow", "value": 40},
            {"color": "green", "value": 60}
          ]
        }
      },
      {
        "title": "Memória Não Utilizada nos Nós",
        "type": "timeseries",
        "targets": [
          {
            "expr": "sum(node_memory_MemAvailable_bytes) / sum(node_memory_MemTotal_bytes) * 100",
            "legendFormat": "% memória livre nos nós"
          }
        ]
      }
    ]
  }
}

Custo do Pipeline de CI/CD

Os próprios pipelines de CI/CD têm um custo que cresce com a frequência de commits e o tamanho do time. Otimizações que reduzem o tempo de pipeline reduzem diretamente o custo de GitHub Actions ou do runner:

# Estratégias para pipelines mais eficientes

jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      # Cache de node_modules — evita npm install em cada run
      - uses: actions/cache@v4
        id: cache-deps
        with:
          path: |
            ~/.npm
            node_modules
          key: ${{ runner.os }}-node-${{ hashFiles('package-lock.json') }}
          restore-keys: |
            ${{ runner.os }}-node-

      - name: Instala dependências (apenas se cache miss)
        if: steps.cache-deps.outputs.cache-hit != 'true'
        run: npm ci

      # Cache de camadas Docker — evita rebuild de camadas inalteradas
      - uses: actions/cache@v4
        with:
          path: /tmp/.buildx-cache
          key: ${{ runner.os }}-buildx-${{ github.sha }}
          restore-keys: |
            ${{ runner.os }}-buildx-

      - uses: docker/setup-buildx-action@v3

      - uses: docker/build-push-action@v5
        with:
          context: .
          cache-from: type=local,src=/tmp/.buildx-cache
          cache-to: type=local,dest=/tmp/.buildx-cache-new,mode=max
          tags: ghcr.io/empresa/api:${{ github.sha }}
          push: true

      # Rotaciona o cache — evita crescimento indefinido
      - name: Rotaciona cache do Buildx
        run: |
          rm -rf /tmp/.buildx-cache
          mv /tmp/.buildx-cache-new /tmp/.buildx-cache

Relatório Semanal de FinOps Automatizado

# .github/workflows/relatorio-finops-semanal.yml
name: Relatório Semanal de FinOps

on:
  schedule:
    - cron: '0 8 * * MON'  # Segunda-feira às 8h
  workflow_dispatch:

jobs:
  relatorio:
    runs-on: ubuntu-latest

    permissions:
      id-token: write
      contents: read

    steps:
      - uses: actions/checkout@v4

      - uses: aws-actions/configure-aws-credentials@v4
        with:
          role-to-assume: ${{ secrets.AWS_FINOPS_ROLE_ARN }}
          aws-region: us-east-1

      - name: Gera relatório de custos
        run: bash scripts/relatorio-custos.sh > relatorio.txt

      - name: Gera recomendações de rightsizing
        run: python3 scripts/analise-queries-lentas.py >> relatorio.txt

      - name: Publica no Slack
        uses: slackapi/slack-github-action@v1.26.0
        with:
          channel-id: ${{ vars.SLACK_FINOPS_CHANNEL }}
          payload: |
            {
              "blocks": [
                {
                  "type": "header",
                  "text": {
                    "type": "plain_text",
                    "text": "📊 Relatório Semanal de FinOps"
                  }
                },
                {
                  "type": "section",
                  "text": {
                    "type": "mrkdwn",
                    "text": "```${{ steps.relatorio.outputs.summary }}```"
                  }
                },
                {
                  "type": "actions",
                  "elements": [
                    {
                      "type": "button",
                      "text": {"type": "plain_text", "text": "Ver no AWS Cost Explorer"},
                      "url": "https://console.aws.amazon.com/cost-management/home"
                    }
                  ]
                }
              ]
            }
        env:
          SLACK_BOT_TOKEN: ${{ secrets.SLACK_BOT_TOKEN }}

O Que Vem a Seguir

O próximo artigo cobre Resiliência e Chaos Engineering — como construir sistemas que falham graciosamente, como usar o AWS Fault Injection Simulator para testar a resiliência em produção e como implementar os padrões de resiliência fundamentais: circuit breaker, retry com backoff exponencial e bulkhead.

Referências para Aprofundamento

FinOps e otimização de custos

Exercícios

Exercício 1

Analise a regra lifecycle-backups do s3-lifecycle.tf: transição para STANDARD_IA aos 7 dias, para GLACIER aos 30 e para DEEP_ARCHIVE aos 90. A intenção é clara — mover o dado para camadas mais baratas o quanto antes. Por que essa configuração não economiza o que aparenta?

Ver resposta

✓ Resposta: Porque cada classe de armazenamento tem uma duração mínima faturável, e a regra viola duas delas. Sair antes do mínimo não economiza: a AWS cobra o período inteiro de qualquer forma.

classemínimotempo na regra
STANDARD_IA30 dias7 → 30 = 23 dias
GLACIER90 dias30 → 90 = 60 dias
DEEP_ARCHIVE180 dias90 → 2555 ✓

Ou seja: o objeto passa 23 dias no IA e é cobrado por 30; passa 60 dias no Glacier e é cobrado por 90. E cada transição tem custo próprio por objeto — com muitos arquivos pequenos, o custo das requisições de transição pode superar a economia de armazenamento. Objetos menores que 128 KB agravam: eles nem são elegíveis para transição automática, e no IA são cobrados como se tivessem 128 KB.

A regra corrigida respeita os patamares, mantendo a mesma intenção:

    transition {
      days          = 30       # cumpre o mínimo do IA antes de sair
      storage_class = "STANDARD_IA"
    }
    transition {
      days          = 120      # 90 dias completos no Glacier
      storage_class = "GLACIER"
    }
    transition {
      days          = 210      # 90 dias completos antes do Deep Archive
      storage_class = "DEEP_ARCHIVE"
    }

Repare que a regra lifecycle-logs-aplicacao, logo acima, está correta: 30 dias em Standard, 60 no IA (mínimo 30 ✓) e 275 no Glacier IR (mínimo 90 ✓). A diferença entre as duas é exatamente o tipo de detalhe que só aparece na fatura, dois meses depois — e é por isso que o lifecycle-assets-frontend delega ao Intelligent-Tiering, que move os objetos sozinho conforme o padrão real de acesso, sem cobrança de recuperação e sem esse cálculo manual.

Exercício 2

A SCP ExigirEtiquetasObrigatorias pretende bloquear a criação de recursos sem a etiqueta Project. Um engenheiro executa aws ec2 run-instances sem nenhuma etiqueta e a instância é criada normalmente. Onde está o erro na condição?

Ver resposta

✓ Resposta: A política usa aws:ResourceTag/Project, que é a chave errada para este caso. aws:ResourceTag avalia as etiquetas de um recurso que já existe — e no momento do RunInstances a instância ainda não existe, então não há etiqueta a inspecionar. A chave que examina as etiquetas enviadas na requisição é aws:RequestTag/Project.

Há um segundo problema no mesmo bloco. As duas chaves estão dentro do mesmo operador Null, e chaves no mesmo operador se combinam com E. A condição só seria verdadeira se aws:RequestedRegion fosse nula ("false" diz que ela não é nula — o que é sempre verdade) e a tag fosse nula. A presença dessa chave de região ali não tem função; ela apenas embaralha a leitura da política.

A forma correta de exigir a etiqueta na criação:

{
  "Sid": "ExigirEtiquetaProject",
  "Effect": "Deny",
  "Action": ["ec2:RunInstances", "rds:CreateDBInstance"],
  "Resource": "*",
  "Condition": {
    "Null": { "aws:RequestTag/Project": "true" }
  }
}

Duas notas sobre SCPs de etiqueta. Elas não concedem permissão — apenas limitam o teto do que as políticas de IAM podem permitir dentro da organização; e como o efeito é Deny, um erro de escrita costuma falhar em aberto, exatamente como aqui: nada quebra, e a governança simplesmente não acontece. Vale também lembrar que o ec2:RunInstances cria vários tipos de recurso (instância, volume, interface de rede), então uma política completa precisa alcançar também o ec2:CreateTags da mesma requisição.

O timestamp() no CreatedAt das tags_comuns merece atenção pelo mesmo motivo de disciplina: essa função devolve um valor novo a cada execução, então todo terraform plan acusa mudança em todos os recursos que a usam.

Exercício 3

No spot-workers.tf, o Auto Scaling Group tem on_demand_base_capacity = 1 e on_demand_percentage_above_base_capacity = 20. Quando o grupo escala para 11 instâncias, quantas serão On-Demand e quantas Spot? E por que o bloco declara cinco tipos de instância diferentes em override?

Ver resposta

✓ Resposta: 3 On-Demand e 8 Spot. O cálculo tem duas partes: a on_demand_base_capacity reserva 1 instância On-Demand como piso garantido, e a porcentagem se aplica apenas ao que passa desse piso — 20% das 10 restantes = 2. Total: 1 + 2 = 3 On-Demand, e as outras 8 em Spot.

A base existe para garantir capacidade mínima que não pode evaporar: mesmo que o mercado Spot fique indisponível por completo, o serviço continua com aquela instância. É o desenho típico — baseline confiável em On-Demand, elasticidade barata em Spot.

Os cinco override respondem a como o Spot funciona: cada combinação de tipo de instância + zona de disponibilidade é um pool de capacidade separado, com preço e disponibilidade próprios. Pedir apenas m6i.large significa depender de um único pool por AZ; quando ele se esgota, o grupo simplesmente não escala. Cinco tipos equivalentes multiplicam os pools disponíveis, e a estratégia price-capacity-optimized escolhe entre eles ponderando preço e profundidade de capacidade — priorizando os pools com menor chance de interrupção, não apenas o mais barato do momento.

O aws_cloudwatch_event_rule de EC2 Spot Instance Interruption Warning fecha o desenho: a AWS avisa dois minutos antes de retomar a instância, e a Lambda usa essa janela para drenar o worker — parar de aceitar trabalho novo e concluir o que está em andamento. Sem esse handler, o desconto de até 90% é pago com trabalho perdido no meio da execução.

Exercício 4

No rightsizing-report.sh, a seção de Lambda imprime a memória atual e a recomendada para cada função. O relatório sai com os dois valores sempre idênticos, para todas as funções. Por quê?

Ver resposta

✓ Resposta: Os dois campos leem exatamente a mesma coisa:

    MemoriaAtual: memorySizeRecommendationOptions[0].memorySize,
    MemoriaRecomendada: memorySizeRecommendationOptions[0].memorySize,

Ambos apontam para a memória recomendada. O valor atual da função está em outro campo da resposta — currentMemorySize. Como está, o relatório é incapaz de mostrar economia alguma: ele compara a recomendação com ela mesma.

    MemoriaAtual: currentMemorySize,
    MemoriaRecomendada: memorySizeRecommendationOptions[0].memorySize,

Há um segundo desalinhamento na mesma seção: o título anuncia funções "sub ou sobredimensionadas", mas o filtro é Values=Overprovisioned — só as sobredimensionadas aparecem. E o campo rotulado PoupancaprojectedUtilizationMetrics[0].value, que é uma projeção de duração de execução, não um valor em dólares.

Vale notar por que subdimensionada importa tanto quanto o contrário em Lambda, e é justamente o que o filtro esconde: a memória alocada determina também a fatia de CPU que a função recebe. Uma função com pouca memória roda mais devagar, e como a cobrança é por GB-segundo, dobrar a memória pode cortar o tempo de execução pela metade e sair pelo mesmo preço — com metade da latência. É o caso em que aumentar o recurso não aumenta o custo, algo que não tem equivalente em EC2.

Exercício 5

O painel "Custo por Requisição Atendida" define a métrica de eficiência assim:

(aws_billing_estimated_charges / on()(sum(increase(http_requests_total[24h])) / 1000))

Um time com tráfego e infraestrutura perfeitamente estáveis observa esse número subindo todo dia, e despencando no primeiro dia de cada mês. O que a fórmula está realmente medindo?

Ver resposta

✓ Resposta: A fórmula divide grandezas de janelas de tempo diferentes. O aws_billing_estimated_charges é o custo acumulado desde o início do mês — ele só cresce, e zera na virada. O denominador conta as requisições das últimas 24 horas, que permanecem estáveis.

O resultado é acumulado-do-mês dividido por tráfego-de-um-dia: no dia 10 o valor é dez vezes o do dia 1, sem que nada tenha mudado na eficiência. O painel não mede custo por requisição — mede, com passos extras, quantos dias se passaram desde o dia 1º. O mergulho na virada do mês é o contador zerando, e não uma melhoria.

Para que a métrica signifique o que promete, os dois lados precisam cobrir o mesmo intervalo — usando o incremento diário do custo:

increase(aws_billing_estimated_charges[24h])
/ on()
(sum(increase(http_requests_total[24h])) / 1000)

Duas ressalvas de ordem prática. O aws_billing_estimated_charges vem do CloudWatch com atualização de poucas vezes por dia e com atraso de várias horas — é uma estimativa grosseira, adequada para tendência, nunca para conciliação financeira, que continua sendo trabalho do Cost Explorer com os dados de faturamento. E uma métrica de custo por requisição só é comparável ao longo do tempo se o denominador for estável no significado: se http_requests_total passar a contar também health checks e chamadas internas de service mesh, o número melhora sem que nada tenha ficado mais eficiente.

É o mesmo risco que o painel de CPU logo abaixo evita ao trazer a descrição explícita de que baixa utilização indica oportunidade de rightsizing: em um dashboard de FinOps, o que o número quer dizer precisa estar escrito ao lado dele — quem lê está decidindo onde cortar gasto.

Comentários

Mais em DevOps

Protegendo Branches e Revisando Código com Pull Requests
Protegendo Branches e Revisando Código com Pull Requests

Pull Requests e proteção de branch: as regras que tornam a revisão…

Introdução ao Terraform: Infraestrutura que Você Pode Versionar
Introdução ao Terraform: Infraestrutura que Você Pode Versionar

O problema dos snowflake servers e a infraestrutura que se pode versionar: o…

Gitea: Self-Hosted Leve para Times Menores
Gitea: Self-Hosted Leve para Times Menores

Servidor Git self-hosted em um único binário Go: instalação por systemd ou…