Olha só o problema: conexões WebSocket de longa duração são difíceis de gerenciar

Requisições HTTP são stateless: chegam, são processadas e retornam uma resposta. Conexões WebSocket são o oposto. São canais persistentes e bidirecionais que precisam ficar abertos, processar mensagens e responder a keep-alive pings. Esse estado introduz desafios operacionais sérios quando você roda uma frota de workers:

  • Falhas de worker: quando um worker cai, ele derruba todas as suas conexões WebSocket. Sem um mecanismo para detectar a falha e reatribuir essas conexões, os dados são perdidos.
  • Rolling deployments: o ECS termina tarefas antigas e inicia novas. Cada tarefa terminada derruba suas conexões, criando uma janela onde conexões ficam sem dono.
  • Escalonamento horizontal: adicionar workers é fácil, mas remover exige drenar conexões e verificar que outros workers assumam antes da tarefa sair.
  • Double-claiming: se dois workers acham que são donos da mesma conexão, você tem processamento duplicado ou erros de protocolo.

Como os workers são clientes WebSocket que iniciam conexões de saída, você precisa de coordenação na camada de aplicação, não na camada de rede. A solução: conditional writes do DynamoDB como lease distribuído.

Este tutorial é baseado no post do AWS Architecture Blog Building resilient real-time streaming workers with Amazon DynamoDB leases.

Diagram of DynamoDB lease table with conditional writes for WebSocket connection ownership

O padrão de lease: implementação core

Um lease é um item no DynamoDB que rastreia quem é o dono de uma conexão e quando essa posse expira. O schema da tabela:

AtributoTipoDescrição
PkString (PK)Connection ID, ex: CONN#meeting-123
desired_stateStringSTARTED ou STOPPED
ws_urlStringURL WebSocket upstream
lease_ownerStringWorker ID dono da conexão
lease_expires_at_msNumberEpoch ms de expiração
last_seqNumberÚltimo número de sequência processado

Um GSI em desired_state (partition key) e lease_expires_at_ms (sort key) permite consultar conexões órfãs de forma eficiente.

Acquire: conditional write como lock distribuído

A peça crítica é a ConditionExpression. Ela só tem sucesso se o lease não existe ainda (attribute_not_exists) ou já expirou (lease_expires_at_ms < :now). O DynamoDB avalia isso atomicamente, então só um worker vence.

import boto3
from botocore.exceptions import ClientError

dynamodb = boto3.resource('dynamodb')
table = dynamodb.Table('connections')

def try_acquire_lease(conn_id: str, worker_id: str, lease_seconds: int = 20):
    now_ms = int(time.time() * 1000)
    try:
        table.update_item(
            Key={'Pk': f'CONN#{conn_id}'},
            UpdateExpression='SET lease_owner = :w, lease_expires_at_ms = :exp',
            ConditionExpression='attribute_not_exists(lease_owner) OR lease_expires_at_ms < :now',
            ExpressionAttributeValues={
                ':w': worker_id,
                ':exp': now_ms + lease_seconds * 1000,
                ':now': now_ms,
            },
        )
        return True
    except ClientError as e:
        if e.response['Error']['Code'] == 'ConditionalCheckFailedException':
            return False  # outro worker já é dono
        raise

Renew: loop de heartbeat

O worker dono renova seu lease a cada HEARTBEAT_EVERY segundos. Se a renovação retornar False, o worker sabe que perdeu a posse e sai limpo.

async def heartbeat_loop(conn_id: str, worker_id: str):
    while not shutdown_event.is_set():
        ok = await renew_lease(conn_id, worker_id)
        if not ok:
            print(f'Lease perdido para {conn_id}, saindo')
            return
        await asyncio.sleep(HEARTBEAT_EVERY)  # 5 segundos

Release: shutdown graceful

Durante o shutdown graceful, defina lease_expires_at_ms = 0 para que outros workers peguem a conexão no próximo ciclo de reconciliação em vez de esperar a expiração natural.

async def graceful_shutdown():
    shutdown_event.set()
    await asyncio.gather(*[
        release_lease(conn_id, worker_id) for conn_id in active_connections
    ])

AWS Fargate workers polling SQS and managing WebSocket connections with lease heartbeats Development Concept Image

Reconciliação de órfãos e escalonamento

O loop de reconciliação é a rede de segurança. A cada 60 segundos, cada worker consulta o GSI por registros onde desired_state = STARTED e lease_expires_at_ms < now. Essas são conexões órfãs — o dono anterior caiu sem liberar. Qualquer worker saudável pode chamar try_acquire_lease nelas. Como o acquire usa conditional write, múltiplos workers podem rodar reconciliação concorrentemente sem risco.

Budget de tempo de recuperação: 20s de expiração do lease + até 60s de intervalo de reconciliação = ~80s no pior caso. O shutdown graceful reduz isso para um ciclo de reconciliação.

Escalonando com métricas do CloudWatch

Cada worker publica uma métrica customizada ActiveConnections. Uma política de target tracking do Application Auto Scaling escala a frota baseada na média. Scale-in é seguro porque o SIGTERM dispara o shutdown graceful, liberando leases para reaquisição imediata.

Otimização de custo

Escritas de heartbeat dominam o custo: um update_item por conexão a cada 5s = 1 WCU. Com 500 conexões, isso dá ~$325/mês on-demand vs ~$47/mês provisioned. Para contagens altas sustentadas, use capacidade provisionada com Auto Scaling. Dobrar o intervalo de heartbeat para 10s corta o consumo de WCU pela metade — só dobre a duração do lease para manter a proporção 4:1.

⚠️ Limitações e cuidados

  • Clock skew: a expiração do lease usa o now fornecido pelo worker, não um relógio server-side. O AWS Fargate fornece Amazon Time Sync, mantendo o skew em milissegundos, mas se você deployar fora da AWS, verifique NTP e aumente a duração do lease pelo skew máximo esperado.
  • Não é um lock client genérico: a biblioteca amazon-dynamodb-lock-client existe para Java, mas não integra estado de domínio no registro do lock. Este padrão combina lock + metadata em um item para reduzir reads.
  • SQS sozinho não basta: o SQS entrega uma unidade de trabalho para um consumidor, mas a posse de WebSocket é um estado contínuo que precisa ser renovado. O SQS não consegue consultar conexões sem dono ativo.

Próximos passos

  • Adicione tracing distribuído com AWS X-Ray para visibilidade end-to-end entre workers.
  • Implemente lógica de reconexão com replay upstream ou retomada por offset usando last_seq para lidar com gaps de dados.
  • Para insights mais profundos sobre minimizar custos de inferência em sistemas distribuídos similares, veja Stop Wasting GPU Cycles: Why Code Agents Beat Tool-Calling for In-Game AI.

Reconciliation loop querying GSI for expired leases to reassign orphaned WebSocket connections Programming Illustration

Principais aprendizados

  1. Conditional writes do DynamoDB são um lock distribuído — sem serviço de coordenação externo. A ConditionExpression no update_item garante que exatamente um worker é dono de cada conexão.
  2. Heartbeat + reconciliação cobre todo o espectro de falhas — expiração de lease lida com crashes, shutdown graceful lida com deploys, e novos workers adquirem leases enquanto workers saindo os liberam.
  3. Este padrão se aplica amplamente — transcrição em tempo real, ingestão IoT, feeds financeiros, streaming de eventos ao vivo — qualquer sistema que gerencie conexões WebSocket de longa duração em escala.

Leitura relacionada

Se você está otimizando performance de infraestrutura cloud, talvez goste também de PostgreSQL on Azure Just Got a Serious Upgrade Inside VS Code — um deep dive em melhorias de tooling de banco de dados que combinam bem com os padrões de backend discutidos aqui.

A implementação completa — worker application, Lambda event router e templates Terraform — está disponível no repositório de samples da AWS. Faça o deploy da infraestrutura e valide o ciclo de vida do lease com um pequeno conjunto de conexões de teste antes de escalar.

Este conteúdo foi elaborado com o auxílio de ferramentas de IA, com base em fontes confiáveis, e revisado pela nossa equipe editorial antes da publicação. Não substitui o aconselhamento de um profissional especializado.