Skip to main content

Microsserviço 5

· 16 min read
Leandro Andrade
Leandro Andrade
Software Developer

Na comunicação entre microsserviços, temos diversas opções de tecnologias para uso.

A escolha sobre qual utilizar deve ser feita sobre o que queremos que a tecnologia escolhida entregue. Os pontos que esperamos como resposta são:

  • facilite a compatibilidade com versões anteriores: precisamos evitar gerar incompatibilidade com os serviços consumidores. Operações simples, como adicinar um novo campo, não devem causar falhas nos clientes.
  • interface explícita: deixar claro os esquemas e funcionalidades expostas pelo microsserviço.
  • API independente de tecnologia: evitar tecnologias de integração que determine os conjuntos de tecnologias que poderão ser usados na implementação.
  • simplicidade para os consumidores: deixar o consumidor livre quanto a escolha da tecnologia.
  • oculte detalhes internos: consumidores não devem depender da nossa implementação interna pois isso resultará em alto acoplamento. Tecnologias que exponham nossa representação interna devem ser evitadas.

As opções de comunicação mais comuns que podemos considerar são:

  • RPC (remote procedure call)
  • REST
  • GraphQL
  • Brokers de mensagens

RPC

A ideia é fazer uma chamada local e tê-la executada em um serviço remoto. O ponto é que usando esse tipo de tecnologia, implica em adotar um protocolo de serialização, por exemplo, o gRPC que usa o formato de serialização protocol buffer. Neste modelo, o schema explícito facilita a geração de código cliente.

Os desafios que estão na utilização são:

  • acoplamento de tecnologias: algumas soluções como o Java RMI estão extremamente vinculadas à plataforma específica.
  • chamadas locais não são como chamadas remotas: o fato de uma chamada local não parecer ser uma chamada remota, esconde diferenças cruciais sobre o custo de execução. Custo de latência e serialização/deserialização são pontos críticos já que as redes não são confiáveis e em algum momento vão falhar.
  • fragilidade: mudanças no schema faz com que os clientes tenham de gerar stubs novamente. Lançamentos sincronizados serão uma realidade no uso dessa tecnologia. Na prática, objetos usados como parte de uma serialização binária transmitida pela rede podem ser pensados como tipos somente expansíveis.

Implementações mais modernas, como o gRPC, são excelentes, já o Java RMI possui sérios problemas de fragilidade.

Evite fazer abstrações de chamadas remotas a ponto de ocultar totalmente a rede.

Direcione a escolha para essa tecnologia somente se tiver controle do lado tanto do cliente, quanto do servidor.

REST

REST não significa HTTP, e HTTP não significa REST.

Quando pensamos em REST, pensamos em recursos. Assim, recursos apresentados ao mundo externo são totalmente desacoplado do modo como é armazenado internamente.

REST é um modelo arquitetural, não uma especificação, já que pode ser implementado em diversos protocolos, sendo o mais comum o HTTP.

O ponto do REST é que, com o HTTP, já temos semântica e significados bem conhecidos sobre o funcionamento dos recursos. Além disso, o HTTP traz consigo um grande ecossistema de ferramentas e tecnologias de apoio, além de diversos controles de segurança que podem ser utilizados para proteção das comunicações.

Dentro do REST, temos o conceito de HATEOAS, que basicamente diz que clientes devem interagir com o servidor por meio de links, no qual esses links podem resultar em mudanças de estado. Entretanto, não é tão comum a criação e utilização deste conceito na indústria.

Um desafio no uso de REST é o desempenho em ambiente restritos. Payloads REST sobre HTTP são compactos, mas não são nem perto do quão menor é um protocolo binário, e também o overhead em cada requisição pode acabar sendo um preocupaçào em ambientes que exigem baixa latência.

Todavia, uma grande vantagem do REST sobre HTTP, considerando uma interface síncrona request/response, é a facilidade de utilização, por ser um estilo amplamente conhecido, muitas pessoas têm familiaridade, além de garantir a interoperabilidade com diversas tecnologias.

GraphQL

O ponto chave do GraphQL é permitir que o serviço upstream tenha a liberdade de definir queries, o que consequentemente pode evitar a necessidade de fazer várias requisições, trazendo melhora de desempenho em dispositivos limitados do lado do cliente. Uma única query, várias informações.

O desafio fica no backend, já que essa dinâmica de executar queries que mudem dinamicamente gera maior carga tanto no servidor, quanto no banco de dados. Caching parece ser algo ignorado no desenvolvimento e idealização do GraphQL. Outros pontos é o GraphQL ser mais apropriado para leituras, mesmo sendo capaz de lidar com escritas. Também é essencial que a API não esteja acoplada ao repositório de dados, ou seja, a resposta do serviço representa exatamente a estrutura do banco de dados.

Basicamente, seu uso é mais direcionado para dispositivos móveis, não sendo um substituto para comunicação genérica entre microsserviços.

Brokers de mensagens

Os brokers de mensagens são intermediários, também conhecidos como middlewares. A abordagem do broker é - ao invés do serviço upstream conversar diretamente com o serviço downstream - o serviço upstream envia uma mensagem para o broker e o serviço downstream recebe essa mensagem do broker. Para isso, podemos usar tópicos ou filas.

Com a fila, o serviço upstream envia a mensagem para a fila e o serviço downstream lê essa fila. Um modelo de distribuição de carga com consumidores concorrentes. Muito usado para comunicação request/response.

Com o tópico, vários serviço downstream estão inscritos no tópico e cada um receberá uma cópia da mensagem. Podemos ter grupos de consumidores. Muito usado para uma colaboração baseada em eventos.

O ponto em usar broker é a garantia de entrega, já que mesmo que o serviço downstream esteja indisponível, o broker garante o armazenamento da mensagem até que possa ser entregue. Evidente que para maior segurança e disponibilidade, a execução do broker deve ser realizada em cluster.

Algumas outras características que os brokers podem oferecer são ordem das mensagens e transação na escrita em vários tópicos.

Formatos de Serialização

Os tipos de serialização mais comuns são formatos textuais e formatos binários. O formato está diretamente relacionado ao tipo de tecnologia que será utilizada.

O formato textual entrega uma fácil leitura, interoperabilidade e muita flexibilidade no envio e consumo de recursos, por exemplo, no uso de APIs REST em request/response.

JSON tornou-se o formato mais comum, superando o XML. Existe também o AVRO, baseado em JSON, mas que utiliza um formato com esquema.

O formato binário acaba sendo uma opção quando o tamanho dos payloads e eficiência na leitura/escrita forem requisitos importantes, além de uma comunicação com baixa latência.

Esquemas

Os esquemas podem ser de vários tipos, e geralmente a escolha do formato de serialização definirá a tecnologia de esquema que será utilizada.

Ter esquemas explícitos para endpoints de microsserviço ajudam muito a representar de forma explícita aquilo que o microsserviço expõe e aceita. Claro que não substituem a necessidade de uma boa documentação, mas ajudam a diminuir o volume de documentação necessária. Outro ponto é facilitar a identificação de incompatibilidades acidentais nos endpoints dos microsserviços.

De modo geral, podemos separar as incompatibilidades de contrato em duas categorias: incompatibilidade estrutural e incompatibilidade semântica.

Na incompatibilidade estrutural, ocorre quando a estrutura do endpoint muda de tal modo que o consumidor deixa de ser compatível, seja porque campos ou métodos foram adicionados ou removidos.

Na incompatibilidade semântica, a estrutura do endpoint do serviço se mantém a mesma, porém há mudanças no comportamento, em que as expectativas dos consumidores não serão mais atendidas. Por exemplo, um endpoint que retorna a idade do usuário em dias passa a retornar em meses. O grande ponto desse tipo de incompatibilidade é que a identificação das incompatibilidades exige o uso de testes.

Por isso, o melhor caminho é ser o mais explícito possível quanto àquilo que o microsserviço expõe ou não, e para isso os esquemas ajudam muito. Mas no cenário que tanto o serviço upstream, quanto o serviço downstream, são de responsabilidade da mesma equipe, pode ser que o fato de não haver esquemas não seja um problema.

Lidando com mudanças entre microsserviços

Podemos ter duas abordagens para lidar com mudanças:

  • evitando mudanças que causam incompatibilidade.
  • gerenciando mudanças que causam incompatibilidade.

Evitando mudanças que causam incompatibilidade

Para essa abordagem de evitar, temos 5 formas:

  • mudanças com expansão: acrescente novo itens; não remova itens antigos.
  • leitor tolerante: ser flexível quanto àquilo que se espera. Evitar que o código do cliente seja excessivamente vinculado à interface de um microsserviço. Por exemplo, caso uma API retorne um json que na raiz do objeto tenha a propriedade nome. Não confie que essa propriedade sempre estará onde está. Crie um mecanismo que obter a propriedade independentemente da posição no objeto. Assim, seja resiliente a ponto de conseguir obter o nome nos objetos { nome: 'john doe' } e { resultado: { nome: 'john doe' }}.
  • tecnologia correta: escolha a tecnologia que facilite alterações. Evite aquelas que sejam frágeis quando de trata de permitir fazer alterações (Java RMI).
  • interface explícita: ser explícito quanto àquilo que o serviço expõe. Esquemas em APIs REST(OpenAPI + JSON Schema) e eventos(CloudEvents) ajudam a deixar claro o que é esperado e o que será entregue.
  • identifique rapidamente as mudança que causam incompatibilidades acidentais: tenha mecanismos que identifiquem mudanças nas interfaces que podem causar falhas nos consumidores o mais rápido possível. Ferramentas que permitam realizar diff de esquemas são ótimas para evidenciar.

Gerenciando mudanças que causam incompatibilidade

Há situações em que será necessário fazer mudanças que causarão incompatibilidades. Para lidar com essa situação, temos 3 abordagens:

  • implantação sincronizada: pode ser necessário no cenário que precisamos dar tempo aos consumidores para que façam o upgrade para a nova interface.
  • coexistência de versões incompatíveis: cenários em que consumidores mais antigos encaminham o tráfego para a versão antiga, e consumidores mais recentes vão para a nova versão. Isso pode ocorrer quando o custo de alterar os consumidores mais antigos seja alto demais. O ponto negativo é ter que manter dois serviços e, além disso, criar uma inteligência em algum middleware que saiba direcionar o tráfego para a versão correta. A longo prazo pode ficar insustentável.
  • emular a interface antiga: manter o serviço com as duas interfaces, a antiga e a nova. Isso permite lançar o novo serviço o mais rápido possível e também damos tempo aos consumidores para que façam as alterações. Esse é um exemplo do padrão expansão e contração. Expandimos as funcionalidades, aceitando a maneira antiga e a maneira nova, e, assim que os consumidores mudarem para o modo novo, contraímos a API, removendo a funcionalidade antiga.

O ponto de atenção é evitar implantações sincronizadas com muita frequência, pois logo terá um monolito distribuído. Assim, a abordagem de emular os endpoints antigos acaba sendo mais sustentável.

Contrato Social

A escolha da abordagem sobre como mudanças serão feitas na API deve estar alinhada com as expectativas dos clientes. Manter a interface antiga tem um custo, e o ideal é que ela seja desativada e a infraestrutura associada removida o rápido possível. Assim, precisa-se ter um consenso sobre como lidar com as alterações.

Por exemplo, quanto tempo os consumidores terão para passar para a nova interface antes que a interface antiga seja removida?

Um ponto a ser sempre observado é que o segredo de uma arquitetura de microsserviço eficaz é adotar uma abordagem do consumidor em primeiro lugar. As necessidades dos consumidores são o que há de mais importante. Assim, mudanças que causarão problemas aos consumidores upstream devem ser levadas em consideração.

Monitorando o uso

Para ter o feedback de que a interface antiga não está mais sendo usada, um logging ativo para cada endpoint exposto pode ajudar, além de um identificador único do serviço upstream que ainda está utilizando o serviço. O header HTTP user-agent pode ser utilizado para esse propósito.

Medidas extremas

Pode acontecer o cenário em que os consumidores estejam procrastinando a migração para a nova versão da API.

A primeira abordagem pode ser oferecer ajuda na migração. Mas se mesmo assim nada for resolvido, uma estratégia pode ser fazer com que o serviço antigo responda mais lentamente, por exemplo, adicionando um sleep, e incrementando gradualmente. Assim, a ideia é desencorajar o uso da interface antiga. Claro que essa é uma abordagem extrema e que deve ser utilizada somente em último caso.

Perigos do DRY nos microsserviços

O princípio DRY diz que devemos evitar duplicação de conhecimento e comportamento em nosso sistema. Caso algum comportamento esteja duplicado em várias partes do sistema, será fácil esquecer de algum ponto em que a mudança também precisa ser feita, resultando em bugs.

Muitos podem pensar em criar uma biblioteca compartilhada que passe a ser utilizada em qualquer lugar. O fato é que compartilhar cógido em ambiente de microsserviço é um pouco mais complicado que isso. Um problema que queremos evitar a todo custo é um excessivo acoplamento entre microsserviços e consumidores, de modo que uma mudança em um microsserviço cause alterações desnecessárias no consumidor. Às vezes, porém, o uso de código compartilhado pode causar exatamente esse tipo de acoplamento. Por exemplo, uma lib com entidades comuns que em um determinado momento alguma entidade deixa de ser válida, o que gera uma atualização em larga escala.

Se o uso de código compartilhado ultrapassar as fronteiras do serviço, possivelmente será introduzido uma forma de acoplamento. Agora, logging, por exemplo, não seria um problema, pois representa conceito interno que não é visível ao mundo externo. Muitas vezes, é preferível copiar a implementação a fim de garantir que não vai introduzir um acoplamento.

Outra questão importante é no que diz respeito ao compartilhamento de código por meio de libs é não ser possível atualizar todos os usos simultaneamente. Fazer essa atualização envolveria reimplantar todos os serviços, uma implantação em larga escala em todos os diferentes serviços. Logo, usar lib compartilhada que cruze as fronteiras do serviço, terá de ser aceito que haverá várias versões da lib existindo simultaneamente.

Agora, se mesmo assim houver a insistência em criar libs compartilhadas, deve-se ter o cuidado de não extravasar a lógica que deveria estar no servidor para a lib. Quanto mais lógica extravasar para a lib, maior será a quebra de coesão, e você se verá tendo que alterar vários clientes para implantar correções em seu servidor. Também limita opções de tecnologia. O cenário ideal é separar quem implementa a lógica no servidor de quem implementa a lib.

A própria Netflix tem o cenário que o uso de lib compartilhada na expectativa de entregar facilidade e agilidade, o que acabou gerando um certo grau de acoplamento entre cliente e servidor, o qual tem sido problemático.

Garanta sempre que os clientes decidam quando fazer um upgrade da lib e que isso não elimine a capacidade de lançar serviços de modo independente.

Descoberta de serviços

Quando temos diversos microsserviços, precisamos saber onde está cada um deles e como encontrá-los, já que isso permite que todos saibam com mais facilidade quais são as API's disponíveis, consequentemente evitando que haja duplicidade de serviços.

Isso se enquadra no conceito de descoberta de serviços (service discovery). As soluções de service discovery existentes partem do ponto de:

  • oferecer um método para que uma instância se registre.
  • oferecer uma forma de encontrar o serviço assim que a instância é registrada.

Uma solução de registro estático de serviço é:

  • DNS: o mais simples. Podemos criar um template de domínio para diferentes ambientes, por exemplo, <nome_servico>-<ambiente>.server.com. Toda entrada de domínio tem um TTL e o problema começa quando precisamos atualizar uma entrada, pois clientes continuarão contando com o IP antigo durante o TTL.

Agora, quando estamos falando de registro dinâmico de serviços, estamos falando que o próprio serviço se inscreve junto a algum registro centralizado. Tecnologias que permitem essa abordagem são:

  • zookeeper
  • consul
  • etcd e kubernetes

Service meshes e API gateway

O service mesh trata de tráfegos leste/oeste, interno ao data center. Já API gateway trata de tráfegos norte/sul, tráfego de entrada/saída do data center para o mundo externo.

Tanto o service mesh, quando o API gateway, podem funcionar como proxies entre os microsserviços.

O API gateway, além de tratar tráfego note/sul, também podem entregar soluções como chaves de API para componentes externos, logging, rate limite, etc. Vale a pena usá-lo quando precisa-se gerenciar muitos usuários que sejam terceiros acessando a API. Agora, evite usá-lo para:

  • agregar chamadas: acaba que processos essenciais aos negócio ficam em ferramentas de terceiros.
  • reescrever protocolo: devemos manter os pipes (intermediários) burros e os endpoints inteligentes. Quanto mais comportamentos estiverem dentro do API gateway, maior será o risco de haver transferência de responsabilidades.
  • chamadas leste/oeste: o API gateway como proxy de rede entre dois microsserviços acaba aumentando a latência. É neste ponto que o service mesh resolve o problema.

Com o service mesh, temos um sidecar junto com o microsserviço e funcionalidades comuns associadas a comunicação entre microsserviços são passadas ao mesh, reduzindo as funcionalidades que um microsserviço precisa implementar internamente, por exemplo, mTLS(TLS mútuo), correlation id, load balancer, entre outros. Evitamos utilizar libs compartilhadas para funcionalidades comuns (todo o acoplamento gerado não existirá). O que estamos passando para o mesh são aspectos genéricos.

Basicamente a arquitetura seria:

- app1 <---> sidecar1 <---------> sidecar2 <---> app2
- sidecar1 <---- Control Panel -----> sidecar2

No Control Panel estaria toda a gestão dos sidecars, por exemplo, gestão de certificados caso estejamos usando mTLS.

Exemplos de service mesh:

  • linkerd
  • istio

Service mesh não é para todos, precisa ter uma escala grande de microsserviços para ajustificar o uso já que adicionam complexidade. Além disso, trocar de service mesh é complicado.

Documentação de serviços

Com o aumento de serviços, junto com os esquemas, precisamos ter um boa documentação de APIs. Precisamos ter essas informações prontamente disponíveis.

Podemos começar simples, com uma wiki ou página estática que consiga extrair ou apresentar informações sobre o sistema em execução. Especificações como OpenAPI e AsyncAPI são bons pontos de exploração.

Assim, garanta que o problema que está tentando resolver oriente a escolha de tecnologia. Use esquemas para ajudar a deixar os contratos mais explícitos.

Faça com que mudanças nos serviços sejam compatíveis com versões anteriores a fim de garantir que implantações independentes continuem sendo uma possibilidade. Mas, se estiver fazendo alterações que causem incompatibilidades, encontre uma forma de permitir que os consumidores tenham tempo para fazer upgrade, evitando implantações sincronizadas.

Deixe explícito a documentação do serviço, o que faz e esquemas associados.