O problema: conexões WebSocket não são HTTP

Olha só, WebSocket é stateful. Cada conexão precisa de exatamente um worker dono dela. Quando esse worker morre do nada, as conexões ficam órfãs e os dados simplesmente param de fluir. Balanceamento de carga tradicional não resolve porque a coordenação tem que rolar na camada de aplicação.

Neste tutorial, você vai aprender um padrão production-ready usando conditional writes do DynamoDB como mecanismo de lease distribuído. Sem ZooKeeper, sem etcd, sem dor de cabeça.

A sacada é simples: o DynamoDB já te dá escrita atômica condicional. Isso é literalmente um lock distribuído de graça.

Diagram of AWS cloud services coordinating WebSocket connections with DynamoDB leases Dev Environment Setup

O ciclo de vida do lease: adquirir, renovar, liberar, expirar

Um lease é só um item no DynamoDB que diz quem é o dono da conexão e até quando. Bora ver o schema:

# Estrutura do item no DynamoDB para um lease de conexão
{
    "Pk": "CONN#meeting-123",          # Chave de partição: ID da conexão
    "desired_state": "STARTED",        # STARTED ou STOPPED
    "ws_url": "wss://upstream.example.com/stream",
    "lease_owner": "worker-abc",       # Dono atual
    "lease_expires_at_ms": 1712345678901,  # Epoch ms
    "last_seq": 42                     # Para retomada
}

Adquirindo um lease (atômico)

A ConditionExpression é o coração da coisa. Só funciona se o lease não existe OU se já expirou:

import boto3
from botocore.exceptions import ClientError

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

Renovando (heartbeat)

A cada 5 segundos o dono renova. Se falhar, é porque perdeu a posse — hora de sair de fininho:

async def renew_lease(table, conn_id, worker_id, lease_seconds=20):
    now_ms = int(time.time() * 1000)
    try:
        table.update_item(
            Key={"Pk": f"CONN#{conn_id}"},
            UpdateExpression="SET lease_expires_at_ms = :e",
            ConditionExpression="lease_owner = :w",
            ExpressionAttributeValues={
                ":e": now_ms + lease_seconds * 1000,
                ":w": worker_id
            }
        )
        return True
    except ClientError:
        return False  # Perdemos a posse

Reconciliação de órfãos

Um GSI em (desired_state, lease_expires_at_ms) permite que qualquer worker ache conexões que deveriam estar ativas mas estão sem dono:

# Consulta o GSI em busca de conexões órfãs
orphans = table.query(
    IndexName="desired_state-lease_expires_at_ms-index",
    KeyConditionExpression="desired_state = :s AND lease_expires_at_ms < :now",
    ExpressionAttributeValues={":s": "STARTED", ":now": now_ms}
)
# Cada worker tenta adquirir; o conditional write garante que só um ganha
for item in orphans["Items"]:
    await try_acquire_lease(table, item["Pk"], worker_id)

Shutdown gracioso

No SIGTERM, libera os leases na hora setando lease_expires_at_ms = 0. Aí os outros workers pegam na próxima reconciliação em vez de esperar expirar naturalmente:

async def release_lease(table, conn_id, worker_id):
    table.update_item(
        Key={"Pk": f"CONN#{conn_id}"},
        UpdateExpression="SET lease_expires_at_ms = :zero",
        ConditionExpression="lease_owner = :w",
        ExpressionAttributeValues={":zero": 0, ":w": worker_id}
    )

Server fleet architecture with Amazon ECS Fargate workers managing persistent WebSocket connections Development Concept Image

Avisos de produção e tuning

Clock skew é seu inimigo

A expiração do lease é comparada com o relógio local do worker, não com o relógio do servidor DynamoDB. No Fargate, o Amazon Time Sync Service mantém o skew em poucos milissegundos — tranquilo para um lease de 20s. Fora da AWS, confira se o NTP tá configurado ou aumente a duração do lease pelo skew máximo esperado.

Custo: a real

As escritas de heartbeat dominam o custo. A cada 5 segundos:

ConexõesWCU/sOn-demand/mêsProvisionado/mês
10020~$65~$10
500100~$325~$47
2.000400~$1.300~$190

Para volumes altos e sustentados, use capacidade provisionada com Auto Scaling. Dobrar o heartbeat pra 10s corta o WCU pela metade, mas dobra a janela de failover — escolha com sabedoria.

Limites de escala

  • O loop check_desired_state por conexão funciona pra frotas pequenas. Em escala, use BatchGetItem pra cortar RCU de N leituras/ciclo pra 1.
  • Queries no GSI durante reconciliação usam leituras eventualmente consistentes por padrão — metade do RCU.
  • Comece com MAX_CONNECTIONS=100 por task e aumente monitorando memória (cada WebSocket consome ~2-5 MB).

O que esse padrão NÃO resolve

  • Replay de dados: se um worker morre no meio do stream, as mensagens entre falha e reaquisição se perdem a menos que o upstream faça buffer ou suporte retomada por offset via last_seq.
  • Failover cross-region: o padrão é single-region. Multi-region exige Global Tables do DynamoDB e resolução de conflitos cuidadosa.
  • Thundering herd: quando muitos leases expiram juntos, todos os workers consultam o GSI de uma vez. Adicione jitter nos intervalos de reconciliação.

Network of real-time streaming workers using DynamoDB conditional writes for distributed locking Algorithm Concept Visual

Conclusão e próximos passos

Você viu como conditional writes do DynamoDB eliminam a necessidade de um serviço de coordenação separado enquanto garantem semântica de exatamente-um-dono. O ciclo completo — adquirir, renovar, liberar, reconciliar — cobre todo o espectro de falhas: crashes, deploys rolling e scale-in.

Comece por aqui: faça deploy da implementação de referência com Terraform, valide o ciclo de vida do lease com um punhado de conexões de teste, depois aumente MAX_CONNECTIONS gradualmente observando o CloudWatch Container Insights.

Próximo nível:

  • Adicione tracing distribuído com AWS X-Ray pra visualizar o caminho de aquisição entre workers.
  • Implemente retomada por offset usando o campo last_seq pra fechar gaps de dados após failover.
  • Explore DynamoDB Streams pra disparar reconciliação de forma reativa em vez de polling.

Quer ver como isso se encaixa em padrões mais amplos de arquitetura AWS? Dá uma olhada na nossa análise de AWS Architecture Trends 2024. E se você curte como decisões de design aparentemente bobas têm implicações éticas grandes, nosso texto sobre lições de pegadinhas de 1º de abril no web design vale a leitura.

Fonte: AWS Architecture Blog

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.