Pontos Principais
- Inicie com pequenos candidatos que são fáceis de extrair, e desta forma adquirir um conhecimento antecipado de microservices
- Foque na automação da construção, implantação e no monitoramento antecipado
- Trate os interesses transversais antecipadamente para evitar consequências indesejáveis, como o crescimento do monolito ou a reimplementação dos interesses transversais em cada microservice
- Projete o sistema para ser orientado a eventos, facilitando sua evolução, e considere fluxos de eventos para reduzir a sobrecarga de duplicação de dados e diminuir a barreira de entrada para novos microservices
- Esteja ciente de que o processo de transformação para microservices não está sendo executado isoladamente. Em vez disso, é afetado por muitas circunstâncias. Cuidado com as circunstâncias que te travam e atrasam, ajustando-as ou pelo menos tornando ciente toda a organização
Ao iniciar uma jornada para microservices, saber o que considerar pode ser valioso - especialmente com um time pequeno. Infelizmente, não existe nenhuma regra de ouro que seja facilmente aplicável. Cada jornada é diferente, uma vez que toda organização está enfrentando circunstâncias distintas. Neste artigo, Susanne Kaiser compartilha algumas lições aprendidas e desafios na perspectiva de uma startup, e o que faria de maneira diferente em sua próxima implementação de microservices.
Como se iniciou a jornada de um monolito para microservices
No início, foi criado um monolito em todos os aspectos: existia uma equipe trabalhando e colaborando em um produto, o qual foi implementado como uma base de código única e baseado em uma tecnologia. Esta arquitetura funcionou bem por um período.
Após um tempo tudo evoluiu: a equipe estava crescendo, foram adicionadas mais funcionalidades ao produto, a base de código ficou cada vez maior e o número de usuários cresceu.
Parecia que estava ótimo, porém as tarefas começaram a demorar mais tempo para serem concluídas - reuniões, discussões e decisões tomavam mais tempo do que antes. Responsabilidades não foram claramente atribuídas. Demorava algum tempo até que alguém se sentisse responsável, por exemplo quando um defeito ocorria. Os processos estavam desacelerando e a produtividade foi afetada.
Quanto mais funcionalidades eram adicionadas, mais complexa se tornava a usabilidade do produto. Esta usabilidade e a experiência do usuário sofreram com as alterações contínuas das funcionalidades. Ao invés de resolver os problemas dos usuários, os deixávamos cada vez mais confusos.
Devido à arquitetura de software monolítica, foi difícil adicionar novas funcionalidades sem afetar todo o sistema e a liberação de novas mudanças se tornou complexa, uma vez que era necessário reconstruir e reimplantar o sistema inteiro, mesmo que a mudança fosse apenas algumas linhas de código. Isto resultou em implantações de alto risco que ocorreram com menos frequência - novas funcionalidades foram entregues lentamente.
Surgiu então a necessidade de dividir e mudar as coisas.
Há mais de três anos, a estratégia do produto foi alterada. O foco foi direcionado para melhorias na usabilidade e experiência do usuário, e o produto JUST SOCIAL foi dividido em aplicações separadas - cada uma cuidando de um caso de uso específico. Foi desenvolvida a idéia de fornecer diferentes aplicações para compartilhamento de documentos, comunicação em tempo real, gerenciamento de tarefas e compartilhamento de conteúdo editorial e notícias corporativas, além do gerenciamento de perfis.
Enquanto isso, o time único foi dividido em múltiplos times menores e a cada um foi atribuído um conjunto específico de aplicativos colaborativos para atingir responsabilidades bem definidas. O objetivo foi estabelecer times autônomos, permitindo-os trabalhar em diferentes partes do sistema de forma independente, em seu próprio ritmo e com o mínimo de impacto entre as equipes.
Após ter dividido o produto em aplicativos colaborativos separados e ter transformado a equipe única em múltiplos times menores, o próximo passo lógico e razoável foi refletir a autonomia e flexibilidade também na arquitetura de software - introduzindo microservices.
A motivação de introduzir microservices foi permitir o trabalho autônomo em diferentes partes do sistema, seguindo seu próprio ritmo, com o mínimo de impacto entre os times. Ao desenvolver, implantar e dimensionar os aplicativos colaborativos independentemente, a entrega de mudanças seria mais rápida.
A jornada de microservices se iniciou através da identificação de bons candidatos para o primeiro microservice. Para identificá-los, foi levado em consideração conceitos chave para modelagem de bons serviços. Os conceitos chave seguem os princípios de baixo acoplamento entre os serviços e alta coesão. A alta coesão dentro de um serviço é geralmente refletida por comportamentos relacionados que devem permanecer consistentes. No Domain Driven Design (DDD), o comportamento relacionado é refletido por contextos delimitados. Um contexto delimitado são limites semânticos nos quais o modelo de domínio reside e descreve os serviços responsáveis por uma função de negócios bem definida.
Os aplicativos colaborativos foram utilizados como contextos delimitados de alto nível, refletindo limites de serviço de baixa granularidade, os quais posteriormente representaram um bom ponto de partida para dividí-los em serviços mais refinados.
O primeiro contexto delimitado foi o JUST DRIVE - aplicativo de colaboração que cuida do gerenciamento de documentos. Cada documento é criado por um autor. Os dados do autor são provenientes dos dados do perfil, os quais são gerenciados pelo contexto delimitado do gerenciamento de perfis que ainda reside no monolito.
O JUST DRIVE foi construído do zero como um serviço coexistente. Na realidade não foi um equivalente exato do atual; em vez disso, foram introduzidas novas interfaces de usuário, adicionadas novas funcionalidades e realizadas mudanças significativas na estrutura de dados. O contexto delimitado do novo serviço é composto de seu modelo de domínio responsável pela lógica de negócios, o serviço de aplicação orquestrando casos de uso e gerenciando transações, e seus adaptadores de entrada e saída, como terminais REST e adaptadores para gerenciamento de persistência. O novo serviço domina o estado do documento exclusivamente - esse é o único serviço que pode ler e gravar documentos.
Como mencionado anteriormente, cada documento é criado por um autor, e os dados do autor são provenientes dos dados do perfil, os quais são gerenciados pelo monolito.
A questão que surgiu foi como o novo serviço e o monolito interagiriam entre si.
Para evitar a solicitação dos dados do autor a partir do serviço de perfil toda vez que o documento é exibido, foi mantida no novo serviço, uma cópia local dos dados relevantes de autor. A redundância de dados está correta desde que a propriedade dos dados não seja prejudicada - contanto que o contexto delimitado relacionado ao perfil ainda possua o estado do perfil com exclusividade.
Como a cópia local e os dados originais podem divergir ao longo do tempo, o monolito precisa notificar o novo serviço sempre que um perfil for atualizado. O monolito publica um ProfileUpdatedEvent assim que um perfil é modificado e o novo serviço é assinado. O novo serviço consome esse evento e atualiza sua cópia local.
Esta interação de serviço orientada a eventos aumentou o desacoplamento entre os serviços, uma vez que não era mais necessário realizar uma consulta remota de contexto diretamente ao monolito. Isto aumentou a autonomia, já que o novo serviço poderia fazer as alterações necessárias em sua cópia local e poderia tratá-los com mais eficiência, unindo os dados do autor localmente por sua cópia local, ao invés da rede.
O ponto de partida foi a criação de um serviço coexistente do zero, e foi introduzido a interação de serviço orientado a eventos para fins de duplicação de dados.
Quais desafios foram enfrentados e como lidar com eles
Criar um serviço coexistente a partir do zero é, em geral, uma boa estratégia de decomposição, especialmente para se afastar de algo, como, por exemplo, se livrar da lógica de negócios obsoleta ou da tecnologia. Porém, ao decompor o primeiro serviço, a equipe se deparou com muitos passos ao mesmo tempo. Como descrito anteriormente, não apenas foi criado um novo serviço coexistente, mas também foi introduzida uma nova interface de usuário, adicionadas mais funcionalidade e realizadas mudanças significativas na estrutura de dados. Devido à grande quantidade de mudanças logo no início, os resultados foram atingidos muito tardiamente. Em particular no começo, é muito importante recuperar resultados rapidamente para obter experiências antecipadas e confiança com microservices.
Com o próximo candidato, foi utilizada uma abordagem diferente. O foco foi concentrado no contexto delimitado de alto nível do aplicativo de bate-papo e na estratégia de decomposição incremental de cima para baixo, através da extração do código existente, passo a passo. Primeiramente, a interface do usuário foi extraída como uma aplicação web separada e foi introduzida uma API REST no lado do monolito, no qual a aplicação web extraída poderia acessar. Nesta etapa, seria possível desenvolver e implantar a aplicação web de forma independente, o que permitiu realizar uma iteração rápida na interface do usuário.
Após extrair a interface do usuário, foi possível prosseguir e decompor a lógica de negócios. Desembaraçar a lógica de negócios cria mudanças significativas no código. De acordo com as dependências, talvez seja necessário fornecer uma API REST temporária que o monolito utiliza para endereçar a lógica de negócios extraída. Neste estágio, o armazenamento de dados ainda era compartilhado.
Para se tornar um serviço desacoplado, o armazenamento de dados precisa ser dividido para garantir que o novo serviço possua o estado do bate-papo com exclusividade.
Em cada bate-papo de discussão, os participantes estão envolvidos. Os dados do participante do bate-papo derivam dos dados do perfil que residem no monolito. Conforme descrito no exemplo anterior do DRIVE, uma cópia local é mantida para os dados do participante do bate-papo e o ProfileUpdatedEvent é assinado para manter essa cópia local em sincronia com os dados originais do monolito.
Deste ponto em diante, seria possível continuar e esculpir o próximo contexto delimitado a partir do monolito ou dividir os serviços de baixa-granularidade em serviços mais refinados.
Outro desafio foi o tratamento de autorizações.
Com quase todos os serviços, foi necessário enfrentar a questão de como lidar com a autorização. Para contextualizar: o tratamento de autorização é muito refinado, até o nível do objeto de domínio. Cada aplicativo de colaboração está controlando a autorização de seus objetos de domínio, por exemplo, a autorização de um documento é controlada pelas configurações de autorização da pasta pai em que ele se encontra.
Por outro lado, a autorização não é somente refinada, mas também dependente entre serviços; em alguns casos, a autorização de um objeto de domínio também depende das informações de autorização dos objetos do domínio pai, os quais residem em um serviço diferente. Por exemplo, a leitura ou a inclusão de um documento anexado a uma página de conteúdo, depende das configurações de autorização desta página, os quais se encontram em um serviço diferente do documento em si.
Devido a esses requisitos complexos, a solução para autorização distribuída demandou muito esforço, e no início, não foi encontrada uma solução. O que ocorreu como resultado foi bastante prejudicial. Uma consequência foi a inclusão de um novo serviço à uma parte do sistema onde a autorização já havia sido solucionada - para o monolito. Dessa forma, o monolito foi crescendo ao invés de diminuir. Outra consequência foi que a autorização começou a ser implementada por serviço. O que pareceu razoável no começo, uma vez que a suposição inicial era de que a autorização pertencia ao mesmo contexto delimitado em que o modelo de domínio reside, porém não foi levado em consideração as dependências entre os serviços. Como resultado, os dados estavam sendo copiados de um lado para o outro e foi criado o risco de colisão.
Para encurtar a história: o tratamento de autorizações foi adicionado à um microservice centralizado.
Junto com os serviços centralizados, existe o risco de criar um monolito distribuído. Quando uma parte do sistema é alterada e é necessário mudar outra parte ao mesmo tempo, há um forte indício de se ter introduzido um monolito distribuído. Por exemplo, quando é criado um novo aplicativo de colaboração que requer autorização e é preciso ajustar o serviço de autorização centralizado ao mesmo tempo, estamos combinando as desvantagens dos dois mundos: os serviços estão fortemente acoplados e, além disso, têm que se comunicar em uma rede lenta e não confiável.
Em vez disso, foi fornecido um contrato comum que o serviço de autorização centralizado possui e que será utilizado por todos os demais serviços. Os serviços traduzem ações que são relacionadas à autorização através de um contrato comum, o qual a autorização entende sem tradução extra. A tradução acontece em cada serviço, mas não no serviço de autorização centralizado. Este contrato comum garante a inclusão de novos serviços sem alterar e reimplementar o serviço de autorização centralizado ao mesmo tempo. Um pré-requisito é que esse contrato comum seja estável, ou pelo menos compatível com versões anteriores, caso contrário, o problema será transferido para os demais serviços que deverão ser atualizados constantemente.
Qual foi o aprendizado
Em especial no início, é melhor começar com pequenos serviços que são fáceis de extrair para obter resultados rápidos e adquirir experiências antecipadas com microservices. Caso esteja lidando com grandes serviços de baixa granularidade, seria mais fácil quebrar a decomposição em etapas incrementais, como por exemplo, uma decomposição incremental de cima para baixo - realizando um passo gerenciável de cada vez.
Lidar com interesses transversais antecipadamente é fundamental para evitar conseqüências indesejáveis, como alimentar o monolito ao invés de diminuí-lo ou reimplementar os recursos transversais em todos os serviços.
Ao introduzir um serviço transversal centralizado, é necessário ter cuidado para não criar um monolito distribuído. Nesse caso, um contrato comum e estável ajuda a evitá-lo.
Para projetar um sistema que seja fácil de evoluir, a interação de serviço orientada a eventos é a chave para alcançar um alto desacoplamento entre os serviços. Os eventos podem ser utilizados para notificação e para fins de duplicação de dados (transferência de estado orientada a evento; consultar o exemplo "Serviço coexistente do zero" acima) e como uma fonte primária de dados por meio de um armazenamento de eventos, retendo-os a longo prazo.
Ao utilizar eventos apenas para fins de notificação, dados adicionais de outro contexto geralmente são solicitados por uma consulta remota de contexto diretamente para sua origem, por exemplo, através de uma requisição REST. Pode ser preferível a simplicidade de uma consulta remota ao invés de lidar com a sobrecarga de manter os conjuntos de dados localmente, principalmente quando os conjuntos de dados aumentam. Porém as consultas remotas adicionam muito acoplamento entre serviços e vinculam serviços entre si em tempo de execução.
Pode-se evitar consultas remotas para outro contexto, internalizando-as, através da inclusão de uma cópia local dos dados relevantes de contexto. Conforme descrito no exemplo anterior do JUST DRIVE, para evitar a solicitação dos dados do autor a partir do serviço de perfil toda vez que o documento é exibido, os dados do autor foram duplicados e uma cópia local foi mantida no microservice de documento. Os dados duplicados precisam ser mantidos em sincronia com os dados originais, ou seja, a cópia local deve ser atualizada assim que os dados originais forem alterados. Para ser notificado dos dados modificados, o serviço assina um evento contendo os dados alterados e atualiza sua cópia local. Nesse caso, os eventos são utilizados para fins de duplicação de dados, o que elimina consultas remotas e aumenta o desacoplamento entre serviços. Além disso, melhora a autonomia, já que o serviço pode fazer o que for necessário com a cópia local.
Para a interação de serviço orientada a eventos, foi introduzido o Apache Kafka - um serviço de mensagens distribuído, tolerante a falhas e escalável. No início, o Apache Kafka foi utilizado principalmente para fins de notificação e duplicação de dados. Recentemente, o Apache Kafka Streams foi utilizado como uma fonte da verdade compartilhada para eliminar a sobrecarga de duplicação de dados, obter alta capacidade de conexão dos serviços e uma menor barreira à entrada de novos serviços.
Um fluxo é uma seqüência ilimitada, ordenada e continuamente atualizada de registros de dados estruturados. Um registro de dados consiste em um par de chave-valor.
Ao iniciar o serviço no contexto de fluxo do Apache Kafka, um tópico do Kafka será carregado no fluxo e será possível processá-lo no escopo do serviço. Um tópico é uma categoria lógica onde os serviços podem publicar e assinar. Cada fluxo é gravado em um armazenamento de estado - um banco de dados leve incorporado e baseado em disco. O fluxo carregado é utilizado em sua própria base de código e não estará executando no Kafka Broker; estará sendo executado no processo do microservice. Os fluxos disponibilizam os dados onde for necessário, o que aumenta o desempenho e autonomia.
O Apache Kafka possui uma Stream API. Os fluxos podem ser unidos, filtrados, agrupados ou agregados utilizando uma Linguagem Específica de Domínio (Domain Specific Language - DSL) e cada mensagem nesse fluxo pode ser processada utilizando operações semelhantes à função, como map, transform, peek, etc.
Ao implementar o processamento de fluxo, geralmente é necessário um fluxo e um banco de dados para enriquecer a solução. A Stream API do Kafka fornece essa funcionalidade por meio de suas abstrações principais para fluxos e tabelas. Na realidade, existe uma relação próxima entre fluxos e tabelas: a chamada dualidade de tabela de fluxo. Um fluxo pode ser considerado como um registro de alterações de uma tabela, onde cada registro de dados no fluxo captura uma mudança de estado da tabela. Uma tabela pode ser considerada como um snapshot, em um determinado momento, do valor mais recente de cada chave em um fluxo.
Com o Kafka Streams é possível exibir um documento com seus dados de autor da seguinte forma: o serviço de documento está criando um KStream do tópico do documento e gostaria de enriquecer os dados do documento com dados de perfil relacionados ao autor, provenientes do tópico de perfil. Para este enriquecimento, o serviço de documento está criando uma KTable a partir do tópico do perfil. Desta forma será possível combinar o fluxo e a tabela, gravando o resultado como um novo armazenamento de estado que pode ser acessado externamente - para funcionar como uma visão materializada embutida. Sempre que um perfil ou documento é alterado, sua visualização materializada relacionada também é atualizada.
Comparado às outras abordagens orientadas a eventos, o Apache Kafka Streams não requer a manutenção de uma cópia local, o que reduz a sobrecarga para a duplicação de dados e os mantém em sincronia. Os dados são enviados para onde for necessário e são executados no mesmo processo do serviço, aumentando a capacidade de conexões. Um novo serviço pode ser conectado e utilizar o fluxo imediatamente sem a necessidade de configurar armazenamentos de dados extras. Isto reduz a sobrecarga e aumenta o desempenho e a autonomia, além de reduzir a barreira à entrada de novos serviços.
O processo de transformação não é executado isoladamente. Em vez disso, é afetado por várias circunstâncias: o tamanho do time, a estrutura e o conjunto de habilidades afetam o que é gerenciável, especialmente no início, por exemplo, uma pequena equipe com poucas práticas de DevOps terá impacto na velocidade de transformação.
O processo de transformação também é afetado pelo fato de que ainda é necessário atuar no sistema legado. O tempo de manutenção reduz o tempo disponível para o processo de transformação. O ambiente de execução também impacta a jornada. A aplicação está sendo executada localmente ou na nuvem nativa? Os serviços gerenciados são confiáveis, por exemplo, uma API-Gateway gerenciada, ou é necessário configurá-la e mantê-la?
E se a estratégia for introduzir novas funcionalidades em um curto período de tempo, deve-se considerar a decisão de onde implementar os novos requisitos: como um novo serviço independente que demora mais tempo de desenvolvimento, ou pegar um atalho e adicioná-lo ao monolito - e arriscar alimentar o monolito ao invés de diminuí-lo.
Devemos nos atentar às circunstâncias que nos travam e nos atrasam, ajustando-as ou pelo menos criando consciência em toda a organização. E lembre-se: toda jornada é diferente - uma jornada pode parecer totalmente diferente da outra.
O que Susanne Kaiser faria diferente na próxima implementação de microservices
Primeiro, Susanne Kaiser verificaria se a estratégia da organização está alinhada com os objetivos dos microservices de maximizar a velocidade do produto e liberar as alterações de forma independente e rápida. Por exemplo, se a organização foca em longos ciclos de entregas e implanta tudo junto, os microservices podem não ser a melhor escolha, pois não será possível aproveitar todos os seus benefícios.
Se decidir iniciar uma jornada de microservices, é necessário que todos estejam comprometidos - incluindo a gerência. E todos precisam estar cientes de que essa jornada é complexa e consome tempo - em particular no começo, quando ainda não há muita experiência.
Equipes autônomas, alinhadas ao produto e multifuncionais trabalham muito bem com microservices, mas a mudança para uma cultura de DevOps deve ser considerada logo no início. Cada equipe deve estar preparada para constantes iterações e ser capaz de desenvolver, entregar, operar e monitorar os serviços pelos quais é responsável.
Decompor o monolito em múltiplos serviços independentes é apenas uma parte da jornada, operá-los é outra. Quanto mais serviços, mais crítica se tornará a automatização dos processos de construção e implantação.
De acordo com Susanne Kaiser se ela tivesse que fazer a jornada novamente, "começaria com um pequeno candidato que é fácil de extrair e daria foco não apenas em sua decomposição, mas também na automação da construção e implantação e no monitoramento antecipado dos primeiros serviços - que poderiam ser utilizados como base para futuros serviços. Para criar essa base, pode ser útil construir uma força-tarefa temporária composta por pessoas de cada equipe."
Cada microservice deve ter seu próprio pipeline de CI/CD desde o início. Outra consideração é utilizar container para cada microservice, obtendo um ambiente de execução consistente, leve e encapsulado entre os estágios - em especial se o foco for executar os serviços em um ambiente de nuvem posteriormente.
Além disso, o monitoramento, incluindo a agregação de logs, deve ser considerado antecipadamente. O monitoramento não apenas do servidor, mas também das métricas de serviço, como latência de requisição, taxa de transferência e erro, são necessárias para acompanhar a integridade e a disponibilidade dos serviços. A estruturação e padronização do log de saída, como o formato de hora (por exemplo, ISO8601) e fuso horário (por exemplo, UTC), e a introdução de contextos de requisição com ids de correlação e agregação de log facilitam os processos de diagnóstico e análise.
Muitas coisas precisam ser resolvidas antecipadamente, o que consome tempo e exige conscientização em toda a organização. Os microservices são um investimento para atingir a velocidade máxima do produto e não para reduzir custos.
Para permanecer competitivo no mercado, a velocidade do produto e as melhorias contínuas são alguns dos principais fatores para se diferenciar dos concorrentes. Os microservices podem promover a velocidade do produto e melhorias contínuas, mas somente se todos estiverem comprometidos, incluindo a gerência.
Sobre a autora
Susanne Kaiser é uma consultora de tecnologia em Hamburgo, na Alemanha, e trabalhou anteriormente como CTO de startups, transformando soluções SaaS de monolito para microservices. Tem formação em ciências da computação, experiência em desenvolvimento e arquitetura de software há mais de 15 anos e regularmente participa de conferências tecnológicas internacionais.