Fundamentos para arquiteturas de sistemas resilientes

Ninguém tropeça em montanhas. É a pedra pequena que faz você tropeçar. Passe por todas as pedrinhas em seu caminho e você descobrirá que você atravessou a montanha.
Autor Desconhecido

Software é cada vez mais importante. Hoje em dia, é difícil imaginar qualquer atividade relevante, dentro e fora das organizações, que não seja suportada direta ou indiretamente por software.

Quando software não funciona a produtividade é prejudicada. Muitas vezes, o trabalho é interrompido e resultados deixam de ser atingidos. Em cenários extremos, software que não funciona coloca vidas em risco.

Software está se tornando cada vez mais complexo. Expectativas estão cada vez mais altas, sistemas estão ficando cada vez mais conectados, cargas de trabalho têm variado em proporções crescentes, prazos para desenvolvimento de inovações e adaptações estão cada vez menores.

Nesse contexto, fica evidente a importância crescente da resiliência.

Definição: Resiliência

Resiliência é um atributo de qualidade observado em sistemas que conseguem se adaptar para suportar condições adversas, atendendo continuamente as especificações, retomando, de maneira automática, quando possível, sua configuração ideal.

Defeito (failure), erro (error) e falha (fault)

O desenvolvimento de arquiteturas resilientes implica no perfeito entendimento e diferenciação de três conceitos fundamentais: defeitos, erros, e falhas.

Defeitos (failures)

Um software é dito defeituoso quando se mostra incapaz de operar de acordo com suas especificações – ou seja, a descrição aceita de funcionalidades que atendem os objetivos de negócio, respeitando restrições, atingindo determinados parâmetros em seus atributos de qualidade.

Os tipos mais comuns de defeitos percebidos em sistemas são: 1) crashes inesperados; 2) geração de resultados incorretos e 3) indisponibilidades.

A condição defeituosa de um sistema é perceptível por seus usuários ou por mecanismos de monitoramento.

Defeitos são causados por erros.

Erros (errors)

Um erro é um comportamento inadequado de um sistema que pode originar um defeito. Eles têm relação com valores que um software gera ou armazena e, também, com montantes de tempo demandados para execução do trabalho.

O projeto cuidadoso da arquitetura deve identificar, o mais cedo possível, a ocorrência de erros de forma a “recuperar” o sistema.

Alguns exemplos comuns de erros são: 1) timerace conditions; 2) loops infinitos; 3) desrespeito a protocolos quanto a formato de mensagens, sequência, pré-condições, pós-condições ou invariantes; 4) inconsistência de dados e; 5) incapacidade para suportar o workload.

Falhas (faults)

Uma falha é uma inconformidade, presente no sistema, que pode causar um erro.

Duas constatações são inquestionáveis em um mundo cada vez mais digital. A primeira é que as especificações de qualquer software relevante serão modificadas com frequências cada vez maiores. A segunda é que falhas são inevitáveis!

Três tipos (muito) diferentes de defeitos

Defeitos podem ser classificados em três tipos: inconsistentes, consistentes ou fail-stop.

Defeitos inconsistentes

O pior tipo de defeito que um software pode apresentar é o “inconsistente”.

Um defeito é dito inconsistente quando é manifesto de formas diferentes (com variações percebidas no tempo ou conforme o observador). Um bom exemplo desse tipo de defeito é quando um artefato não responde para determinados clientes, fornece respostas incorretas para outros, sem indicar anomalias perceptíveis para sistemas de monitoramento.

Byzantine Failures

Defeitos inconsistentes também são conhecidos como Byzantine Failiures.

“Contornar” defeitos bizantinos demanda estratégias complexas de clustering adotando, eventualmente, protocolos de consenso como Paxos Raft.

Entendendo o protocolo Raft

Manual do Arquiteto de Software: Sistemas distribuídos

Neste capítulo apresento um protocolo chamado Raft. Uma solução possível para o problema de garantir consenso entre unidades de processamento em sistemas distribuídos.

Ler capítulo

Para um determinado componente que precise ser tolerante a falhas que ocasionem defeitos inconsistentes serão necessárias 3n+1 réplicas (onde n corresponde a quantidade de tipos de falhas).

Practical Byzantine Fault Tolerance and Proactive Recovery

Em 1999, Miguel Castro e Barbara Liskov apresentaram um algoritmo, avançado, para contornar falhas bizantinas de maneira extremamente eficiente.

Acessar artigo

Defeitos consistentes

A segunda categoria de defeitos é a daqueles ditos “consistentes”. Ou seja, manifestações dos “mesmos erros” independente de tempo de execução ou mecanismo observador. 

Para um determinado componente que precise ser tolerante a falhas que ocasionem defeitos consistentes serão necessárias 2n+1 réplicas (onde n corresponde a quantidade de tipos de falhas).

Defeitos fail-stop

A última categoria de defeitos é aquela onde falhas (fail-silent) causam interrupção imediata no funcionamento do componente (crash failure) que a manifesta.

Definição: Fail-silent

Uma falha é dita “silenciosa” quando não impede um componente de produzir resultados corretos ou quando, em outro extremo, o impede de produzir quaisquer resultados.

Para um determinado componente que precise ser tolerante a falhas que ocasionem defeitos fail-stop serão necessárias n+1 réplicas (onde n corresponde a quantidade de tipos de falhas).

The Power of Abstraction

Em 2009, Barbara Liskov, um dos maiores nomes na história da computação, compartilhou sua percepção a respeito da computação e de abstrações. Palestra imperdível.

Acessar vídeo

Métricas relacionadas a resiliência

Não se gerencia o que não se mede, não se mede o que não se define, não se define o que não se entende, e não há sucesso no que não se gerencia.

William Edwards Deming

Há três indicadores importantes para determinar o nível de resiliência de um sistema: coveragereliability availability.

Índice de Cobertura (Coverage factor)

O índice de cobertura é calculado com base na probabilidade de identificar a ocorrência de um determinado tipo de falha e da probabilidade do sistema conseguir se recuperar plenamente, automaticamente, em um tempo considerado razoável.

Confiabilidade (Reliability)

Confiabilidade é a probabilidade de um sistema produzir os resultados esperados, comportando-se conforme a especificação.

De forma simplificada, podemos assumir confiabilidade como sendo:

MTTF, MTTR e MTBF

Três métricas são frequentemente associadas com confiabilidade.

MTTF é o tempo médio para falhas, ou seja, entre um sistema entrar em operação e entrar em estado falho.

MTTR é o tempo médio para um sistema se recuperar de falhas, ou seja, entre entrar em estado falho e estar recuperado.

MTBF é a soma de MTTF e MTTR.

Site Reliability Engineering: How Google Runs Production Systems

Este livro já pode ser considerado um clássico. Ele formaliza SRE – conjunto de práticas para resiliência da Google.

Acessar livro

Disponibilidade (Availability)

A disponibilidade é a proporção do tempo em uptime (MTTF) e o tempo total em operação, considerando o tempo em downtime (MTTF + MTTR).

Mindset para criação de sistemas resilientes

Um defeito pode ser causado pela ocorrência de diferentes tipos de erros que, por sua vez, podem ser causados por diversas  falhas. Por exemplo, um sistema de e-commerce será defeituoso caso não consiga gerar pedidos assertivamente. Pedidos estarão “errados” quando relacionarem itens não solicitados pelos clientes ou quando, por outro lado, deixarem de relacionar itens solicitados. As falhas que podem gerar esses dois erros, por sua vez, são diversas.

O mindset para criação de sistemas resilientes é desenvolvido pelo hábito de questionar, continuamente, em todas as atividades de engenharia, o que pode potencialmente dar errado como resultante do trabalho que está sendo realizado ou das decisões que estão sendo tomadas.
O arquiteto de software colabora com a resiliência, questionando especialistas sobre potenciais fontes de falha e registrando respostas nas ADRs.

Times com mindset resiliente empregam práticas de programação defensiva, adotam pontos de tensão (como em metodologias de chaos engineering para, de forma responsável, identificar e mitigar falhas), adotam testes automatizados, invariavelmente adicionam redundância ao projetar design, utilizam ferramentas de análise estática.

Deprecating Simplicity 3.0

Nesta palestra, Casey Rosenthal, idealizador do Chaos Engineering, compartilha suas percepções sobre gestão da complexidade e resiliência. Imperdível!

Acessar vídeo

Release It!

Sem dúvidas, um dos melhores livros já escritos sobre projeto de sistemas resilientes.

Acessar livro

Iniciativas arquiteturais para resiliência

Sob o ponto de vista da arquitetura de software, o projeto de sistemas resilientes implica em considerar, nas decisões de design alternativas, para: 1) detectar a ocorrência erros; 2) recuperar o sistema de um erro; 3) impedir a propagação e; 4) tratar falhas.

Atenção especial deve ser dada as estratégias que serão empregadas para suportar a carga no período entre um erro ser detectado e o sistema estar plenamente recuperado e pronto para continuar (MTTR).

Modularizar considerando unidades de mitigação

Uma das principais atribuições do design arquitetural é segmentar responsabilidades em módulos de forma a reduzir a complexidade. Sob a perspectiva do desenvolvimento de sistemas resilientes, a modularização também deve levar em conta a possibilidade de criar “unidades de mitigação”.

Todo módulo ou componente em uma arquitetura é, por definição, uma unidade de mitigação. Afinal, pode possuir estratégias para detectar e impedir a propagação de erros que coloquem o sistema inteiro em estado defeituoso.

Unidades de mitigação bem delimitadas habilitam o uso eficiente de mecanismos de failover (tais como fallbacks). Além disso, isolam intervenções para detecção de erros e para recuperação.

Adicionar mecanismos de auditoria e correção de dados

Parâmetros falhos, geram dados falhos que colocam sistemas em estados falhos que, eventualmente, causam erros.

O projeto arquitetural deve considerar, com muito cuidado, comunicações, sobretudo entre unidades de mitigação, de forma a validar que contratos estão sendo respeitados – adicionando verificações para pré-condições, pós-condições e invariantes.

Volumes maiores de dados ou dados sensíveis devem ser assinados e verificados.

Eventualmente, implementações alternativas para um mesmo serviço podem ser executados, paralelamente, tanto para melhorar o desempenho percebido quanto para comparar resultados.

Adotar estratégias de redundância adequadas ao tipo de erros

Como minimizar os impactos percebidos no período entre um erro ser detectado em um componente, em produção, e o procedimento de recuperação estar concluído? Adotando redundância adequada, tanto em funcionamento quando em quantidade.

Lembre-se, tipos diferentes de defeitos demandam volumes diferentes de redundância.

Adotar redundância adequada considerando workloads

Esperança não é estratégia.

Frase popular entre praticantes de SRE

Considere um sistema que precise suportar, consistentemente, 1.000 requisições por segundo. Cada servidor de aplicação deste sistema demonstra capacidade para suportar 300 requisições por segundo. Daí, infere-se a necessidade de quatro instâncias.

O problema é que uma eventual indisponibilidade em um dos servidores implica em sobrecarga para as demais instâncias.

Com o traffic intensity desfavorável (arrival rate > departure rate) ocorre, então, provável saturação de recursos (falha) levando a erros e defeitos (indisponibilidade do serviço) em, provavelmente, pouco tempo.

A estratégia para resiliência, no exemplo, consiste em adicionar redundância. Em termos simples, um quinto servidor “sobrando” seria a forma evidente para tolerar falhas.

Redundância, essencial para resiliência, implica em aumento de custos.

Não há resiliência sem redundância. Entretanto, esta é apenas a primeira medida e, provavelmente, a mais cara.

Adotar recovery blocks e fallbacks

Unidades de mitigação críticas para o funcionamento de um sistema devem, invariavelmente, possuir mecanismos de fallback.

Definição: Fallback

Fallback  é uma alternativa de substituição para uma determinada unidade de mitigação, capaz de cumprir sua premissa funcional enquanto ela estiver indisponível ou inviável.

Eventualmente, deve-se considerar o desenvolvimento de implementações alternativas (recovery blocks), dentro de uma mesma organização, para uma mesma unidade de mitigação, como forma de mitigar as chances de falhas de código ou de entendimento.

Projetar interfaces administrativas

A execução de atividades administrativas devem ter pouco impacto no desempenho de uma solução. Idealmente, elas estão apartadas e permitem a execução de operações corretivas, bem como verificações de sanidade (como health checkers).

Readiness e Liveness

A adoção de tecnologias como Kubernetes levaram a uma ampliação do conceito de health checking. Modernamente admite-se dois tipos diferentes de verificação: readiness e liveness.

Readiness trata da “prontidão” de um pod para tratar workload. Tal prontidão é impactada pelo pod em si, mas, também, por suas dependências.

Liveness trata da saúde de um pod. É indicativo para a necessidade de “reciclagem”.

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.

Considere ter duas versões de healthcheckers: para consumo interno e para consumo externo.

Interfaces administrativas devem ser projetadas para operar em nível de segurança diferentes.

Projetar agentes e critérios de decisão e escalation

Agentes de decisão são componentes de software, como watchdogs, desenvolvidos interna ou externamente, que consomem as interfaces administrativas afim de detectar a ocorrência de defeitos em componentes do software, eventualmente, disparando ações corretivas.

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.

Adotar 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.

Adotar Bulkheads

Considere um sistema com três componentes: “A”, “B” e “C”. Onde “A” e “B” dependem sincronamente de “C” para funcionar.

Caso “C” fique instável, “A” e “B” podem ter dificuldades para continuar funcionando bem, o que pode espalhar falhas em “efeito dominó”. Ou seja, a instabilidade de “C” pode acabar gerando instabilidades em “A” e “B” que, eventualmente, irão causar instabilidades em outros componentes.


A maioria dos projetos de sistemas onde há dependência forte entre componentes têm contingência para falhas mais graves. No exemplo, provavelmente “A” e “B” estão preparados para cenários onde “C” simplesmente pare de responder. Entretanto, poucas vezes encontramos arquiteturas preparadas para lidar com response times mais altos. Geralmente, lentidão, mais do que indisponibilidade total, é a causa raiz da maioria dos problemas mais críticos em produção.
0
Considerações?x

Uma forma comum de tentar mitigar os impactos de instabilidades em um componente é torná-lo escalável na horizontal para, então, levantar várias instâncias com demanda distribuída por um load balancer.

O problema, entretanto, é que, com frequência, instabilidades, sobretudo de lentidão, são ocasionadas por dificuldades de um componente para atender “requisições envenenadas” provenientes de um “ofensor externo”. Em nosso exemplo, caso “A” gere “requisições envenenadas” para “C”, estas podem acabar afetando todas as instâncias do serviço, mesmo escalados na horizontal, apenas retardando os efeitos ruins, sem evitá-los.

Uma alternativa eficiente para mitigar o risco dessa situação é compartimentar instâncias para as diversas origens de requisição. Ou seja, “levantar” instâncias específicas para cada natureza de solicitação, mantendo-as apartadas.

Dessa forma, a “pressão” de “A” sobre “C” não impactará “B”. Nesse mesmo raciocínio, a “pressão” de “B” sobre “C” também não causará problemas para “A”.

No “mundo real”, já vimos essa solução ser aplicada, por exemplo, em grandes varejistas para apartar o tratamento de requisições de aplicações móveis, site, robôs de comparação de preços e o Google (como você pode imaginar, para um varejista, é tremendamente prejudicial ser “penalizado” pelo gigante das buscas).
0
Mais exemplos?x

O nome dessa técnica de design é bulkhead, em alusão a forma como navios eram projetados para impedir que danos em uma parte do cascos causassem um naufrágio.

Bulkheads obviamente, introduzem ociosidade desnecessária (leia-se desperdício) de recursos a uma arquitetura, tornando-a economicamente menos interessante. Entretanto, essa ineficiência pode ser necessária e, geralmente, é mais do que compensada com a minimização e isolamento de downtimes. Um de seus principais atrativos é que pode ser adotada, frequentemente, com poucas alterações (ou até nenhuma) de código.
0
Considerações?x

Suportar 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.

Suportar 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.

Determinar Timeouts 

Pior do que componentes que param de responder são aqueles que passam a operar com lentidão incomum.
0
Consideraçõesx
 Estabelecer timeouts é importante para impedir que componentes clientes esperem “tempo demais”, seja para solicitações síncronas quanto assíncronas.


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.

Adotar 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.

Adotar o padrão Transaction Outbox

Comandos – requisições que modificam o estado do servidor – demandam, atomicamente, atualizações em bancos de dados e envio de mensagens (via algum mecanismo de mensageria).

Em termos simples, em uma transação, quando um commit acontece, mensagens devem ser enviadas. Entretanto, se for necessário um rollback,

A saída simples é adicionar uma tabela adicional, no banco de dados onde as transações estão sendo processadas, para “registrar” a demanda do envio de uma mensagem. Ao executar uma transação, essa tabela deve receber, também uma inclusão. Outro agente fica responsável por “ler a tabela” e efetivar o envio de mensagens.

Implantar Circuit breakers

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:

  1. 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”.
  2. Circuito aberto
    • Nenhuma requisição do “cliente” deve ser encaminhada ao “servidor”, falhando imediatamente
    • Transcorrido um determinado tempo, o circuito deve ficar “meio aberto”
  3. Circuito meio aberto
    1. Algumas requisições devem ser encaminhadas para o “servidor”,  outras negadas
    2. Se as falhas persistirem, o circuito deverá “abrir” novamente
    3. Se as falhas não ocorrerem mais, o circuito deverá “fechar”.

// TODO

Antes de avançar para o próximo capítulo, recomendo as seguintes reflexões:

  1. Que padrões para resiliência já adotou?
  2. Qual a relação entre a complexidade crescente dos sistemas e a demanda por resiliência?

Referências bibliográficas

ERDER, Murat; PUREUR, Pierre; WOODS, Eoin. Continuous Architecture in Practice: software architecture in the age of agility and devops. Boston, Ma: Addison-Wesley, 2021. (Vaughn Vernon signature).

HANMER, Robert S.. Patterns for Fault Tolerant Software. San Francisco, Ca: John Wiley & Sons Ltd, 2007.

NYGARD, Michael T.. Release It!: design and deploy production-ready software. 2. ed. Gravenstein Highway North, Ca: Pragmatic Booksheld, 2018. 518 p.

Compartilhe este capítulo:

Compartilhe:

Comentários

Participe da construção deste capítulo deixando seu comentário:

Inscrever-se
Notify of
guest
0 Comentários
Feedbacks interativos
Ver todos os comentários

Fundador e CEO da EximiaCo, atua como tech trusted advisor ajudando diversas empresas a gerar mais resultados através da tecnologia.

Mentoria

para arquitetos de software

Imersão, em grupo, supervisionada por Elemar Júnior, onde serão discutidos tópicos avançados de arquitetura de software, extraídos de cenários reais, com ênfase em systems design.

Consultoria e Assessoria em

Arquitetura de Software

EximiaCo oferece a alocação de um Arquiteto de Software em sua empresa para orientar seu time no uso das melhores práticas de arquitetura para projetar a evolução consistente de suas aplicações.

ElemarJúnior

Fundador e CEO da EximiaCo, atua como tech trusted advisor ajudando diversas empresas a gerar mais resultados através da tecnologia.

+55 51 99942-0609 |  contato@eximia.co

+55 51 99942-0609  contato@eximia.co

0
Quero saber a sua opinião, deixe seu comentáriox
()
x