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.
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.
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)
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.
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
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.
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
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
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.
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.
# 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)
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ística | ALB | NLB |
|---|---|---|
| Weighted routing | Sim — nativo | Não — precisa Route 53 |
| Blue/Green com CodeDeploy | Sim — nativo | Parcial — via DNS swap |
| Latência overhead | ~1-2ms | ~0.1ms |
| Header-based routing | Sim | Não |
| Ideal para | Externo: usuário→agente, A2A REST | Interno: A2A alta frequência, gRPC |
Comparativo das três estratégias
| Critério | Blue/Green | Canary | Rolling |
|---|---|---|---|
| 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 |