Yago Marinho
Volver a los artículos
  • Microservicios
  • Arquitectura
  • AWS
  • Docker
  • FFmpeg
  • NodeJS

Davna AI: cuándo decido refactorizar un monolito a microservicio

En Davna, solo FFmpeg salió del monolito. Cuento por qué — el tamaño del binario y el CI/CD —, comparo costos de EC2 contra API Gateway y Lambda sin tomar partido y muestro cómo pruebo cada lado.

Foto de Yago MarinhoPor: Yago MarinhoÚltima actualización realizada el:
Casi toda discusión sobre microservicios empieza por la respuesta y no por la pregunta. "Los microservicios escalan mejor", "el monolito es legado", "el monolito modular lo resuelve todo". Cada una de esas frases es verdadera en algún contexto y falsa en otro — y el contexto es justamente la parte que suele quedar afuera.
En Davna, la arquitectura tiene un monolito, la monolith.api, y un único microservicio, el multimedia.processing, que ejecuta FFmpeg y ffprobe. No fue un accidente ni una etapa de una migración planificada: fue una decisión puntual, tomada por motivos concretos. Y el resto siguió como monolito, también por motivos concretos.
Este artículo trata sobre esos motivos. Sobre cuándo separo una parte del sistema, cuándo no la separo, cuánto cuesta cada opción y cómo cada una cambia la forma de probar.

Tabla de contenidos

El punto de partida: un audio sin duración

Recapitulando el problema que trajo FFmpeg al proyecto: el WebM que genera el MediaRecorder del navegador suele llegar sin la duración en el encabezado. Y la duración es la base del control de uso de Davna — cada alumno tiene un límite diario en minutos de conversación.
La solución fue convertir todo audio entrante a AAC y, después de la conversión, leer la duración real con ffprobe. Funcionó. Pero la siguiente pregunta fue: ¿dónde vive este código?
La respuesta obvia sería dentro de la API. Ya recibe el audio, ya habla con S3, ya conoce el dominio. Bastaría con instalar FFmpeg y llamar a spawn. Fue justamente al mirar todo lo que ese "bastaría" traía consigo que la decisión cambió.

El peso que viene con el binario

FFmpeg no es una biblioteca de JavaScript. Es un binario nativo — y grande. En Davna, entra a través de los paquetes ffmpeg-static y ffprobe-static:
  • ffmpeg-static → descarga, durante la instalación, el binario de la plataforma. Solo el de Linux x64 pesa cerca de 80 MB;
  • ffprobe-static → publica en npm los binarios de todas las plataformas a la vez: el paquete descomprimido supera los 350 MB.
Si todo eso viviera dentro de la API, cada imagen de la API cargaría ese peso. Incluso la imagen generada para corregir un mensaje de error, ajustar un handler del socket o cambiar una regla del límite de uso. En la práctica, eso significa:
  • Builds más lentos → la instalación de dependencias descarga y copia cientos de megabytes en cada build;
  • Push y pull más grandes → cada deploy sube la imagen al registry y la instancia la vuelve a descargar;
  • Mayor superficie de vulnerabilidad → un binario nativo con decenas de códecs entra en la misma imagen y en el mismo contenedor que guardan los tokens y hablan con la base de datos.
Y lo más molesto: FFmpeg casi nunca cambia. La conversión a AAC se escribió una vez y, desde entonces, solo la toqué para ajustar el timeout. La API, en cambio, cambia todo el tiempo. Juntar las dos en una sola imagen es hacer que la parte que nunca cambia pague el precio de la parte que cambia siempre.

CI/CD: subir todo en cada cambio

Ese desfase de ritmos aparece con aún más fuerza en el pipeline.
Con las dos partes separadas, el workflow de GitHub Actions empezó a hacer una pregunta antes que nada: ¿qué cambió? Un filtro de rutas (dorny/paths-filter) decide si tiene que correr el job de la API, el de FFmpeg o los dos:
- name: Detect changed paths id: paths uses: dorny/paths-filter@v2 with: filters: | api: - 'apps/api/**' ffmpeg: - 'apps/ffmpeg/**'
Cada imagen se construye en dos etapas, con las pruebas corriendo dentro de la etapa de build: si una prueba falla, la imagen ni siquiera llega a existir. Después, el runner self-hosted en EC2 actualiza solo el contenedor que cambió.
En un monolito con FFmpeg adentro, esa pregunta no existe. Cualquier commit rehace la imagen entera, corre la suite entera, sube la imagen entera y reinicia el proceso entero — incluso las conversiones de audio que estaban a mitad de camino.
No es un problema exclusivo de Davna. En este mismo blog, cada sitio de Netlify solo hace build cuando cambia su propia ruta; el razonamiento es el mismo, solo que a nivel de sitio y no de contenedor.

Separar no es cambiar de máquina

Hay un argumento que aparece en casi toda conversación sobre FFmpeg, y que yo mismo llegué a usar: "convertir audio consume mucha CPU, así que no puede competir por espacio con la API". Parece un motivo para separar. No lo es.
Primero, porque incluso dentro del monolito FFmpeg nunca competiría por el event loop de Node.js. Se ejecuta con spawn, como un proceso aparte del sistema operativo. Node solo escribe el audio en stdin y lee el resultado de stdout; quien quema CPU es el proceso de FFmpeg, no la API.
Segundo, porque hoy los dos contenedores corren en la misma EC2, junto con MongoDB, orquestados con Docker Compose. La CPU es la misma. Si la máquina entera se queda sin aire, los dos sufren juntos — exactamente como sufrirían dentro de un monolito.
Separar un microservicio es una decisión de estructura: pasa a tener su propia imagen, su propio deploy y un contrato que atraviesa la capa de comunicación. Dónde corre es una decisión de infraestructura, que se toma después y por otros motivos. La separación no resuelve la competencia por CPU; solo hace barato resolverla el día en que exista. Como la API habla con FFmpeg únicamente a través del SDK, por HTTP, mover el servicio a otra máquina, escalarlo por separado o ponerlo en una Lambda es cambiar una URL en la configuración — y no una refactorización.

Por qué el resto siguió como monolito

Si separar FFmpeg trajo tantas ventajas, ¿por qué no separar también el aula, la identidad y el monitoreo?
Porque esos módulos no tienen ninguno de los dos motivos anteriores. No cargan un binario pesado y cambian al mismo ritmo que el resto de la API. Y ya están separados donde importa: en el código. Cada uno es un módulo en modules/, con sus handlers, sus contratos y sus repositorios. La frontera existe — solo que no pasa por la red.
Y pasar por la red tiene un costo que suele subestimarse. Una llamada a función:
  • no tiene latencia de red;
  • no falla por timeout;
  • no necesita reintentos ni idempotencia;
  • no necesita versionar el contrato;
  • la verifica el compilador.
Una llamada HTTP entre servicios no tiene ninguna de esas garantías. Cada frontera que se vuelve red empieza a exigir autenticación entre servicios, manejo de fallas parciales, observabilidad distribuida y un deploy coordinado cuando cambia el contrato.
Por eso mi regla es simple: una parte sale del monolito cuando tiene una dependencia o un ritmo de deploy distinto del resto. "Quedar más organizado" no entra en la lista — la organización se resuelve con módulos. Y "consumir mucha CPU" tampoco: eso se resuelve eligiendo dónde corre cada cosa, algo que solo se vuelve fácil cuando la frontera de red ya existe.

Cuánto cuesta cada uno: EC2 contra API Gateway y Lambda

El costo es donde las discusiones suelen volverse barra brava. Voy a intentar lo contrario: mostrar dónde cada modelo es barato y dónde se vuelve caro.
Los precios a continuación son los bajo demanda de us-east-1 (Norte de Virginia), revisados en octubre de 2026, sin considerar la capa gratuita.
  • EC2 t3.small (2 vCPU, 2 GB) → ~USD 0.0208 por hora, o ~USD 15 por mes;
  • Lambda, solicitudes → USD 0.20 por millón;
  • Lambda, cómputo (x86) → ~USD 0.0000167 por GB-segundo;
  • API Gateway (HTTP API) → USD 1.00 por millón de solicitudes.

FFmpeg como Lambda

Imagina una conversión que tarda 2 segundos en una Lambda de 1 GB de memoria:
  • cómputo: 2 GB-s × USD 0.0000167 ≈ USD 0.0000333;
  • solicitud de la Lambda: ≈ USD 0.0000002;
  • solicitud de API Gateway: ≈ USD 0.000001;
  • total: cerca de USD 0.000035 por conversión.
Dividiendo los USD 15 de la t3.small por ese valor, la cuenta se empata alrededor de 430 mil conversiones por mes — unas 14 mil por día, o diez por minuto, todo el día.
Por debajo de eso, Lambda es más barata, y por mucho: con pocos alumnos, el costo de FFmpeg sería de centavos por mes, contra los mismos USD 15 fijos de la instancia encendida de madrugada sin que nadie la use. Por encima de eso, la cuenta de Lambda crece en línea recta, mientras la instancia sigue costando lo mismo hasta su propio límite.

Lo que la tabla no muestra

Los dos lados tienen costos ocultos, y aquí es donde la comparación se vuelve justa:
En EC2:
  • Créditos de CPU → la familia t3 es burstable. Convertir audio todo el día consume los créditos, y a partir de ahí o la CPU queda limitada, o AWS cobra el excedente (en el modo unlimited);
  • Disco, backups y transferencia → EBS y la salida de datos se cobran aparte;
  • Tu tiempo → actualizar el sistema operativo, renovar certificados, cuidar Docker, el runner self-hosted y el espacio en disco. No aparece en la factura, pero es un costo.
En Lambda:
  • Límites de ejecución → hasta 15 minutos por invocación y hasta 250 MB descomprimidos entre la función y sus layers. FFmpeg cabe, pero ffprobe-static con todas las plataformas no — habría que llevar solo el binario de Linux, o publicar la función como imagen de contenedor (hasta 10 GB);
  • Tamaño del audio → en una invocación síncrona, la solicitud y la respuesta de la Lambda tienen hasta 6 MB cada una. Una frase de un minuto cabe con holgura; un audio largo tendría que pasar por S3, con la Lambda leyendo y escribiendo allí en lugar de recibir el archivo en el cuerpo;
  • Cold start → la primera conversión después de un período inactivo es más lenta, y el alumno lo nota;
  • La arquitectura alrededor → el resto de Davna no encaja tan bien. La API usa Socket.IO, y las conexiones persistentes en Lambda exigen la WebSocket API de API Gateway, con otro modelo de programación y cobro por mensaje y por minuto conectado. MongoDB también sufre cuando decenas de funciones abren conexiones al mismo tiempo.
O sea: para FFmpeg, que es una función pura — entra audio, sale audio —, Lambda es una candidata excelente. Para la API, que mantiene sockets abiertos y habla con una base de datos todo el tiempo, una instancia siempre encendida es el encaje natural. La respuesta cambia por cada parte del sistema, no para el sistema entero.

Testabilidad: qué cambia en cada lado

La frase "los microservicios son más fáciles de probar" es una verdad a medias. Cada lado es más fácil de probar en una cosa.

En el monolito, todo el flujo cabe en memoria

En la monolith.api, los handlers son funciones puras que reciben sus dependencias por Reader. Para probar un turno completo de la conversación — recibir el audio, transcribir, responder, consumir el límite —, inyecto fakes: un repositorio en memoria, un storage en memoria y la FakeAI en lugar de OpenAI. La prueba atraviesa varios módulos sin abrir un puerto, sin levantar un contenedor y sin gastar un centavo.
Y cuando cambia un contrato interno, TypeScript avisa en todos los lugares que lo usan, antes de que corra cualquier prueba.

En el microservicio, cada lado se prueba solo — y el medio queda descubierto

El multimedia.processing tiene sus propias pruebas: los handlers de conversión y de metadatos, y los servicios que llaman al FFmpeg real. La monolith.api prueba su lado con un fake del SDK. Los dos lados quedan en verde.
El problema es lo que queda entre ellos. Mira cómo el SDK lee la duración que devuelve el servicio:
duration: response.headers.get('x-file-duration') ? parseFloat(response.headers.get('x-file-duration') as string) : 0,
Si algún día el servicio renombra ese encabezado, ningún compilador se va a quejar y ninguna prueba unitaria se va a romper. El SDK va a devolver duración cero — y el control de uso, que era el motivo de que todo esto existiera, va a dejar de funcionar en silencio.
Ese es el precio de una frontera de red: el contrato deja de ser un tipo y pasa a ser una convención. Por eso el pipeline de Davna levanta los contenedores con Docker Compose y corre pruebas de integración antes de publicar las imágenes. Son pruebas más lentas y más frágiles que las unitarias, pero son las únicas que ven el medio del camino.
En resumen:
  • Monolito → prueba mejor el flujo completo; los contratos internos los verifica el compilador;
  • Microservicio → prueba mejor cada parte aislada; los contratos entre servicios necesitan pruebas propias (de integración o de contrato) para no romperse en silencio.

Cuándo va a salir la próxima parte

Si llegaste hasta aquí esperando que anuncie la migración de todo Davna a microservicios, te voy a decepcionar. Migrar no es un deseo mío — es una respuesta a señales que da el sistema. Y separo esas señales en dos tipos, porque piden respuestas distintas.
Las que piden cambiar de máquina, y no de estructura:
  • La CPU de la instancia → si las conversiones empiezan a competir por la máquina con la API, FFmpeg pasa a otra instancia o a una Lambda. El código no cambia: la API solo va a notar una URL distinta en la configuración;
  • La cola de audios → si los alumnos empiezan a esperar la conversión, el camino natural es una cola (SQS) delante de FFmpeg, para absorber picos sin perder mensajes.
Las que piden separar un nuevo servicio:
  • Ritmos de cambio distintos → si una parte del sistema empieza a cambiar a un ritmo muy distinto del resto, o a estar a cargo de otra persona, gana su propio deploy;
  • Dependencias que nadie más usa → si una parte empieza a arrastrar bibliotecas o binarios que solo ella necesita, como pasó con FFmpeg;
  • Requisitos de disponibilidad distintos → si una parte necesita seguir en línea cuando otra se cae, no pueden ser el mismo deploy.
Y el próximo candidato ya está en el roadmap: el orquestador del turno de la conversación. Hoy, transcribir, generar la respuesta de la profesora, traducir y sintetizar la voz ocurre dentro del handler del socket. Es la parte del sistema que más cambia: el prompt de la profesora, el orden de las etapas, cuáles pueden correr en paralelo, qué modelo usar en cada una. Ya empezó a tomar forma en el repositorio como interaction.conductor, pero todavía no está en producción.
Fíjate que cumple el mismo criterio que FFmpeg, desde el lado opuesto: FFmpeg casi nunca cambia, y el orquestador cambia todo el tiempo. En ambos casos, el ritmo de deploy es distinto del resto de la API — ajustar un prompt no debería exigir volver a subir el login. El resto — identidad, monitoreo, el aula misma — sigue junto, porque sigue sin motivo para salir.

Conclusión

FFmpeg no se volvió un microservicio porque los microservicios estén de moda. Se volvió uno porque cargaba un binario de cientos de megabytes y cambiaba a un ritmo completamente distinto del de la API. Dos motivos concretos, que se pueden medir. La CPU, que parecía el motivo más obvio, no era motivo alguno: es una cuestión de dónde corre el servicio, y la separación solo hizo más fácil resolver ese problema cuando aparezca.
El resto de Davna siguió como monolito por el mismo criterio, aplicado al revés. Y sigue organizado: los módulos tienen fronteras claras, contratos explícitos y pruebas que lo atraviesan todo en memoria. Cuando alguno de ellos tenga un motivo concreto para salir, la frontera ya está dibujada — solo falta cambiar una llamada a función por una llamada de red, y aceptar conscientemente todo lo que viene con ella.
Al final, la pregunta que me hago no es "¿monolito o microservicio?". Es "¿qué tiene esta parte de distinto del resto?". Si la respuesta es "nada", se queda donde está.
Foto de Yago Marinho
Yago MarinhoIngeniero de Software Sénior. Construyo productos con IA, del diseño al despliegue.
Ver portafolio ↗