Pontos Principais
- Redundância e isolamento: dois lados de uma mesma moeda;
- Proteger-se dos seus próprios bugs e não apenas de fatores ambientais;
- É mais fácil uma lentidão ultrapassar as barreiras entre os serviços do que passar por indisponibilidade;
- Evite APIs síncronas, porque permitem propagar falhas;
- Padrões estáveis de nuvem funcionam, mesmo sem schedulers e malhas. O "dia um" da arquitetura deve ser o mais simples possível.
A resiliência significa ter tolerância a falhas e não eliminá-las.
Evitar falhas a todo custo é um desperdício. Seguir por esta opção resultará na construção de um sistema irremediavelmente frágil. Para construir um sistema resiliente, este deve estar preparado para absorver qualquer tipo de problema e continuar ou se recuperar.
O Starling Bank foi construído do zero em um ano, contra um cenário de interrupções altamente públicas entre os bancos incumbentes. Os engenheiros sabiam que seriam necessárias abordagens infalíveis para resiliência. Era necessário ter a garantia que a engenharia do caos permite, a busca focada em simplicidade e a priorização brutal que tem permitido as melhores startups crescerem.
Estes são os princípios e crenças que regem a abordagem para a resiliência no Starling Bank. Existem algumas características desta abordagem que não são cobertas neste artigo, mas que sem dúvida são essenciais, sendo:
- Engenharia do Caos - pois a resiliência tem que ser testada;
- Monitoramento - a resiliência requer suportabilidade;
- Respostas a incidentes - porque a resiliência se aplica tanto para o sistema quanto para a organização que o opera.
No entanto, este artigo foca em uma arquitetura resiliente a partir da infraestrutura.
Arquitetura Resiliente
Ter um serviço resiliente para os clientes significa garantir que quando ocorrer uma falha, a parte do sistema afetada pelo erro seja pequena em comparação ao sistema com um todo.
Existem duas formas de ter esta garantia. A redundância consiste em garantir que o sistema como um todo se estenda além do escopo da falha. Por mais que isso seja prejudicial, deve-se ter uma reserva. O isolamento significa garantir que o escopo da falha seja contido em uma pequena área e não se espalhe além dos limites do sistema.
Apesar do sistema ter seu design definido, deve-se ter respostas para ambas as formas. Com isto em mente, é preciso testar este design mentalmente contra todas as falhas imagináveis.
Para ajudar, considere as dimensões de falhas a seguir:
- Falhas no nível infra-estrutural (como falhas na rede), bem como falhas no nível de aplicação (como exceções ou panes);
- Falhas que são intrínsecas ao software construído (causadas no desenvolvimento, como bugs) e aquelas que são extrínsecas (causadas por outros, por exemplo mensagens inválidas).
Não é suficiente assumir que falhas intrínsecas serão encontrados nos testes. É igualmente importante proteger-se das falhas internas como das falhas externas.
O maior fator em quão fácil é raciocinar sobre falhas é a simplicidade do design, por isso escolha sempre o design mais simples que se adapte à necessidade.
Redundância
É mais simples ter uma infraestrutura redundante na nuvem. A infraestrutura como código permite criar muito do que precisar de forma tão fácil quanto criar apenas um recurso.
Os provedores de nuvens, como AWS e GCP, permitem IaaS primitivos como balanceadores de carga e grupos de escalonamento com padrões bem documentados para saber como melhor usá-los. Os ambientes de execução como Kubernetes oferecem recursos para publicações e controles de replicação que permitem continuamente que muitas réplicas de um serviço estejam funcionando e disponíveis ao mesmo tempo. As tecnologias serverless irão escalar seus serviços sem nenhuma configuração.
No entanto existem ressalvas.
O estado é um velho inimigo. Torna-se muito mais difícil escalar os serviços quando instâncias fazem suposições sobre a precisão de um estado local. Ou o estado precisa ser compartilhado externamente por meio de uma via de armazenamento de dados altamente disponível, ou mecanismos de coordenação de clusters devem ser introduzidos para garantir que cada instância local esteja consistente com as instâncias pares, em efeito tornando os servidores em um cache distribuído.
A recomendação, ao menos para startups, é estabelecer um "dia um" realmente simples. Com o banco de dados sendo usado para todos os estados compartilhados e estes estados sendo usados até que o limite seja atingido. Isto resultará em uma camada computacional claramente "imutável" e um banco de dados que terá requisitos operacionais compreensíveis. A infraestrutura deverá ser evoluída em algum momento, por isso deve ser arquitetada para mudanças e deve ter suas métricas monitoradas. Até o momento da mudança, o escalonamento simples e a redundância são a melhor estratégia.
Ambientes serverless normalmente oferecem um serviço de função stateless e serviços de banco de dados separados para estados, o que torna o padrão de separação simples e limpo.
A limitação de recursos compartilhados pode ser outro problema. Não faz sentido escalar um serviço para 10 instâncias quando o banco de dados aceita conexões suficientes para suportar somente 3. Na verdade, escalar serviços pode ser perigoso quando isto carrega componentes que são impossíveis de suportar. Aumentar a capacidade em uma parte dos sistema pode impactar o sistema como um todo. A verdade é que para entender e encontrar todos esses limites é necessário um alto grau de testes.
Isolamento
Na camada de infraestrutura da nuvem, a AWS oferece zonas de disponibilidade separadas em todas as regiões, que fornece forte isolamento entre os componentes de infraestrutura. Balanceadores de carga e grupos de escalonamento podem abranger múltiplas zonas de disponibilidade.
Os padrões estabelecidos para construir na AWS garantem que o básico seja feito corretamente. Outros provedores de nuvens têm capacidades similares.
Na camada de aplicação, os microservices, os sistemas auto-contidos e até mesmo o tradicional SOA tem um design de abordagens que podem induzir a fortes barreiras entre serviços, permitindo que estes componentes falhem independentemente sem comprometer a viabilidade do todo.
Se uma única instância de um serviço fica indisponível, o serviço não é interrompido. Se todas as instâncias de um serviço ficam indisponíveis, a experiência do cliente pode ser prejudicada de alguma forma, mas os outros serviços devem continuar funcionando.
Usando este esquema, quanto mais o sistema for dividido, menor será a amplitude do impacto quando um sistema falhar. Mas deve-se ficar atento aos detalhes. Existem diversas formas que os serviços podem depender de um outro que ultrapassa os limites entre serviços, permitindo a propagação de erros pelo sistema. Quando os serviços têm uma relação muito próxima, pode ser mais simples tratá-los e construí-los como um serviço único.
Algumas formas podem ser menos claras, como protocolos ocultos. As instâncias de um serviço podem ser conectadas por meio de protocolos de cluster que executam uma eleição ou protocolos de confirmação de duas fases que votam por toda a rede em cada transação. Estas são interações que engenheiros frequentemente abstraem, mas que amarram os serviços juntos em formas que permitem a propagação de erros.
Como na vida, a coabitação não é uma decisão fácil. Se vários serviços estão em execução em uma mesma instância, seja por deliberação, agrupamento estático ou usando schedulers como o Kubernetes, estes serviços estão menos isolados do que se estivessem em máquinas separadas. Todos estão propícios às mesmas condições de falhas - não menos a falhas de hardware.
Se as instâncias dos serviços X, Y e Z executam juntas no mesmo host e o serviço X tem problemas que afetam o host, o serviço X afetará também os serviços Y e Z, independente da quantidade de instâncias. Ao agrupá-los, será eliminado o isolamento entre os serviços, mesmo que a tecnologia utilizada devesse garantir que isto não ocorra.
Os schedulers podem mitigar este tipo de situação ao evitar que o mesmo grupo de serviços sejam publicados em conjunto. Então, se um serviço com problema afeta seu host, isto não afetará qualquer outro serviço, mas, em vez disso, um impacto parcial mais difuso em toda uma gama mais ampla de serviços que pode não afetar a experiência do cliente.
Na arquitetura de publicação, o Starling Bank optou por um modelo de serviço único por instância do EC2. Este foi um trade-off muito voltado para a simplicidade e não para a economia, mas que também oferece um isolamento muito forte. A perda de uma instância quase não se nota. Mesmo que fosse implantado um serviço que causasse problemas ao seu host, ele não poderia afetar facilmente outros serviços.
Existem diversas formas de propagar erros na camada de aplicação. Talvez o fator mais perverso seja seu próprio tempo.
Em qualquer sistema tradicional, o tempo é sem dúvida o maior recurso de valor. Se um serviço não está agendado em um processador, recursos vitais podem ser bloqueados até que todo o processamento seja executado, por exemplo: conexões, bloqueios, arquivos e sockets. Se houver demora, isto pode privar que outros serviços utilizem deste recurso, e se uma outra thread não puder progredir, esta pode não liberar os recursos que está usando. Isto pode forçar que outros serviços fiquem em comportamento de espera, o que pode ser propagado pelo sistema.
Enquanto deadlocks são tidos como grandes vilões na programação concorrente, na realidade a falta de recurso causado por transações lentas é provavelmente o problema mais comum.
Isto é facilmente ignorado. O primeiro instinto de um engenheiro é ser correto e seguro: faça um bloqueio, execute uma transação, talvez considere brevemente usar um temporizador e então o desconsidera - (engenheiros odeiam qualquer coisa que pareça arbitrário, então escolhem a opção talvez não tão eficiente de usar um temporizador). Estas simples tentações durante o desenvolvimento podem causar problemas em produção.
Lentidão e atrasos não podem ser propagados a outros sistemas e, a menos que se tenha soluções para precaver este tipo de comportamento, não é difícil imaginar cenários em que o sistema esteja vulnerável. Existem pools de conexões de banco de dados com tamanho fixo? Ou existem pools de processamento? Existe a certeza de que nunca haverá um bloqueio das conexões de banco de dados durante uma chamada REST? E se existe, é possível então utilizar um pool de conexões à exaustão quando a rede tiver problemas? O que mais poderia gastar o tempo? E pontos que são mais difíceis de encontrar como os registros de logs? Ou talvez existam métricas sendo geradas repetidamente que precisam de acesso ao banco de dados que começa a falhar por já estar sobrecarregado, introduzindo toneladas de registros inúteis nos logs. Talvez se torne necessário ir além dos limites da arquitetura de logs e perder a visibilidade de tudo o que está acontecendo. É infinita a possibilidade de cenários ruins que podem começar por uma simples lentidão.
Precauções abundantes: circuit breakers, balanceamento de carga, rate limit, auto dimensionamento, entre outros. Existem bibliotecas que ajudam a implementar estes padrões nos softwares (hystrix e resilience4j) e frameworks de service mesh ou equivalentes que os oferecem em um nível mais baixo (istio, conduit e linkerd). Mas nenhuma destas soluções irá fazer o que for necessário sem investir tempo ou esforço. Como sempre, o desempenho e os modos de falha de qualquer trecho de código voltam diretamente para o engenheiro.
Chamadas síncronas de um serviço tomam tempo de execução emprestado de outro serviço. Em uma API síncrona, por sólidos princípios de design, geralmente não se tem ideia do quão precioso o tempo é para quem a chama ou quão vulnerável ele está caso ocorra uma lentidão ou atraso na resposta. Cria-se um refém. Por esta razão, os engenheiros do Starling Bank preferiram APIs assíncronas entre serviços e o uso de APIs síncronas somente quando absolutamente necessário ou para chamadas de consultas muito simples.
Os front-ends podem fazer alterações "otimistas" nas interfaces ao invés de esperar por confirmação e mostrar informações obsoletas quando estiver offline. Na maioria dos casos existem custos para se tomar decisões sobre informações obsoletas, mas este custo não precisa ser proibitivo. Como Pat Helland escreveu: "a computação consiste em memórias, palpites e desculpas". Sempre há custo quando as informações de um sistema divergem da realidade, e as informações sempre irão divergir da realidade no momento em que a realidade mudar. Em sistemas distribuídos não há um "agora" real. É preciso avaliar o custo de estar "errado" antes de decidir se é preciso ter semânticas transacionais perfeitas ou respostas síncronas.
Para sistemas bancários, permitir falhas significa permitir a falha sem perder a informação. Então, enquanto filas não são necessárias para implementar sistemas assíncronos, o sistema como um todo deve manifestar algumas propriedades idênticas às das filas, como entregas ao-menos-uma-vez e no-máximo-uma-vez mesmo quando houver falhas. Assim pode-se garantir que algum serviço sempre tomará uma ação para um conjunto de dados ou um comando.
Atingir ambos é possível usando a idempotência e o processamento catch-up nos serviços (como na arquitetura "DITTO" do Starling Bank com muitos serviços autônomos tentando continuamente fazer a idempotência entre eles), usar filas para garantir entregas ao-menos-uma-vez com cargas idempotentes para entregas no-máximo-uma-vez (como na infraestrutura de Kafka do Nubank), ou tentar delegar ambos para um "enterprise service bus" (como em diversas arquiteturas legadas dos bancos).
Usando estas técnicas, é perfeitamente possível criar aplicações responsivas com suporte entre serviços assíncronos.
Resumo
Hoje em dia, é muito mais fácil construir sistemas resilientes. Nos primeiros doze meses de operação, o Starling Bank sofreu diversas falhas localizadas e degradações de partes do sistema, mas nunca tiveram uma indisponibilidade completa do sistema. Isto vem principalmente da ênfase na redundância e isolamento em todos os níveis.
Os engenheiros projetaram uma arquitetura que tolera a interrupção de serviços e tratam seriamente a percepção de que um serviço lento pode ser uma ameaça maior ao usuário final que um serviço inativo.
Nada disso importaria se o Starling Bank não tivesse uma abordagem sólida para os itens listados no início: engenharia do caos, monitoração e resposta a incidentes, mas estes são pontos a ser abordados em outro artigo.
Sobre o Autor
Greg Hawkins é um consultor independente de tecnologia, fintech, cloud e devops. Ele foi o CTO do Starling Bank entre 2016 e 2018, o banco desafiador mobile-only do Reino Unido. Durante este tempo a fintech adquiriu a licença para operação bancária e foi de zero downloads aos impressivos mais de 100 mil downloads em ambas as plataformas móveis. Hawkins continua sendo um conselheiro sênior no Starling Bank. O Starling Bank construiu sistemas bancários full-stack do zero, tornou-se a primeira conta corrente do Reino Unido disponível para o público em geral a ser implantada inteiramente na nuvem e atendeu a todas as expectativas de regulamentação, segurança e disponibilidade que se aplicam ao banco de varejo.