Yago Marinho
Voltar para os artigos
  • Microsserviços
  • Arquitetura
  • AWS
  • Docker
  • FFmpeg
  • NodeJS

Davna AI: quando eu decido refatorar um monolito para microsserviço

No Davna, só o FFmpeg saiu do monolito. Conto o porquê — o tamanho do binário e o CI/CD —, comparo custos de EC2 contra API Gateway e Lambda sem puxar sardinha e mostro como testo cada lado.

Foto de Yago MarinhoPor: Yago MarinhoÚltima atualização realizada em:
Quase toda discussão sobre microsserviços começa pela resposta e não pela pergunta. "Microsserviço escala melhor", "monolito é legado", "monolito modular resolve tudo". Cada frase dessas é verdadeira em algum contexto e falsa em outro — e o contexto é exatamente a parte que costuma ficar de fora.
No Davna, a arquitetura tem um monolito, a monolith.api, e um único microsserviço, o multimedia.processing, que roda o FFmpeg e o ffprobe. Não foi um acidente nem uma etapa de uma migração planejada: foi uma decisão pontual, tomada por motivos concretos. E o resto continuou monolito, também por motivos concretos.
Este artigo é sobre esses motivos. Sobre quando eu separo um pedaço do sistema, quando eu não separo, quanto cada escolha custa e como cada uma muda a forma de testar.

Tabela de conteúdos

O ponto de partida: um áudio sem duração

Recapitulando o problema que trouxe o FFmpeg para o projeto: o WebM gerado pelo MediaRecorder do navegador frequentemente chega sem a duração no cabeçalho. E a duração é a base do controle de uso do Davna — o aluno tem um limite diário em minutos de conversa.
A solução foi converter todo áudio que chega para AAC e, depois da conversão, ler a duração real com o ffprobe. Funcionou. Mas a pergunta seguinte foi: onde esse código mora?
A resposta óbvia seria dentro da API. Ela já recebe o áudio, já fala com o S3, já conhece o domínio. Bastaria instalar o FFmpeg e chamar um spawn. Foi justamente olhando para o que esse "bastaria" carregava junto que a decisão mudou.

O peso que vem junto com o binário

O FFmpeg não é uma biblioteca JavaScript. É um binário nativo — e grande. No Davna, ele entra pelos pacotes ffmpeg-static e ffprobe-static:
  • ffmpeg-static → baixa, na instalação, o binário da plataforma. Só o de Linux x64 tem perto de 80 MB;
  • ffprobe-static → publica no npm os binários de todas as plataformas de uma vez: o pacote descompactado passa de 350 MB.
Se isso tudo morasse dentro da API, toda imagem da API carregaria esse peso. Inclusive a imagem gerada para corrigir um texto de erro, ajustar um handler do socket ou mudar uma regra do limite de uso. Na prática, isso significa:
  • Builds mais lentos → a instalação das dependências baixa e copia centenas de megabytes a cada build;
  • Push e pull maiores → cada deploy sobe a imagem para o registry e a instância baixa de volta;
  • Mais superfície de vulnerabilidade → um binário nativo com dezenas de codecs entra na mesma imagem e no mesmo container que guardam os tokens e conversam com o banco.
E o mais incômodo: o FFmpeg quase nunca muda. A conversão para AAC foi escrita uma vez e, desde então, só mexi nela para ajustar timeout. A API, por outro lado, muda o tempo todo. Juntar os dois numa imagem só é fazer a parte que nunca muda pagar o preço da parte que muda sempre.

CI/CD: subir tudo a cada mudança

Esse desencontro de ritmos aparece com ainda mais força no pipeline.
Com os dois separados, o workflow do GitHub Actions passou a fazer uma pergunta antes de qualquer coisa: o que mudou? Um filtro de caminhos (dorny/paths-filter) decide se o job da API, o do FFmpeg ou os dois precisam rodar:
- name: Detect changed paths id: paths uses: dorny/paths-filter@v2 with: filters: | api: - 'apps/api/**' ffmpeg: - 'apps/ffmpeg/**'
Cada imagem é construída em dois estágios, com os testes rodando dentro do estágio de build: se um teste falha, a imagem nem chega a existir. Depois, o runner self-hosted na EC2 atualiza apenas o container que mudou.
Num monolito com o FFmpeg dentro, não existe essa pergunta. Qualquer commit refaz a imagem inteira, roda a suíte inteira, sobe a imagem inteira e reinicia o processo inteiro — inclusive as conversões de áudio que estavam no meio do caminho.
Não é um problema exclusivo do Davna. Neste próprio blog, cada site da Netlify só faz build quando muda o caminho dele; o raciocínio é o mesmo, só que no nível do site e não do container.

Separar não é mudar de máquina

Existe um argumento que aparece em quase toda conversa sobre o FFmpeg, e que eu mesmo cheguei a usar: "converter áudio consome muita CPU, então ele não pode disputar espaço com a API". Parece um motivo para separar. Não é.
Primeiro, porque mesmo dentro do monolito o FFmpeg nunca disputaria o event loop do Node.js. Ele é chamado por spawn, como um processo à parte do sistema operacional. O Node só escreve o áudio no stdin e lê o resultado do stdout; quem queima CPU é o processo do FFmpeg, não a API.
Segundo, porque hoje os dois containers rodam na mesma EC2, junto com o MongoDB, orquestrados pelo Docker Compose. A CPU é a mesma. Se a máquina inteira ficar sem fôlego, os dois sofrem juntos — exatamente como sofreriam dentro de um monolito.
Separar um microsserviço é uma decisão de estrutura: ele passa a ter a própria imagem, o próprio deploy e um contrato que atravessa a camada de comunicação. Onde ele roda é uma decisão de infraestrutura, tomada depois e por outros motivos. A separação não resolve disputa de CPU; ela só torna barato resolvê-la no dia em que ela existir. Como a API fala com o FFmpeg apenas pelo SDK, por HTTP, mover o serviço para outra máquina, escalá-lo sozinho ou colocá-lo numa Lambda é trocar uma URL na configuração — e não uma refatoração.

Por que o resto continuou monolito

Se separar o FFmpeg trouxe tantas vantagens, por que não separar também a sala de aula, a identidade, o monitoramento?
Porque esses módulos não têm nenhum dos dois motivos acima. Eles não carregam binário pesado e mudam no mesmo ritmo do resto da API. E eles já estão separados onde importa: no código. Cada um é um módulo em modules/, com seus handlers, seus contratos e seus repositórios. A fronteira existe — ela só não passa pela rede.
E passar pela rede tem um custo que costuma ser subestimado. Uma chamada de função:
  • não tem latência de rede;
  • não falha por timeout;
  • não precisa de retry, nem de idempotência;
  • não precisa de versionamento de contrato;
  • é verificada pelo compilador.
Uma chamada HTTP entre serviços não tem nenhuma dessas garantias. Cada fronteira que vira rede passa a exigir autenticação entre serviços, tratamento de falha parcial, observabilidade distribuída e um deploy coordenado quando o contrato muda.
Por isso, a minha regra é simples: um pedaço sai do monolito quando tem uma dependência ou um ritmo de deploy diferente do resto. "Ficar mais organizado" não entra na lista — organização se resolve com módulos. E "consumir muita CPU" também não: isso se resolve escolhendo onde cada coisa roda, o que só fica fácil depois que a fronteira de rede já existe.

Quanto custa cada um: EC2 contra API Gateway e Lambda

Custo é onde as discussões costumam virar torcida. Vou tentar fazer o contrário: mostrar onde cada modelo é barato e onde ele fica caro.
Os preços abaixo são os sob demanda de us-east-1 (Norte da Virgínia), conferidos em outubro de 2026, sem considerar o free tier.
  • EC2 t3.small (2 vCPU, 2 GB) → ~US$ 0,0208 por hora, ou ~US$ 15 por mês;
  • Lambda, requisições → US$ 0,20 por milhão;
  • Lambda, computação (x86) → ~US$ 0,0000167 por GB-segundo;
  • API Gateway (HTTP API) → US$ 1,00 por milhão de requisições.

O FFmpeg como Lambda

Imagine uma conversão que leva 2 segundos numa Lambda de 1 GB de memória:
  • computação: 2 GB-s × US$ 0,0000167 ≈ US$ 0,0000333;
  • requisição da Lambda: ≈ US$ 0,0000002;
  • requisição do API Gateway: ≈ US$ 0,000001;
  • total: perto de US$ 0,000035 por conversão.
Dividindo os US$ 15 da t3.small por esse valor, a conta empata em torno de 430 mil conversões por mês — algo como 14 mil por dia, ou dez por minuto, o dia inteiro.
Abaixo disso, a Lambda é mais barata, e com muita folga: com poucos alunos, o custo do FFmpeg seria de centavos por mês, contra os mesmos US$ 15 fixos da instância ligada de madrugada sem ninguém usando. Acima disso, a conta da Lambda cresce em linha reta, enquanto a instância continua custando o mesmo até o limite dela.

O que a tabela não mostra

Os dois lados têm custos escondidos, e é aqui que a comparação fica justa:
Na EC2:
  • Créditos de CPU → a família t3 é burstable. Converter áudio o dia inteiro consome os créditos, e a partir daí ou a CPU é limitada, ou a AWS cobra o excedente (no modo unlimited);
  • Disco, backup e transferência → o EBS e a saída de dados entram na conta separadamente;
  • O seu tempo → atualizar o sistema operacional, renovar certificados, cuidar do Docker, do runner self-hosted e do espaço em disco. Não aparece na fatura, mas é custo.
Na Lambda:
  • Limites de execução → até 15 minutos por chamada e até 250 MB descompactados entre função e layers. O FFmpeg cabe, mas o ffprobe-static com todas as plataformas não — teria de ir só o binário de Linux, ou a função como imagem de container (até 10 GB);
  • Tamanho do áudio → numa chamada síncrona, a requisição e a resposta da Lambda têm até 6 MB cada. Uma fala de um minuto cabe com folga; um áudio longo teria de passar pelo S3, com a Lambda lendo e gravando lá em vez de receber o arquivo no corpo;
  • Cold start → a primeira conversão depois de um período parado é mais lenta, e o aluno percebe;
  • A arquitetura em volta → o resto do Davna não se encaixa tão bem. A API usa Socket.IO, e conexões persistentes na Lambda exigem o WebSocket API do API Gateway, com outro modelo de programação e cobrança por mensagem e por minuto conectado. O MongoDB também sofre com dezenas de funções abrindo conexões ao mesmo tempo.
Ou seja: para o FFmpeg, que é uma função pura — áudio entra, áudio sai —, a Lambda é uma candidata excelente. Para a API, que mantém sockets abertos e conversa com um banco o tempo todo, uma instância sempre ligada é o encaixe natural. A resposta muda por pedaço do sistema, não pelo sistema inteiro.

Testabilidade: o que muda em cada lado

A frase "microsserviços são mais fáceis de testar" é meia verdade. Cada lado é mais fácil de testar em uma coisa.

No monolito, o fluxo inteiro cabe na memória

Na monolith.api, os handlers são funções puras que recebem as dependências por Reader. Para testar um turno inteiro da conversa — receber o áudio, transcrever, responder, consumir o limite —, eu injeto fakes: um repositório em memória, um storage em memória e a FakeAI no lugar da OpenAI. O teste atravessa vários módulos sem abrir uma porta, sem subir um container e sem gastar um centavo.
E quando um contrato interno muda, o TypeScript avisa em todos os lugares que o usam, antes de qualquer teste rodar.

No microsserviço, cada lado se testa sozinho — e o meio fica descoberto

O multimedia.processing tem os próprios testes: os handlers de conversão e de metadados, e os serviços que chamam o FFmpeg de verdade. A monolith.api testa o seu lado com um fake do SDK. Os dois lados ficam verdes.
O problema é o que fica entre eles. Olhe como o SDK lê a duração que volta do serviço:
duration: response.headers.get('x-file-duration') ? parseFloat(response.headers.get('x-file-duration') as string) : 0,
Se um dia o serviço renomear esse cabeçalho, nenhum compilador vai reclamar e nenhum teste unitário vai quebrar. O SDK vai devolver duração zero — e o controle de uso, que era o motivo de tudo isso existir, vai parar de funcionar em silêncio.
Esse é o preço de uma fronteira de rede: o contrato deixa de ser um tipo e passa a ser uma convenção. Por isso o pipeline do Davna sobe os containers com Docker Compose e roda testes de integração antes de publicar as imagens. São testes mais lentos e mais frágeis que os unitários, mas são os únicos que enxergam o meio do caminho.
Resumindo a troca:
  • Monolito → testa melhor o fluxo inteiro; os contratos internos são verificados pelo compilador;
  • Microsserviço → testa melhor cada parte isolada; os contratos entre serviços precisam de testes próprios (integração ou contrato) para não quebrarem em silêncio.

Quando o próximo pedaço vai sair

Se você chegou até aqui esperando que eu anuncie a migração do Davna inteiro para microsserviços, vou decepcionar. Migrar não é uma vontade minha — é uma resposta a sinais que o sistema dá. E eu separo esses sinais em dois tipos, porque eles pedem respostas diferentes.
Os que pedem mudar de máquina, e não de estrutura:
  • A CPU da instância → se as conversões começarem a disputar a máquina com a API, o FFmpeg vai para outra instância ou para uma Lambda. O código não muda: a API só vai perceber uma URL diferente na configuração;
  • A fila de áudios → se os alunos começarem a esperar pela conversão, o caminho natural é uma fila (SQS) na frente do FFmpeg, para absorver picos sem perder mensagens.
Os que pedem separar um novo serviço:
  • Ritmos diferentes de mudança → se uma parte do sistema começar a mudar num ritmo muito diferente do resto, ou a ser cuidada por outra pessoa, ela ganha o próprio deploy;
  • Dependências que ninguém mais usa → se uma parte começar a arrastar bibliotecas ou binários que só ela precisa, como aconteceu com o FFmpeg;
  • Requisitos diferentes de disponibilidade → se uma parte precisar continuar no ar quando outra cair, elas não podem ser o mesmo deploy.
E o próximo candidato já está no roadmap: o orquestrador do turno da conversa. Hoje, transcrever, gerar a resposta da professora, traduzir e sintetizar a voz acontece dentro do handler do socket. É a parte do sistema que mais muda: o prompt da professora, a ordem das etapas, quais delas podem rodar em paralelo, qual modelo usar em cada uma. Ele já começou a ganhar forma no repositório, como interaction.conductor, mas ainda não está em produção.
Repare que ele passa no mesmo critério do FFmpeg, por outro lado: o FFmpeg quase nunca muda, e o orquestrador muda o tempo todo. Em ambos os casos, o ritmo de deploy é diferente do resto da API — ajustar um prompt não deveria exigir subir de novo o login. O resto — identidade, monitoramento, a própria sala de aula — continua junto, porque continua sem motivo para sair.

Conclusão

O FFmpeg não virou microsserviço porque microsserviços são modernos. Virou porque carregava um binário de centenas de megabytes e mudava num ritmo completamente diferente da API. Dois motivos concretos, que dá para medir. A CPU, que parecia o motivo mais óbvio, não era motivo nenhum: ela é um problema de onde o serviço roda, e a separação só deixou esse problema mais fácil de resolver quando ele aparecer.
O resto do Davna continuou monolito pelo mesmo critério, aplicado ao contrário. E continua organizado: os módulos têm fronteiras claras, contratos explícitos e testes que atravessam tudo em memória. Quando um deles tiver um motivo concreto para sair, a fronteira já está desenhada — só falta trocar uma chamada de função por uma chamada de rede, e aceitar conscientemente tudo o que vem junto com ela.
No fim, a pergunta que eu faço não é "monolito ou microsserviço?". É "o que este pedaço tem de diferente do resto?". Se a resposta for "nada", ele fica onde está.
Foto de Yago Marinho
Yago MarinhoEngenheiro de Software Sênior. Construo produtos com IA, do design ao deploy.
Ver portfólio ↗