Artigos /
Circuit Breaker: isolando falhas antes que elas se espalhem
Como Circuit Breakers ajudam a conter falhas, proteger recursos e aumentar a resiliência de aplicações.
Como não deixar que fogo se espalhe enquanto ainda está crescendo
A arte do isolamento
Estive pensando nas últimas semanas em como evitar falhas generalizadas em diferentes tipos de arquiteturas, desde monólitos a microservices, principalmente em como podemos evitar que um pedaço, domínio, serviço ou sistema externo que esteja em falha afete o resto do sistema e da jornada do usuário.
Tendo isso em mente, gosto de trazer o conceito de circuit breaker como uma analogia a uma mata em chamas, quando uma parte da mata pega fogo, o que fazemos é isolar as áreas que estão em chamas para que o fogo não se alastre para as demais áreas e acabe com um desastre muito maior.
A ideia de Circuit Breaker parte do mesmo príncipio, enquanto tudo está bem, o tráfego chega em todas as áreas necessárias normalmente, porém, quando alguma área se deteriora ou fica indisponível, o mesmo entra em ação impedindo que o tráfego continue para a peça afetada e aumente o estrago, drene recursos. E principalmente, impedindo que a aplicação tente realizar uma ação que é fadada a falhar, ou ainda, que gere uma degradação em massa, caso o processo esteja no meio da jornada do usuário.
Estágios do Circuit Breaker
Fechado
A requisição é roteada para o serviço (ou sistema externo) normalmente, e o proxy mantém uma contagem de vezes que a requisição falhou em um período de tempo, se a requisição falha, o circuit breaker aciona o estado Aberto e começa com um timeout de tempo, quando este timeout expira, o estado é alterado para Meio-aberto
Aberto
Toda requisição que é enviada para o serviço falha rapidamente, para que o caller não fique tempo esperando, e possa seguir em frente com a próxima requisição.
Meio-aberto
Neste estado, um número limitado de requisições é permitido a chegar até a aplicação, e são monitoradas, novamente pelo proxy, caso o resultado dessas requisições seja sucesso, o circuit breaker volta para o estado Fechado. Caso qualquer uma das requisições que passou resulte em uma falha, o circuit breaker entende que a degradação permanace e o estado volta para Aberto, recomeçando assim o timer e todo o fluxo.
Implementações em Go e Java
Apesar do conceito ser o mesmo, a implementação de um Circuit Breaker pode acontecer de diferentes formas. Ele pode existir dentro da própria aplicação, envolvendo chamadas para dependências externas, ou em uma camada de infraestrutura como um proxy ou service mesh.
Independentemente de onde esteja implementado, a lógica que queremos atingir é praticamente a mesma:
- Executar a chamada normalmente enquanto o circuito estiver fechado.
- Monitorar as falhas ocorridas durante essas chamadas.
- Abrir o circuito quando a quantidade ou proporção de falhas ultrapassar um limite aceitável.
- Falhar rapidamente enquanto o circuito estiver aberto.
- Depois de determinado período, permitir algumas chamadas de teste.
- Fechar novamente o circuito caso a dependência tenha se recuperado.
Go
Uma forma simples de pensar em uma implementação em Go seria envolver a chamada que queremos proteger em uma função controlada pelo Circuit Breaker.
result, err := breaker.Execute(func() (interface{}, error) {
return callExternalService()
})
O interessante aqui não é exatamente a biblioteca utilizada, mas a separação de responsabilidades.
A função callExternalService não deveria precisar saber se existe um circuit breaker protegendo sua execução. Para ela, sua responsabilidade continua sendo realizar a chamada.
É o breaker que decide se a chamada pode ou não acontecer naquele momento.
Isso também significa que, quando o circuito estiver aberto, podemos retornar imediatamente sem sequer consumir uma conexão HTTP, abrir uma conexão com outro serviço ou esperar por um timeout que sabemos que provavelmente irá acontecer.
Java
Em Java a ideia é praticamente a mesma.
Podemos envolver uma chamada externa com um Circuit Breaker e deixar que ele monitore o resultado das execuções.
Supplier<Response> decoratedSupplier =
circuitBreaker.decorateSupplier(() -> externalService.call());
Response response = decoratedSupplier.get();
Frameworks como Spring também permitem que esse tipo de comportamento seja aplicado de maneira mais declarativa, mas por baixo dos panos a decisão continua sendo parecida.
Sobre tuning
Talvez uma das partes mais complicadas de utilizar Circuit Breakers esteja justamente na configuração dos seus gatilhos. Não existe um número mágico que funcione para todos os sistemas.
Imagine que configuramos nosso circuito para abrir após duas falhas consecutivas, em uma aplicação que recebe milhares de requisições por segundo, duas falhas podem representar apenas um pequeno ruído completamente normal, por outro lado, se configurarmos o circuito para esperar centenas de falhas antes de reagir, talvez quando ele finalmente seja aberto o estrago já tenha sido feito.
Por isso algumas informações precisam ser consideradas.
Janela de requisições
Precisamos definir qual conjunto de chamadas será utilizado para decidir se uma dependência está saudável.
Por exemplo:
Nas últimas 100 requisições, quantas falharam?
Ou:
Nos últimos 30 segundos, qual porcentagem das requisições resultou em erro?
Essas duas perguntas parecem semelhantes, mas podem produzir comportamentos bastante diferentes dependendo do volume da aplicação.
Threshold de falhas
Depois precisamos decidir qual quantidade de falhas é considerada suficiente para abrir o circuito.
Por exemplo, podemos considerar uma dependência degradada quando 50% das chamadas dentro da janela observada resultarem em erro.
Mas novamente isso depende do domínio.
Uma taxa de erro de 5% talvez seja completamente inaceitável para uma operação de pagamento, enquanto outro processo menos crítico consiga conviver temporariamente com ela.
Timeout do estado aberto
Quando o circuito abre, também precisamos decidir quanto tempo devemos esperar antes de testar novamente a dependência.
Se tentarmos novamente cedo demais, podemos continuar pressionando um sistema que ainda está tentando se recuperar.
Se esperarmos demais, podemos manter uma funcionalidade indisponível mesmo depois da dependência já ter voltado ao normal, e é tudo que mais queremos evitar.
É justamente para resolver esse equilíbrio que existe o Meio-aberto.
Quais erros realmente contam?
Nem todo erro deveria contribuir para abrir um Circuit Breaker.
Um 400 Bad Request, por exemplo, normalmente indica que existe algo errado com a requisição enviada pelo caller, e tentar novamente daqui a cinco segundos provavelmente não vai mudar nada.
Já erros como timeouts, falhas de conexão ou determinados erros 5xx podem indicar que a dependência realmente está degradada.
Trade-offs
Como nem tudo são flores, e a única certeza, além de que tem louça para ser lavada na pia neste momento, é que tudo tem prós e contras, existem alguns custos que vêm junto com esse pattern.
O primeiro deles é complexidade.
O Circuit Breaker adiciona mais um estado ao sistema e, consequentemente, mais comportamentos que precisam ser entendidos e testados.
Não precisamos mais pensar apenas:
A dependência está funcionando?
Agora também precisamos pensar:
O circuito está fechado, aberto ou meio-aberto?
Ele abriu por qual motivo?
Quanto tempo falta para tentar novamente?
Quantas requisições estão sendo utilizadas como teste?
Além disso, existe uma necessidade muito maior de observabilidade.
Se um Circuit Breaker abriu em produção, provavelmente queremos saber.
Queremos saber qual dependência causou a abertura, qual era a taxa de erro naquele momento, há quanto tempo o circuito está aberto e quantas vezes ele alternou entre seus diferentes estados.
Caso contrário podemos chegar em uma situação curiosa onde o Circuit Breaker está fazendo exatamente aquilo para que foi criado, protegendo nossa aplicação, enquanto uma dependência permanece quebrada silenciosamente atrás dele.
Outro ponto é que falhar rápido ainda é falhar.
O Circuit Breaker não torna uma dependência magicamente disponível, muito pelo contrário, ele apenas impede que uma falha conhecida continue consumindo recursos e se espalhando pelo restante do sistema.
Por isso, normalmente precisamos combinar o pattern com alguma estratégia de degradação.
Se uma API de recomendações estiver indisponível, talvez possamos simplesmente esconder as recomendações.
Se um serviço de cálculo de frete estiver indisponível, talvez não seja possível concluir a compra.
A forma como reagimos ao circuito aberto depende diretamente da importância daquela dependência para a jornada sendo executada.
Circuit Breaker não trabalha sozinho
Um Circuit Breaker fica ainda mais interessante quando utilizado em conjunto com outros mecanismos de resiliência.
Timeouts limitam quanto tempo estamos dispostos a esperar por uma dependência.
Retries permitem repetir operações que podem ter falhado por problemas transitórios.
Circuit Breakers impedem que continuemos tentando quando temos evidências suficientes de que a dependência está degradada.
Esses mecanismos se complementam, mas também podem trabalhar uns contra os outros quando configurados de maneira incorreta.