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.
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ípio | Decisão | Por que |
|---|---|---|
| Uma imagem, múltiplos comportamentos | Agent e Recovery Worker usam a mesma imagem ECR | Sincroniza versões automaticamente — sem risco de worker rodar código desatualizado |
| Sem CMD no Dockerfile | Entrypoint definido no task definition via command | Permite override sem rebuild — mesma imagem serve múltiplos propósitos |
| Sidecar por task | X-Ray Daemon no mesmo task que o agente | Acesso via localhost — sem configuração de rede, sem security group entre containers |
| Secrets Manager para segredos | CLIENT_SECRET e chave RSA nunca em env plaintext | Rotação de secrets sem rebuild de imagem; auditoria de acesso |
| Least privilege por serviço | IAM Task Role separado por ECS Service | Recovery Worker comprometido não acessa Secrets Manager do agente |
| VPC Endpoints | ECR, Secrets Manager, CloudWatch via endpoints privados | Sem tráfego para internet — reduz superfície de ataque e latência |
Tabela de serviços
| Service | Cluster | desired_count | command |
|---|---|---|---|
booking-agent | booking | 3+ | python app.py |
recovery-worker | booking | 1 | python recovery_worker.py |
search-agent | search | 5+ | python app.py |
recovery-worker | search | 1 | python recovery_worker.py |
auth-service | shared | 2 | python auth_service/main.py |
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
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.
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.
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.
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
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
| Prefixo | Escrito por | Lido por |
|---|---|---|
checkpoint:* | LangGraph RedisSaver (agent) | RecoveryConsumer, aget_state() |
threads:in_progress | run_agent() — sadd/srem | OrphanScanner |
heartbeat:* | AgentHeartbeat | OrphanScanner |
lock:thread:* | RedisThreadLock (agent) | RedisThreadLock |
idempotency:* | @idempotent_node | @idempotent_node |
recovery:queue | OrphanScanner, QueueGuard | RecoveryConsumer (BLPOP) |
recovery:dlq | DLQManager | QueueMonitor, operador |
recovery:dlq:meta:* | DLQManager | QueueMonitor, 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.
# 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.
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.
# 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.
# 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]
}