Circuit Breaker: lidando com falhas em micro serviços

Henrique Braga
luizalabs
Published in
8 min readOct 28, 2019

Introdução

Muitas empresas rodam/rodavam aplicações em sistemas com funcionalidades do negócio e seus respectivos módulos em uma máquina só.

Isso traz alguns problemas:

  • Complexidade: toda alteração em um módulo pode gerar problemas para todo o sistema (mesmo que as partes estejam desacopladas, estão compartilhando o mesmo recurso computacional);
  • Escalabilidade limitada: Quando escalamos o sistema devido somente à uma funcionalidade que precise de mais recursos, estaremos escalando todas as partes;
  • Manutenção e novas funcionalidades de código: Para fazer uma alteração em determinada parte, precisamos fazer o deploy de todo o sistema. Isso acaba tornando o processo de lançamento de novas features em produção e correção de bugs bem menos produtivo.

Para resolver esses problemas, surge em 2011 em uma conferência o conceito de microsserviços com o conceito de isolar cada módulo em sistemas menores.

Muitas pessoas se deram conta das vantagens deste tipo de arquitetura e abraçaram a ideia para novas funcionalidades. Porém, há um desafio para lidar: migrar as aplicações monolíticas que já existem para sistemas menores e independentes entre elas.

Com muitas aplicações sendo migradas de sistemas monolíticos para arquitetura de micro serviços, separamos as responsabilidades em que cada serviço será “dono” de determinado domínio (tanto regras de negócio, como os dados).

Isso gerou diversas vantagens como:

  • Produtividade por ter times mais focados em cada área específica de negócio;
  • As falhas ocorrem em pontos isolados por conta da própria proposta de micro serviços, que separa aplicações de acordo com o domínio;
  • Entregas de features para produção mais frequentes;
  • Facilidade para escalar aplicações;
  • Flexibilidade para times trabalharem e experimentarem novas tecnologias.
Exemplo de arquitetura de micro serviços. Fonte: https://docs.oracle.com/en/solutions/learn-architect-microservice/img/microservice_architecture.png

Porém, surgem alguns efeitos colaterais que devemos refletir: faremos mais chamadas para recursos externos para conseguir obter dados específicos de um domínio para realizar alguma operação.

Com sistemas monolíticos não havia quaisquer preocupações com isso: geralmente todos os dados e a lógica necessária para atender o negócio já estavam ali. Em outras palavras, não havia a necessidade de realizar chamadas remotas para fazer alguma coisa, tudo já estava lá. Em outras palavras, tudo se comunicava localmente.

Porém, quando precisamos solicitar informações para outros recursos, as coisas nem sempre ocorrem da maneira que esperamos. Em algum momento, esses recursos externos passarão por algum tipo de problema.

Os mais comuns são:

  • Latência de rede;
  • Deploy com bugs em determinado micro serviço;
  • Algum recurso de um serviço externo (banco de dados, por exemplo) ter problemas e afetar a aplicação.

Isso pode gerar um problema em massa, pois em uma arquitetura de micro serviços existem chamadas remotas para diversos lugares e problemas em um deles pode ocasionar um "efeito dominó" de falhas em todo o fluxo.

Então já sabemos: não podemos deixar que a aplicação que está consumindo este recurso seja degradada por conta de recursos externos.

Vamos imaginar que trabalhamos em uma empresa que fornece serviços de viagens e fazemos parte do time que cuida do cálculo do preço de uma determinada rota de chegada e destino.

Para determinar isso, precisamos obter algumas respostas:

  • Quantos km entre a chegada e o destino?
  • Como está o trânsito?
  • Baseado na região que o cliente está, o preço deve ser maior ou menor?
  • O cliente deve ou não ter algum tipo de desconto baseado no número de viagens, rating, dentre outros parâmetros

Para obter essas respostas, precisamos nos comunicar com quatro micro serviços diferentes:

  • API de rota responsável pelo cálculo em km baseado na latitude/longitude do cliente;
  • API responsável aplicar um peso maior ou menor no preço com base na região do trajeto do passageiro e outras informações, como índice de criminalidade, IDH da região etc.;
  • API responsável por retornar o tempo baseado no trânsito do trecho;
  • API responsável por retornar se o cliente tem algum tipo de desconto a ser aplicado na viagem;

Para ficar mais claro, temos o seguinte fluxo:

  • Cliente solicita a carona passando endereço de partida e destino (em latitude / longitude);
  • A API de cálculo de preço recebe os dados e solicita à API de cálculo de rota a quilometragem, passando a latitude e longitude recebidos;
  • A API de cálculo de preço solicita à API de fator da região (dado a região da viagem, pode-se por um peso no valor do preço);
  • A API de cálculo de preço recebe os dados da API de cálculo de rota e solicita à API de cálculo de trânsito, baseado na latitude/longitude e km percorridos;
  • A API de cálculo de preço solicita à API de clientes informações sobre rating e se o mesmo possui algum desconto à ser aplicado na carona.
  • Por fim, a API de cálculo de preço realiza as operações necessárias com os dados obtidos e devolve para o cliente o preço final calculado.
Exemplo: Fluxo de comunicação com as APIs para calcular o preço de uma viagem

Imagine que em um determinado dia, a API de fator por região esteja com problemas.

Exemplo: A API de fator região está com problemas para responder nossas requisições

Se a aplicação não souber lidar com as falhas desse serviço, podemos parar todo o fluxo de viagens e as consequências serão graves: a empresa deixará de faturar, além de oferecer uma péssima experiência aos clientes (no caso, o motorista e o passageiro).

Nesse caso, o que pode ser feito? Quando houver problemas, poderíamos assumir um peso “padrão” independente da região do trajeto da viagem. Em outras palavras, o sistema assumiria um comportamento padrão quando não for possível obter os dados.

Porém, existe outro problema. Precisamos parar de requisitar a API instável, pois se continuarmos solicitando mais recursos, não vamos permitir que o serviço se recupere ou atrasaremos a volta a normalidade.

Para resolver esses problemas, existe um padrão em Engenharia de Software chamado Circuit Breaker.

Como funciona um Circuit Breaker?

A ideia do Circuit Breaker não é nova. Na verdade, o termo foi copiado da engenharia elétrica. Para dar mais contexto da ideia por trás desse padrão em engenharia de software, trata-se de um dispositivo com um mecanismo para proteção da rede elétrica, evitando que dispositivos queimem.

Quando há uma descarga elétrica anormal que não é segura para se passar entre os fios, o Circuit Breaker desliga (se fecha), evitando que a carga chegue aos dispositivos.

Circuit Breaker: Exemplo de funcionamento em engenharia elétrica. Fonte: https://energytoday.biz/uploads/_624xAUTO_crop_center-center/dryer_circuit_breaker_size.png

Voltando para engenharia de software, o Circuit Breaker tem basicamente dois objetivos:

  • Evitar que dependências do sistema possam causar degradação do serviço;
  • Permitir que as aplicações que solicitam recursos tenham a chance de se recuperar dos problemas que estão passando;

A ideia é ter uma certa tolerância a falhas de serviços externos. Ao requisitarmos dados, precisamos verificar se o mesmo retornou algum erro ou acabou demorando muito para responder.

Caso isso tenha ocorrido, computamos um erro a mais em uma contagem que armazenamos por um determinado tempo. E, a partir do momento que as falhas chegarem em um número configurado para aquele circuito, o sistema assume um comportamento padrão e cessa a solicitação ao recurso externo problemático.

Vale lembrar novamente que essa contagem precisa estar em uma janela de tempo e depois zerar, caso contrário, em algum momento sempre iremos abrir o Circuit Breaker.

Quando o Circuit Breaker chega no ponto acima, dizemos que o mesmo está aberto. Após um determinado tempo aberto, precisamos verificar se o serviço já retornou ao normal por meio de algumas requisições. Em algumas implementações, chamamos esse estado de meio aberto.

Caso o serviço esteja ok, dizemos que o Circuit Breaker fechou (assim como um Circuit Breaker na engenharia elétrica).

Resumidamente, podemos ter três estados:

  • Fechado: estamos fazendo requisições normalmente para o serviço;
  • Aberto: atingimos o número máximo de falhas e no momento não estamos fazendo requisições para o serviço;
  • Meio aberto (em algumas implementações): estamos verificamos se podemos fechar ou manter o Circuit Breaker aberto.

Existem algumas questões que surgem quando precisamos implementamos um Circuit Breaker:

  • Onde iremos armazenar o estado do Circuit Breaker (se ele está aberto ou qual a quantidade de erros)?;
  • Qual será o número de erros para abrir o Circuit Breaker?
  • Qual será o comportamento padrão para a aplicação que abrimos o Circuit Breaker?
  • Qual o tempo que o Circuit Breaker deve ficar aberto para aquela aplicação até voltar a enviar requisições?
  • Utilizaremos uma biblioteca pronta ou faremos nossa própria implementação?

Com todas as questões respondidas, já é possível ter uma noção de como será a implementação de um mecanismo de Circuit Breaker para a sua aplicação.

A maioria destas aplicações possuem implementação de Circuit Breaker para suas dependências. Isso garante que mesmo que algum micro serviço esteja passando com problemas, outras aplicações não sejam afetadas.

Outro ponto que vale destacar: independente da linguagem de programação que você domine, provavelmente já deve existir uma biblioteca que cuida de tudo isso para você, bastando apenas configurar o circuito de acordo com o que vimos acima.

Exemplo implementação Circuit Breaker da API de cálculo de preço para a API de peso de preços por região: Definido o limite de 1000 erros em 1 minuto para fechar o circuito.
Exemplo implementação Circuit Breaker da API de cálculo de preço para a API de peso de preços por região: Atingido o limite de 1000 erros em 1 minuto, logo o circuito fechou e é assumido o comportamento padrão de não aplicar nenhum valor baseado na região da viagem.
Exemplo implementação Circuit Breaker da API de cálculo de preço para a API de peso de preços por região: Após o tempo que o circuito foi configurado para ficar fechado, são realizadas algumas requisições para verificar se a API está retornando o peso por região da viagem normalmente.
Exemplo implementação Circuit Breaker da API de cálculo de preço para a API de peso de preços por região: Após ter tido sucesso nas requisições realizadas, o circuito volta a ficar aberto e o fluxo volta a funcionar normalmente.

Conclusão

Com a migração de sistemas monolíticos para micro serviços, é necessário fazer mais chamadas remotas para obter informações.

Por isso, se faz necessário que serviços com dependências saibam lidar com falhas e permitir que o micro serviço instável possa se recuperar de forma adequada.

O padrão Circuit Breaker (termo que surgiu na engenharia elétrica) soluciona ambos os problemas, pois define um threshold de erros em uma janela de tempo. Caso atinja a quantidade de erros, o circuito fecha e o serviço assume um comportamento padrão até que o serviço fique estável novamente.

No Luizalabs, temos aplicações cruciais que precisam sempre estar estáveis para o funcionamento de todo o ecossistema da empresa. O processo de checkout é um destes exemplos: um pedido possui várias dependências para ser concluído e neste caso, temos implementação de Circuit Breaker para a maioria delas.

Outro ponto que vale destacar: independente da linguagem de programação que você domine, provavelmente já deve existir uma biblioteca amplamente utilizada pela comuidade feita por alguém para cuidar que cuida de tudo isso para você, bastando apenas configurar o circuito de acordo com o que vimos acima. Então, não precisa reinventar a roda, mas agora sabemos como um Circuit Breaker funciona por baixo dos panos :).

--

--

Henrique Braga
luizalabs

“If you’re not making someone else’s life better, then you’re wasting your time. Your life will become better by making other lives better.”