Aumentando o desempenho de workers do Sidekiq com Go
A Resultados Digitais vem crescendo em ritmo acelerado, o que tem nos levado a encarar cada vez mais desafios em nossa engenharia relacionados com escalabilidade e disponibilidade.
Neste post vamos contar como reescrevemos parte de um dos nossos serviços em Ruby utilizando Go, o que nos ajudou a aumentar a velocidade de processamento dos nossos eventos, diminuir o consumo de memória e reduzir a latência de entrega da informação para nosso cliente.
O problema
O nosso crescimento é acelerado e constante, o que nos leva a conseguir estimar como será o volume de dados que vamos ter que lidar durante os meses seguintes. Mas mesmo em casos onde é possível criar estimativas, existem cenários em que variáveis podem mudar drasticamente a maneira como os clientes usam o produto. Para muitas empresas que atuam com a internet, uma dessas variáveis tem um nome: Black Friday.
A Black Friday de Novembro de 2019 foi desafiadora, ao final do mês contabilizamos mais de 1 bilhão de emails enviados pelos nossos clientes. Isso nos mostrou diversas oportunidades de melhorias no nosso ecossistema.
Uma das nossas grandes oportunidades está no sistema que cuida do processamento de eventos de email, chamado Notify. Toda vez que você recebe um email, abre, clica, marca como spam e outras interações, isso gera um evento para que o nosso cliente tenha consciência de como os contatos dele estão interagindo com as campanhas de Email Marketing. Depois da Black Friday nós criamos ações de curto prazo e de longo prazo para tornar o Notify ainda mais resiliente. Uma das ações de curto prazo que colocamos no ar ainda em dezembro foi otimizar parte do processamento de eventos usando workers escritos em Go.
Analisando opções e como definimos uma solução
Nossa arquitetura atual se baseia no modelo produtor/consumidor em que a aplicação web que recebe os eventos utiliza o Sidekiq para enfileirar as requisições no Redis, que serão consumidas por um worker que vai processar os eventos. No dia da Black Friday nossa aplicação recebeu tantos eventos em um curto período de tempo que mesmo escalando os workers para o dobro de instâncias eles não conseguiam atingir a vazão necessária.
Ficou claro para a equipe que o gargalo estava no Redis. Mesmo podendo escalar os workers ainda assim havia um limite que não podíamos ultrapassar pois comprometeria outros recursos (banco de dados por exemplo). Substituir o Redis implicaria em reescrever todas as nossas aplicações e alterar a arquitetura, o que seria feito posteriormente pois levaria bastante tempo e precisávamos de uma ação de curto prazo. Assim, como conseguiríamos aumentar a performance da nossa aplicação em um curto período de tempo sem alterar a arquitetura?
A primeira opção seria identificar gargalos no código do worker. Porém, ele era bem simples: apenas consumia os eventos da fila, persistia no banco de dados e enviava os eventos para outra fila. Não havia muito o que fazer.
A segunda opção seria aumentar a performance do banco, que era o local onde o worker gastava a maior parte do tempo de processamento (fine-tunning ou sharding, por exemplo). Mas esse mesmo banco era utilizado por outras aplicações, e qualquer alteração poderia ocasionar impactos imprevisíveis nas outras aplicações.
A terceira opção surgiu através de um experimento feito por um dos membros do time: existe uma biblioteca chamada Go-Workers que permite a escrita de workers em Go compatíveis com o Sidekiq e que replica exatamente as mesmas funcionalidades do original como retry, controle de concorrência e algumas features da versão pró como filas confiáveis com BRPOPLPUSH e graceful shutdown.
Por que Go?
O Sidekiq é uma biblioteca rápida e robusta, utilizada para execução de tarefas em background e muito utilizada no mundo Ruby. Apesar de tudo isso, ela possui uma limitação intrínseca da linguagem: não tem paralelismo de execução no mesmo processo.
Mas o que isso significa? Que se você executar um worker com 10 threads em uma máquina com cpu de 4 cores, o worker vai utilizar apenas um core, e não importa quantas threads você esteja utilizando. Existem estratégias que tentam contornar essa situação (como executar diversos processos), mas nenhuma delas resolve o problema de fato, apenas melhoram um pouco a situação.
Isso acontece por causa de um mecanismo de bloqueio chamado GIL, Global Interpreter Lock, utilizado tanto no Ruby quanto no Python. O GIL impede duas threads de serem executadas ao mesmo tempo, um mecanismo de segurança para evitar compartilhamento de código não thread-safe entre diferentes threads.
Go por outro lado é uma linguagem que não possui esse tipo de limitação, ela possui total suporte a concorrência e paralelismo através de primitivas como goroutines e channels, isso sem contar o fato da linguagem ser compilada, que por si só já garante um desempenho melhor do que o de uma linguagem interpretada. Se você criar um worker em Go com 10 goroutines (que são threads mais “leves”), em um processador com 4 núcleos, o worker irá utilizar todos eles.
Somado isso a alguns outros critérios que levantamos, Go parecia ser a escolha perfeita:
- Precisávamos aumentar a capacidade do Notify em dar vazão aos eventos que ele recebia;
- A solução tinha que sair em duas semanas, pois o problema era latente e podíamos voltar a ter alguma instabilidade com as ações de fim de ano;
- O ideal era dar preferência para soluções que o time já tivesse familiaridade, pois como era uma solução de curto prazo não era ideal que uma solução estranha fosse adicionada ao nosso pipeline;
- Um dos membros do nosso time já possuía familiaridade com a linguagem;
- Estamos adotando essa linguagem em outros serviços;
- Com ela seria possível alcançar uma performance melhor para o nosso cenário. Já havíamos feito uma PoC que indicava isso.
Olhando para nossa arquitetura
O desenho atual da nossa solução é em sua totalidade escrito em Ruby. Tanto nossa aplicação web, em Sinatra, quanto os workers que processam os eventos do Sidekiq.
Se tratando de uma ação de curto prazo e tendo em mente que iríamos reescrever essa solução (vamos falar disso em outro post), o melhor caminho que podíamos tomar era otimizar nos pontos de saída. Optamos por otimizar o NotifyJob pois era nele que teríamos mais ganho. Nosso objetivo era não mudar em nada a arquitetura, só colocar um serviço para consumir eventos mais rapidamente.
O que ganhamos?
- Performance
Nosso primeiro ganho foi sem dúvida performance. Em um teste de processamento de 10 mil eventos, reduzimos de 25s com Ruby, para 6s com Go:
Claro, essa informação por si só poderia ser enganosa, já que um ambiente local tem suas particularidades e cargas diferentes que produção. Nesse caso também optamos por olhar a latência da fila em produção.
Os gráficos claramente mostram que a velocidade com que o novo worker processa os eventos é muito maior, logo, a vazão de processamento desses aumentou bastante.
- Economia de memória
Uma grata surpresa quando colocamos o novo worker no ar, foi a quantidade de memória que ele consumia. Enquanto o worker antigo escrito em Ruby precisava de no mínimo 100mb, e em alguns casos até 500mb para processar a carga, a nova aplicação consumia no pior caso apenas 46mb, e a mediana ficou em 11mb!
- Imagem do Docker menor
Aqui na RD executamos nossas aplicações em containers docker que rodam no Kubernetes. O tamanho da imagem é um fator importante, principalmente quando se faz deploy constante, que se replica em diversos pods. Quanto menor, melhor. Logo, vale mencionar que outro benefício inesperado foi o de reduzir muito o tamanho da imagem original da aplicação:
A imagem do worker original em ruby ocupava 1.12gb. Aplicando uma estratégia de multi-stage-build no Docker, aproveitando-se do fato de que o Go é uma linguagem compilada e só precisamos do binário para rodar a aplicação, conseguimos diminuir esse tamanho para incríveis 19.7mb.
Nem tudo são flores
Ficamos muito felizes com o desempenho da nova aplicação, mas tivemos alguns desafios também:
- Monitoramento
Mesmo a documentação do Datadog sendo bem completa, colocar todos os componentes para funcionar juntos não foi tão simples assim. Tivemos algumas dificuldades em relação ao processo assíncrono e o envio de todas as métricas ao Datadog. Só conseguimos ajustar os detalhes de execução por span no final de Janeiro.
Métrica do Datadog mostrando a separação da execução por serviços internos
- Dados não tão estruturados
O Notify cuida de todos os eventos de email, mas nem sempre eles são disparados da mesma fonte. Eles podem vir de campanhas, automações, emails de boas vindas, e mais outras fontes. Cada uma dessas pode adicionar informações específicas para conseguir identificar o evento depois. Isso trouxe a complexidade de lidar com diferentes tipos de dados, já que o Go faz uso de estruturas que não são tão flexíveis assim. É possível fazer uso de interface{} criando algumas generalizações, mas isso traz complexidade e diminui a manutenibilidade de algumas partes da aplicação.
Conclusão
O time de engenharia da Resultados Digitais vem cada vez mais apostando no Go para situações onde queremos melhorar a performance. Isso tem trazido bons resultados, tanto no quesito da performance como na redução de custos de infraestrutura.
Esse artigo foi escrito por Email Team, Plataforma — Resultados Digitais
Se você quiser saber mais sobre o que estamos fazendo com Go por aqui, dá uma olhada nesses artigos: