skip to content
Tessel

Search

Cloudflare Containers vs Fly.io: bot Discord

8 min read

Relato técnico de uma migração entre Cloudflare Containers e Fly.io para um bot do Discord, e a lição sobre debugar antes de migrar.

Cloudflare Containers vs Fly.io: o que aprendi (e errei) migrando um bot do Discord

Esse post é um relato técnico de uma semana migrando um bot do Discord entre dois provedores de container — Cloudflare Containers e Fly.io — pra resolver um bug que, no fim, não tinha nada a ver com o provedor. O título mais honesto seria “como evitar uma migração desnecessária”, mas a jornada ensinou bastante sobre os dois produtos, então vale registrar.

Se você só quer a TL;DR: a causa raiz era o Discord deprecando o protocolo de voz v4 em 2025, não UDP bloqueado. O resto desse texto explica como cheguei nessa conclusão errada, o que descobri sobre cada plataforma no caminho, e a arquitetura que ficou.

Contexto

O Tessel é uma plataforma pra mestres de RPG de mesa. Uma das features é gravar a sessão de voz no Discord, transcrever, e extrair eventos pra timeline da campanha. O bot precisa:

  • Receber interações HTTP do Discord (slash commands, via TCP/443)
  • Manter uma conexão WebSocket com o Discord Gateway (TCP/443)
  • Receber áudio em UDP dos servidores de voz do Discord
  • Salvar PCM em disco, fragmentar em chunks de 10 minutos, e enviar pro pipeline de transcrição

A primeira versão rodava em Cloudflare Containers com Durable Objects roteando interações pra um container Node.js que mantinha a sessão de voz. Funcionou bem em testes locais. Em produção, parou de funcionar.

O bug

O sintoma era frustrante: a conexão de voz era estabelecida com sucesso, o bot entrava no canal, mas nenhum pacote de áudio chegava. Os logs mostravam VoiceConnectionStatus.Ready, e depois silêncio. O arquivo PCM final tinha tamanho zero.

Reproduzia em CF, então pra eliminar o provedor da equação eu duplei a stack no Fly.io. Mesmo bug. Pacotes não chegavam.

O diagnóstico errado

Aqui foi onde eu errei feio. Procurei “discord voice udp cloudflare containers” e achei discussões antigas (de 2023) sugerindo que CF Workers não suportavam outbound UDP. CF Containers é um produto novo (em open beta na época) e a documentação não era explícita sobre UDP. Conclusão precipitada: CF Containers herda a limitação do Workers, vamos pro Fly.io.

Migrei tudo. Configuração fly.toml, flyctl deploy, machine config, auto-stop/start, secrets, monitoring. Levou dias.

E o bug continuou.

A pista real

Foi quando, vasculhando issues do discord.js, eu vi:

Discord is deprecating voice gateway versions ≤ v6 in 2025. Use @discordjs/voice 0.19+ which negotiates v8 (DAVE encryption).

O bot estava em @discordjs/voice@0.18 configurado com daveEncryption: false. Em 2024 isso funcionava. Em 2025, o Discord passou a rejeitar a flag silenciosamente — a conexão era aceita, mas o servidor de voz nunca enviava pacotes porque a sessão nunca era considerada negociada.

A correção foi:

  1. @discordjs/voice 0.18 → 0.19.2 (força negociação de v8)
  2. Remover daveEncryption: false (não é mais opcional)
  3. Trocar o pipeline subscribe → opus.Decoder → fs.WriteStream por decode per-packet com @discordjs/opus OpusEncoder.decode()

O último ponto merece explicação: com DAVE (Discord Audio Voice Encryption), cada pacote chega criptografado individualmente. O pipeline antigo do prism-media assumia stream contínuo — quebrava nos primeiros pacotes E2EE. A solução é manter um decoder por usuário e decodificar pacote a pacote.

// antes (quebra com DAVE)
receiver.subscribe(userId)
  .pipe(new prism.opus.Decoder({ rate: 48000, channels: 2, frameSize: 960 }))
  .pipe(fs.createWriteStream(path))
 
// depois (compatível com v8)
const decoder = new OpusEncoder(48000, 2)
receiver.subscribe(userId).on("data", (opusPacket) => {
  const pcm = decoder.decode(opusPacket)
  fileStream.write(pcm)
})

Após o upgrade, funcionou em ambos os provedores. UDP nunca tinha sido o problema.

O teste que eu deveria ter feito primeiro

Pra fechar a lacuna do diagnóstico, escrevi um projeto isolado de 50 linhas: cf-udp-test. Um Worker rodando um container minimal que:

  1. Tenta DNS query (UDP/53) → ✅ funcionou
  2. Tenta STUN binding request (UDP/3478) → ✅ funcionou
  3. Tenta envio de pacote pro IP de voz do Discord → ✅ funcionou

CF Containers suporta UDP outbound sem qualquer problema. O assumido a partir de docs antigas do Workers estava errado. Documentei isso no roadmap como mea-culpa pública.

A lição é simples e velha: isole antes de migrar. Um teste de 50 linhas teria economizado uma semana de trabalho.

Aprendizados sobre Cloudflare Containers

Mesmo a migração tendo sido desnecessária, descobri várias coisas sobre o produto que vale registrar:

1. Migrations de Durable Object são strict

Não dá pra simplesmente renomear ou substituir uma classe DO. Cada mudança estrutural precisa de uma tag de migration:

"migrations": [
  { "tag": "v1", "new_sqlite_classes": ["BotContainer"] },
  { "tag": "v2", "deleted_classes": ["BotContainer"] },
  { "tag": "v3", "new_sqlite_classes": ["BotContainer"] }
]

Eu tive que fazer create → delete → create de novo durante a iteração. Tentar pular passos resulta em erros do tipo “already exists with different namespace”.

2. Env vars são baked no instance start

Atualizar um secret via wrangler secret put não atualiza instâncias rodando. O container já em memória continua com o valor antigo até ser destruído. Isso me custou horas debugando um “invalid token” depois de já ter rotacionado o token.

A correção é forçar destruição:

wrangler containers delete <application-id>

Aí na próxima request o DO sobe um container novo com os secrets atualizados.

3. Aplicações órfãs precisam de cleanup manual

Se você muda o nome do DO namespace ou recria, a “application” do container fica órfã. Erro:

There is already an application with the name X deployed that is associated with a different durable object namespace

Solução: wrangler containers delete <id> antes de redeploy.

4. Cold start é real e cascateia

Container parado: /gravar start espera ~3-5 segundos pelo primeiro container subir + login no Gateway do Discord (~2s adicionais). Total: ~5-8s da interação até o “estou pronto”.

Pior: a criação de sessão no Postgres tinha um trigger síncrono que chamava uma Edge Function externa pra indexar no Vectorize. Em cold start, esse trigger fazia o INSERT estourar o statement_timeout de 5s do PostgREST e a transação fazia rollback. Resultado: cold start propagava como “Failed to create session” pro usuário.

A correção foi migrar o trigger pra pg_net async:

CREATE OR REPLACE FUNCTION public.sync_note_to_vectorize()
RETURNS trigger LANGUAGE plpgsql SECURITY DEFINER SET search_path TO ''
AS $$
DECLARE
  request_id BIGINT;
BEGIN
  -- ... build payload ...
  SELECT net.http_post(
    url := edge_function_url,
    body := payload,
    headers := jsonb_build_object('Content-Type', 'application/json', ...)
  ) INTO request_id;
  RAISE LOG 'Queued vectorize sync (pg_net request_id: %)', request_id;
  RETURN COALESCE(NEW, OLD);
END;
$$;

Agora o INSERT enfileira a request em net.http_request_queue e retorna imediatamente. A entrega acontece em background, com retry automático e logs em net._http_response.

Aprendizados sobre Fly.io

Fly.io é mais maduro pra esse caso de uso (containers Node.js de longa duração) e tem características diferentes:

1. auto-stop/start funciona bem pra cargas burst

[[services]]
  auto_stop_machines = "stop"
  auto_start_machines = true
  min_machines_running = 0

Cold start é mais rápido que CF Container (~1-2s pra spin-up vs 3-5s), porque a máquina fica em estado suspenso ao invés de destruída.

2. Logs estruturados são primeira classe

fly logs mostra stdout direto, sem precisar configurar Logpush ou tail. Pra debug rápido, é mais ergonômico que o equivalente em Cloudflare.

3. UDP funciona out of the box

Sem config especial, sem flag. É o esperado pra um produto que vende “qualquer container”.

Arquitetura final: toggle Fly ↔ CF

Já que ambas as plataformas funcionam após a correção do protocolo, mantive as duas implementações ativas. Um único env var no Worker decide pra onde forwardar:

type Env = {
  BACKEND: "fly" | "cf"
  BOT_URL: string                     // Fly endpoint
  BOT: DurableObjectNamespace<...>    // CF binding
  // ...
}
 
async function callBot(env: Env, path: string, init?: RequestInit) {
  if (env.BACKEND === "cf") {
    const stub = env.BOT.get(env.BOT.idFromName("singleton"))
    return await stub.fetch(`https://bot${path}`, init)
  }
  return await fetch(`${env.BOT_URL}${path}`, init)
}

Uma limitação importante: o token do bot do Discord só pode ser usado por uma instância simultânea. Logo, só um backend fica ativo por vez. Pra rodar os dois em paralelo seria preciso um segundo bot registrado no Discord Developer Portal. Pra A/B test isso vale a pena; pra HA stateless, é overhead.

Lições gerais

  1. Diagnose antes de migrar. Um probe isolado de 50 linhas vale mais que dias de migração baseada em hipótese.

  2. Documentação de produtos novos envelhece rápido. “Workers não suportava UDP em 2023” não diz nada sobre “Containers em 2026”. Sempre confirme com um teste atual.

  3. Cold starts cascateiam por toda a stack. O bug aparece no bot, mas a causa raiz pode estar num trigger de Postgres. Quando algo síncrono no caminho crítico chama uma rede externa, considere migrar pra fila/async.

  4. Ferramentas de plataforma têm peculiaridades. CF Containers bake env vars no boot. Fly bake nada — secrets são lidos dinâmicos. Saber isso muda como você pensa em rotação de credentials.

  5. Manter duas implementações é caro mas valida hipóteses. Se eu tivesse mantido o toggle desde o início, teria reproduzido o bug em CF e Fly em paralelo no mesmo dia, e o protocolo v8 teria saltado aos olhos antes da migração começar.

  6. Protocolos de voz/realtime mudam. Discord deprecou v4 silenciosamente — sem 410 Gone, só “ok mas não envia pacotes”. Pra qualquer integração WebRTC-like, vale ter um teste de smoke que valida fluxo de dados, não só handshake.

Fechamento

A migração não foi necessária — mas não foi desperdiçada. Aprendi os internals dos dois produtos, validei que ambos servem pra esse caso de uso, e fiquei com uma arquitetura que aceita troca de provedor sem rewrite. O bug real (protocolo v8) era invisível até a gente forçar uma reinvestigação, e o teste isolado de UDP ficou como artefato pra próxima vez que aparecer uma hipótese parecida.

Se você está construindo algo voice-heavy no Discord em 2026: comece com @discordjs/voice@0.19.2+, decode per-packet, e teste o pipeline completo end-to-end antes de assumir que a infra é o problema.