Com a transformação digital, sistemas estão cada vez mais conectados. Clientes, parceiros e fornecedores têm cada vez mais acesso a sistemas que até bem pouco tempo eram acessados apenas “dentro de casa”. As “janelas para downtime” estão ficando cada vez menores. Nunca disponibilidade e confiabilidade foram tão importantes.
Resiliência
Resiliência, como propriedade da física, implica é a propriedade que alguns corpos apresentam de retornar à forma original após terem sido submetidos a uma deformação elástica. Informalmente, é a capacidade de se recobrar facilmente ou se adaptar à má sorte ou às mudanças.
Definições para disponibilidade e confiabilidade
Disponibilidade é um atributo de qualidade, mensurável, que indica a relação entre o tempo em que um sistema ou componente está disponível e o tempo em que ele “poderia” (ou “deveria”) estar. Entendendo-se, nesse contexto, que um sistema está disponível quando funciona com a performance projetada.
Confiabilidade, também um atributo de qualidade mensurável, implica que, além de disponível, um sistema ou componente opere sem erros nem defeitos.
Para ser confiável, um sistema ou componente precisa se manter disponível. Entretanto, nem todo sistema disponível é confiável.
Estratégia para disponibilidade
Historicamente, disponibilidade é obtida através de clusterização e replicação. A ideia é tentar reduzir o impacto de saturação de alguma instância de componente, distribuindo cargas.
Estratégia para confiabilidade
Devido aos custos de escala e a fragmentação crescente de componentes (como em arquiteturas baseadas em microsserviços), ultimamente tem-se adotado, outra estratégia: a resiliência.
Para ser resiliente, um componente precisa adaptar seu comportamento, reconhecendo eventuais erros ou defeitos nos demais, tolerando problemas com latência, eventualmente implantando mecanismos de retentativas, circuit-breaking, reinicialização automática, etc. Sistemas resilientes não são, necessariamente, mais disponíveis, mas, sem dúvidas, são mais confiáveis.
Sobre falhas, erros e defeitos
Em sistemas de software, toda falha é uma espécie de rachadura pequena que, se não forem contidas, eventualmente, “espalhadas”, convertem-se em defeito.
Pequenas falhas (faults) – como bugs ou validações insuficientes em “entradas” fornecidas por usuários – deixam o sistema em estado inconsistente, o que, ocasionalmente, dá origem a erros (errors), ou seja, comportamentos indesejados do software que, eventualmente, culminam em defeitos (failures), geralmente indisponibilidades.
Falhas provocam erros que provocam defeitos. Por exemplo, consultas SQL mal escritas são falhas que, eventualmente, geram lentidão no banco para responder outras consultas que, por sua vez, causam o aumento nas filas de requisições que, se prolongadas, “topam” memória que, finalmente, tornam um sistema indisponível.
Reconhecendo que falhas são inevitáveis e cada vez mais comuns
Sistemas estão ficando maiores, logo, mais complexos nos últimos anos. Esse “crescimento” associado com tecnologias como a nuvem, conduziram ao desenvolvimento de soluções com cada vez mais componentes, cada vez menores, mais fáceis de manter e distribuir. Entretanto, “não há almoço grátis”, maior a fragmentação, maiores os riscos a confiabilidade.
Estatisticamente, é fácil demonstrar que quanto mais componentes formam um sistema, maiores são as chances da ocorrência de incidentes com potencial para gerar instabilidade. Em termos simples, falhas, além de inevitáveis, devem ocorrer com frequência cada vez maior. Por isso, implementações ingênuas resultam em sistemas cada vez menos confiáveis.
Resiliência implica em contenção de falhas
A estratégia mais efetiva para evitar defeitos é impedir que falhas se convertam em erros. Para isso, é importante, além de tentar minimizá-las, impedir que seus efeitos “se espalhem”.
Em sistemas complexos, sem o devido cuidado, falhas em um componente “se espalham” rapidamente gerando erros em componentes com acoplamento eferente mais alto. Por isso, sob ponto de vista arquitetural, é importante cuidar dos integration points adotando estratégias que mitiguem impactos de falhas, erros ou defeitos de um componente nos demais.
Métricas importantes para confiabilidade
Há dois pares de métricas fundamentais para o design arquitetural de software com disponibilidade e confiabilidade.
- Sob a perspectiva funcional, MTTR e MTBF;
- Sob a perspectiva de dados, RPO e RTO.
MTTR e MTBF
MTTR (Mean time to recover – tempo médio para recuperação) tem relação com o tempo em que uma falha destrói valor em produção. MTBF (Mean time between failures – tempo médio entre falhas) tem relação com o intervalo de tempo até falhas serem que destroem valor serem observadas em produção.
Historicamente, é mais comum dar ênfase a medidas como MTBF, principalmente, quando se adota práticas mais focadas em disponibilidade do que em confiabilidade. Entretanto, para ser efetiva, demanda sistemas menos complexos e menos fragmentados. Quando a ênfase é confiabilidade, a métrica mais apropriada é MTTR.
RPO e RTO
Pensar sobre disponbilidade para dados é mais desafiador do que para outros artefatos. Afinal, quando não há perda de dados, soluções mais críticas tem relação, apenas, com replicação ou reinicialização.
As métricas associadas com dados são, respectivamente RPO (recovery point objective) e RTO (recovery time objective).
RPO tem relação com o volume de dados que podem ser perdidos no caso de ocorrência de uma falha. RTO define quanto tempo é tolerado para recuperar dados para devolver o sistema ao RPO, impactando diretamente o MTTR.
Geralmente o RPO é indicado pela “janela de tempo” máxima onde perdas são toleradas. Quando menor for esta janela, geralmente maior é o RTO.
Táticas para identificar a ocorrência de falhas
Health Checks (verificação de integridade)
A melhor forma de saber que um componente está funcionando bem é “perguntando” para ele. Sob o ponto de vista arquitetural, isto implica em adicionar uma função de verificação para a “saúde” em cada componente, geralmente acessível através de um endpoint específico.
As health checks devem ser utilizadas tanto por load balancers, orquestrados de contêineres e por ferramentas de monitoramento.
Eventualmente, as verificações podem incluir as principais dependências dos componentes, como bancos de dados, serviços remotos, etc.
É importante configurar adequadamente mecanismos de health check com intervalos e alguma estrutura de caching para evitar sobrecargas desnecessárias.
Watchdogs
Enquanto health checks operam passivamente, fornecendo informações sobre a “saúde” dos componentes, watchdogs atuam ativamente, muitas vezes acionando health checks, para, sob determinadas circunstâncias, disparar algum tipo de ação.
Um watchdog é um programa, frequentemente associado a ferramentas de APM e métricas de infraestrutura. Seu objetivo é detectar automaticamente possíveis problemas de aplicativo e infraestrutura, observando continuamente tendências e padrões nas métricas e procurando comportamento atípico.
Táticas para impedir a propagação de falhas
Comunicação assíncrona
Substituir chamadas diretas por trocas de mensagens é, provavelmente, a medida mais eficiente para aumentar a resiliência de sistemas de software. A ideia é substituir chamadas a componentes potencialmente instáveis por mecanismos de mensageria comprovadamente sólidos e estáveis.
A abordagem mais simples é utilizar filas point-to-point. Uma alternativa mais sofisticada (e menos acoplada) é a adoção de pub/sub.
A alternativa tradicional é utilizar estratégias de alta-disponibilidade com replicações e load balancers.
Bulkheads
Já tratamos de bulkheads quando falamos sobre estratégias para suportar escalabilidade. Entretanto, o padrão também é útil para criação de sistemas resilientes.
A ideia é criar instâncias dedicadas de determinados componentes para alguns cenários de uso. Dessa forma, impedindo que falhas ou eventos em um contexto de consumo propaguem para os demais.
Táticas para previnir falhas
Back pressure
Sempre o traffic intensity for desfavorável para um componente, o ideal é que este passe a recusar novas demandas (usando load shedding ou rate limiting), devolvendo a “pressão” para o cliente que deverá implementar alguma estratégia de retentivas ou, até mesmo, reduzir o volume de demandas com alguma estratégia de gracefully degradation.
Do ponto de vista do componente que está adotando back pressure, a implementação é restrita a algum mecanismo de sinalização (talvez retornando 429 – Too many requests) para o cliente indicando a condição. Caberá ao cliente adotar estratégia apropriada, conforme sinalização.
Load shedding (Governor pattern)
Assim como um rate limiter, um componente de load shedding opera como um middleware que monitora os recursos computacionais necessários para um componente, bem como das dependências, e recusa ativamente novas requisições até que níveis saudáveis sejam restaurados.
Timeout
Embora não haja uma “receita de bolo” para escolher um timeout correto, eles devem ser relativamente curtos. A recomendação segura é 150% do tempo médio de resposta do serviço.
Use Proxies para componentes que não estão sob controle
Todo componente que não está sob controle do time de desenvolvimento interno e que precisa estar em conformidade com as táticas de resiliência, deve estar “envelopada” por um proxy.
O proxy (Envoy, PgBound, HAProxy, etc) consegue resolver regras como load shedding, time outs, entre outros.
Circuit breaker
Circuit breaker é uma instância de máquina de estados implementada entre dois componentes, um “cliente” e o outro “servidor”. O objetivo de um Circuit breaker é proteger o “servidor” de requisições enquanto este estiver enfrentando dificuldades (potencial saturação).
O funcionamento da máquina de estados é a seguinte:
- Circuito fechado
- Toda requisição do “cliente” deve ser encaminhada ao “servidor”
- Se houverem mais falhas do que o “aceitável” dentro de um intervalo de tempo, então o circuito deve “abrir”.
- Circuito aberto
- Nenhuma requisição do “cliente” deve ser encaminhada ao “servidor”, falhando imediatamente
- Transcorrido um determinado tempo, o circuito deve ficar “meio aberto”
- Circuito meio aberto
- Algumas requisições devem ser encaminhadas para o “servidor”, outras negadas
- Se as falhas persistirem, o circuito deverá “abrir” novamente
- Se as falhas não ocorrerem mais, o circuito deverá “fechar”.
Sumarizando
Estratégias tradicionais para disponibilidade, baseadas em replicação e clusters, não são mais suficientes para manter sistemas confiáveis.
Tão importante quanto garantir que há recursos suficientes para atender as demandas de performance, sem saturação, é também importante adotar práticas e cuidados arquiteturais que identifiquem, previnam e interrompam a propagação de falhas, para que elas não se tornem erros e, finalmente, defeitos.
// TODO
Antes de avançar para o próximo capítulo, recomendo as seguintes reflexões:
- Que padrões para resiliência já adotou?
- Qual a relação entre a complexidade crescente dos sistemas e a demanda por resiliência?
Artigo interessante relacionado com esse assunto: Autonomous Computing https://pathelland.substack.com/p/autonomous-computing-short-version
Dentre as “Táticas para impedir a propagação de falhas”, podemos colocar também a Poison Queue.
Elemar, ao ler a seção “Táticas para impedir a propagação de falhas” me lembrei de Erlang/Elixir onde uma das prerrogativas é o “let it crash”, ou seja, deixar o processo com bug/falha morrer e ser reiniciado com um estado correto de modo que a falha não se propague. O mecanismo de supervisão e modelo de atores – entre outros – propicia a criação de sistemas resilientes (vide Akka para o .NET).