Escrevendo um micro serviço gRPC em Go: estrutura, definição e deploy no Google Kubernetes Engine
Breves considerações
Você já deve ter se deparado com alguns tutoriais aqui no Medium que se tratam justamente da criação de micro serviços gRPC em Go. Eu mesmo já li alguns excelentes quando comecei a me aventurar por esse assunto, os quais me nortearam durante minha jornada inicial. Ainda assim, decidi escrever um guia mais prático com o propósito de compartilhar alguns dos aprendizados que obtive depois de quase um ano utilizando desta arquitetura em um dos nossos produtos na unico.
Não me aprofundarei nos detalhes mais técnicos de tecnologias específicas, como: gRPC, Protocol Buffers, HTTP/2.0 e Kubernetes. Muito menos em discussões conceituais sobre os desafios de uma arquitetura orientada a micro serviços e as vantagens/desvantagens do uso de gRPC sobre REST.
Assumo que você já possua algum conhecimento mínimo com relação à esses tópicos e que, portanto, já esteja decidido que essa é a stack/arquitetura que deseja utilizar no seu sistema. Se você ainda não está familiarizado com esses conceitos, recomendo estudá-los primeiro. Alguns artigos que sugiro:
Pré-requisitos
Nesse tutorial, faremos o deploy da nossa aplicação para um cluster Kubernetes do Google (GKE). Se você deseja seguir esse step, é importante que já tenha um cluster criado, pois não abordarei a criação do mesmo neste guia.
Estrutura e organização do projeto
Antes de começar propriamente a definição do micro serviço, vamos entender o layout do projeto. Utilizo como base um modelo mantido pela própria comunidade (não oficial), que define um conjunto de padrões fundamentados em algumas práticas comuns dentro do ecossistema Go.
O projeto vai ter a seguinte estrutura de diretórios e arquivos:
.
├── Dockerfile
├── Makefile
├── cmd
│ └── main.go
├── go.mod
├── go.sum
├── internal
│ └── svc
│ └── handlers.go
├── k8s
│ └── deploy.yaml
├── pkg
│ └── pb
└── proto
└── greeting
├── common
│ └── v1
│ └── message.proto
└── v1
└── service.proto
Sendo que:
cmd/
é o diretório que aloca o arquivomain.go
, que idealmente deve possuir apenas a função principal para invocar o código contido nos pacotesinternal/
oupkg/
.internal/
é o diretório onde estarão definidos os pacotes do projeto que serão privados, ou seja, que não poderão ser importados como uma biblioteca por outras aplicações Go. Essa é uma regra imposta pelo próprio compilador.k8s/
: é o diretório onde definiremos os manifestos YAML necessários para fazermos o deploy da aplicação no Kubernetes.pkg/
: é o diretório onde deixaremos expostos os pacotes que podem ser importados como uma biblioteca por outras aplicações Go. Nesse exemplo: os stubs gerados pelos arquivos Protobuf.proto/
: é o diretório onde definiremos os arquivos Protobuf.
Naturalmente, conforme seu projeto for crescendo, você pode e deve adotar novos padrões que melhor se adaptam ao sistema que está construindo. Por exemplo, se sua aplicação trabalha com um banco de dados MySQL você pode decidir adicionar um diretório migrations/
para armazenar os seus scripts .sql
. E por aí vai. Esse modelo é apenas uma sugestão, que não interfere de forma alguma no desenvolvimento do micro serviço em si.
DICA: Se estiver interessado em mais detalhes sobre esse layout, vale a pena checar o repositório golang-standards/project-layout.
Aplicação
A aplicação que iremos desenvolver é o habitual Hello World, para que não tenhamos que elaborar lógicas de negócio que não competem com o o foco deste artigo: demonstrar como podemos criar de forma simples um serviço gRPC.
Antes de prosseguir, certifique-se de que você já tenha criado a estrutura de diretórios apresentada no tópico anterior e que tenha iniciado seu projeto Go:
go mod init
Definição do serviço
Quando executamos uma requisição REST, a informação trafegada é um arquivo JSON. Não há restrições explícitas entre cliente e servidor de que informações deve conter o objeto e, portanto, cabe unicamente ao servidor validar se o objeto está estruturado da forma como espera receber.
Por outro lado, quando fazemos uso de gRPC, nós trafegamos binários que são serializados com base em uma estrutura de dados definida por arquivos Protobuf (sigla para Protocol Buffers). Ou seja, podemos pensar que esses arquivos representam um contrato explícito estabelecido entre cliente e servidor e que, portanto, quaisquer alterações nessa estrutura podem quebrar efetivamente a comunicação entre ambas as partes.
Sendo assim, é muito propício que comecemos a definição do nosso serviço pela escrita dos nossos “contratos” (arquivos .proto
). Comece criando um arquivo message.proto
dentro de proto/greeting/common/v1:
Nele, definimos a mensagem Message
que contém dois campos:
greeting
: é a representação do cumprimento que enviaremos nas requisições, sendo definida por um enumGreeter
, que especifica uma lista pré-definida de constantes que podem ser utilizadas.name
: é uma string que representa o nome da pessoa que iremos cumprimentar.
Agora vamos definir propriamente o serviço gRPC. Para isso, crie um arquivo service.proto
dentro do diretório proto/greeting/v1
:
Onde:
GreeterService
: é o nome que determinamos para o serviço. Ele possui apenas um método RPC chamadoGreet
, que recebe como parâmetro uma mensagem do tipoGreetRequest
e retorna uma respostaGreetResponse
.GreetRequest
: estabelece a estrutura da requisição, possuindo apenas um campomsg
do tipoMessage
(struct definida no pacotecommon/v1
que acabamos de escrever).GreetResponse
: representa a resposta enviada pelo método, contendo apenas uma string, que concatenará a mensagem de cumprimento que enviarmos na requisição.
Note que configuramos o parâmetro go_package
em ambos os arquivos .proto
, o qual nos permite especificar o caminho onde os stubs gerados em Go (arquivos .pb.go
) serão criados e o nome do pacote que queremos usar.
O caminho dos arquivos .proto
deve coincidir com o nome especificado pelo package
, caso contrário você receberá um erro de compilação dos stubs. É uma boa prática utilizar versões, como por exemplo v1
, para que possamos manter outros contratos ainda válidos em uma eventual quebra de compatibilidade.
Supondo que você esteja rodando seu server local na porta 50051
, a URL do seu endpoint teria o seguinte formato:
http://localhost:50051/greeting.v1.GreeterService/Greet
⚠️ Não esqueça de alterar a variável {{YOUR_REPO}} nos gists acima para o caminho do seu repositório no GitHub (ou qualquer outra plataforma que utilize para versionar seu código).
Agora que definimos como será a estrutura do nosso micro serviço, precisamos gerar os stubs em Go, que serão usado na implementação do server. Para isso, você precisará ter o protoc-gen-go
instalado. Eu recomendo a utilização de uma imagem Docker que já possua todas as dependências instaladas.
Utilizaremos nesse exemplo a imagem thethingsindustries/protoc, que oferece suporte à uma série de linguagens e inclui alguns plugins interessantes do ecossistema Go
. Você pode checar o repositório pra visualizar a lista completa suportada por essa imagem. Se preferir optar pela instalação na sua máquina, vale checar a documentação oficial do Google.
Preparei um arquivo Makefile
contendo um comando para geração dos stubs. É uma boa prática tentarmos automatizar esse tipo de tarefa, tornando sustentável operações que serão habituais no nosso fluxo de desenvolvimento:
O que o comando compile-proto-go
faz basicamente é: executar o protoc
na imagem Docker para todos os arquivos .proto
do diretório proto
. Além disso, configuramos o output para compilar os stubs em Go
com suporte a gRPC. Sendo assim, basta rodar:
make compile-proto-go
Se você não recebeu nenhuma mensagem no bash, significa que a compilação terminou com sucesso. Você pode conferir o diretório pkg/pb
para visualizar os stubs que foram gerados, respeitando a mesma hierarquia de diretórios definida nos arquivos Protobuf:
pkg/pb
└── greeting
├── common
│ └── v1
│ └── message.pb.go
└── v1
└── service.pb.go
Implementação do servidor
A implementação do servidor é bem simples. O ideal é começarmos escrevendo os handlers
do serviço. Para isso, crie um arquivo handlers.go
no diretório internal/svc
:
O que nós fizemos foi:
- Criar uma struct
GreeterService
que representa a interface do serviço nos stubs que foram compilados a partir das especificações dos arquivos Protobuf. - Implementar todas as funções que representam os endpoints do serviço. Nesse exemplo, temos apenas o método
Greet
.
No método Greet
existe primeiramente uma validação do campo Msg
. Caso este valor seja nulo, retornamos um erro específico existente no gRPC para argumentos inválidos (lista completa de status aqui). Caso contrário, retornamos a resposta com a mensagem de cumprimento formatada com o nome da pessoa enviado na requisição.
Uma vez que todos os handlers
estejam implementados, podemos configurar o servidor. Crie um arquivo main.go
no diretório cmd
:
Esse arquivo contém a lógica “principal” da aplicação. A função main
será a primeira a ser executada, na goroutine principal. Os passos que executamos são:
- Inicializamos um listener TCP no endereço onde serviremos o serviço gRPC. Nesse exemplo, adotamos
localhost:50051
. - Instanciamos o servidor gRPC. Nessa etapa, existe uma série de configurações extras que podem ser feitas de acordo com as necessidades de sua aplicação, como por exemplo: middlewares, health probe, tracing, gateway, etc. Vale a pena conferir os repositórios open-source disponíveis no github.com/grpc-ecosystem.
- Registramos os
handlers
implementados no pacoteinternal/svc
. - Iniciamos uma goroutine que escuta em um channel algum evento de interrupção da aplicação. Caso isso ocorra, conseguimos parar o servidor em modo graceful.
- Por fim, servimos o micro serviço no endereço especificado no listener.
Build e deploy no GKE
Estamos prontos para buildar a aplicação e fazermos o deploy no Google Kubernetes Engine. Faremos isso utilizando o seguinte Dockerfile
:
Não há muito segredo nas instruções necessárias para gerar a imagem da aplicação, mas você pode criar seu próprio arquivo Dockerfile
com as instruções que atendem ao seu projeto.
Gere a imagem usando o comando:
docker build -t gcr.io/{{PATH_OF_YOUR_IMAGE}}:{{VERSION}} .
Onde:
PATH_OF_YOUR_IMAGE
: é o caminho no qual você deseja salvar sua imagem dentro do Google Container Registry (uma vez que faremos o deploy no GKE). Caso você esteja utilizando outro provedor de Cloud (AWS, Azure, etc.), basta adaptar esse pedaço do comando.VERSION
: é a versão da imagem. Por exemplo: v0.0.1.
Em seguida, faça o upload da imagem no GCR:
docker push gcr.io/{{PATH_OF_YOUR_IMAGE}}:{{VERSION}}
Para finalizar, precisamos do manifesto que usaremos no deploy do serviço para o Kubernetes.
⚠️ Você precisa ter um cluster criado no GKE e autenticação já configurada para poder prosseguir com os próximos passos.
Crie um arquivo deploy.yaml
no diretório k8s
:
Em seguida, crie um namespace chamado greeting
no cluster do Kubernetes:
kubectl create ns greeting
Certifique-se de que você tenha setado o parâmetro image
do seu manifesto apontando para o caminho correto da imagem no GCR do seu projeto. Feito isso, aplique as configurações:
kubectl apply -f k8s/deploy.yaml
Você pode verificar se o seu micro serviço está rodando nos logs do pod
, através dos comandos:
Testando o serviço
Nosso micro serviço está no ar! Como podemos testá-lo agora? Existem algumas maneiras de fazermos isso e apresentarei nesse artigo uma delas.
Como nossa aplicação está rodando em um cluster remoto, uma das opções é fazermos o port-forward (roteamento de porta) do pod
para a nossa máquina. Esse é o método que considero mais fácil, já que requer o mínimo de configurações na arquitetura do cluster. Que fique claro que esta não é a solução ideal para um ambiente de produção, onde você precisaria configurar um DNS e no mínimo um Load Balancer para rotear a carga de requisições externas para os pods
dos seus micro serviços. Mas isto é assunto para um próximo artigo.
Para fazer esse roteamento, basta executar:
O que esse comando faz é: rotear a porta 50051
do pod
onde está rodando o micro serviço para a porta 50051
local da nossa máquina.
Feito isso, podemos fazer uma requisição para o serviço através do grpcurl
: um CLI bastante similar ao conhecido curl
, mas que tem o propósito de interagir com servidores gRPC. Para macOS, você pode instalar essa ferramenta utilizando o próprio brew
:
brew install grpcurl
Se você está utilizando outro SO, basta seguir os passos para instalação na documentação oficial.
Uma vez instalado, você está pronto para realizar requisições no seu serviço. No diretório do seu projeto, execute o comando:
Algumas informações importantes sobre os parâmetros que utilizamos nesse exemplo:
import-path
: como não habilitamos reflection no nosso servidor, é necessário especificarmos o diretório onde nossos arquivos Protobuf estão localizados.proto
: o caminho relativo aoimport-path
do serviço RPC que faremos a requisição.plaintext
: necessário uma vez que não configuramos TLS.d
: necessário quando não enviamos uma requisição vazia.
Existem outras ferramentas muito interessantes, como o bloomrpc, que oferecem uma UI interativa para realizar requisições gRPC. Vale a pena conferir.
Conclusão
Chegamos ao fim! Espero que este artigo tenha servido como um guia no desenvolvimento do seu micro serviço gRPC e que tenha aberto sua mente para algumas boas práticas e novas possibilidades. Nós abordamos pontos desde a organização do projeto e concepção de um serviço até o deploy em uma plataforma Cloud.
Caso você tenha se interessado por esse conteúdo e seja apaixonado por tecnologia, a unico está contratando engenheir@s. Basta conferir as vagas aqui. 😄 🚀
Alexandre Miziara é engenheiro na unico.