Contenedores de Cloudflare vs Fly.io: lo que aprendí (y erré) migrando un bot de Discord

8 min read

# Cloudflare Containers vs Fly.io: lo que aprendí (y erré) migrando un bot de Discord Este post es un relato técnico de una semana migrando un bot de Discord e

Cloudflare Containers vs Fly.io: lo que aprendí (y erré) migrando un bot de Discord

Este post es un relato técnico de una semana migrando un bot de Discord entre dos proveedores de contenedores — Cloudflare Containers y Fly.io — para resolver un error que, al final, no tenía nada que ver con el proveedor. El título más honesto sería “cómo evitar una migración innecesaria”, pero el viaje enseñó bastante sobre los dos productos, así que vale la pena registrarlo.

Si solo quieres la TL;DR: la causa raíz era el Discord deprecando el protocolo de voz v4 en 2025, no UDP bloqueado. El resto de este texto explica cómo llegué a esa conclusión errónea, lo que descubrí sobre cada plataforma en el camino, y la arquitectura que quedó.

Contexto

O Tessel é una plataforma para maestros de RPG de mesa. Una de las características es grabar la sesión de voz en Discord, transcribir, y extraer eventos para la línea de tiempo de la campaña. El bot necesita:

  • Recibir interacciones HTTP de Discord (slash commands, via TCP/443)
  • Mantener una conexión WebSocket con el Discord Gateway (TCP/443)
  • Recibir audio en UDP de los servidores de voz de Discord
  • Guardar PCM en disco, fragmentar en chunks de 10 minutos, y enviar al pipeline de transcripción

La primera versión corría en Cloudflare Containers con Durable Objects ruteando interacciones a un contenedor Node.js que mantenía la sesión de voz. Funcionó bien en pruebas locales. En producción, dejó de funcionar.

El error

El síntoma era frustrante: la conexión de voz se establecía con éxito, el bot entraba en el canal, pero ningún paquete de audio llegaba. Los registros mostraban VoiceConnectionStatus.Ready, y luego silencio. El archivo PCM final tenía tamaño cero.

Se reproducía en CF, entonces para eliminar al proveedor de la ecuación duplicé la pila en Fly.io. El mismo error. Los paquetes no llegaban.

El diagnóstico erróneo

Aquí fue donde me equivoqué. Busqué “discord voice udp cloudflare containers” y encontré discusiones antiguas (de 2023) sugiriendo que CF Workers no soportaban UDP saliente. CF Containers es un producto nuevo (en open beta en ese momento) y la documentación no era explícita sobre UDP. Conclusión precipitada: CF Containers hereda la limitación de Workers, vamos a Fly.io.

Migré todo. Configuración fly.toml, flyctl deploy, configuración de máquina, auto-detención/inicio, secretos, monitoreo. Llevó días.

Y el error continuó.

La pista real

Fue cuando, revisando issues de discord.js, vi:

Discord está descontinuando las versiones del gateway de voz ≤ v6 en 2025. Usa @discordjs/voice 0.19+ que negocia v8 (encriptación DAVE).

El bot estaba en @discordjs/voice@0.18 configurado con daveEncryption: false. En 2024 esto funcionaba. En 2025, Discord comenzó a rechazar la bandera silenciosamente — la conexión era aceptada, pero el servidor de voz nunca enviaba paquetes porque la sesión nunca se consideraba negociada.

La corrección fue:

  1. @discordjs/voice 0.18 → 0.19.2 (fuerza negociación de v8)
  2. Eliminar daveEncryption: false (ya no es opcional)
  3. Cambiar el pipeline subscribe → opus.Decoder → fs.WriteStream por decode por paquete con @discordjs/opus OpusEncoder.decode()

El último punto merece explicación: con DAVE (Discord Audio Voice Encryption), cada paquete llega encriptado individualmente. El pipeline antiguo de prism-media asumía un stream continuo — se rompía en los primeros paquetes E2EE. La solución es mantener un decoder por usuario y decodificar paquete por paquete.

// antes (se rompe con DAVE)
receiver.subscribe(userId)
  .pipe(new prism.opus.Decoder({ rate: 48000, channels: 2, frameSize: 960 }))
  .pipe(fs.createWriteStream(path))
 
// después (compatible con v8)
const decoder = new OpusEncoder(48000, 2)
receiver.subscribe(userId).on("data", (opusPacket) => {
  const pcm = decoder.decode(opusPacket)
  fileStream.write(pcm)
})

Después de la actualización, funcionó en ambos proveedores. UDP nunca había sido el problema.

La prueba que debería haber hecho primero

Para cerrar la brecha del diagnóstico, escribí un proyecto aislado de 50 líneas: cf-udp-test. Un Worker ejecutando un contenedor mínimo que:

  1. Intenta DNS query (UDP/53) → ✅ funcionó
  2. Intenta STUN binding request (UDP/3478) → ✅ funcionó
  3. Intenta enviar paquete al IP de voz de Discord → ✅ funcionó

CF Containers soporta UDP outbound sin ningún problema. Lo asumido a partir de documentos antiguos de Workers estaba equivocado. Documenté esto en el roadmap como mea culpa pública.

La lección es simple y antigua: isola antes de migrar. Una prueba de 50 líneas habría ahorrado una semana de trabajo.

Aprendizajes sobre Cloudflare Containers

Aunque la migración haya sido innecesaria, descubrí varias cosas sobre el producto que vale la pena registrar:

1. Las migraciones de Durable Object son estrictas

No se puede simplemente renombrar o reemplazar una clase DO. Cada cambio estructural necesita una etiqueta de migración:

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

Tuve que hacer create → delete → create de nuevo durante la iteración. Intentar saltar pasos resulta en errores del tipo “already exists with different namespace”.

2. Las variables de entorno se fijan al inicio de la instancia

Actualizar un secreto a través de wrangler secret put no actualiza instancias en ejecución. El contenedor ya en memoria continúa con el valor antiguo hasta ser destruido. Esto me costó horas depurando un “invalid token” después de haber rotado el token.

La corrección es forzar la destrucción:

wrangler containers delete <application-id>

Entonces, en la siguiente solicitud, el DO levanta un contenedor nuevo con los secretos actualizados.

3. Las aplicaciones huérfanas necesitan limpieza manual

Si cambias el nombre del namespace del DO o lo recreas, la “aplicación” del contenedor queda huérfana. Error:

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

Solución: wrangler containers delete <id> antes de redeploy.

4. El cold start es real y se propaga

Contenedor parado: /gravar start espera ~3-5 segundos por el primer contenedor subir + inicio de sesión en el Gateway de Discord (~2s adicionales). Total: ~5-8s de la interacción hasta el “estoy listo”.

Peor aún: la creación de sesión en Postgres tenía un trigger síncrono que llamaba a una Edge Function externa para indexar en Vectorize. En cold start, ese trigger hacía que el INSERT superara el statement_timeout de 5s de PostgREST y la transacción hacía rollback. Resultado: el cold start se propagaba como “Failed to create session” al usuario.

La corrección fue migrar el trigger a 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;
$$;

Ahora el INSERT encola la solicitud en net.http_request_queue y retorna inmediatamente. La entrega ocurre en segundo plano, con reintento automático y registros en net._http_response.

Aprendizados sobre Fly.io

Fly.io é más maduro para este caso de uso (containers Node.js de larga duración) y tiene características diferentes:

1. auto-stop/start funciona bien para cargas burst

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

Cold start es más rápido que CF Container (~1-2s para spin-up vs 3-5s), porque la máquina queda en estado suspendido en vez de destruida.

2. Logs estructurados son primera clase

fly logs muestra stdout directamente, sin necesidad de configurar Logpush o tail. Para debug rápido, es más ergonómico que el equivalente en Cloudflare.

3. UDP funciona out of the box

Sin configuración especial, sin flag. Es lo esperado para un producto que vende “cualquier container”.

Arquitectura final: toggle Fly ↔ CF

Ya que ambas las plataformas funcionan después de la corrección del protocolo, mantuve las dos implementaciones activas. Una única variable de entorno en el Worker decide a dónde reenviar:

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)
}

Una limitación importante: el token del bot de Discord solo puede ser usado por una instancia simultánea. Por lo tanto, solo un backend queda activo a la vez. Para ejecutar los dos en paralelo sería necesario un segundo bot registrado en el Discord Developer Portal. Para A/B test esto vale la pena; para HA stateless, es overhead.

Lecciones generales

  1. Diagnostica antes de migrar. Una sonda aislada de 50 líneas vale más que días de migración basada en hipótesis.

  2. La documentación de productos nuevos envejece rápido. “Workers no soportaba UDP en 2023” no dice nada sobre “Contenedores en 2026”. Siempre confirma con una prueba actual.

  3. Los arranques en frío se propagan por toda la pila. El error aparece en el bot, pero la causa raíz puede estar en un disparador de Postgres. Cuando algo síncrono en el camino crítico llama a una red externa, considera migrar a la cola/asíncrono.

  4. Las herramientas de plataforma tienen peculiaridades. CF Containers hornea variables de entorno en el arranque. Fly no hornea nada — los secretos se leen dinámicamente. Saber esto cambia cómo piensas en la rotación de credenciales.

  5. Mantener dos implementaciones es caro pero valida hipótesis. Si hubiera mantenido el interruptor desde el principio, habría reproducido el error en CF y Fly en paralelo el mismo día, y el protocolo v8 habría saltado a la vista antes de que comenzara la migración.

  6. Los protocolos de voz/realtime cambian. Discord depreció v4 silenciosamente — sin 410 Gone, solo “ok pero no envía paquetes”. Para cualquier integración similar a WebRTC, vale la pena tener una prueba de humo que valide el flujo de datos, no solo el handshake.

Fechamiento

La migración no fue necesaria — pero no fue desperdiciada. Aprendí los internals de los dos productos, validé que ambos sirven para este caso de uso, y me quedé con una arquitectura que acepta el cambio de proveedor sin rewrite. El bug real (protocolo v8) era invisible hasta que forzamos una reinvestigación, y el test aislado de UDP quedó como artefacto para la próxima vez que aparezca una hipótesis parecida.

Si estás construyendo algo voice-heavy en Discord en 2026: comienza con @discordjs/voice@0.19.2+, decodifica por paquete, y prueba el pipeline completo end-to-end antes de asumir que la infra es el problema.