Multi-Agent Production System · Operação

Operação e Deploy

A presença de checkpoints LangGraph ativos muda o que "deploy sem downtime" significa. Threads em execução precisam terminar antes que o ECS task seja removido — ou serem retomados pelo Recovery Worker.

Blue/GreenCanaryRolling CodeDeployALBMulti-cluster

Por que deploy com checkpoints ativos é diferente

Em um sistema sem estado persistido, deploy é simples: sobe nova versão, drena a antiga, pronto. Em um sistema com LangGraph e RedisSaver, há threads em execução no momento do deploy. Quando o ECS drena o container, esses threads são interrompidos.

O melhor caso: o graceful shutdown funciona corretamente, o thread termina antes do SIGKILL, o checkpoint final é salvo. O caso mais comum sem configuração adequada: o ECS mata o processo antes do thread terminar, o checkpoint fica no estado do nó anterior, o Recovery Worker retoma em até 50s, o usuário experimenta uma pausa.

O objetivo das estratégias de deploy aqui não é apenas "zero downtime" no sentido de disponibilidade HTTP — é minimizar a janela em que threads ficam interrompidos e precisam ser retomados pelo Recovery Worker.

Graceful shutdown — deve ser configurado antes de qualquer estratégia

Independente de qual estratégia de deploy você escolher, o graceful shutdown é o pré-requisito. Sem ele, o ECS mata o processo imediatamente após SIGTERM, interrompendo qualquer thread em execução. Com ele, o processo para de aceitar novos requests e aguarda os em andamento terminarem.

O stop_timeout no task definition define quanto tempo o ECS espera entre o SIGTERM e o SIGKILL. Deve ser maior ou igual ao tempo máximo esperado de execução de um agente. Para um agente de booking que pode levar até 60s, configure 120s — com margem para overhead de shutdown e operações de cleanup.

app.py — graceful shutdown python
from contextlib import asynccontextmanager

_active_tasks: set[asyncio.Task] = set()
_shutting_down = False


@asynccontextmanager
async def lifespan(app):
    yield
    global _shutting_down
    _shutting_down = True
    if _active_tasks:
        logger.info("Aguardando %d task(s) ativas...", len(_active_tasks))
        # 25s de timeout: deixa margem para overhead após as tasks terminarem.
        # stop_timeout no task definition deve ser 120s — bem acima disso.
        await asyncio.wait(_active_tasks, timeout=25.0)


@app.post("/agents/invoke")
async def invoke(body: InvokeRequest):
    if _shutting_down:
        raise HTTPException(status_code=503, detail="Em manutenção.")
    task = asyncio.current_task()
    _active_tasks.add(task)
    try:
        return await run_agent(body.thread_id, body.input)
    finally:
        _active_tasks.discard(task)
terraform — stop_timeout e deregistration_delay hcl
resource "aws_ecs_task_definition" "agent" {
  container_definitions = jsonencode([{
    name        = "agent"
    stopTimeout = 120  # segundos entre SIGTERM e SIGKILL — deve ser >= max exec time
  }])
}

resource "aws_lb_target_group" "agent" {
  deregistration_delay = 100  # ALB drena conexões antes de remover o target
  # Deve ser ligeiramente menor que stop_timeout — deixa o processo terminar limpo
  health_check { path = "/health"; interval = 10; healthy_threshold = 2 }
}

Blue/Green — o mais seguro para checkpoints ativos

O Blue/Green com CodeDeploy mantém dois Target Groups — BLUE (versão atual) e GREEN (nova versão). O tráfego continua 100% no BLUE enquanto o GREEN sobe, aquece e passa nos health checks. A troca é instantânea — uma modificação no ALB listener. Se algo der errado, o rollback é igualmente instantâneo — volta para o BLUE que ainda está rodando.

Para checkpoints ativos, a vantagem é clara: as Tasks BLUE continuam processando threads em andamento normalmente enquanto as Tasks GREEN servem novos requests. Quando o CodeDeploy drena o BLUE após a troca, o graceful shutdown cuida dos threads em andamento. Não há momento em que uma Task está sendo drenada enquanto ainda recebe novos requests de alta carga.

Por que Blue/Green é mais seguro que Rolling para checkpoints

No Rolling Update, cada Task v1 começa a ser drenada antes de todas as Tasks v2 estarem prontas. Durante a transição, Tasks v1 (sendo drenadas) e Tasks v2 (recebendo novos requests) coexistem — e o mesmo thread_id poderia teoricamente chegar em Tasks de versões diferentes se o lock distribuído não estiver configurado.

No Blue/Green, a separação é absoluta: BLUE serve requests, GREEN aquece sem tráfego de usuário. A troca acontece em um único momento, depois do qual todo o tráfego vai para GREEN e BLUE começa a drenar. A janela de coexistência é controlada e previsível.

Deploy Blue/Green:

1. CodeDeploy sobe Tasks GREEN (v2) — sem tráfego de usuário
2. Tasks GREEN passam nos health checks
3. Smoke tests via test listener (:8080) → validam v2 diretamente
4. CodeDeploy troca: ALB → GREEN (100%)
5. Tasks BLUE recebem SIGTERM
   └─► graceful shutdown: threads em andamento terminam normalmente
   └─► Recovery Worker captura qualquer órfão remanescente (raro)
6. BLUE descartado após drenar

Rollback:
   └─► CodeDeploy reverte: ALB → BLUE (100%) — instantâneo
   └─► GREEN descartado

Terraform — dois Target Groups

terraform/blue_green.tf hcl
resource "aws_ecs_service" "agent" {
  deployment_controller { type = "CODE_DEPLOY" }
  load_balancer {
    target_group_arn = aws_lb_target_group.blue.arn
    container_name   = "agent"; container_port = 8000
  }
  # lifecycle: CodeDeploy gerencia a alternância — Terraform não deve interferir
  lifecycle { ignore_changes = [task_definition, load_balancer] }
}

resource "aws_lb_target_group" "blue" {
  name                 = "agent-blue"; port = 8000
  protocol             = "HTTP"; target_type = "ip"; vpc_id = var.vpc_id
  deregistration_delay = 100
  health_check { path = "/health"; interval = 10 }
}

resource "aws_lb_target_group" "green" {
  name                 = "agent-green"; port = 8000
  protocol             = "HTTP"; target_type = "ip"; vpc_id = var.vpc_id
  deregistration_delay = 100
  health_check { path = "/health"; interval = 10 }
}

# Listener de produção: tráfego real → BLUE (CodeDeploy alterna para GREEN)
resource "aws_lb_listener" "prod" {
  load_balancer_arn = aws_lb.main.arn; port = 443; protocol = "HTTPS"
  default_action { type = "forward"; target_group_arn = aws_lb_target_group.blue.arn }
}

# Listener de teste: smoke tests acessam GREEN antes da troca
resource "aws_lb_listener" "test" {
  load_balancer_arn = aws_lb.main.arn; port = 8080; protocol = "HTTP"
  default_action { type = "forward"; target_group_arn = aws_lb_target_group.green.arn }
}

resource "aws_codedeploy_deployment_group" "agent" {
  deployment_config_name = "CodeDeployDefault.ECSAllAtOnce"
  ecs_service { cluster_name = aws_ecs_cluster.main.name; service_name = aws_ecs_service.agent.name }
  load_balancer_info {
    target_group_pair_info {
      prod_traffic_route { listener_arns = [aws_lb_listener.prod.arn] }
      test_traffic_route { listener_arns = [aws_lb_listener.test.arn] }
      target_group { name = aws_lb_target_group.blue.name }
      target_group { name = aws_lb_target_group.green.name }
    }
  }
  auto_rollback_configuration {
    enabled = true
    events  = ["DEPLOYMENT_FAILURE", "DEPLOYMENT_STOP_ON_ALARM"]
  }
}

AppSpec e smoke tests

O hook BeforeAllowTraffic roda antes de qualquer tráfego de usuário chegar ao GREEN. É o momento para smoke tests — validar que a nova versão responde corretamente nos endpoints críticos antes de expor ao usuário.

appspec.yml yaml
version: 0.0
Resources:
  - TargetService:
      Type: AWS::ECS::Service
      Properties:
        TaskDefinition: "<TASK_DEFINITION>"
        LoadBalancerInfo:
          ContainerName: "agent"
          ContainerPort: 8000
Hooks:
  - BeforeAllowTraffic: "SmokTestsHook"   # valida GREEN antes da troca
  - AfterAllowTraffic: "MonitoringHook"    # monitora métricas pós-troca
hooks/smoke_tests.py python
import httpx, sys

TEST_ENDPOINT = "http://alb-dns:8080"  # test listener — acessa GREEN diretamente

checks = [
    ("/health",                200),
    ("/health/circuit-breakers", 200),
    ("/health/bulkheads",      200),
]

for path, expected in checks:
    resp = httpx.get(f"{TEST_ENDPOINT}{path}", timeout=10)
    if resp.status_code != expected:
        print(f"FAIL: {path} → {resp.status_code} (esperado {expected})")
        sys.exit(1)
    print(f"OK: {path}")

print("Smoke tests passaram. Liberando tráfego para GREEN.")

Canary — validação progressiva com rollback automático

O Canary envia uma fração pequena do tráfego (10%) para a nova versão enquanto monitora métricas. Se error rate ou latência aumentam além dos thresholds configurados, o CodeDeploy reverte automaticamente — sem intervenção manual. Se as métricas ficam estáveis, progride para 100%.

O Canary é ideal para releases de features novas onde você quer validar comportamento em produção antes de expor a todos os usuários. Não é ideal para breaking changes de schema A2A — nesses casos, use Blue/Green com sequência controlada.

Rollback automático via CloudWatch Alarms

terraform/canary.tf hcl
resource "aws_codedeploy_deployment_group" "canary" {
  # 10% do tráfego por 5 minutos, depois 100% se métricas OK
  deployment_config_name = "CodeDeployDefault.ECSCanary10Percent5Minutes"

  auto_rollback_configuration {
    enabled = true
    events  = ["DEPLOYMENT_FAILURE", "DEPLOYMENT_STOP_ON_ALARM"]
  }

  alarm_configuration {
    enabled = true
    alarms  = [aws_cloudwatch_metric_alarm.error_rate.name,
               aws_cloudwatch_metric_alarm.p99_latency.name]
  }
}

resource "aws_cloudwatch_metric_alarm" "error_rate" {
  alarm_name          = "agent-5xx-canary"
  metric_name         = "HTTPCode_Target_5XX_Count"
  namespace           = "AWS/ApplicationELB"
  comparison_operator = "GreaterThanThreshold"
  evaluation_periods  = 2; period = 60; statistic = "Sum"; threshold = 10
}

resource "aws_cloudwatch_metric_alarm" "p99_latency" {
  alarm_name          = "agent-p99-canary"
  metric_name         = "TargetResponseTime"
  namespace           = "AWS/ApplicationELB"
  comparison_operator = "GreaterThanThreshold"
  evaluation_periods  = 2; period = 60; extended_statistic = "p99"; threshold = 3
}

Rolling Update — quando usar e quando evitar

O Rolling Update é a estratégia nativa do ECS sem CodeDeploy. O ECS substitui Tasks gradualmente — sobe uma nova antes de drenar uma antiga — com minimum_healthy_percent=100 garantindo que nunca cai abaixo da capacidade configurada.

O Rolling é adequado para hotfixes urgentes onde a simplicidade importa mais que o controle fino, ou para mudanças que não afetam o estado do grafo — correções de bug em lógica de negócio, ajustes de configuração, atualizações de dependências sem mudança de schema.

terraform/rolling.tf hcl
resource "aws_ecs_service" "agent" {
  deployment_controller { type = "ECS" }

  # circuit_breaker: rollback automático se health checks falharem.
  # Evita que uma versão quebrada continue sendo deployada.
  deployment_circuit_breaker { enable = true; rollback = true }

  # Com desired_count=3:
  # maximum_percent=150 → pode ter até 4 tasks durante o deploy
  # minimum_healthy_percent=100 → nunca cai abaixo de 3 tasks saudáveis
  deployment_maximum_percent         = 150
  deployment_minimum_healthy_percent = 100
}

Limitação crítica em multi-cluster

O Rolling Update não oferece controle de ordem entre clusters. Se você tem booking-agent e search-agent em clusters separados com um contrato A2A que está evoluindo, o Rolling pode deployar os dois em qualquer ordem. Se o search-agent v2 (que responde com o novo formato) for deployado antes do booking-agent v2 (que sabe interpretar o novo formato), o booking-agent v1 ainda em produção vai receber respostas que não sabe processar.

Para qualquer mudança que afete o contrato A2A entre clusters, use Blue/Green com sequência controlada no pipeline de CI/CD.

Ordem de deploy entre clusters

A regra fundamental é: deploy do consumidor antes do produtor. O agente que consome a API deve aceitar o novo formato antes do agente que produz começar a emiti-lo. Isso garante que durante a janela de migração — enquanto os dois estão sendo deployados — qualquer combinação de versões funciona.

Ordem errada (produtor primeiro):
1. Deploy search-agent v2 → responde com formato v2
2. booking-agent v1 chama search-agent v2
3. booking-agent v1 recebe formato v2 → não sabe parsear → erro

Ordem correta (consumidor primeiro):
1. Deploy booking-agent v2 → aceita v1 e v2 de resposta
2. Valida por 15 min → métricas estáveis
3. Deploy search-agent v2 → responde com v2
4. booking-agent v2 processa v2 corretamente ✓
5. Após estável: deploy search-agent v2.1 remove backward compat com v1

Versionamento de contrato A2A

Durante a janela de migração, o search-agent v2 precisa responder em ambos os formatos. O header X-API-Version permite que o booking-agent sinalize qual versão espera.

a2a_client.py + app.py search-agent python
# No booking-agent v2: sinaliza que espera v2
headers = {
    "Authorization":   f"Bearer {token}",
    "X-API-Version":   "v2",
    "X-Min-API-Version": "v1",  # aceita v1 também — backward compat
}

# No search-agent v2: responde com a versão solicitada
@app.post("/search/invoke")
async def search_invoke(request: Request, body: SearchRequest):
    requested = request.headers.get("X-API-Version", "v1")
    result    = await run_search_graph(body)
    # Suporta v1 e v2 durante a janela de migração.
    # Após booking-agent v2 estável e nenhum v1 em produção: remova o branch v1.
    return format_response_v1(result) if requested == "v1" else format_response_v2(result)
.github/workflows/deploy.yml yaml
jobs:
  deploy-booking-v2:
    steps:
      - name: Deploy booking-agent v2
        run: aws deploy create-deployment --deployment-group booking-agent-dg ...
      - name: Aguarda estabilização (15min)
        run: sleep 900
      - name: Valida métricas
        run: python scripts/validate_metrics.py --service booking-agent

  deploy-search-v2:
    needs: deploy-booking-v2  # só após consumidor estável
    steps:
      - name: Deploy search-agent v2
        run: aws deploy create-deployment --deployment-group search-agent-dg ...
      - name: Aguarda estabilização (15min)
        run: sleep 900

  remove-v1-compat:
    needs: deploy-search-v2
    steps:
      - name: Deploy search-agent v2.1 (remove compat v1)
        run: aws deploy create-deployment --deployment-group search-agent-dg ...

NLB vs ALB para o sistema multi-agent

A escolha entre NLB e ALB tem implicações diretas nas estratégias de deploy disponíveis. O ALB suporta weighted routing nativo — necessário para Canary e para o dual-listener do Blue/Green. O NLB não suporta weighted routing — Blue/Green via NLB exige DNS swap no Route 53.

A recomendação para este sistema: ALB externo para tráfego usuário→agente, onde você precisa de Blue/Green e Canary. NLB interno para comunicação A2A de alta frequência entre agentes, onde a latência menor importa e você controla o deploy de ambos os lados.

CaracterísticaALBNLB
Weighted routingSim — nativoNão — precisa Route 53
Blue/Green com CodeDeploySim — nativoParcial — via DNS swap
Latência overhead~1-2ms~0.1ms
Header-based routingSimNão
Ideal paraExterno: usuário→agente, A2A RESTInterno: A2A alta frequência, gRPC

Comparativo das três estratégias

CritérioBlue/GreenCanaryRolling
Rollback Instantâneo — troca de Target Group Automático por CloudWatch Alarm Circuit breaker ECS — mais lento
Checkpoints ativos Zero impacto — BLUE processa até drenar com graceful shutdown Mínimo — só 10% vai para v2 durante canary Médio — Tasks v1 drenadas enquanto v2 recebe novos requests
Custo durante deploy Alto — dobra as Tasks temporariamente Alto — duas versões simultâneas durante canary Baixo — +33% Tasks temporariamente
Smoke tests pré-tráfego Sim — test listener porta 8080 Sim — hook BeforeAllowTraffic Não — primeira Task nova já recebe tráfego
Multi-cluster Melhor — ordem explícita no pipeline Bom — progressivo por cluster Arriscado — sem controle de ordem
Ideal para Breaking changes A2A, schema migration, releases críticas Features novas, validação de performance em produção Hotfixes urgentes, mudanças sem impacto em estado