- 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.
Este artigo continua o Davna AI: construindo um parceiro de conversa para treinar inglês. O código está no repositório do projeto.
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çãoO peso que vem junto com o binárioCI/CD: subir tudo a cada mudançaSeparar não é mudar de máquinaPor que o resto continuou monolitoQuanto custa cada um: EC2 contra API Gateway e LambdaO FFmpeg como LambdaO que a tabela não mostraTestabilidade: o que muda em cada ladoNo monolito, o fluxo inteiro cabe na memóriaNo microsserviço, cada lado se testa sozinho — e o meio fica descobertoQuando o próximo pedaço vai sairConclusão
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-staticcom 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á.
Yago MarinhoEngenheiro de Software Sênior. Construo produtos com IA, do design ao deploy.