DEV Community

Como eu colocaria um portal de operações de SLA na AWS (e o que pesei no caminho)?

Contexto

Eu montei um portal interno que acompanha SLA de várias filas operacionais no Jira. Ele lê tickets via API do Jira, horas via API do Tempo, calcula um health score por equipe e mostra tudo num dashboard. Hoje ele roda como um servidor Flask simples, com cache em memória e alguns arquivos JSON para guardar histórico.

Funciona. Mas "roda na minha máquina" não é arquitetura. Então sentei pra pensar: se eu precisasse subir isso de verdade, com a galera dependendo dele, como seria na AWS?

Esse post é esse exercício. Não é um tutorial passo a passo de Terraform, é mais sobre as decisões e os trade-offs. Vou tentar ser honesto sobre o que faz sentido e o que seria overkill.


Tela inicial do portal: health score consolidado e por equipe.

O que o portal faz hoje

Antes de jogar AWS em cima, vale entender as partes:

  • Backend: um servidor Flask em Python. Expõe uns endpoints REST (/api/queues/:id/data, /api/queues/overview, /api/trends, etc).
  • Frontend: um HTML único com bastante JavaScript e Chart.js. Sem framework, sem build step.
  • Integrações externas: API REST do Jira e API do Tempo. É daí que vem todo o dado.
  • Cache: um dicionário em memória com TTL de 5 minutos. A primeira chamada de uma fila grande (tipo Service Desk, com ~2.500 tickets) leva uns 30 segundos; as próximas vêm instantâneas.
  • Persistência: dois arquivos JSON. Um é o registro das filas (configuração), outro guarda snapshots diários do health score pra montar gráficos de tendência.
  • Job diário: uma vez por dia ele tira um "retrato" de todas as filas e salva no histórico.
  • Segredos: tokens do Jira e Tempo, hoje em variáveis de ambiente.

Repara que é um sistema pequeno. Isso importa, porque a tentação de jogar dez serviços AWS num projeto desse tamanho é grande e quase sempre errada.


Página de operações de uma fila: SLA de primeira resposta e resolução, health score multidimensional e distribuição por analista.

O desenho na AWS

Vou separar por responsabilidade.

Onde o backend roda

A primeira pergunta é: Lambda ou container?

O backend tem um detalhe chato: a chamada que busca todos os tickets de uma fila grande pode passar de 30 segundos por causa da paginação da API do Jira. Lambda tem timeout máximo de 15 minutos, então caberia, mas 30s de cold start somado a uma chamada síncrona desse tamanho me deixa desconfortável pra uma UI. Ninguém gosta de clicar e esperar meio minuto.

Então fui de ECS Fargate. Um container com o Flask (atrás de um Gunicorn, não o servidor de dev do Flask), rodando como um serviço. Eu não preciso gerenciar EC2, pago pelo que uso, e não tenho a dança de cold start pra requisições mais pesadas.

Coloco um Application Load Balancer na frente. Ele cuida do HTTPS (com certificado do ACM) e distribui pros containers. Se um dia o uso crescer, ligo o auto scaling do Fargate por CPU. Hoje, com o time atual, uma ou duas tarefas resolvem.

Uma alternativa que considerei foi App Runner. É mais simples que o Fargate puro e abstrai o load balancer. Pra um projeto desse tamanho, App Runner provavelmente seria suficiente e daria menos coisa pra configurar. Fiquei no Fargate porque já conheço melhor e quero controle do networking, mas se eu estivesse com pressa, App Runner seria uma escolha defensável.

O frontend

O frontend é um HTML estático. Não precisa de servidor pra isso.

Jogo o arquivo num bucket S3 e ponho o CloudFront na frente. CloudFront cacheia o HTML perto do usuário e serve por HTTPS. O Flask para de ter a responsabilidade de servir o HTML — ele vira só API.

Detalhe prático: como o frontend chama a API em /api/..., configuro o CloudFront com dois origins. Requisição que bate em /api/* vai pro Load Balancer (o backend); o resto vem do S3. Assim o usuário só conhece um domínio e eu não preciso me preocupar com CORS.

A persistência

Aqui é onde eu mais precisei segurar a mão pra não complicar.

Os dois arquivos JSON têm naturezas diferentes:

Registro das filas (queue_registry.json): é configuração. Muda raramente, na mão. Isso pode continuar sendo um arquivo — só que num bucket S3 em vez do disco local. O backend lê na inicialização (ou com um cache curto). Não preciso de banco pra isso.

Histórico de snapshots (trends.json): isso cresce todo dia e eu faço consultas tipo "me dá os últimos 30 dias dessa fila". Um arquivo JSON único que vai crescendo é uma má ideia a médio prazo — toda vez eu leio o arquivo inteiro pra pegar um pedaço. Aqui o DynamoDB encaixa bem. Cada snapshot vira um item, com a data como parte da chave. Eu consigo buscar um intervalo de datas sem ler o histórico todo. E é serverless, então não tenho instância de banco pra cuidar num projeto pequeno.

Reparei que poderia ter ido de RDS, mas não tenho relacionamento nenhum complexo nem JOINs. Seria pagar por algo que não uso. DynamoDB com um padrão de acesso simples (chave da fila + data) resolve.

O cache

Hoje o cache vive na memória do processo. Funciona porque é um processo só. No momento em que eu tenho duas tarefas no Fargate, cada uma tem seu próprio cache, e aí começa a inconsistência — um usuário pega dado de 1 minuto atrás, outro de 4 minutos.

A resposta padrão pra isso é ElastiCache (Redis). Um cache compartilhado entre as tarefas, com TTL. As tarefas escrevem e leem do mesmo lugar.

Mas e aqui é onde eu seria honesto no artigo de verdade — pra esse volume, talvez eu nem subisse o Redis no dia um. Manter o cache em memória com duas tarefas significa, no pior caso, que o dado pode estar 5 minutos "fora de fase" entre uma tarefa e outra. Pra um dashboard de SLA que se atualiza a cada 5 minutos mesmo, isso é tolerável. Eu colocaria o Redis quando o número de tarefas crescesse ou quando o custo de re-buscar do Jira começasse a incomodar. É uma daquelas coisas que dá pra adiar sem vergonha.

O job diário

O snapshot diário hoje é um cron. Na AWS isso vira EventBridge Scheduler disparando uma vez por dia.

O que ele dispara? Aqui Lambda faz sentido. O job não tem o problema de latência da UI, ele roda no background, pode demorar os 30-40 segundos buscando todas as filas, e ninguém está esperando na tela. Uma função Lambda que faz o snapshot e grava no DynamoDB. Barato, sem servidor ligado o dia todo pra rodar uma vez.

Segredos

Os tokens do Jira e do Tempo não podem ficar em variável de ambiente em texto puro num lugar sério. Vão pro Secrets Manager. O container e a Lambda pegam os tokens em runtime via IAM role. Bônus: o Secrets Manager faz rotação se a gente configurar, embora token de API de terceiro nem sempre suporte rotação automática.

Logs e o mínimo de observabilidade

Tudo manda log pro CloudWatch Logs. Isso já vem quase de graça com Fargate e Lambda.

Eu colocaria pelo menos dois alarmes no CloudWatch:

  • Se a taxa de erro 5xx no Load Balancer subir, eu quero saber.
  • Se o job diário falhar (a Lambda der erro), eu quero saber — senão o gráfico de tendência fica com buraco e eu só descubro semanas depois.

Não vou montar dashboard de observabilidade elaborado pra um projeto desse tamanho. Os logs do CloudWatch e dois alarmes cobrem o que importa.

Juntando tudo

Na versão AWS, o usuário acessaria o portal por um domínio assim:

https://d1a2b3c4d5e6f7.cloudfront.net
Enter fullscreen mode Exit fullscreen mode

(esse é o domínio que o CloudFront gera automaticamente; em produção eu apontaria um CNAME tipo cldops.minhaempresa.com pra ele via Route 53)

O fluxo fica assim:

  1. Usuário acessa https://d1a2b3c4d5e6f7.cloudfront.net → CloudFront.
  2. CloudFront serve o HTML do S3.
  3. O JavaScript chama /api/... → CloudFront roteia pro Load Balancer → Fargate (Flask/Gunicorn).
  4. O Flask checa o cache (memória ou Redis), e se precisar, busca no Jira/Tempo.
  5. Configuração das filas vem de um JSON no S3; histórico vem do DynamoDB.
  6. Uma vez por dia, o EventBridge dispara uma Lambda que tira o snapshot e grava no DynamoDB.
  7. Tokens ficam no Secrets Manager; logs e alarmes no CloudWatch.

Em diagrama de caixinha, fica mais ou menos isso:

                          ┌─────────────────┐
   usuário ──HTTPS──────► │   CloudFront     │
                          │ d1a2b3c4d5e6f7   │
                          │ .cloudfront.net  │
                          └────────┬─────────┘
                          /            \
                   /api/* │             │ resto
                          ▼             ▼
                  ┌──────────────┐   ┌──────────┐
                  │     ALB      │   │    S3     │
                  └──────┬───────┘   │ (HTML)    │
                         ▼           └──────────┘
                  ┌──────────────┐
                  │ ECS Fargate  │──► Jira API
                  │ Flask/Gunic. │──► Tempo API
                  └──────┬───────┘
                         │
              ┌──────────┼──────────┐
              ▼          ▼          ▼
        ┌─────────┐ ┌─────────┐ ┌──────────┐
        │DynamoDB │ │  S3     │ │ Secrets  │
        │(trends) │ │(config) │ │ Manager  │
        └─────────┘ └─────────┘ └──────────┘

   EventBridge (1x/dia) ──► Lambda (snapshot) ──► DynamoDB
Enter fullscreen mode Exit fullscreen mode


A comparação cross-queue: dá pra ver de relance qual fila está saudável e qual precisa de atenção.

Como isso vira IaC

Diagrama é bonito, mas não sobe nada. Eu escreveria isso em Terraform pra não ficar clicando no console e esquecendo o que fiz. Não vou colar o módulo inteiro aqui (ia ficar gigante), mas dá pra mostrar o esqueleto pra você ter ideia do tamanho.

A estrutura de arquivos que eu usaria:

infra/
├── main.tf            # providers e backend do state
├── variables.tf       # região, nome do projeto, etc
├── network.tf         # VPC, subnets, security groups
├── ecs.tf             # cluster, task definition, service
├── alb.tf             # load balancer e target group
├── frontend.tf        # bucket S3 + CloudFront
├── data.tf            # DynamoDB + bucket de config
├── scheduler.tf       # EventBridge + Lambda do snapshot
├── secrets.tf         # Secrets Manager
└── outputs.tf         # URL do CloudFront, etc
Enter fullscreen mode Exit fullscreen mode

Um pedaço do frontend.tf, que é o que gera aquele domínio do CloudFront:

# Bucket que guarda o HTML do dashboard
resource "aws_s3_bucket" "frontend" {
  bucket = "${var.project}-frontend"
}

resource "aws_cloudfront_distribution" "portal" {
  enabled             = true
  default_root_object = "dashboard.html"

  # Origin 1: o HTML estático no S3
  origin {
    domain_name              = aws_s3_bucket.frontend.bucket_regional_domain_name
    origin_id                = "s3-frontend"
    origin_access_control_id = aws_cloudfront_origin_access_control.frontend.id
  }

  # Origin 2: a API no load balancer
  origin {
    domain_name = aws_lb.api.dns_name
    origin_id   = "alb-api"
    custom_origin_config {
      http_port              = 80
      https_port             = 443
      origin_protocol_policy = "https-only"
      origin_ssl_protocols   = ["TLSv1.2"]
    }
  }

  # Tudo que NÃO for /api/* vem do S3
  default_cache_behavior {
    target_origin_id       = "s3-frontend"
    viewer_protocol_policy = "redirect-to-https"
    allowed_methods        = ["GET", "HEAD"]
    cached_methods         = ["GET", "HEAD"]
    cache_policy_id        = data.aws_cloudfront_cache_policy.optimized.id
  }

  # /api/* vai pro backend, sem cache (dado dinâmico)
  ordered_cache_behavior {
    path_pattern           = "/api/*"
    target_origin_id       = "alb-api"
    viewer_protocol_policy = "redirect-to-https"
    allowed_methods        = ["GET", "HEAD", "OPTIONS", "POST"]
    cached_methods         = ["GET", "HEAD"]
    cache_policy_id        = data.aws_cloudfront_cache_policy.disabled.id
  }

  restrictions {
    geo_restriction { restriction_type = "none" }
  }

  viewer_certificate {
    cloudfront_default_certificate = true
  }
}

output "portal_url" {
  value = "https://${aws_cloudfront_distribution.portal.domain_name}"
}
Enter fullscreen mode Exit fullscreen mode

Repara no detalhe dos dois cache_behavior: o HTML estático eu deixo o CloudFront cachear à vontade, mas o /api/* eu marco como sem cache, porque é dado dinamicamente de SLA. Se eu cacheasse a API no CloudFront, ia brigar com o cache de 5 minutos que já existe no backend e o dado ficaria velho de formas difíceis de debugar.

E o outputs.tf me cospe a URL no final do terraform apply:

# depois do apply:
# portal_url = "https://d1a2b3c4d5e6f7.cloudfront.net"
Enter fullscreen mode Exit fullscreen mode

Um trecho do ecs.tf pra dar ideia de como o backend é declarado:

resource "aws_ecs_task_definition" "api" {
  family                   = "${var.project}-api"
  requires_compatibilities = ["FARGATE"]
  network_mode             = "awsvpc"
  cpu                      = 512
  memory                   = 1024
  execution_role_arn       = aws_iam_role.ecs_execution.arn
  task_role_arn            = aws_iam_role.ecs_task.arn

  container_definitions = jsonencode([{
    name  = "flask-api"
    image = "${aws_ecr_repository.api.repository_url}:latest"
    portMappings = [{ containerPort = 8080 }]
    # tokens vêm do Secrets Manager, não hardcoded
    secrets = [
      { name = "JIRA_API_TOKEN", valueFrom = aws_secretsmanager_secret.jira.arn },
      { name = "TEMPO_API_TOKEN", valueFrom = aws_secretsmanager_secret.tempo.arn }
    ]
    logConfiguration = {
      logDriver = "awslogs"
      options = {
        "awslogs-group"         = aws_cloudwatch_log_group.api.name
        "awslogs-region"        = var.region
        "awslogs-stream-prefix" = "flask"
      }
    }
  }])
}
Enter fullscreen mode Exit fullscreen mode

Não é o arquivo completo, faltam a aws_ecs_service, o auto scaling, as policies de IAM com permissão certa pros secrets, o network.tf com a VPC. Mas dá pra ver que não é mágica: é declarar cada caixinha do diagrama e ligar uma à outra pelos IDs.

Se eu fosse fazer isso de verdade, provavelmente nem escreveria tudo na mão, usaria um módulo pronto da comunidade pra VPC (o terraform-aws-modules/vpc/aws é bem testado) e focaria meu tempo no que é específico do projeto.

O que eu deixei de fora de propósito

Acho que metade do valor desse tipo de exercício está no que você NÃO coloca.

  • Sem Kubernetes (EKS). Pra um container só, EKS seria carregar um trator pra ir na padaria.
  • Sem API Gateway separado. O Load Balancer já dá conta do roteamento. API Gateway faria sentido se eu tivesse várias APIs, autorização complexa, throttling por cliente. Não é o caso.
  • Sem data lake, sem Athena, sem Glue. É histórico de SLA, não big data. DynamoDB resolve.
  • Sem multi-região. É uma ferramenta interna. Se a região cair por algumas horas, o time olha o Jira direto. Não vale a complexidade.

Custo aproximado

Não vou fingir que sei o valor exato, porque depende muito do uso. Mas a ordem de grandeza pra um sistema interno desse tamanho:

  • Fargate: o maior item, mas com uma ou duas tarefas pequenas, é controlável.
  • S3 + CloudFront: centavos pra essa quantidade de tráfego.
  • DynamoDB: no on-demand, pagando por escrita/leitura, com um snapshot por dia e consultas pontuais, fica baixíssimo.
  • Lambda + EventBridge: roda uma vez por dia, praticamente de graça.
  • Secrets Manager: cobra por segredo/mês alguns dólares.

O grosso do custo é o Fargate rodando o tempo todo. Se o uso fosse muito esporádico, aí sim valeria reconsiderar Lambda no backend e aceitar o cold start.

Migração, se eu fosse fazer de verdade

Não faria tudo de uma vez. A ordem que eu seguiria:

  1. Conteinerizar o Flask e subir no Fargate atrás do Load Balancer. O sistema continua usando arquivos JSON locais no container (efêmero, mas funciona pra começar).
  2. Mover o frontend pro S3 + CloudFront.
  3. Tirar os tokens do ambiente e botar no Secrets Manager.
  4. Migrar o histórico de snapshots do JSON para DynamoDB. Esse é o passo que muda código de verdade.
  5. Mover o job diário para EventBridge + Lambda.
  6. Só então, se precisar, subir o Redis no ElastiCache.

Cada passo deixa o sistema funcionando. Nada de big bang.

A página de tendências usa os snapshots diários exatamente como o dado que migraria pro DynamoDB.

Fechando

O ponto que eu queria passar é que arquitetura boa não é a que usa mais serviços é a que usa os suficientes pro problema que você tem. Esse portal é pequeno e provavelmente vai continuar pequeno. A versão AWS dele reflete isso: Fargate pro backend, S3/CloudFront pro front, DynamoDB pro histórico, Lambda pro job, e o resto é encanamento (Secrets Manager, CloudWatch, IAM).

Se um dia ele crescer pra dezenas de times e milhares de acessos por dia, eu revisito. Mas projetar pra esse futuro hipotético agora seria gastar tempo e dinheiro com problema que eu ainda não tenho.

Se você tem um projetinho parecido, uma ferramenta interna que "roda na sua máquina", recomendo fazer esse exercício. Você aprende mais desenhando a versão de produção de algo pequeno do que copiando diagrama de arquitetura de empresa grande.

Comentários e correções são bem-vindos. Se você tivesse feito diferente em algum ponto, me conta principalmente na parte do cache, que é onde eu mais fiquei na dúvida.

Top comments (0)