top of page
pedrobusko

Projetando a arquitetura orientada a eventos da Loggi

Um novo capítulo para a comunicação de serviços da Loggi


Este é um artigo traduzido originalmente publicado dia 17/03/2023 pelo Ernesto Matos no blog do Loggi: "Designing Loggi’s event-driven architecture". Siga o Ernesto no Medium para se manter atualizado com novas publicações.


Fonte: Florian Klauer on Unsplash

Sistemas distribuídos é um tópico complexo, especialmente a comunicação de sistemas distribuídos. As noções de relógio e ordem são confusas, nem sempre fica claro como devemos lidar com falhas de comunicação, por exemplo: o que acontece se uma mensagem for enviada duas vezes? Ou pior, e se alguma mensagem “travar” no pipeline de comunicação e acabamos enviando a mesma mensagem centenas de vezes em um curto intervalo?


Nos últimos anos, os negócios da Loggi cresceram muito e, como resultado, nossos sistemas ficaram mais distribuídos, levando nossa arquitetura a um novo patamar para que pudéssemos lidar com esse crescimento. Nesta postagem do blog, apresentaremos como a Loggi lida com a comunicação de microsserviços, com um foco mais profundo em como estamos fazendo a comunicação de microsserviços assíncronos até agora.


Sempre tentamos fazer o síncrono primeiro


Na Loggi, determinamos que usamos REST para APIs externas e para comunicação de front-end para back-end, para serviço a serviço, usamos principalmente gRPC para comunicação horizontal.


Tentamos evitar ao máximo a complexidade em nossos sistemas e desenvolver soluções simples é um de nossos valores fundamentais. Portanto, se algo pode ser feito em uma solicitação síncrona, nós o fazemos. Imagine um cenário onde um usuário está tentando realizar alguma ação em uma interface (web ou mobile), incentivamos as pessoas a fazerem uma requisição síncrona neste caso, principalmente para lidar melhor com as falhas. Em vez de ter uma arquitetura muito complexa para lidar com essa falha ou ser exposto a um bug de dados silencioso, o usuário pode simplesmente tentar novamente pressionando um botão novamente.


As solicitações síncronas são simples, mas ainda precisamos ter cuidado:


  • Tentamos usar solicitações iniciadas pelo usuário o máximo possível. Isso tem fortes implicações para a experiência do usuário no sistema e ajuda a não ficar sobrecarregado com novas tentativas automáticas. Também torna mais forte a correlação entre o comportamento do sistema e a aparência;

  • Se for uma requisição que atinge vários serviços, não misturamos requisições síncronas com requisições assíncronas;

  • Além disso, evitamos solicitações encadeadas por vários microsserviços (por exemplo, o serviço A chama o serviço B, o serviço B chama o serviço C e assim por diante) porque elas podem resultar em falhas em cascata que prejudicam a confiabilidade. Como regra geral, tentamos evitar cadeias de chamadas com mais de 2 serviços;

  • Caso precisemos fazer requisições que alterem dados em vários serviços, devemos conseguir reverter as alterações, caso contrário deixaremos dados inconsistentes em um ou mais serviços;

  • A ordem em nosso código é importante. Se o código precisar fazer uma solicitação para um serviço externo e salvar algo no banco de dados em uma transação, não salvamos os dados no banco de dados antes de termos certeza de que a solicitação retornou uma resposta bem-sucedida;

  • Sempre aplique técnicas de contrapressão (timeouts, circuit breaks, etc.). É importante evitar uma sobrecarga do sistema.

E a comunicação assíncrona?


Kafka foi a decisão mais óbvia ao avaliar a plataforma de streaming de eventos como a espinha dorsal de nossa arquitetura orientada a eventos, uma vez que é usada por milhares de empresas, incluindo mais de 80% da Fortune 100 em suas plataformas de streaming de dados e projetos orientados a eventos.


No entanto, operar uma implantação do Kafka é um trabalho complicado, principalmente porque o Kafka é um sistema grande e complexo por conta própria. Além disso, traz complexidades adicionais ao integrar com os sistemas do cliente, o que pode atrapalhar muitas coisas. Para evitar isso, decidimos usar a plataforma Confluent Cloud para alavancar seus produtos e experiência. Além disso, não queríamos ter uma equipe dedicada e especializada para gerenciar clusters Kafka.


Como o Kafka é uma infraestrutura de baixo nível, podemos configurá-lo de várias maneiras e requer algum esforço para entender quais configurações devemos usar para um caso de uso específico (por exemplo, devemos usar produtores idempotentes ou produtores transacionais?). Em vez de permitir que cada equipe codifique seus próprios produtores e consumidores Kafka e lide com a complexidade, desenvolvemos uma fina camada de infraestrutura para abstrair parte dela. A ideia principal aqui era que Kafka deveria ser o mais invisível possível para todos em nossa equipe de engenharia.


Além da simplicidade, estes eram os requisitos quando estávamos projetando esta nova arquitetura:


  • Tinha que funcionar em todas as nossas pilhas de tecnologia;

  • Deve ser capaz de abstrair a maior parte das complexidades de Kafka;

  • Os eventos produzidos devem ser disponibilizados para consumo em nosso data lake para análise histórica;

  • Tenha fortes garantias transacionais.

Como é o design final da arquitetura


Há muita coisa acontecendo nesta imagem, então vamos dividi-la e explicar como cada parte funciona.


Produzindo de eventos


Um evento é criado em um serviço de produtor. Fornecemos uma API simples (que na verdade é apenas uma única função) para produzir eventos que usam esquemas definidos como Protocol Buffers em nosso repositório de protocol buffers compartilhado. Ao usar protocol buffers, estamos adquirindo evolução de esquema e compatibilidade com versões anteriores e futuras para nossos eventos. Como estamos compartilhando objetos que são usados em diferentes microsserviços, para garantir que tenhamos padrões para todos os nossos protocol buffers, usamos o protocolo do Uber como linter no repositório compartilhado.


Para evitar solicitações desnecessárias a outros serviços para enriquecer os dados do evento, também adotamos o conceito Event-Carried State Transfer nesse design, o que, na prática, significa que adicionamos todas as informações exigidas por um serviço do consumidor no evento. Antigamente sofríamos muito com esses tipos de requisições sobrecarregando outros serviços com filas de mensagens acumuladas durante recuperações de incidentes.


Aqui está um trecho mostrando como um evento é criado usando Python:


event = PackageDeliveredEvent(package=package.to_proto())
build_event(event).save()

Este trecho de código está instanciando o protocol buffer PackageDeliveredEvent e salvando o evento no banco de dados do serviço produtor. No passado, vimos o uso generalizado dos hooks on_commit do Django e isso criou uma grande confusão de inconsistência. Com todos os aprendizados que acumulamos ao longo do tempo, construímos uma alternativa ao on_commit que realmente funciona como esperávamos.


Como mencionamos anteriormente, queríamos ter fortes recursos transacionais nesse design, pois não queríamos nos preocupar com transações distribuídas. Suponha que temos um método em nosso serviço produtor que salva alguns dados relacionais no banco de dados e também envia um evento para Kafka. Tudo isso deve acontecer no mesmo bit de transação, se não conseguirmos enviar o evento para Kafka, devemos reverter as alterações que fizemos no banco de dados relacional, o que aumenta a complexidade, então queríamos que funcionasse como uma simples transação de banco de dados que a maioria das pessoas já conhece.


Para atingir esse objetivo, implementamos oOutbox Pattern transacional na função build_event. Esta função também adiciona alguns metadados ao evento, como um timestamp para sua hora de criação, serviço de origem, uuid, etc. Uma vez que o evento é salvo no banco de dados, usamos oDebezium, que é umKafka Connector, para obter o evento do banco de dados logs e envie para um tópico Kafka.


Aqui está como é feito no código:


@atomic
defdeliver_package(package):
   package.status = ‘delivered’
   package.save()
   event = PackageDeliveredEvent(package=package.to_proto())
   build_event(event).save()

Com a implementação acima, o evento é primeiro salvo no banco de dados do produtor antes de ser enviado para o Kafka, tudo funciona como uma transação normal, e se a função build_event falhar ao salvar o evento, a transação será revertida e o status do pacote não será alterado Atualizada.


Consumindo de Eventos


Uma vez que um evento é produzido e atinge seu tópico Kafka designado, ele será consumido por um microsserviço chamado Event Broker, conforme mostrado na imagem a seguir:


O Event Broker é responsável por ler os eventos do Kafka e enviá-los aos consumidores que precisam recebê-los. Os eventos são enviados ao consumidor por meio de solicitações gRPC. Não temos garantias sobre a ordem de entrega do evento. Como estamos fazendo retentativas automáticas, isso dificulta muito a ordem das entregas dos eventos.


Um de nossos objetivos era simplificar ao máximo a adição de novos eventos. No final chegamos a um ponto em que um engenheiro só precisa fazer duas coisas:

  1. Adicionar uma configuração no Event Broker informando quais serviços do consumidor precisam de quais eventos;

  2. Implementar um servidor gRPC para receber o evento no serviço do consumidor.

A configuração no Event Broker fica assim:



consumers:
service-a:
grpc-server-hostname:"service-a-hostname"
grpc-server-port:"12345"
max-retry-attempts:15
batch-size:10
retry-topic:"service_a_retry"
dlq-topic:"service_a_dlq"
distribution:
-topic:"event_y"
services:
-"EndpointA"
-"EndpointB"
service-b:
grpc-server-hostname:"service-b-hostname"
grpc-server-port:"54321"
max-retry-attempts:5
batch-size:5
retry-topic:"service_b_retry"
dlq-topic:"service_b_dlq"
distribution:
-topic:"event_x"
services:
-"EndpointC"

Temos uma lista de configurações para cada serviço de consumidor que informa quais eventos devem ser enviados para qual consumidor e também quais servidores gRPC receberão os eventos. O Event Broker instancia um Kafka Consumer para cada serviço de consumidor, ele também usa diferentes nomes de grupos de consumidores, o que torna o consumo de eventos completamente independente para cada serviço de consumidor registrado.


Também temos algumas informações sobre o servidor gRPC do consumidor, como o número de tentativas de repetição antes de enviar o evento para um tópico DLQ (falaremos mais sobre isso posteriormente) e os nomes dos tópicos de repetição e DLQ do consumidor.


Quanto ao servidor gRPC no serviço do consumidor, sua interface também é definida em nosso repositório de protocol buffers compartilhado e cada serviço que deseja ouvir um evento deve implementar essa interface em seus servidores gRPC. Aqui está como parece:



classEventsAPI(EventsAPIServicer):
defPackageDelivered(self, request: PackageDeliveredEvent, context) -> Empty:

## do something with the event received 

return Empty()


Lidando com falhas


Temos um mecanismo de re-tentativas instalado que reenvia o evento para o serviço do consumidor N vezes, onde N é o valor definido na configuração do Event Broker, conforme mostrado acima. Também temos novas tentativas na memória que usam backoffs exponenciais e disjuntores. Aprendemos uma lição depois que as implementações anteriores mataram alguns de nossos serviços :-).


É importante mencionar que exigimos que todos os serviços do consumidor tenham endpoints idempotentes, já que temos uma semântica at least once, podemos enviar eventos mais de uma vez em caso de falhas.


Se o Event Broker falhar em entregar o evento ao consumidor N vezes, ele desistirá e enviará o evento para um tópico DLQ. As equipes são responsáveis por monitorar os tópicos DLQ de seus serviços e, quando necessário, podem usar um trabalho do processador DLQ que reenvia as mensagens para um tópico de repetição para que possamos começar a processá-las novamente.


Observe que temos tópicos separados de re-tentativa e DLQ para cada serviço do consumidor. Isso é necessário porque podemos entregar com sucesso o Evento X ao Serviço A, mas não conseguir entregá-lo ao Serviço B. Quando isso acontecer, não queremos enviar a mensagem novamente ao Serviço A porque ela já foi entregue. É por isso que precisamos de tópicos separados para cada consumidor.


Considerações finais


Esta arquitetura está em produção há mais de dois anos e temos dezenas de tipos de eventos criados, desde eventos de pacotes até eventos de transferência de carga e eventos de contabilidade. O Event Broker entrega milhões de eventos todos os dias para vários serviços ao consumidor.


No final, o design foi prático o suficiente para que nossa equipe de engenharia se envolvesse e fosse produtiva, era fácil criar e consumir novos eventos.


Também gastamos muita energia em observabilidade e implementamos logs e métricas personalizadas para que as pessoas possam ver facilmente o que está acontecendo nos bastidores de seus eventos.


Não tivemos grandes problemas com nenhum dos componentes dessa arquitetura e, quando se trata de desempenho, tivemos que implementar alguns ajustes para alguns eventos de alto volume. Originalmente, tínhamos um consumidor Kafka para cada serviço de consumidor e, para eventos de alto volume, isso poderia resultar em alguma competição por recursos. Felizmente, foi uma solução fácil e tivemos apenas que configurar consumidores Kafka dedicados para esses eventos.


Uma das principais lições aprendidas foi que poderíamos ter implementado o microsserviço Event Broker de forma diferente, visto que no design que apresentamos aqui, acaba sendo um único ponto de falha. Mesmo que nunca tenhamos tido nenhum incidente grave com isso, essa ainda é uma preocupação que flutua em nossas cabeças de vez em quando. Poderíamos ter implementado de forma mais distribuída, por exemplo, como um sidecar para cada atendimento ao consumidor.


Também experimentamos alguns trade-offs organizacionais. Como o microsserviço Event Broker era mantido por uma única equipe de plataforma, acabamos criando uma forte dependência dessa equipe para vários outros grupos de engenharia. Se uma equipe precisasse de uma nova funcionalidade nessa arquitetura, teria que esperar até que alguém da equipe da plataforma se disponibilizasse para ajudá-la.


Hoje, entendemos que deveríamos ter capacitado mais nossas equipes e evitado essa dependência, e é nessa direção que estamos indo agora. Em vez de fornecer uma solução única para todos, estamos concentrando nossos esforços em fornecer estradas pavimentadas com alguns caminhos que as pessoas podem escolher.


251 visualizações0 comentário

Commentaires


bottom of page