Multi-Agent Production System · Infraestrutura

Arquitetura de Infraestrutura

Como os componentes de todos os outros documentos se materializam em infraestrutura AWS — ECS Services, task definitions, ElastiCache, IAM roles, e os alarmes que tornam o sistema operável.

ECS FargateElastiCacheX-Ray Daemon IAMCloudWatchTerraformService Discovery

Topologia completa

VPC
├── Public Subnets
│    └── ALB externo :443 → booking-agent (requests de usuário)
│
├── Private Subnets
│    ├── ECS Cluster: booking
│    │    ├── booking-agent Service (desired: 3)   [agent + xray-daemon]
│    │    └── recovery-worker Service (desired: 1)  [worker + xray-daemon]
│    │
│    ├── ECS Cluster: search
│    │    ├── search-agent Service (desired: 5)    [agent + xray-daemon]
│    │    └── recovery-worker Service (desired: 1)  [worker + xray-daemon]
│    │
│    ├── auth-service Service (desired: 2)          [auth + xray-daemon]
│    │
│    └── ElastiCache Redis (compartilhado por todos)
│
└── AWS Services via VPC Endpoints
     ├── ECR (pull de imagens sem sair da VPC)
     ├── Secrets Manager (CLIENT_SECRET, chave privada RSA)
     ├── CloudWatch Logs
     └── X-Ray API

Princípios que guiam as decisões de infraestrutura

Cada decisão de infraestrutura aqui tem uma razão específica ligada aos outros documentos. Não são escolhas arbitrárias — emergem dos requisitos de lifecycle, observabilidade e segurança.

PrincípioDecisãoPor que
Uma imagem, múltiplos comportamentosAgent e Recovery Worker usam a mesma imagem ECRSincroniza versões automaticamente — sem risco de worker rodar código desatualizado
Sem CMD no DockerfileEntrypoint definido no task definition via commandPermite override sem rebuild — mesma imagem serve múltiplos propósitos
Sidecar por taskX-Ray Daemon no mesmo task que o agenteAcesso via localhost — sem configuração de rede, sem security group entre containers
Secrets Manager para segredosCLIENT_SECRET e chave RSA nunca em env plaintextRotação de secrets sem rebuild de imagem; auditoria de acesso
Least privilege por serviçoIAM Task Role separado por ECS ServiceRecovery Worker comprometido não acessa Secrets Manager do agente
VPC EndpointsECR, Secrets Manager, CloudWatch via endpoints privadosSem tráfego para internet — reduz superfície de ataque e latência

Tabela de serviços

ServiceClusterdesired_countcommand
booking-agentbooking3+python app.py
recovery-workerbooking1python recovery_worker.py
search-agentsearch5+python app.py
recovery-workersearch1python recovery_worker.py
auth-serviceshared2python auth_service/main.py
Por que um Recovery Worker por cluster e não um global

O Recovery Worker precisa chamar graph.ainvoke() com o mesmo grafo compilado que os agentes daquele cluster usam. Um worker global precisaria instanciar todos os grafos de todos os clusters — acoplamento forte e complexidade operacional alta. Um worker por cluster garante que ele usa exatamente o mesmo código, checkpointer e configuração dos agentes que monitora — e é deployado junto com eles, garantindo compatibilidade de versão.

Uma imagem, dois entrypoints

O booking-agent e o recovery-worker usam exatamente a mesma imagem ECR. A diferença está no campo command do task definition. Quando o Terraform aplica uma nova imagem, ambos os services são atualizados — não há risco de o worker estar rodando uma versão antiga do código do agente enquanto o agente já está na versão nova.

Dockerfile sem CMD

Dockerfile dockerfile
FROM python:3.12-slim
WORKDIR /app

# Dependências primeiro — layer cacheada independente do código
COPY requirements.txt .
RUN pip install -r requirements.txt --no-cache-dir

COPY . .

# Sem CMD: o command[] do ECS task definition define o entrypoint.
# booking-agent task definition: command = ["python", "app.py"]
# recovery-worker task definition: command = ["python", "recovery_worker.py"]
# Mesma imagem, comportamento diferente — sem manter dois Dockerfiles.

HEALTHCHECK --interval=10s --timeout=3s --retries=3 \
  CMD python -c "import httpx; httpx.get('http://localhost:8000/health')" || exit 1

Agent Task Definition — dois containers

Cada task do agente tem dois containers: o agente principal e o X-Ray Daemon como sidecar. Os dois compartilham o namespace de rede do task — o agente acessa o daemon via localhost:4317 sem nenhuma configuração adicional.

terraform/task_agent.tf hcl
resource "aws_ecs_task_definition" "agent" {
  family                   = "booking-agent"
  requires_compatibilities = ["FARGATE"]
  network_mode             = "awsvpc"
  cpu                      = "1024"  # 1 vCPU — ajuste conforme carga
  memory                   = "2048"
  task_role_arn            = aws_iam_role.agent_task_role.arn
  execution_role_arn       = aws_iam_role.ecs_execution_role.arn

  container_definitions = jsonencode([
    {
      name      = "agent"
      image     = "${aws_ecr_repository.app.repository_url}:${var.image_tag}"
      command   = ["python", "app.py"]
      essential = true
      stopTimeout = 120  # segundos entre SIGTERM e SIGKILL

      portMappings = [{ containerPort = 8000, protocol = "tcp" }]

      environment = [
        { name = "REDIS_URL",         value = "redis://${local.redis_host}:6379" },
        { name = "SERVICE_NAME",      value = "booking-agent" },
        { name = "OTLP_ENDPOINT",     value = "http://localhost:4317" },
        { name = "ENVIRONMENT",       value = var.environment },
        { name = "TRACE_SAMPLE_RATE", value = "0.1" },
      ]

      # Secrets via Secrets Manager — nunca plaintext em environment.
      # O ECS injecta o valor como variável de ambiente no momento do start.
      secrets = [
        { name = "CLIENT_SECRET",   valueFrom = aws_secretsmanager_secret.client_secret.arn },
        { name = "AUTH_SERVICE_URL", valueFrom = aws_secretsmanager_secret.auth_url.arn },
      ]

      logConfiguration = {
        logDriver = "awslogs"
        options   = {
          "awslogs-group"         = "/ecs/booking-agent"
          "awslogs-region"        = var.aws_region
          "awslogs-stream-prefix" = "ecs"
        }
      }

      healthCheck = {
        command     = ["CMD-SHELL", "curl -f http://localhost:8000/health || exit 1"]
        interval    = 10; timeout = 5; retries = 3; startPeriod = 30
      }
    },

    {
      name      = "xray-daemon"
      image     = "amazon/aws-xray-daemon:latest"
      essential = false  # falha do sidecar não derruba o agente
      cpu       = 32     # sidecar leve — apenas forwarda spans
      memory    = 256

      portMappings = [
        { containerPort = 4317, protocol = "tcp" },  # OTLP gRPC
        { containerPort = 2000, protocol = "udp" },  # X-Ray nativo
      ]

      logConfiguration = {
        logDriver = "awslogs"
        options   = {
          "awslogs-group"         = "/ecs/booking-agent-xray"
          "awslogs-region"        = var.aws_region
          "awslogs-stream-prefix" = "xray"
        }
      }
    }
  ])
}

Recovery Worker Task Definition

O Recovery Worker tem CPU e memória menores que o agente — sua carga é quase exclusivamente Redis I/O e invocações ocasionais de grafo. Não expõe porta HTTP — é um script, não um servidor.

terraform/task_recovery.tf hcl
resource "aws_ecs_task_definition" "recovery_worker" {
  family = "recovery-worker"
  cpu    = "256"   # leve — Redis I/O e invocações ocasionais
  memory = "512"

  container_definitions = jsonencode([
    {
      name    = "recovery-worker"
      image   = "${aws_ecr_repository.app.repository_url}:${var.image_tag}"
      command = ["python", "recovery_worker.py"]  # mesma imagem, entrypoint diferente

      environment = [
        { name = "REDIS_URL",               value = "redis://${local.redis_host}:6379" },
        { name = "RECOVERY_MAX_ATTEMPTS",   value = "3" },
        { name = "RECOVERY_QUEUE_MAX_SIZE", value = "100" },
        { name = "SERVICE_NAME",            value = "recovery-worker" },
        { name = "OTLP_ENDPOINT",           value = "http://localhost:4317" },
      ]
      # Sem portMappings — não é um servidor HTTP
      # Sem healthCheck de porta — ECS usa o exit code do processo
    },
    {
      name = "xray-daemon"; image = "amazon/aws-xray-daemon:latest"
      essential = false; cpu = 32; memory = 128
      portMappings = [
        { containerPort = 4317, protocol = "tcp" },
        { containerPort = 2000, protocol = "udp" },
      ]
    }
  ])
}

Por que sidecar e não daemon separado

Em ECS Fargate não há EC2 hosts para rodar um daemon global. O sidecar por task é a solução natural: cada task tem seu próprio daemon, acessível via localhost:4317, sem configuração de rede entre containers. O essential=false garante que uma falha ou reinicialização do sidecar não afeta o container principal — você perde alguns spans, mas o agente continua processando.

Por que um Redis compartilhado entre todos os serviços

A primeira intuição costuma ser separar o Redis por serviço — um Redis para checkpoints LangGraph, outro para locks, outro para a recovery queue. A intuição parece razoável mas quebra o sistema inteiro.

O Recovery Worker precisa acessar os checkpoints criados pelo agente para chamar graph.ainvoke(). O OrphanScanner precisa acessar os heartbeats criados pelo agente. O lock distribuído precisa ser visível por todas as tasks do agente simultaneamente. Se cada serviço tem seu Redis, nenhum desses mecanismos funciona — cada processo fala com seu próprio Redis e não vê o estado dos outros.

Namespacing por prefixo resolve o isolamento lógico

Um Redis compartilhado não significa caos. Cada componente usa prefixos distintos — checkpoint:, heartbeat:, lock:thread:, recovery:, idempotency:. O isolamento é lógico, não físico. O benefício: todos os componentes que participam do lifecycle de um thread podem ver o estado completo daquele thread sem crossing de fronteiras de serviço.

Configuração ElastiCache

terraform/elasticache.tf hcl
resource "aws_elasticache_replication_group" "main" {
  replication_group_id = "agent-redis"
  description          = "Redis compartilhado: checkpoints, lifecycle, locks"

  # r7g.large: memory-optimized — checkpoints LangGraph podem ser grandes
  # 2 nodes: primary + 1 replica para HA sem perda de dados em failover
  node_type          = "cache.r7g.large"
  num_cache_clusters = 2
  automatic_failover_enabled = true
  engine         = "redis"
  engine_version = "7.1"

  # Checkpoints podem conter dados sensíveis do usuário
  at_rest_encryption_enabled = true
  transit_encryption_enabled = true

  subnet_group_name  = aws_elasticache_subnet_group.main.name
  security_group_ids = [aws_security_group.redis.id]

  # Snapshot: checkpoints são os dados mais críticos do sistema.
  # Em falha catastrófica do Redis, o snapshot permite recuperar o estado.
  snapshot_retention_limit = 7
  snapshot_window          = "03:00-04:00"

  log_delivery_configuration {
    destination      = aws_cloudwatch_log_group.redis.name
    destination_type = "cloudwatch-logs"
    log_format       = "json"
    log_type         = "slow-log"  # útil para diagnosticar Lua scripts lentos
  }
}

Ownership de chaves por componente

PrefixoEscrito porLido por
checkpoint:*LangGraph RedisSaver (agent)RecoveryConsumer, aget_state()
threads:in_progressrun_agent() — sadd/sremOrphanScanner
heartbeat:*AgentHeartbeatOrphanScanner
lock:thread:*RedisThreadLock (agent)RedisThreadLock
idempotency:*@idempotent_node@idempotent_node
recovery:queueOrphanScanner, QueueGuardRecoveryConsumer (BLPOP)
recovery:dlqDLQManagerQueueMonitor, operador
recovery:dlq:meta:*DLQManagerQueueMonitor, alarmes

Security Groups — quem pode falar com quem

O princípio é: cada serviço aceita apenas de origens explicitamente autorizadas. O Redis aceita apenas de tasks ECS autorizadas — não de qualquer processo na VPC. Isso limita o raio de explosão de um comprometimento de instância.

terraform/security_groups.tf hcl
# Redis: aceita apenas de ECS tasks autorizadas — não da VPC inteira
resource "aws_security_group" "redis" {
  ingress {
    from_port       = 6379; to_port = 6379; protocol = "tcp"
    security_groups = [
      aws_security_group.booking_agent.id,
      aws_security_group.search_agent.id,
      aws_security_group.recovery_worker.id,
      aws_security_group.auth_service.id,
    ]
  }
}

# booking-agent: aceita do ALB e de outros agentes via A2A
resource "aws_security_group" "booking_agent" {
  ingress {
    from_port       = 8000; to_port = 8000; protocol = "tcp"
    security_groups = [aws_security_group.alb.id]
    description     = "Requests do ALB externo"
  }
  egress {
    from_port   = 0; to_port = 0; protocol = "-1"
    cidr_blocks = ["0.0.0.0/0"]
    description = "Saída: Redis, auth-service, search-agent, APIs externas"
  }
}

Service Discovery — resolução de endereços A2A

Em vez de hardcodar IPs ou usar load balancers internos para comunicação A2A, o AWS Cloud Map registra automaticamente cada task com um DNS entry privado. Quando uma task é substituída (deploy, crash, scale), o DNS é atualizado automaticamente.

terraform/service_discovery.tf hcl
resource "aws_service_discovery_private_dns_namespace" "agents" {
  name = "agents.internal"; vpc = var.vpc_id
}

# search-agent → search-agent.agents.internal:8000
# TTL curto: quando uma task é substituída, DNS converge em 10s.
# TTL longo (60s+) causaria erros durante deploys — clientes tentariam
# conectar em IPs de tasks antigas já drenadas.
resource "aws_service_discovery_service" "search_agent" {
  name = "search-agent"
  dns_config {
    namespace_id   = aws_service_discovery_private_dns_namespace.agents.id
    routing_policy = "MULTIVALUE"  # retorna múltiplos IPs — client-side balancing
    dns_records { ttl = 10; type = "A" }
  }
  health_check_custom_config { failure_threshold = 1 }
}

Least privilege — cada serviço tem apenas o que precisa

O Recovery Worker não precisa de acesso ao Secrets Manager — ele não faz chamadas A2A autenticadas. O agente não precisa de acesso a S3 ou DynamoDB. Cada Task Role é criado com o mínimo necessário para aquele serviço operar.

terraform/iam.tf hcl
# Agent Task Role — permissões para operar normalmente
resource "aws_iam_role_policy" "agent" {
  policy = jsonencode({
    Statement = [
      # X-Ray: envia traces via OTLP para o sidecar que repassa para a API
      { Effect = "Allow"; Action = ["xray:PutTraceSegments", "xray:PutTelemetryRecords"]; Resource = "*" },
      # Secrets Manager: apenas os dois secrets específicos deste agente
      { Effect = "Allow"; Action = ["secretsmanager:GetSecretValue"]
        Resource = [aws_secretsmanager_secret.client_secret.arn, aws_secretsmanager_secret.auth_url.arn] },
      # CloudWatch Logs: apenas para o log group deste serviço
      { Effect = "Allow"; Action = ["logs:CreateLogStream", "logs:PutLogEvents"]
        Resource = "arn:aws:logs:*:*:log-group:/ecs/booking-agent:*" },
    ]
  })
}

# Recovery Worker Task Role — subconjunto das permissões do agente
# Não tem Secrets Manager: não faz chamadas autenticadas para outros agentes
resource "aws_iam_role_policy" "recovery" {
  policy = jsonencode({
    Statement = [
      { Effect = "Allow"; Action = ["xray:PutTraceSegments", "xray:PutTelemetryRecords"]; Resource = "*" },
      { Effect = "Allow"; Action = ["logs:CreateLogStream", "logs:PutLogEvents"]
        Resource = "arn:aws:logs:*:*:log-group:/ecs/recovery-worker:*" },
    ]
  })
}

CloudWatch Alarms — o que monitorar

Quatro alarmes cobrem os pontos críticos do sistema. Cada um monitora um aspecto específico que não seria visível apenas nos logs de aplicação.

terraform/alarms.tf hcl
# 1. Recovery Worker parou de rodar.
# desired_count=1 mas running_count=0 → ninguém está recuperando threads órfãos.
# Threads vão acumular como zumbis sem que ninguém saiba.
resource "aws_cloudwatch_metric_alarm" "recovery_worker_down" {
  alarm_name          = "recovery-worker-not-running"
  metric_name         = "RunningTaskCount"
  namespace           = "ECS/ContainerInsights"
  comparison_operator = "LessThanThreshold"
  threshold           = 1; evaluation_periods = 2; period = 60; statistic = "Average"
  dimensions          = { ClusterName = "booking", ServiceName = "recovery-worker" }
  alarm_actions       = [aws_sns_topic.ops_alerts.arn]
}

# 2. DLQ contém itens — threads irrecuperáveis aguardando intervenção manual.
# QueueMonitor emite { "dlq_size": N } como JSON nos logs.
# Logs Insights parseia o campo dlq_size automaticamente.
resource "aws_cloudwatch_log_metric_filter" "dlq_size" {
  name           = "dlq-size"
  log_group_name = "/ecs/recovery-worker"
  pattern        = "{ $.dlq_size > 0 }"
  metric_transformation {
    name = "DLQSize"; namespace = "MultiAgent/Recovery"; value = "$.dlq_size"
  }
}

resource "aws_cloudwatch_metric_alarm" "dlq_not_empty" {
  alarm_name          = "recovery-dlq-not-empty"
  metric_name         = "DLQSize"; namespace = "MultiAgent/Recovery"
  comparison_operator = "GreaterThanThreshold"
  threshold           = 0; evaluation_periods = 1; period = 60; statistic = "Maximum"
  alarm_actions       = [aws_sns_topic.ops_alerts.arn]
}

# 3. Recovery queue próxima do limite — backpressure iminente.
# Alarme em 80% (80/100) — não quando já está cheio, mas antes.
# Dá tempo para investigar e escalar antes de threads irem para DLQ.
resource "aws_cloudwatch_metric_alarm" "queue_high" {
  alarm_name          = "recovery-queue-near-limit"
  metric_name         = "RecoveryQueueSize"; namespace = "MultiAgent/Recovery"
  comparison_operator = "GreaterThanThreshold"
  threshold           = 80; evaluation_periods = 2; period = 60; statistic = "Maximum"
  alarm_actions       = [aws_sns_topic.ops_alerts.arn]
}

# 4. Redis com memória alta — checkpoints podem não caber.
# Se o Redis ficar sem memória e o eviction policy for noeviction,
# novos writes vão falhar — checkpoints não são salvos, dados perdidos.
resource "aws_cloudwatch_metric_alarm" "redis_memory" {
  alarm_name          = "redis-memory-high"
  metric_name         = "DatabaseMemoryUsagePercentage"
  namespace           = "AWS/ElastiCache"
  comparison_operator = "GreaterThanThreshold"
  threshold           = 80; evaluation_periods = 3; period = 300; statistic = "Average"
  alarm_actions       = [aws_sns_topic.ops_alerts.arn]
}