Servindo 1MM de mensagens simultâneas com RabbitMQ

Este artigo aborda o uso do RabbitMQ, um software de mensagens com código aberto que se tornou o coração de toda a comunicação assíncrona no Grupo Boticário. Por meio dele, aumentamos nossa tolerância às falhas em um contexto onde mais de 60 serviços e inúmeros microsserviços interagem entre si e atingimos a marca de 1MM de mensagens em um teste de carga relevante para a empresa.

Clayton Cavaleiro
gb.tech
16 min readMay 21, 2024

--

Antes de tudo que tal ficarmos na mesma página?

Se o assunto de mensagens assíncronas te deixa com dúvidas ou desconfortável para prosseguir, veja esse artigo maravilhoso do Matheus Fidelis

Sobre o Grupo Boticário

Somos o maior ecossistema de beleza da américa latina, onde o time que trabalho é responsável pelas lojas de e-commerce de Beleza na Web, Boticário e demais lojas da holding do Grupo.

Entendendo o status inicial

Antes de iniciarmos com o overview de todo o processo que iremos compartilhar, vamos olhar pelo que tínhamos inicialmente:

arquitetura inicial do rabbitMQ
Arquitetura inicial do RabbitMQ

Cluster em ec2 manual e com possibilidade de downtime se um dos nós caírem (não trabalhamos com quorum de mensagens).

Problemas:

  • As filas por não trabalharem nem com Mirror e nem Quorum, não tinham resiliência quando caía um nó, isso significa que quando um nó sai do ar as filas que pertenciam ao nó ficam indisponíveis.
  • Muitas vezes não era simplesmente recuperar o nó, havia um processo árduo de tentar recuperar arquivos corrompidos quando o nó resetava bruscamente. Isso demandava entender perfeitamente toda a estrutura de persistência de mensagem do RabbitMQ.
  • A queda de um nó representava perda de no mínimo 1/3 das aplicações, fora os reflexos subsequentes por dependências indiretas.
Um nó corrompeu, vamos para a war-room tentar resolver?

Diante desse cenário, associado à frequência maiores de incidentes no cluster de RabbitMQ que nos ajudou a suportar por uns bons anos, decidimos ir para a prancheta e repensar em um novo Cluster.

Antes de desenharmos o cluster vamos conhecer nossas "constraints"

Definimos como "constraints" os limites que temos no nosso uso do dia a dia do cluster e principalmente nosso atual conhecimento de time nas tecnologias a serem utilizadas.

Inicialmente, pensamos em usar o operador de RabbitMQ para kubernetes, mas devido ao nível de criticidade do serviço e ao estágio inicial que o operador estava implementado (fizemos o cluster no início de 2023), definimos que o primeiro requisito seria que não iríamos colocar o cluster debaixo do kubernetes por questões de simplicidade. Embora o operador resolvesse muitos problemas, iria adicionar uma camada de complexidade. Logo temos nossa primeira constraint:

  • Reduzir ao máximo participação de Orquestradores na solução

No nosso cluster, usamos muito de um plugin chamado delayed messages que em resumo leva ao RabbitMQ a funcionalidade de atrasar o processamento de mensagens que já existem em outras soluções como SQS.

funcionamento de delay no SQS

Usamos esse comportamento principalmente para aumentar a resiliência das aplicações, criando fluxos de retentativas, como processos de pagamentos.

Legal o plugin, mas isso pode implicar na decisão de arquitetura? a resposta curta é sim, mas esta é a resposta longa:

O AmazonMQ que é uma solução já bem consolidada fornecido pela AWS, possui suporte ao RabbitMQ, mas não tem suporte ao plugin de delayed messages, e até presente data que esse artigo foi feito, ainda não possui suporte.

Com isso vem a segunda constraint:

  • Devemos manter suporte ao plugin de delayed messages

Essa limitação acabou retirando da lista de possíveis soluções o AmazonMQ

As demais constraints se limitam a como a solução possa escalar:

  • Cluster altamente disponível e com capacidade de tolerância a queda de ao menos uma zona de disponibilidade.
  • zerar incidentes de estouro de disco, mesmo com operações errôneas como uma fila crescer muito durante algum tempo
  • Operações de mudança no cluster precisam ser feitas sem indisponibilidade de nenhum serviço durante o processo

Somando com as duas primeiras temos 5 constraints:

  • Reduzir ao máximo participação de Orquestradores na solução
  • Devemos manter suporte ao plugin de delayed messages
  • Cluster altamente disponível e com capacidade de tolerância a queda de ao menos uma zona de disponibilidade.
  • zerar incidentes de estouro de disco, mesmo com operações errôneas como uma fila crescer muito durante algum tempo
  • Operações de mudança no cluster precisam ser feitas sem indisponibilidade de nenhum serviço durante o processo

Primeira versão da Solução

Um estudo foi feito e chegamos a essa primeira solução:

Primeira versão da solução

Essa primeira solução foi toda construída usando Autoscaling Group e Application Load Balancer da AWS. O Autoscaling possui algumas particularidades.

  • Note que, ao invés de usarmos 1 Autoscaling Group com número de instâncias fixos, optamos por usar 3 Autoscaling Groups com 1 nó cada. Cada Autoscaling Group representa uma az (zona de disponibilidade) fixa.
  • A estratégia visa controle total de onde cada instancia é provisionada e definimos que em cada az teremos nada mais e nada menos que uma instância, e a função do autoscaling não é de escalar, e sim de manter sempre 1 nó ativo em cada az. Além disso, a disponibilidade respeita inclusive como a AWS projeta sua tolerância à falha.
  • Para aumentar nossa tolerância à falha, temos que permitir que as filas sejam altamente disponíveis, pois na primeira solução, embora estávamos em cluster, as filas ficavam presas aos nós que eram criados, para isso escolhemos o modelo Quorum de replicação. O modelo Quorum, além de oferecer performance superior, conforme descrito nesse artigo, cada fila é totalmente independente para gerir sua alta disponibilidade graças ao seu algoritmo de raft consensus (já utilizamos amplamente em muitas soluções altamente disponíveis).
podemos observar nas propriedades da fila Quorum que ele tem mais de um servidor e um eleito como "líder"

Problemas da primeira solução

Uma vez que começamos a provisionar a primeira versão dessa solução, tivemos dois problemas:

  • Embora as filas sejam altamentes disponíveis, o plugin do delayed message armazena a mensagem na exchange, que por sua vez por ser projetado pra ser efêmero, acaba tendo persistência que é perdida enquanto estiver mensagem pendente e se esse nó for trocado em caso de perda.
  • Quando um nó restaura no cluster, embora ele venha com mesmo hostname do nó anterior, ele assume o nome do dns da rede interna da AWS (por exemplo ip-172–10–1–1.us-east-1.compute.internal), e por sua vez por não restaurar com mesmo nome, mesmo que tenhamos como recuperar as informações do nó, ele não restaura, parecendo que um nó foi perdido e entrou outro no lugar, ao invés de mostrar que o nó perdido foi recuperado. Outro problema acontece por conta disso mas iremos falar mais na frente quando resolvermos o problema citado no item anterior

Mantendo as informações não efêmeras em um cluster de RabbitMQ

Para resolver o primeiro problema listado no tópico anterior teremos que considerar que temos informações que precisam ser persistidas e recuperadas quando o nó é alterado e substituído.

Podemos resolver por duas abordagens:

  • Usar um volume EBS e ligar ao nó que estiver entrando no cluster: teríamos 3 volumes, cada um representando uma az, mas começamos a ter problemas devido ao volume de ebs não poder vir do template de Autoscaling, pois senão cada troca de instancia trocaria o volume junto. Mesmo criando volume fora, exige uma certa sincronia nos scripts de startup e desligamento da máquina para que o volume possa sair e voltar de forma satisfatória.
  • Usar um volume EFS e ligar aos 3 nós: como a camada de comunicação do EFS com o host é em cima do nfs, o processo de bootrstrap da instância fica muito mais simples e não temos a tarefa de ligar o volume ao host na camada da AWS, somente mandar um comando de nfs na montagem:
mount -t efs ${filesystem_id} /mnt/efs

Por razões de simplicidade escolhemos o EFS, mas ainda precisamos resolver o segundo problema que também impacta no primeiro problema:

Ao trocar o host, o novo ao invés de recuperar os arquivos em sua totalidade, não consegue, pois nos arquivos estão todos vinculados a um host antigo, existe um processo programático pra alterar o host dentro dos arquivos, mas permita-me resumir que é dramático!

tentando resolver programaticamente a troca de nome do host

Para resolver isso iremos ao próximo tópico.

Evitando troca de nomes do nó do cluster de RabbitMQ na troca de instâncias

Como descrito antes, um dos problemas que acontece na hora que trocamos uma das instâncias é a forma que o RabbitMQ nomeia a instância a nível de RabbitMQ, cada nó tem um nome que é fortemente vinculado aos dados que ele persiste, se você tentar recuperar arquivos de um nó em outro nó, ele não será recuperado a não ser que entre em todos os arquivos que fazem referência ao antigo nó e troque. Outro problema que falamos antes vai ser mais estético, pois os nós trocados continuarão aparecendo na dashboard do admin do RabbitMQ e irão aparecer como desconectados, sendo que você colocou exatamente outro igual com os mesmos dados.

Nessa etapa, confesso que fiquei extremamente pensativo, e para processar essa ideia, dei uma olhada em uma parte do processo do RabbitMQ chamado discovery:

  • É o processo que permite que um nó de um cluster descubra na inicialização quais outros nós fazem parte do cluster
  • Nesse processo podemos ter controle aos nomes que cada nó terá ao entrar no processo de descoberta.
  • Sem ele os nós subiriam ilhados, ou seja, nós isolados que teriam somente o nome do cluster igual.

O processo de discovery padrão adotado foi o de AWS que identifica por tags todos os nós que fazem parte do cluster, mas o problema dessa abordagem é que não tem flexibilidade ao definir o nome do nó no cluster aceitando somente variações do DNS privado que é fornecido pelo dhcp da rede da AWS, não faz sentido mudar a lógica dele somente por causa de um cluster e ter o risco de afetar o restante da rede.

Nos processos existentes temos dois gerenciados por service discovery:

Dentre as opções o Consul fornece além do discovery, ele tem uma interface de DNS, no qual permite que possamos chamar serviços e nós pelo DNS interno se configurado corretamente. A configuração do discovery fica bem simples:

cluster_formation.peer_discovery_backend = consul # tipo do discovery
cluster_formation.consul.host = localhost # onde o consul server está
cluster_formation.consul.svc_addr_use_nodename = true # usar o nome ao invés do ip
cluster_formation.consul.use_longname = false # desconsiderar o ip como parte do nome
cluster_formation.consul.svc_addr_auto = true # fixo true para que o consul gerencie essa parte
cluster_formation.consul.port = 8500 # consul http port
cluster_formation.consul.scheme = http # protocolo a ser usado na api
cluster_formation.consul.svc = rabbitmq # nome do serviço a ser declarado no discovery
cluster_formation.consul.svc_ttl = 30 # intervalo entre as chamadas de api
cluster_formation.consul.deregister_after = 60 # retirar do cluster se não comunicar por mais de 60 segundos
visão pelo lado do consul dos nós do RabbitMQ sendo descoberto
visão pelo lado do RabbitMQ dos nós formando cluster usando os nomes do consul

Pelo lado do consul precisamos configurar os nomes dos nós para assumir o mesmo nome dado ao hostname, onde alimentamos uma variável de ambiente LAN_NODENAME, que por sua vez é utilizado na inicialização do consul:

LAN_NODENAME=ecomm-rabbitmq-sa-east-1c
LAN_ADDRESS=<ip-rede-privada-aws>
[Unit]
Description="HashiCorp Consul"
Documentation=https://www.consul.io/
Requires=systemd-networkd.service
After=systemd-networkd.service
ConditionFileNotEmpty=/etc/consul.d/join.json

[Service]
Type=simple
EnvironmentFile=/etc/environment
ExecStart=/usr/bin/consul agent -config-dir=/etc/consul.d/ -advertise-wan $LAN_ADDRESS -bind $LAN_ADDRESS -advertise $LAN_ADDRESS -node $LAN_NODENAME
ExecReload=/usr/bin/consul reload
Restart=on-failure
KillMode=process
LimitNOFILE=65536

O consul por sua vez utiliza tags da AWS para se "autodescobrir" e formar o cluster:

{
"retry_interval": "30s",
"retry_interval_wan": "30s",
"retry_join": ["provider=aws tag_key=App tag_value=ecomm-rabbitmq-ec2"],
"retry_max": 0,
"retry_max_wan": 0
}

E como o consul deve ser provisionado para atender a demanda do RabbitMQ? existem várias maneiras:

  • criar 3 nós exclusivos para o consul server
  • colocar o consul dentro do kubernetes e permitir que nosso Rabbit acesse ele
  • embarcar o consul server nos mesmos nós que estão os nós do Rabbit

Optamos pela última abordagem por conta de simplificar o processo e por conta de algumas justificativas:

  • O consumo de cpu e memória do consul server é extremamente baixo
  • As condições de disponibilidade do consul, por também trabalhar com protocolo raft consensus é o mesmo das filas do Rabbit, ou seja, assim como as filas em quorum, o consul pode tolerar a perda de um nó sem perder sua funcionalidade.
  • O consul só é essencial no processo de entrada da instancia, uma vez o Rabbit dentro do cluster o serviço do consul não é mais utilizado pelo RabbitMQ, o cuidado é somente garantir o consul funcional sempre que um nó do cluster de Rabbit entrar no cluster.

Com essa solução estabelecida podemos chegar no design do cluster novo.

Segunda versão da Solução

Com base na resolução dos dois problemas citados anteriormente, chegamos no novo diagrama:

segunda versão da solução (versão final)

Um ponto que não está detalhado nesse diagrama é que o consul e o Rabbit estão instalados na mesma instancia, conforme citado anteriormente sobre essa decisão.

Realizamos muitos testes de resiliência onde tivemos ótimos resultados de eficiência.

Testes que não tiveram perda de funcionalidade

  • remover um nó aleatóriamente (o autoscaling repara)
  • remover 2 nós aleatoriamente em sequência, com intervalo suficiente para o nó removido recuperar (o autoscaling repara)
  • remover 3 nós aleatoriamente em sequência, com intervalo suficiente para o nó removido recuperar (o autoscaling repara)

Testes que tiveram perda de funcionalidade mas não teve perda dos dados:

  • remover dois nós simultaneamente (é necessário reparar o consul e reiniciar o RabbitMQ em cada nó reparado)
  • remover três nós simultaneamente (é necessário reparar o consul e reiniciar o RabbitMQ em cada nó reparado)

Testes que tivemos perda total

  • remover os 3 nós e forçar remoção dos dados no EFS

Ficamos muito satisfeitos com os resultados dos testes e podemos já partir para um desafio bem maior agora… como iremos migrar de cluster sem nenhuma perda?

Realizando o êxodo para o cluster novo

Simbora!

Como dito no início do artigo, estamos falando de mais 60 microsserviços processando milhares de requisições simultaneamente, como fazer essa mudança sem impacto?

Antes de irmos pros finalmentes, vamos passar brevemente por alguns recursos bacanas do Rabbit:

Federation

Antes vamos definir duas premissas que gosto de chamar de caminho feliz:

  • uma mensagem só pode ser enviada a uma exchange e jamais diretamente na fila
  • a fila deve ficar atrás da exchange

Com essas premissas definidas podemos usar federação nas exchanges sem problemas como na figura abaixo:

fluxo de uma exchange federada

Se tivermos dois clusters, sendo um cluster chamado "upstream" e outro chamado "downstream", teremos as seguintes situações:

  • Se uma mensagem for enviada para uma exchange de upstream, ela é replicada para a mesma exchange de downstream
  • Se uma mensagem for enviada para uma exchange de downstream, ela não é replicada.

Perceba que o caminho segue sempre o fluxo da upstream para o downstream.

Shovel

O shovel é um recurso mais fácil de explicar, ele é amplamente utilizado em operações como mover mensagens no painel administrativo do Rabbit

painel de configuração do shovel

Ele basicamente intervém coletando as mensagens da origem e aplica no destino, é como se tivesse uma aplicação constantemente coletando as mensagens. Para filas ele concorre com uma aplicação que estiver escutando ela, afetando inclusive o funcionamento da aplicação que pode inclusive parar de receber mensagens, pois o shovel é muito mais rápido para coletar mensagens

Nunca é bom deixar o shovel ligado em uma fila que estiver processando com uma aplicação, ele é mais útil quando por exemplo você quer mover mensagens remanescentes do cluster antigo para o novo, e é justamente isso que faremos no final.

Agora que conhecemos esses dois recursos do Rabbit precisamos classificar as aplicações pelo seu perfil de uso no RabbitMQ:

  • consumidor: a aplicação somente reage nas filas como um simples worker e não realiza publicação de mensagens em nenhum lugar
  • produtor: a aplicação somente escreve em exchanges, não consome nenhuma fila, ela simplesmente produz mensagens
  • produtor-consumidor: a aplicação escreve e consome filas, muitas delas consomem o que ela mesmo produz
planilha onde classificamos as aplicações, e avaliamos vários fatores que definem seu risco (possibilidade de falhar no processo de rollout)

Outro ponto que tivemos que adaptar era garantir que todas as aplicações faziam controle de idempotência da mensagem, pois a mesma mensagem pode chegar ao consumidor mais de uma vez durante o processo de transição.

Conhecendo o fluxo temos duas opções:

  • Produtoras vão primeiro.
  • Consumidoras vão primeiro.

Escolhemos a segunda opção por um simples motivo: Se fosse o contrário, as mensagens produzidas iriam demorar pra ser processada e iriam começar a acontecer à medida que os consumidores fossem migrados para o novo cluster, e isso seria percebido pelo cliente, vou exemplificar em um caso de uso:

  • O cliente pede pra trocar a senha da loja, e no backend manda uma mensagem pra um consumidor processar e enviar o email.
  • A mensagem é criada no lado do novo cluster e o consumidor ainda não chegou.
  • O consumidor demora 10 minutos pra chegar no lado do cluster novo pois ele estava bem no final da fila.
  • O consumidor quando chegar vai encontrar uma fila bem cheia e ainda terá o tempo para processar tudo
  • O consumidor envia o email e o cliente tem a percepção de ter demorado mais de 10 minutos pra enviar o email.

No caso em questão esperar por um email por 10 minutos pode até ser "aceitável", mas imagina isso na hora de pagar seu produto na loja?

E para finalizar, organizamos os grupos por "baterias" onde agrupávamos 4 a 5 aplicações por bateria e controlávamos em um processo de war-room com todos os times o processo da troca:

Planilha que acompanhava o processo de troca dos serviços dos times

Afinal, como ficou o processo de virada (rollout)?

Com todos os pontos esclarecidos podemos agora colocar bem de forma resumida os passos:

  • A planilha com os serviços ordenados (consumidores primeiro) é compartilhado com todos na war-room
  • Além da classificação entre produtores/consumidores o grau de risco é ordenado, partindo dos de risco mais baixo até o mais alto por último é ordenado
  • A federação é ligada, o cluster antigo é o upstream, e o cluster novo é o downstream
  • As filas do downstream começam a encher
  • Enviamos os consumidores
  • Por conta da federação, muitas mensagens que já foram processadas na upstream, são reprocessadas na downstream, mas já estamos vacinados do controle de idempotência.
  • À medida que os produtores começam a transferir, as mensagens começam subir mais devagar até parar de acontecer na upstream
  • Ao final do processo, é ligado o shovel e pega as mensagens remanescentes na upstream e é aplicado na downstream, e muitas são duplicadas

Legal, migrou tudo, tudo feliz e as mensagens delayed? aquelas que ficam presas nas exchanges de delayed message, como fica?

Para esse problema a solução foi manter os shovels ligados durante 7 dias que é o maior tempo que temos registrado em uma mensagem desse tipo.

Perderam requisição no processo?

Não perdemos nenhuma requisição, em um processo que envolveu:

  • 18 times
  • ~60 serviços
  • ~50 pessoas coordenando uma planilha

Fizemos o processo todo em uma 1 hora e ficamos a segunda hora somente monitorando pra ver se tinha algum efeito colateral.

Conseguimooooos!
Conseguimooooos!

Aprendizados com o novo cluster em produção

Nem tudo foi felicidade com esse modelo novo, com arquitetura nova vem aprendizados novos, e no terceiro dia tivemos um incidente.

  • Inicialmente tivemos mensagens travando
  • O cluster estava operacional, mas estava travando para entrar no painel admin
  • Entramos na instancia e estava tudo ok, não tínhamos processo e memória alta em nenhum momento
  • Tentamos reiniciar os nós em sequencia mas nada mudava
  • Nos logs de inicialização, notei que a leitura dos arquivos que estavam no EFS estavam demorando muito pra carregar
  • Fomos no monitoramento do EFS e encontramos o Tp permitido lá em cima:
Monitoramento Throughput permitido

Aprendemos na dor que o EFS limita o TPS (Throughput permitido), que é definido por três formas:

  • Diretamente proporcional ao volume utilizado
  • Provisionado diretamente
  • Elástico (o modelo sob demanda e mais caro do EFS)

No cenário que estávamos, fizemos a mudança pra elástico para encerrar o incidente, e logo depois fizemos um estudo do nosso consumo e no final usamos o modelo provisionado.

O incidente aconteceu por conta do volume de TPS ter ficado alto por 3 dias seguidos, muito além do permitido, pois usamos pouco espaço em disco. Ao final de 3 dias os créditos de Bursting (crédito pra extrapolar o disponível) esgotaram, e nesse ponto tivemos incidente.

Teste de carga e como o ambiente se comportou na Beauty Week 2023

A Beauty Week é o evento mais importante no nosso ecossistema de beleza no Grupo Boticário, onde ocorre na semana da Black Friday, e acontece o maior fluxo de movimentação do ano dentro da nossas lojas (Beleza na Web, Boticário, entre outras).

Para nos prepararmos para essa semana fizemos um teste de carga para validar o desempenho do nosso RabbitMQ.

Durante o teste de carga, conseguimos produzir próximo de 1MM mensagens/s conforme abaixo:

Taxa de envio de mensagens em uma escala mais alta tende a ter o valor maior que o real, nesse caso estamos usando escala de 15 minutos de janela

Aplicando uma resolução maior podemos notar que chegamos a 900k

Aproximamos de 900k mensagens no ambiente mas não conseguimos chegar a 1MM por limitação da ferramenta de teste de carga

Nos testes não conseguimos chegar a 1MM por limitação da ferramenta de teste, mas isso não impediu que o teste de carga fosse classificado como sucesso, pois chegamos a 20x do valor médio de taxa de envio:

Volume médio de msg/s das demais filas do cluster

Mas após o teste de carga tinha uma fila específica que superou durante a Beauty Week (peço perdão pela falta de evidência, não salvei a imagem do monitoramento e terão que confiar no que estou dizendo) que superou a taxa de 1MM. Essa fila era responsável por uma integração com parceiro que por uma razão gerava muitas mensagens nesse período.

Juro mesmo!

Quais reflexos tivemos ao realizar o teste de carga? Em processamento e memória, os processos ficaram "estáveis" aumentando a memória, mas de forma tranquilo, não superando o nosso limite de alarme de 75% da memória disponível, o único comportamento que realmente oscilou foi o TPS do EFS conforme abaixo:

EFS abaixo de 20% mesmo com a carga full

Conclusão

Nessa experiência maravilhosa que foi conduzir essa virada e evolução ficou uns aprendizados bem bacanas:

  • Aprendemos a fundo todo funcionamento do Rabbit, e indo além de sua infraestrutura para conduzir uma migração de sucesso, usamos todos os recursos possíveis dele a nosso favor e isso foi chave para o sucesso da migração
  • Controlamos o processo conhecendo o fluxo de messageria, o que não daria certo se não tivéssemos conhecimento sobre sistemas distribuídos
  • Aprendemos também com os erros, e o EFS é muito bom sempre testar o seu TPS antes de homologar o ambiente pronto pra produção
  • Ainda quero saber qual o limite desse RabbitMQ!

Legal isso tudo aí… tem código?

Tem sim senhor! o código como estava muito atrelado ao modelo de negócios ele está ainda em fase de ajuste para ficar bem abstraído e funcional, mas o código pode ser acessado aqui.

--

--

Clayton Cavaleiro
gb.tech

Brazilian developer in pursuit to design a perfect reliable system