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) time e race 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 e 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. |
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. |
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. |
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: coverage, reliability e 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. |
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. |
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! |
Release It! Sem dúvidas, um dos melhores livros já escritos sobre projeto de sistemas resilientes. |
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. |
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.
Filas geram saturação A formação de filas, invariavelmente, gera saturação de recursos, eventualmente gerando indisponibilidades. Interessado em saber mais? Acessar tópico |
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.
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.
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”.
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.
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
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:
- 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”.
// 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?