- Iniciando
- Documentação
Arquitetura
Políticas e Padrões
Infraestrutura
- Ecossistema
Ambientes
Integração Contínua
Monitoramento
Utilitários
Documentos
- Equipe
Estratégia Arquitetural
Existem diferentes formas de projetar software. Pelos últimos 20 anos, a indústria de software tem usado vários métodos para criar seus produtos, cada um com suas vantagens e desvantagens. Trabalhamos com a intenção de aplicar um método que surgiu e evoluiu ao longo das duas últimas décadas, mas que vem se consolidando mais claramente durante os últimos anos: Domain-Driven Design
Acreditamos que DDD só mostra seu real valor quando aplicado em nível estratégico. Acreditamos que podemos usar DDD para decompor nossas soluções atuais, que são todas elas monolíticas em essência, em serviços fisicamente distribuídos, e acreditamos que usar a arquitetura baseada em microserviços seja a solução adequada para o momento.
Nosso objetivo aqui não é apresentar DDD ou a arquitetura de microserviços, mas sim demonstrar como nós, aqui no STF, lidamos com todos os novos desafios inerentes a implantação de tal abordagem para definirmos um modelo de referência que suporte a definição, a construção, a implantação e a operação dos nossos produtos.
Modelo Conceitual
Um ponto chave para construção de um modelo de referência é a definição de um modelo conceitual, com diretrizes para definição da granularidade, das fronteiras e das interações entre os componentes da arquitetura. Sem esse modelo, nós provavelmente voltaríamos a algo como a Big Ball of Mud, ou arquitetura espaguete, como é popularmente conhecida.
Achar um forma de sair de uma arquitetura monolítica para uma solução modular, distribuída, talvez seja o maior desafio aqui. Essencialmente, o que precisamos é encontrar uma forma de aplicar o princípio da responsabilidade única ao nível arquitetural. Domain-Driven Design, criado por Eric Evans, introduz uma técnica chamada Strategic Design, que nos ajuda a modelar grandes soluções como um conjunto de soluções menores. O pattern central da ferramenta é o Bounded Context. Com DDD, cada pequeno modelo é projetado como um Bounded Context, ou seja, cada BC tem seu próprio conjunto de conceitos, seu próprio modelo, ou, ainda, na linguagem DDD, tem sua própria Ubiquitous Language. Com Strategic Design, o conjunto dos vários BC’s que compõem a solução deve ser mapeado em um Context Map, que explicita o tipo de relacionamento entre eles. Esses relacionamentos são especialmente importantes porque em um cenário em que múltiplas equipes estão envolvidas na solução, uma por BC, o tipo de relacionamento entre os BC’s define também o tipo de relacionamento entre as equipes.
Usaremos um BC por serviço porque, como o próprio Evans, acreditamos que “a forma como um microserviço opera faz dele um ótimo bounded context”. Portanto, horizontalmente, o mapa com os contextos definirá as fronteiras de cada microserviço e as integrações entre eles. Verticalmente, os serviços devem ser organizados em camadas, de acordo com o tipo de serviço, conforme ilustrado na figura abaixo.
Uma importante consequência dessa divisão é que você pode usar diferentes arquiteturas em diferentes bounded contexts. Por exemplo, um BC pode ser implementado usando uma arquitetura baseada em DDD, outro pode usar uma arquitetura em três camadas para CRUD’s e outra pode usar uma arquitetura derivada do pattern CQRS. A figura abaixo ilustra o sistema com múltiplos BC’s, cada um usando um estilo arquitetural diferente. Observe também que cada BC é full-stack, contém tudo o que é necessário para implementar uma determinada função de negócio, desde a persistência de dados até a interface do usuário, que juntamente com a interface de outros BC’s compõe a interface final do sistema, no caso, formando uma Single-Page Application.
Uma vantagem dessa abordagem é que você pode definir a arquitetura mais apropriada considerando as características de cada contexto. Por exemplo, um contexto com funcionalidades acessórias, de cadastro, não demanda a aplicação de uma arquitetura como DDD, que só entrega benefícios quando aplicado a um contexto mais complexo, geralmente presentes nos principais contextos que compõem a solução, denominados Core Domain.
Até pouco tempo atrás o estilo arquitetural dominante era o famoso modelo data-centric, com a arquitetura em três camadas (apresentação, negócio e persistência). Recentemente, passamos a observar uma mudança, da arquitetura data-centric para uma arquitetura mais model-centric. Existem duas grandes diferenças entre essas duas arquiteturas. Primeiro, a camada de negócio é dividida em duas novas camadas: application layer e domain layer. Segundo, a camada de persistência se transforma em uma camada de infraestrutura.
Ao mesmo tempo, também observamos o crescimento de Event-Driven Architecture, com desenvolvedores cada vez mais experimentando os benefícios de padrões como Command/Query Responsibility Segregation (CQRS) e Event Sourcing (ES) CQRS não é uma abordagem abrangente como, por exemplo, é o caso com DDD. CQRS é um padrão que serve como guia para definição da arquitetura de um BC específico, possívelmente em um sistema complexo. Executar o design estratégico com DDD e identificar cada BC de seu sistema continua sendo o primeiro passo. Depois, CQRS pode ser uma alternativa válida para Domain Model e CRUD para implementação de um BC em particular.
A mudança que CQRS introduz é a possibilidade de dividir o modelo conceitual de um BC em modelos separados para alteração e consulta de informações. Como qualquer outro pattern, CQRS não se aplica para todos os casos. A lógica é que em alguns casos, particularmente em domínios mais sofisticados, ter o mesmo modelo conceitual para operações de alteração e consulta pode levar a um modelo mais complexo, desnecessariamente. CQRS é ideal para sistemas colaborativos, task-based, em que múltiplos usuários podem alterar/consultar as mesmas informações. Nesse tipo de sistema, os usuários competem pelos mesmos recursos, o que significa que cada um deles pode estar vendo informações desatualizadas. Isso ocorre porque a lógica de negócio é particularmente complexa e geralmente envolve diversos módulos, alguns dos quais podendo até mesmo demandar carregamento dinâmico. Com CQRS a lógica de negócio pode ser decomposta em um conjunto de comandos individuais, que podem ser gerenciados mais facilmente, reduzindo a complexidade geral do sistema. De outra parte, a exigência de múltiplas representações da mesma informação pode ser tratada com modelos específicos para consultas específicas, o que também ajuda a resolver potenciais problemas de performance.
Existem algumas variações do pattern. Os modelos separados podem estar em módulos diferentes e talvez até em hardware’s diferentes. Os modelos podem compartilhar a mesma base de dados, que pode usada para integração entre os dois modelos. Neste caso, embora menos comum, os modelos podem inclusive compartilhar objetos. No caso de usarem bases separadas, deve haver um mecanimo de comunicação entre os dois modelos.
Como vimos, CQRS é apenas um guia. Ele direciona a definição do modelo conceitual de cada bounded context. Se aplicável, a implementação de cada modelo deve ser feita de acordo com suas características. Geralmente, podemos aplicar DDD para construção do modelo de escrita (Command Stack), enquanto uma abordagem mais data-centric pode ser aplicada para construção do modelo de leitura (Query Stack).
Componentes
Encontrar uma forma de construir soluções menores é um dos nossos grandes desafios, como vimos anteriormente, no entanto existem vários outros. Para endereçar cada um deles, vamos precisar de novas soluções, que impactarão diretamente na arquitetura. Abaixo, o conjunto de componentes que julgamos importantes:
- Configuration Service Invés de um componente de configuração local, por serviço, nós precisamos de um serviço centralizado para gerenciamento das configurações. Os demais serviços deverão acessar esse serviço via API para carregamento das informações de configuração.
- Service Discovery Invés de acompanhar manualmente cada novo serviço implantado e associar um nome ao host e porta, nós precisamos de um serviço de consulta de nomes, com API para permitir que cada novo serviço se auto registre assim que for iniciado.
- Dynamic Routing e Load Balancer Componentes de routeamento podem usar as informações do Service Discovery para identificar quais os locais onde um serviço está ativo e componentes de balanceamento de carga podem decidir para onde redirecionar uma requisição se houver mais de um serviço ativo.
- Circuit Breaker Para evitar a propagação de falhas por toda a cadeia de serviços, nós podemos aplicar o Circuit Breaker pattern. Ele basicamente monitora falhas nas chamadas a um dado serviço. Se as falhas alcançarem um certo limite, ele “desarma” o circuito, e novas chamadas ao serviço sempre retornarão o mesmo erro, sem que o serviço seja invocado novamente.
- Monitoring Service Dado que temos circuit breakers monitorando as chamadas a cada serviço, nós podemos coletar informações estatísticas para determinar a saúde de cada serviço. Essa informação pode ser apresentada em dashboards com possibilidade de adicionar alertar automáticos para limites configuráveis.
- Logging Service Para rastrear mensagem de log e diagnosticar problemas mais rápido, nós precisamos de um serviço de análise de logs centralizado, que seja capaz de coletar as mensagens de log de cada serviço. Esse serviço deve armazenar essas informações de log em um banco de dados central e fornecer funcionalidades para pesquisa e montagem de dashboards.
- Gateway Service Para expor a API dos serviços externamente e prevenir acessa não autorizadoa serviços internos, nós precisamos de um servidor de borda, por onde passe todo o tráfego externo. Esse servidor pode usar os componentes de roteamento e balanceamento de carga baseado no serviço de discovery descrito acima. Ele deverá atuar como um proxy reverso, que não precisa ser manualmente atualizado quando algum dos serviços internos for alterado.
- Security Service Para proteger a API dos serviços, o padrão OAuth 2.0 standard é o recomendado. Utilizaremos um componente para atuar OAuthAuthorization Server. Os serviços atuarão como OAuth Resource Server. Os consulmidores atuarão com OAuth Clients. O Edge Server vai atuar como OAuth Token Relay, o que significa que ele vai agir como OAuth Resource Server e vai passar sempre repassar o OAuth Access Tokens que vem nas requisições externas para a API dos serviços.
Modelo de Referência
Se aplicarmos ao Modelo Conceitual descrito acima o conjunto de serviços de suporte necessários para viabilizar uma arquitetura como a descrita aqui, teremos o Modelo de Referência ilustrado na figura abaixo.
Implementação
Até aqui definimos um modelo de referência para construção do ecosistema que suportará a arquitetura proposta. Agora, já entrando na construção desse ecosistema, precisamos definir como implementar cada componente do modelo. Spring Cloud, Netflix OSS e ELX Stach serão usados para implementar todos os componentes necessários.
Depois de uma breve introdução aos componentes do Spring Cloud, o Netflix OSS e da ELX, nós vamos apresentar o ecosistema que usaremos para implementar os serviços da autuação de processos.
Componentes
Spring Cloud é um projeto relativamente novo na família spring.io. Ele fornece um conjunto de componentes que podem ser usados para implementar o modelo conceitual descrito acima. Grande parte deles usam os componente da Netflix OSS, fornecendo uma forma mais simples de configuração e utilização baseada na proposta Spring Boot. Ou seja, você pode usar Spring Boot para configurar e usar qualquer dos componentes da Netflix utilizando, parar tanto, a camada de abstração fornecida pelos componentes do Spring Cloud. A tabela abaixo mapeia os componentes genéricos do nosso modelo conceitual para os componentes que efetivamente vão ser usados nos casos de uso reais.
Componentes Arquiteturais | |
---|---|
Service Discovery | Netflix Eureka |
Dynamic Routing e Load Balancer | Netflix Ribbon |
Circuit Breaker | Netflix Hystrix |
Monitoring | Netflix Hystrix Dashboard e Turbine |
Gateway | Netflix Zull |
Configuration | Spring Cloud Config |
Security | Spring Cloud e Sprint Security OAuth2 |
Logging | Logstash, Elasticsearch e Kibana |
A figura abaixo ilustra a arquitetura com os componentes que serão utilizados para implementar os serviços do modelo conceitual.
Integração
Uma aplicação monolítica geralmente tem um único banco relacional. Um benefício chave de se usar um banco como esse é que sua aplicação pode usar transações ACID, que garante a consistência dos dados. Basta abrir a transação, fazer as operações necessárias (insert, update e delete) e fazer o commit. Outro importante benefício de usar uma base relacional é que ela é acessada via SQL, que é a liguagem padrão de consultas. Você pode facilmente escrever uma consulta que combina dados de várias tabelas. Como todos os dados estão em um único banco de dados, é sempre fácil acessar as informações.
Infelizmente, acessar dados externos se torna bem mais difícil quando migramos para uma arquitetura de microserviços. Isso ocorre porque os dados de um microserviço são de propriedade exclusiva daquele microserviço e só podem ser acessados via API. O encapsulamento desses dados em cada microserviço reduz o acomplamento entre eles, garantindo que cada um possa envoluir independentemente dos outros.
Para piorar, diferentes microserviços podem usar diferentes tipos de bases. Aplicações modernas armazenam e processam diversos tipos de dados e um banco relacional nem sempre é a melhor escolha. Para alguns casos de uso, um tipo particular de banco NoSQL pode ser mais adequado por oferecer melhor performance e maior escalabilidade. Consequentemente, microserviços podem usar uma mistuta de bancos SQL e NoSQL, a denominada persistência poliglota.
Aplicar persistência poliglota pode trazer vários benefícios, incluindo a redução do acoplamento, melhor performance e melhor escalabilidade. Entretano, ela introduz alguns desafios importantes. O principal está em como garantir que as transações mantenham a consistência entre múltiplos serviços.
Event-Driven Architecture pode nos ajudar a resolver esses desafios. Nessa arquitetura, um micricroserviço publica um evento quando algo relevante acontece, como quando uma entidade de negócio é atualizada. Outros microserviços podem se registrar para receber esses eventos. Quando um deles recebe um evento, ele pode atualizar suas próprias entidades de negócio. Você pode usar eventos para implementar transações que envolvem múltiplos serviços. Um transação consiste de uma série de passos. Cada passo representa um microserviço que atualiza suas entidades de negócio e publica um evento que dispara o próximo passo. É importante observar que essas transações não são ACID. Elas oferecem garantias mais fracas como a consistência eventual. Este modelo de transação tem sido referenciado como BASE Model.
Com Event-Driven Architecture existe também o problema de atomicamente atualizar a base de dados e publicar um evento. Por exemplo, o Serviço de Petição pode inserir um registro na tabela de petições e publicar o evento Petição Recebida. É fundamental que essas duas operações sejam atomicas. Se o serviço cair depois de atualizar a base mas antes de publicar o evento, o sistema ficará inconsistente. A forma mais usual de garantir atomicidade é usar uma transação distribuída envolvendo o banco de dados e o message broker. Entretanto, for razões já descritas aqui e outras como as descritas no teorema CAP, isso é exatamente o que não queremos fazer.
Existem algumas formas de se resolver esse problema. Pela simplicidade, optamos por um processo que envolve apenas transações locais. Precisamos ter um tabela de eventos, que funciona como uma fila de mensagens, na mesma base de dados que armazena as entidades de negócio. A aplicação inicia uma transação local, atualiza o estados da entidade de negócio, insere um evento na tabela de eventos e fecha a transação. Um processo separado consulta periodicamente a tabela de eventos, publica os eventos para o message broker e então uma transação local para marcar o evento como publicado. A figura abaixo ilustra esse design.
Estrutura de Empacotamento
A arquitetura proposta aqui não prevê a utilização de componentes EJB, ou seja, somente são utilizados componentes web (Servlets). Este design simplifica a estrutura de empacotamento já que podemos colocar todo o código do sistema em um único módulo web. Em aplicações como essas, a opção mais comum é empacotar todo a aplicação em um único arquivo “.war” (Web Archive), no entanto, optamos por empacotar os serviços em arquivos “.jar” (Java Archive). Para tanto, precisamos usar um servidor HTTP embarcado. Uma das vantagens dessa abordagem é que ela facilita muito o trabalho de execução da aplicação. Você pode rodar sua aplicação da mesma forma que roda qualquer outra aplicação Java. Debugar também é muito fácil; você não precisa de qualquer instalação adicional, nem usar IDE’s ou plugins especiais. Além disso, podemos selecionar o servidor HTTP mais apropriado, sendo que atualmente existem opções muito leves, capazes de reduzir o tempo de statup da aplicação para apenas alguns segundos.
Para viabilizar a arquitetura de implantação definida abaixo, adicionaremos o Jar do serviço em uma imagem Docker, que nesse caso servirá como recipiente para execução da aplicação Java rodando um determinado serviço. Conforme definido anteriormente, usaremos Spring Boot como principal framework para implementação dos serviço. Quando usamos a a combinação Spring Boot e Docker geralmente obtemos imagens com arquitetura semelhante à ilustrada abaixo:
IMAGE CREATED CREATED BY SIZE COMMENT |
Observe, em vermelho, que o Jar do serviço contém todas as bibliotecas utilizadas. Como consequência, uma única alteração no Jar da aplicação invalida toda a layer, fazendo com que 86.81 MB sejam baixados novamente sempre que a imagem for atualizada no repositório de imagens. Para evitar isso, usamos a solução descrita no artigo Spring Boot’s fat jars vs. Docker. Basicamente, a lógica da solução é separar os jars mais modificados daqueles menos modificados para reduzir o tamanho de download a cada atualização da imagem. No nosso caso, podemos dividir as libs em três categorias: bibliotecas externas, ocupam o maior espaço no Jar e são atualizadas com baixa frequência; bibliotecas compartilhadas do stfdigital (core, por exemplo), ocupam pouco espaço do Jar, mas são atualizadas com uma frequência média; e a aplicação em si, que também ocupam pouco espaço do Jar, mas são atualizadas com frequência. Cada uma dessas categorias serão serapadas em layers específicas, resultando na seguinte estrutura:
IMAGE CREATED CREATED BY SIZE COMMENT |
Agora a layer em vermelho não será baixada quando houver modificação das bibliotecas compartilhadas ou na aplicação em si. Isso diminuirá consideravelmente o tempo de atualização em 90% dos casos (irá baixar apenas cerca de 400kB no exemplo acima). Somente quando uma biblioteca externa for atualizada é que os 86MB serão baixados novamente.
Estrutura de Diretórios
TODO: Descrever a estrutura das imagens docker
Implantação
Implantar uma aplicação monolítica significa fazer o deploy de cópias idênticas de uma única aplicação em vários servidores (físicos ou virtuais). A implantação de uma aplicação monolítica nem sempre é totalmente simples, mas ela é muito mais simples do que a implantação de um aplicativo dividido em microservices.
Uma aplicação microservices consiste em dezenas ou mesmo centenas de serviços. Cada serviço pode utilizar um conjunto diferente de frameworks. Cada um é um mini-aplicativo com o seu próprio conjunto de requisitos. Por exemplo, você pode precisar executar um determinado número de instância de cada serviço baseados na demanda por esse serviço. Além disso, cada instância de serviço deve ser fornecido com o processador adequado, memória e recursos de armazenamento. O que é ainda mais desafiador é que, apesar desta complexidade, implantar esses serviços deve ser rápido, confiável e de baixo custo.
Existe alguns diferentes tipos de patterns que podem ser usados para implantação de microservices. Aqui nós vamos apresentar os patterns que julgamos mais adequados para o nosso caso.
Nossa Estratégia
Optamos por adotar o pattern Service Instance per Host. Neste pattern, você executa cada serviço em seu próprio host, isolado de qualquer outro serviço. Existem duas especializações desse pattern: Service Instance per Virtual Machine and Service Instance per Container.
Usaremos o Service Instance per Container pattern, em que cada serviço roda em seu próprio container. Containers são um mecanismo de virtualização no nível do sistema operacional. Um container consiste de um ou mais processos rodando em uma “sandbox”. Você pode limitar a memória e os recursos de CPU. Algumas implementações também permitem limitar a taxa de I/O. Há várias tecnologias que implementam esse conceito. Nós optamos por usar Docker. A figura abaixo ilustra o modelo descrito aqui.
Para usar este pattern, você deve empacotar seu serviço como uma imagem. A imagem é um arquivo contendo o serviço e todas as bibliotecas necessárias para executar o serviço. Uma vez tendo gerado a imagem, você pode executá-la em um ou mais containers e cada container pode rodar em um ou mais hosts. Para facilitar o gerenciamento dos containers, nós poderíamos usar um cluster manager como o Kubernetes. Um cluster manager trata os hosts como recursos. Ele decide, automaticamente, em que host colocar cada container baseado nos recursos necessários pelo serviço e os recursos disponíveis em cada host.
Escolhemos utilizar containers porque eles são muito fáceis de se construir e iniciam muito rápido. Isso facilita muito a tarefa de montagem do ambiente. Atualmente nós conseguimos montar nosso ambiente, com mais de 10 container em nossas máquinas de desenvolvimento, sem que isso prejudique o desempenho geral da máquina e sem ter que entar nos detalhes de configuração de cada serviço.