Leo

Sobre tecnologia...

40 mil reais anuais de redução de custo, refatorando apenas uma classe.

1 week ago · 7 MIN READ

Introdução

Neste post, vou mostrar como uma refatoração relativamente simples nos permitiu reduzir significativamente os custos de infraestrutura. O objetivo não é apresentar uma técnica revolucionária, mas compartilhar um caso real de otimização em que a escolha da ferramenta certa para um problema específico resultou em uma economia de mais de R$ 40 mil por ano.

Ao longo do texto, utilizarei alguns conceitos relacionados a mensageria e também mostrarei um pouco de como funciona a arquitetura de microsserviços da Convenia.

Quando eu mencionar um listener, estarei me referindo a um consumidor de mensagens (subscriber) do padrão Publish/Subscribe (Pub/Sub).

Se você quiser entender melhor como funcionam os listeners ou conhecer mais detalhes da arquitetura da Convenia, recomendo a leitura do artigo Event Driven na Convenia.

Entendendo o nosso custo

Na Convenia, utilizamos o Laravel na maioria dos serviços de backend. O Larave é um framework construído em PHP, com um ecossistema muito amplo e coerente. Como o time tem muita proficiência com esse framework e com a linguagem PHP, às vezes também criamos outras ferramentas em PHP para nos auxiliar no dia a dia.

Gostamos de manter a stack homogênea porque, ao longo do tempo, entendemos que isso simplifica muito as tarefas que precisamos executar durante o ciclo de desenvolvimento. Temos dezenas de pipelines de CI/CD que são praticamente idênticos e cerca de 80 containers sendo executados com a mesma imagem. Isso facilita bastante, principalmente quando precisamos realizar alguma ação de forma generalizada.

Na Convenia, temos um serviço chamado "Cyberhook", cujo objetivo é funcionar como uma espécie de conversor entre os protocolos AMQP e HTTP. O Cyberhook registra listeners em filas do RabbitMQ e, assim que uma mensagem chega a uma fila, um desses listeners é responsável por enviá-la para um endpoint HTTP pré-configurado.

listener-diagram.png diagrama mostrando como o listener ouve AMQP e posta em um endpoint HTTP

No momento em que escrevo esse post temos 415 listeners, isso significa que temos 415 formas de reagir aos eventos que acontecem em toda plataforma da Convenia, inclusive serviços externos, essa é a base da nossa mensageria. Para executar esse listener utilizamos o supervisor, que além de executar o processo é responsável por manter ele vivo, se alguma exceção ocorrer o supervisor reinicia o processo:

[program:partners.disc.assessment.session.created.community]
process_name=%(program_name)s_%(process_num)02d
command=php artisan event 409
autostart=true
autorestart=true
startretries=5
user=app
numprocs=1
stdout_logfile=/dev/stdout
stdout_logfile_maxbytes=0
redirect_stderr=true

A configuração acima é um exemplo de um listener configurado no supervisord, o que o cyberhook faz é, no momento do build do container, gerar esse arquivo de configuração com 415 entradas, uma para cada listener da plataforma. Para configurar novos listeners o Cyberhook conta com uma interface onde podemos configurar qual evento vamos ouvir e em qual endpoints vamos postar esse evento, em outras palavras conseguimos configurar o comportamento do listener:

cyberhook-interface.png imagem mostrando a interface do cyberhook onde os listeners são cadastrados

Perceba que, na interface, temos um seletor chamado "Scale". Esse campo configura a propriedade numprocs do Supervisor, definindo quantos processos serão executados simultaneamente para cada listener. Isso é importante para eventos de alta recorrência e é uma ótima característica da nossa stack. O Cyberhook acaba atuando como uma espécie de buffer, impedindo que picos de requests sejam propagados internamente entre os serviços.

Se um serviço emitir 300 mil eventos, não faremos 300 mil requisições simultâneas aos serviços de destino. A concorrência será controlada pelo seletor "Scale", se o Scale não for suficiente para dar vazão para todas as mensagens, o excedente permanecerá enfileirado no RabbitMQ.

Com essa configuração, teremos no mínimo 415 processos de listeners, que nada mais são do que comandos do Laravel utilizando o Convenia Pigeon para consumir as mensagens das filas.

Se pararmos para analisar, um processo em PHP já é relativamente custoso em termos de memória, pois precisamos carregar o interpretador do PHP, que por sua vez carrega diversas bibliotecas utilizadas pela aplicação. Como se isso não bastasse, ainda carregamos o Laravel, que traz um conjunto bastante amplo de ferramentas para oferecer uma excelente experiência de desenvolvimento para aplicações web. No fim, estamos falando de processos que podem consumir cerca de 30 MB de memória cada. E temos 415 deles... kkk

docker-stats-artisan.png imagem do outputo do comando docker stats mostrando o container utilizando 15.9 GB

Essa é a imagem do comando docker stats mostrando quantos gigas o Cyberhook estava consumindo.

O grande problema é que na Convenia executamos os workloads 100% na AWS e se contarmos esses 15.9 GB mais algumas ferramentas necessárias, incluindo o sistema operacional da instancia, chegaremos a conclusão que vamos precisar de instancias t3.2xlarge ou similares para acomodar esse serviço.

Uma instancia dessas realmente não é problema, mas temos um ambiente de desenvolvimento provisionado sob demand, onde conseguimos entregar uma convenia completa com dado semeados exclusiva para cara desenvolvedor, com apenas um click em um workflow do github. Em um dia normal, para acomodar todas as sprints e processos de QA, precisamos de cerca de 12 instancias t3.2xlarge que permanecem ligadas durante o horário comercial, isso sim pode trazer um custo relevante.

O que foi necessário refatorar?

Agora o problema ficou óbvio: temos dezenas de serviços rodando na mesma instância, cada um consumindo menos de 300 MB de memória, mas existe um único serviço ocupando quase 16 GB.

A parte menos óbvia é que a classe responsável por esse listener permaneceu praticamente intocada nos últimos anos. Ela continua funcionando exatamente da mesma forma, o que nos levou à conclusão de que, nesse caso, não precisamos da excelente experiência de desenvolvimento oferecida pelo PHP, muito menos de todo o ecossistema do Laravel. Afinal, estamos falando de uma aplicação de linha de comando (CLI) que, na prática, acaba desperdiçando grande parte dos recursos disponibilizados pelo framework.

Para resolver esse problema de consumo de memória, decidimos substituir esse listener, que era implementado como um comando do Artisan, por um CLI escrito em Go. Essa é uma excelente escolha por se tratar de uma linguagem compilada, capaz de gerar um binário minimalista que carrega apenas as bibliotecas necessárias para executar sua função, em vez de carregar um interpretador completo, como acontece com PHP ou Python.

Outra estratégia que se mostrou bastante promissora foi passar, como parâmetros do processo, todas as informações necessárias para executar o listener. Na implementação anterior, feita com o Artisan, era necessário informar apenas o ID do listener armazenado no banco de dados. A partir desse ID, o próprio comando consultava o banco, carregava o registro e iniciava o processamento.

Ao fornecer todas essas informações diretamente na inicialização do processo, eliminamos a necessidade de acesso ao banco de dados. Com isso, o novo CLI não precisa carregar bibliotecas de acesso a banco, tornando o processo ainda mais leve e reduzindo ainda mais o consumo de memória.

Antes o comando era executado dessa forma:

[program:partners.disc.assessment.session.created.community]
process_name=%(program_name)s_%(process_num)02d
command=php artisan event 409
autostart=true
autorestart=true
startretries=5
user=app
numprocs=1
stdout_logfile=/dev/stdout
stdout_logfile_maxbytes=0
redirect_stderr=true

Agora ele é executado dessa forma:

[program:partners.disc.assessment.session.created.community]
process_name=%(program_name)s_%(process_num)02d
command=./native-listener -queue partners.disc.assessment.session.created.community.cyberhook -routing-key partners.disc.assessment.session.created -url http://community.convenia.local/api/v1/target-service
autostart=true
autorestart=true
startretries=5
user=app
numprocs=1
stdout_logfile=/dev/stdout
stdout_logfile_maxbytes=0
redirect_stderr=true

Após a refatoração, como já era esperado, cada listener passou a consumir menos de 1 MB de memória, e o container completo passou a utilizar cerca de 700 MB:

docker-stats-golang.png imagem do outputo do comando docker stats mostrando o container utilizando 724,9 MB

Essa redução de consumo nos permitiu diminuir o tamanho das instâncias utilizadas nos ambientes de desenvolvimento para instâncias t3.large, gerando uma economia aproximada de R$ 30 mil por ano (considerando 12 instâncias ligadas apenas durante o horário comercial).

No ambiente de produção, a redução foi ainda maior. Como essa instância executa apenas esse serviço, foi possível migrá-la para uma instância t3.small, gerando uma economia adicional de aproximadamente R$ 11 mil por ano.

Somando os ambientes de desenvolvimento e produção, a iniciativa resultou em uma redução de custos superior a R$ 40 mil por ano, além de diminuir significativamente o consumo de memória da infraestrutura.

grafico mostrando a redução de custo obtida no ultimo mês. De aproximadamente 500 dolares

A imagem acima mostra a redução de custos obtida apenas nos ambientes de desenvolvimento. Antes de junho de 2026, o custo médio mensal era de aproximadamente US$ 940. A partir de junho, esse valor caiu para cerca de US$ 400 por mês, o que representa uma economia anual superior a R$ 30 mil, considerando a conversão para reais.

Para consolidar a redução completa podemos adicionar cercade 11 mil reais auais provenientes da redução da instancia de produção.

Porque nao utilizar um unico processo

Uma pergunta que pode surgir é: por que não aproveitar a concorrência do Go para criar um único processo mestre capaz de disparar os webhooks de forma concorrente?

Embora essa arquitetura seja ainda mais eficiente em termos de consumo de memória, e esse tipo de abordagem seja um dos grandes pontos fortes do Go, preferimos manter a arquitetura atual, baseada em processos estáticos. Além de ser mais simples, ela preserva a vantagem do buffer mencionada anteriormente, impedindo que picos de carga sejam propagados para os serviços de destino.

Isso não significa que abrimos mão da escalabilidade. É perfeitamente possível tornar essa solução autoescalável; apenas optamos por não incorporar essa responsabilidade ao listener. Dessa forma, o listener continua responsável apenas por consumir e processar mensagens, enquanto o componente de autoscaling pode evoluir de forma independente, mantendo cada elemento da arquitetura responsável apenas pelo que realmente lhe compete.

Conslusão

Essa refatoração nos deixou com duas conclusões importantes:

  1. FinOps é uma disciplina que precisa ser exercitada continuamente. Com frequência, nos deparamos com oportunidades "escondidas" de redução de custos. Elas estão por toda parte; o desafio é desenvolver um olhar treinado para identificá-las.

  2. Não existe bala de prata. Por mais que o PHP tenha evoluído nos últimos anos e hoje permita desenvolver até mesmo aplicações desktop e mobile utilizando o NativePHP, isso não significa que ele seja a melhor alternativa para todos os cenários. Em alguns casos, vale a pena abrir mão da familiaridade com uma tecnologia específica para utilizar outra stack que resolva aquele problema de forma mais eficiente. Escolher a ferramenta certa para cada contexto continua sendo uma das decisões mais importantes da engenharia de software.

···

Leonardo Lemos


comments powered by Disqus


Proudly powered by Canvas · Sign In