Deployando microsserviços com RabbitMQ e MongoDB no Kubernetes

Uma dos pontos fortes do Kubernetes é que ele nos empodera como desenvolvedores para que possamos, de forma simples, levantar, configurar e monitorar sistemas em produção, liberando nossos colegas de infra para que façam menos trabalho operacional no nível das aplicações e possam focar na infraestrutura como um todo.

Nesse tutorial vamos deployar sistemas usando uma arquitetura de microsserviços onde os mesmas se comunicam usando o RabbitMQ como message broker e persistem os dados no MongoDB. Veremos também como criar cronjobs e como armazenar de forma correta informações sensíveis (como senhas) no Kubernetes.

Pré-requisitos

Não é obrigatório, mas idealmente você primeiro deveria seguir meu tutorial anterior onde mostro como deployar sua primeira aplicação com Kubernetes.

Para esse laboratório você precisará de:

  1. kubectl
  2. Minikube
  3. Helm

Verifique se o Minikube está rodando corretamente usando o comando minikube status. Em seguida mude o contexto do kubectl para o Minikube pelo comando kubectl config set-context minikube. Por fim inicialize o Helm no cluster:

helm init --history-max 200
# Creating /home/eduardo/.helm 
# Creating /home/eduardo/.helm/repository 
# Creating /home/eduardo/.helm/repository/cache 
# Creating /home/eduardo/.helm/repository/local 
# Creating /home/eduardo/.helm/plugins 
# Creating /home/eduardo/.helm/starters 
# Creating /home/eduardo/.helm/cache/archive 
# Creating /home/eduardo/.helm/repository/repositories.yaml 
# Adding stable repo with URL: https://kubernetes-charts.storage.googleapis.com 
# Adding local repo with URL: http://127.0.0.1:8879/charts 
# $HELM_HOME has been configured at /home/eduardo/.helm.
# 
# Tiller (the Helm server-side component) has been installed into your Kubernetes Cluster.
# 
# Please note: by default, Tiller is deployed with an insecure 'allow unauthenticated users' policy.
# To prevent this, run `helm init` with the --tiller-tls-verify flag.
# For more information on securing your installation see: https://docs.helm.sh/using_helm/#securing-your-helm-installation

O comando acima instala o Tiller em nosso cluster. Esse componente é responsável por lidar com todos os pacotes instalados no Kubernetes através do Helm. A flag --max-history é recomendada para evitar guardar um histórico ilimitado de configurações de pacotes instalados pelo Helm, o que pode ocupar bastante espaço em disco.

Antes de usar o Helm para instalar dependências, é importante aguardar o Tiller (serviço do Helm que roda no cluster) atingir o status Running. Para ver o status você pode usar o comando abaixo:

kubectl get pod --selector="name=tiller" -n kube-system
# NAME                             READY   STATUS    RESTARTS   AGE
# tiller-deploy-69d5cd79bb-lhrcs   1/1     Running   0          113s

O que é o Helm?

Helm é uma espécie de gerenciador de pacotes pro Kubernetes. Bancos de dados são um exemplo clássico de aplicação que sempre precisamos instalar, e no fim das contas o que muda entre uma instalação e outra são só algumas configurações. O Helm resolve exatamente esse problema, permitindo que instalemos esses serviços num cluster Kubernetes mudando somente as configurações que julgarmos relevantes, como espaço em disco, usuário, senha etc.

Levantando o RabbitMQ

Com o Helm instalado e o Tiller rodando em nosso cluster, já temos condições de instalar o RabbitMQ usando seu chart oficial:

helm install stable/rabbitmq \
  --name my-rabbitmq \
  --set="rabbitmq.username=guest" \
  --set="rabbitmq.password=guest" \
  --set="persistence.size=1Gi"
# NAME:   my-rabbitmq
# LAST DEPLOYED: Tue Jun 18 21:40:10 2019
# NAMESPACE: default
# STATUS: DEPLOYED
# 
# RESOURCES:
# ==> v1/ConfigMap
# NAME                DATA  AGE
# my-rabbitmq-config  2     1s
# 
# ==> v1/Pod(related)
# NAME           READY  STATUS   RESTARTS  AGE
# my-rabbitmq-0  0/1    Pending  0         1s
# 
# ==> v1/Role
# NAME                         AGE
# my-rabbitmq-endpoint-reader  1s
# 
# ==> v1/RoleBinding
# NAME                         AGE
# my-rabbitmq-endpoint-reader  1s
# 
# ==> v1/Secret
# NAME         TYPE    DATA  AGE
# my-rabbitmq  Opaque  2     1s
# 
# ==> v1/Service
# NAME                  TYPE       CLUSTER-IP     EXTERNAL-IP  PORT(S)                                AGE
# my-rabbitmq           ClusterIP  10.103.148.52  <none>       4369/TCP,5672/TCP,25672/TCP,15672/TCP  1s
# my-rabbitmq-headless  ClusterIP  None           <none>       4369/TCP,5672/TCP,25672/TCP,15672/TCP  1s
# 
# ==> v1/ServiceAccount
# NAME         SECRETS  AGE
# my-rabbitmq  1        1s
# 
# ==> v1beta2/StatefulSet
# NAME         READY  AGE
# my-rabbitmq  0/1    1s
# 
# 
# NOTES:
# 
# ** Please be patient while the chart is being deployed **
# 
# Credentials:
# 
#     Username      : guest
#     echo "Password      : $(kubectl get secret --namespace default my-rabbitmq -o jsonpath="{.data.rabbitmq-password}" | base64 --decode)"
#     echo "ErLang Cookie : $(kubectl get secret --namespace default my-rabbitmq -o jsonpath="{.data.rabbitmq-erlang-cookie}" | base64 --decode)"
# 
# RabbitMQ can be accessed within the cluster on port  at my-rabbitmq.default.svc.cluster.local
# 
# To access for outside the cluster, perform the following steps:
# 
# To Access the RabbitMQ AMQP port:
# 
#     kubectl port-forward --namespace default svc/my-rabbitmq 5672:5672
#     echo "URL : amqp://127.0.0.1:5672/"
# 
# To Access the RabbitMQ Management interface:
# 
#     kubectl port-forward --namespace default svc/my-rabbitmq 15672:15672
#     echo "URL : http://127.0.0.1:15672/"

No exemplo acima três parâmetros foram passados na instalação do RabbitMQ: username, password e persistence.size. Para uma lista completa de parâmetros você pode consultar a documentação desse chart.

O output do comando dá diversas dicas interessantes, entre elas como podemos acessar o RabbitMQ, como podemos resgatar a senha etc. No caso específico da senha, ela fica armazenada em um secret, assunto que abordarei mais tarde nesse tutorial.

Para acessarmos o painel de administração, podemos fazer um redirecionamento de portas do serviço que o Helm criou. Lembrando que a porta padrão do painel de administração do RabbitMQ é 15672:

kubectl port-forward svc/my-rabbitmq 15672:15672

Agora podemos acessar http://localhost:15672 no nosso navegador e fazer login com usuário/senha guest/guest, conforme parâmetros passados na instalação pelo Helm.

Aproveitando que estamos com o RabbitMQ aberto, vamos criar três filas que usaremos mais a frente. Pela minha falta de criatividade, vou sugerir os nomes queue-1, queue-2 e queue-3. No painel de administração do RabbitMQ navegue até a aba Queues, e em seguida na seção Add a new queue preencha o campo Name com queue-1 e confira se a opção Durability está definida como Durable, por fim clique em Add queue. Faça o mesmo para queue-2 e queue-3.

As filas devem aparecer imediatamente no painel:

Levantando o MongoDB

Assim como fizemos com o RabbitMQ, também instalaremos o MongoDB usando o chart oficial do Helm:

helm install stable/mongodb \
  --name="my-mongo" \
  --set="mongodbRootPassword=root-password" \
  --set="mongodbUsername=dummy" \
  --set="mongodbPassword=spam" \
  --set="mongodbDatabase=my-database" \
  --set="persistence.size=1Gi"
# NAME:   my-mongo
# E0618 22:04:22.887538   12573 portforward.go:372] error copying from remote stream to local connection: readfrom tcp4 127.0.0.1:41113->127.0.0.1:51308: write tcp4 127.0.0.1:41113->127.0.0.1:51308: write: broken pipe
# LAST DEPLOYED: Tue Jun 18 22:04:22 2019
# NAMESPACE: default
# STATUS: DEPLOYED
# 
# RESOURCES:
# ==> v1/PersistentVolumeClaim
# NAME              STATUS  VOLUME                                    CAPACITY  ACCESS MODES  STORAGECLASS  AGE
# my-mongo-mongodb  Bound   pvc-2c8cd9b9-922e-11e9-b2d1-080027847a95  1Gi       RWO           standard      0s
# 
# ==> v1/Pod(related)
# NAME                              READY  STATUS   RESTARTS  AGE
# my-mongo-mongodb-58f4d6d9d-f94kd  0/1    Pending  0         0s
# 
# ==> v1/Secret
# NAME              TYPE    DATA  AGE
# my-mongo-mongodb  Opaque  1     0s
# 
# ==> v1/Service
# NAME              TYPE       CLUSTER-IP    EXTERNAL-IP  PORT(S)    AGE
# my-mongo-mongodb  ClusterIP  10.107.5.198  <none>       27017/TCP  0s
# 
# ==> v1beta1/Deployment
# NAME              READY  UP-TO-DATE  AVAILABLE  AGE
# my-mongo-mongodb  0/1    1           0          0s
# 
# 
# NOTES:
# 
# 
# ** Please be patient while the chart is being deployed **
# 
# MongoDB can be accessed via port 27017 on the following DNS name from within your cluster:
# 
#     my-mongo-mongodb.default.svc.cluster.local
# 
# To get the root password run:
# 
#     export MONGODB_ROOT_PASSWORD=$(kubectl get secret --namespace default my-mongo-mongodb -o jsonpath="{.data.mongodb-root-password}" | base64 --decode)
# 
# To connect to your database run the following command:
# 
#     kubectl run --namespace default my-mongo-mongodb-client --rm --tty -i --restart='Never' --image bitnami/mongodb --command -- mongo admin --host my-mongo-mongodb --authenticationDatabase admin -u root -p $MONGODB_ROOT_PASSWORD
# 
# To connect to your database from outside the cluster execute the following commands:
# 
#     kubectl port-forward --namespace default svc/my-mongo-mongodb 27017:27017 &
#     mongo --host 127.0.0.1 --authenticationDatabase admin -p $MONGODB_ROOT_PASSWORD

Da mesma forma que na instalação do RabbitMQ, o output do comando acima exibirá diversas informações úteis (super recomendo que as leia).

O MongoDB pode levar alguns minutos para ficar acessível. Para saber o status do Pod você pode usar o seguinte comando:

kubectl get pods --selector="app=mongodb"
# NAME                                READY   STATUS    RESTARTS   AGE
# my-mongo-mongodb-588f6bdcc5-pkzbb   1/1     Running   0          4m29s

Vamos acessar o banco de dados localmente pra garantir que tudo está funcionando como esperado. Primeiro temos de redirecionar a porta 27017 do serviço criado:

kubectl port-forward svc/my-mongo-mongodb 27017:27017

Em seguida conectamos no banco (é necessário ter o client do MongoDB instalado localmente):

mongo localhost:27017/my-database -u dummy -p spam
# connecting to: mongodb://localhost:27017/my-database
# MongoDB server version: 4.0.10

>

Pra ter certeza que nosso usuário do banco tem permissão de criar e remover documentos, vamos fazer algumas queries no console do Mongo:

> db.collections.aDummyCollection.insert({ foo: 'bar' })
# WriteResult({ "nInserted" : 1 })

> db.collections.aDummyCollection.find()
# { "_id" : ObjectId("5d0c2addcaf167b3b789e78d"), "foo" : "bar" }

> db.collections.aDummyCollection.deleteOne({ _id: ObjectId('5d0c2addcaf167b3b789e78d') })
# { "acknowledged" : true, "deletedCount" : 1 }

Se você teve resultados semelhantes aos de cima, então tudo deve ter funcionando corretamente. Se não funcionou, além das credenciais de acesso você pode verificar se o client do Mongo instalado na sua máquina é compatível com a versão do Mongo que está rodando no seu cluster Kubernetes.

Levantando os serviços intermediários

Para simular serviços se comunicando através de filas, vamos usar a imagem ematos/bypass (https://github.com/eduardo-matos/bypass). Essa imagem joga mensagens de uma fila de origem para uma fila de destino, além de permitir uma configuração de tempo mínimo e máximo para processamento da mensagem e taxa de erros (a mensagem é enviada de volta pra fila de origem caso ocorra algum erro). Vamos criar o deployment desse serviço:

apiVersion: apps/v1
kind: Deployment
metadata:
  name: microservice-1
spec:
  replicas: 1
  selector:
    matchLabels:
      app: microservice-1
  template:
    metadata:
      labels:
        app: microservice-1
    spec:
      containers:
        - name: my-container
          image: ematos/bypass
          imagePullPolicy: Always
          env:
            - name: RABBITMQ_SOURCE_QUEUE_NAME
              value: queue-1
            - name: RABBITMQ_DESTINATION_QUEUE_NAME
              value: queue-2
            - name: RABBITMQ_HOST
              value: my-rabbitmq
            - name: RABBITMQ_PORT
              value: "5672"
            - name: RABBITMQ_USER
              value: guest
            - name: RABBITMQ_PASS
              value: guest
            - name: APP_MIN_DURATION_IN_MILLISECONDS
              value: "0"
            - name: APP_MAX_DURATION_IN_MILLISECONDS
              value: "1000"
            - name: APP_ERROR_RATE
              value: "0.1"

Gostaria de chamar a atenção pro fato das variáveis de ambiente numéricas estarem entre aspas. Isso é obrigatório pra evitar erro de parsing do yaml ao deployar o serviço. Obrigatoriamente variáveis de ambiente devem ser uma string, e nunca um número.

Pra garantir que tudo está funcionando, primeiro vamos aplicar esse yaml:

kubectl apply -f microservice-1.yaml
# deployment.apps/microservice-1 created

Em seguida vamos adicionar uma mensagem à fila queue-1 e ver se ela aparece na fila queue-2. No painel de administração do RabbitMQ vamos à aba Queues, clicamos em queue-1 para acessar a fila, e na seção Publish message preenchemos o campo Payload com um JSON vazio ({}), daí clicamos em Publish message.

Clicando novamente na aba Queues, se tudo der certo em alguns segundos a fila queue-2 exibirá uma mensagem.

Pra ver a mensagem na fila queue-2, basta acessar essa fila, abrir a seção Get messages e clicar em Get message(s).

A chave no JSON diz qual a fila de origem, qual a fila de destino, e o tempo (em milissegundos) que a mensagem levou pra ser processada.

Agora vamos criar um novo serviço, dessa vez pra jogar mensagens da fila queue-2 pra fila queue-3:

apiVersion: apps/v1
kind: Deployment
metadata:
  name: microservice-2
spec:
  replicas: 1
  selector:
    matchLabels:
      app: microservice-2
  template:
    metadata:
      labels:
        app: microservice-2
    spec:
      containers:
        - name: my-container
          image: ematos/bypass
          imagePullPolicy: Always
          env:
            - name: RABBITMQ_SOURCE_QUEUE_NAME
              value: queue-2
            - name: RABBITMQ_DESTINATION_QUEUE_NAME
              value: queue-3
            - name: RABBITMQ_HOST
              value: my-rabbitmq
            - name: RABBITMQ_PORT
              value: "5672"
            - name: RABBITMQ_USER
              value: guest
            - name: RABBITMQ_PASS
              value: guest
            - name: APP_MIN_DURATION_IN_MILLISECONDS
              value: "100"
            - name: APP_MAX_DURATION_IN_MILLISECONDS
              value: "700"
            - name: APP_ERROR_RATE
              value: "0.4"

Em relação ao microservice-1, além do nome e label mudei as filas de origem e destino (RABBITMQ_SOURCE_QUEUE_NAME e RABBITMQ_DESTINATION_QUEUE_NAME), os valores mínimo e máximo para tempo de processamento da mensagem (APP_MIN_DURATION_IN_MILLISECONDS e APP_MAX_DURATION_IN_MILLISECONDS) e a taxa de erros (APP_ERROR_RATE). Por fim podemos aplicar essa nova configuração:

kubectl apply -f microservice-2.yaml 
# deployment.apps/microservice-2 created

Acessando o painel administrativo do RabbitMQ, em poucos segundos a fila queue-2 deve aparecer zerada, e a fila queue-3 deve aparecer com uma mensagem.

Pegando essa mensagem na fila queue-3, podemos ver uma nova chave no JSON.

Existe uma chance de 40% de ter dado erro ao processar a mensagem (devido à variável APP_ERRRO_RATE ser igual a 0.4):

kubectl logs -l "app=microservice-2"
# I, [2019-06-21T03:28:03.305874 #1]  INFO -- : [*] Awaiting for messages
# I, [2019-06-21T03:28:03.725884 #1]  INFO -- : Oops, some error occurred! Sending message back to source queue
# I, [2019-06-21T03:28:04.862051 #1]  INFO -- : Message processed in 1125.4 milliseconds

Você pode inserir diversas mensagens na fila queue-1 e em alguns segundos elas apareceção na fila queue-3.

Levantando o Persister

Agora que temos as aplicações microservice-1 e microservice-2 movendo mensagens entre as filas no RabbitMQ, falta levantar uma aplicação que salva as mensagens da fila queue-3 no MongoDB. Para isso usaremos a imagem ematos/persister (https://github.com/eduardo-matos/persister). A configuração do yaml fica assim:

apiVersion: apps/v1
kind: Deployment
metadata:
  name: microservice-persister
spec:
  replicas: 1
  selector:
    matchLabels:
      app: microservice-persister
  template:
    metadata:
      labels:
        app: microservice-persister
    spec:
      containers:
        - name: my-container
          image: ematos/persister
          imagePullPolicy: Always
          env:
            - name: RABBITMQ_SOURCE_QUEUE_NAME
              value: queue-3
            - name: RABBITMQ_HOST
              value: my-rabbitmq
            - name: RABBITMQ_PORT
              value: "5672"
            - name: RABBITMQ_USER
              value: guest
            - name: RABBITMQ_PASS
              value: guest
            - name: RABBITMQ_VHOST
              value: "/"
            - name: APP_CONCURRENCY
              value: "1"
            - name: APP_MIN_DURATION_IN_MILLISECONDS
              value: "50"
            - name: APP_MAX_DURATION_IN_MILLISECONDS
              value: "800"
            - name: APP_ERROR_RATE
              value: "0.3"
            - name: MONGODB_URL
              value: "mongodb://dummy:spam@my-mongo-mongodb:27017"
            - name: MONGODB_DATABASE_NAME
              value: my-database
            - name: MONGODB_COLLECTION_NAME
              value: events

Aplicando essa configuração no Kubernetes:

kubectl apply -f microservice-persister.yaml
# deployment.apps/microservice-persister created

Após alguns segundos a aplicação deve estar rodando. Quando isso acontecer podemos consultar os logs pra ver o que aconteceu:

kubectl logs -l app=microservice-persister
# Failed to insert "{"from_queue-1_to_queue-2":876.064292,"from_queue-2_to_queue-3":1125.379281}" Error: A simulated error occurred
#     at /app/src/index.js:12:13
#     at async /app/src/rabbit.js:18:24
# Inserted Id="5d0ce850bf60a2000fdb778b"

No meu caso ocorreu um erro na primeira tentativa de inserir a mensagem no banco de dados, e na segunda tentativa funcionou corretamente.

Testando o processo de ponta a ponta

Acessando novamente o painel de administração do RabbitMQ (não se esqueça de usar kubectl port-forward) nós podemos adicionar uma mensagem na fila queue-1, e ver se ela realmente vai aparecer no banco de dados. Sugiro que você inicie três terminais. O primeiro exibe os logs do microservice-1, o segundo exibe os logs do microservice-2 e o terceiro exibe os logs do microservice-persister:

# No primeiro terminal
kubectl logs --tail 0 --follow -l app=microservice-1

# No segundo terminal
kubectl logs --tail 0 --follow -l app=microservice-2

# No terceiro terminal
kubectl logs --tail 0 --follow -l app=microservice-persister

Após inserir um JSON vazio como mensagem na fila queue-1, você deve ter um resultado parecido com esse:

Levantando o Publisher

Inserir mensagens manualmente na fila é tedioso. Pra resolver isso vamos usar o ematos/publisher (https://github.com/eduardo-matos/publisher), um sistema que publica mensagens em uma dada fila do RabbitMQ. Pra tornar essa inserção automática, vamos usar um CronJob no Kubernetes.

apiVersion: batch/v1beta1
kind: CronJob
metadata:
  name: microservice-publisher
spec:
  schedule: "* * * * *" # every minute
  concurrencyPolicy: Forbid
  suspend: false
  successfulJobsHistoryLimit: 3
  failedJobsHistoryLimit: 5
  jobTemplate:
    spec:
      template:
        spec:
          restartPolicy: Never
          containers:
          - name: publisher
            image: ematos/publisher
            imagePullPolicy: Always
            env:
              - name: RABBITMQ_DESTINATION_QUEUE_NAME
                value: "queue-1"
              - name: RABBITMQ_HOST
                value: "my-rabbitmq"
              - name: RABBITMQ_PORT
                value: "5672"
              - name: RABBITMQ_USER
                value: "guest"
              - name: RABBITMQ_PASS
                value: "guest"
              - name: RABBITMQ_VHOST
                value: "/"
              - name: APP_MIN_MSG_COUNT_TO_PUBLISH
                value: "0"
              - name: APP_MAX_MSG_COUNT_TO_PUBLISH
                value: "100"

No Kubernetes um CronJob dispara um Job de acordo com um dado schedule (spec.schedule), por isso definimos um template de Job em spec.jobTemplate. O formato do schedule é o mesmo que usamos em crontabs.

O campo spec.suspend diz se o cronjob está ativo ou inativo. O uso dsse campo é facultativo, e seu valor padrão é false. Os campos spec.successfulJobsHistoryLimit e spec.failedJobsHistoryLimit informam quantos pods manteremos disponíveis em caso de sucesso e falha respectivamente.

O campo spec.concurrencyPolicy diz como o Kubernetes deve lidar nos casos onde um cronjob for executado enquanto outro ainda estiver rodando. As possíveis opções são Forbid (não permitir rodar um novo cronjob), Allow (permitir rodar o mesmo cronjob simultaneamente) e Replace (matar o cronjob que está rodando antes de iniciar um novo).

O campo spec.jobTemplate.spec.template.spec.restartPolicy é obrigatório, e informa ao Kubernetes o que deve ser feito caso o job falhe. A documentação oficial do Kubernetes explica detalhadamente como essa opção funciona.

Aplicando no Kubernetes:

kubectl apply -f microservice-publisher.yaml

De acordo com as configurações das variáveis de ambiente, essa aplicação vai publicar um número aleatório de mensagens (entre 0 e 100) na fila queue-1.

Após aproximadamente um minuto já podemos visualizar novamente o processo funcionando de ponta a ponta conforme fizemos na seção anterior:

# No primeiro terminal
kubectl logs --tail 0 --follow -l app=microservice-1

# No segundo terminal
kubectl logs --tail 0 --follow -l app=microservice-2

# No terceiro terminal
kubectl logs --tail 0 --follow -l app=microservice-persister

Configurando os secrets

Nesse momento as variáveis de ambiente estão hardcoded no arquivos yaml que criamos, o que significa que dados sensíveis como senha, por exemplo, ficariam visíveis para qualquer pessoa que tivesse acesso ao repositório, o que é uma má prática.

Pra resolver esse problema, vamos usar Secrets no Kubernets. Existem duas formas de criar Secrets: A primeira é usando arquivos yaml com Kind: Secret. A segunda é usando um comando imperativo. Nessa seção mostrarei como fazer usando o comando imperativo.

Primeiro vamos criar um secret para o RabbitMQ:

kubectl create secret generic my-rabbitmq-secret \
  --from-literal=host=my-rabbitmq \
  --from-literal=port="5672" \
  --from-literal=user=guest \
  --from-literal=password=guest \
  --from-literal=vhost="/"
# secret/my-rabbitmq-secret created

É possível configurar permissão de acesso por usuário no Kubernetes, ou seja, poderíamos permitir que alguns usuários conseguissem visualizar as secrets enquanto outros não, porém essa configuração está fora do escopo desse tutorial.

Podemos visualizar as secrets forçando um output do tipo yaml:

kubectl get secret my-rabbitmq-secret             
# NAME                 TYPE     DATA   AGE
# my-rabbitmq-secret   Opaque   4      1m

kubectl get secret my-rabbitmq-secret -o yaml
# apiVersion: v1
# data:
#   host: bXktcmFiYml0bXE=
#   password: Z3Vlc3Q=
#   port: NTY3Mg==
#   user: Z3Vlc3Q=
# kind: Secret
# metadata:
#   creationTimestamp: "2019-06-21T15:44:51Z"
#   name: my-rabbitmq-secret
#   namespace: default
#   resourceVersion: "18096"
#   selfLink: /api/v1/namespaces/default/secrets/my-rabbitmq-secret
#   uid: 81d89a06-943b-11e9-8850-0800277140f5
# type: Opaque

Repare que os valores das secrets estão no formato base64. Para pegar o valor literal podemos fazer a conversão:

echo "bXktcmFiYml0bXE=" | base64 --decode
# my-rabbitmq

echo "Z3Vlc3Q=" | base64 --decode
# guest

echo "NTY3Mg==" | base64 --decode
# 5672

echo "Z3Vlc3Q=" | base64 --decode
# guest

Agora vamos criar as secrets do MongoDB:

kubectl create secret generic my-mongo-secret \
  --from-literal=url="mongodb://dummy:spam@my-mongo-mongodb:27017" \
  --from-literal=db_name=my-database \
  --from-literal=collection_name=events
# secret/my-mongo-secret created

Acessando as secrets do MongoDB:

kubectl get secret my-mongo-secret        
# NAME              TYPE     DATA   AGE
# my-mongo-secret   Opaque   3      1m

kubectl get secret my-mongo-secret -o yaml
# apiVersion: v1
# data:
#   collection_name: ZXZlbnRz
#   db_name: bXktZGF0YWJhc2U=
#   url: bW9uZ29kYjovL2R1bW15OnNwYW1AbXktbW9uZ28tbW9uZ29kYjoyNzAxNw==
# kind: Secret
# metadata:
#   creationTimestamp: "2019-06-21T15:53:56Z"
#   name: my-mongo-secret
#   namespace: default
#   resourceVersion: "18968"
#   selfLink: /api/v1/namespaces/default/secrets/my-mongo-secret
#   uid: c6b93b19-943c-11e9-8850-0800277140f5
# type: Opaque

Podemos também visualizar todas as secrets:

kubectl get secrets
# NAME                      TYPE                                  DATA   AGE
# default-token-6d6vw       kubernetes.io/service-account-token   3      3h
# my-mongo-mongodb          Opaque                                2      3h
# my-mongo-secret           Opaque                                3      3m
# my-rabbitmq               Opaque                                2      3h
# my-rabbitmq-secret        Opaque                                4      12m
# my-rabbitmq-token-psqff   kubernetes.io/service-account-token   3      3h

Aqui vemos não somente as secrets que criamos, como também aquelas que o Helm criou quando instalamos o MongoDB e o RabbitMQ.

Carregando variáveis de ambiente através das secrets

Uma vez que criamos as secrets, temos de voltar em todos os arquivos yaml para substituir os valores hardcoded:

Para o microservice-publisher

apiVersion: batch/v1beta1
kind: CronJob
metadata:
  name: microservice-publisher
spec:
  schedule: "* * * * *" # every minute
  concurrencyPolicy: Forbid
  suspend: false
  successfulJobsHistoryLimit: 3
  failedJobsHistoryLimit: 5
  jobTemplate:
    spec:
      template:
        spec:
          restartPolicy: Never
          containers:
          - name: publisher
            image: ematos/publisher
            imagePullPolicy: Always
            env:
              - name: RABBITMQ_DESTINATION_QUEUE_NAME
                value: "queue-1"
              - name: RABBITMQ_HOST
                valueFrom:
                  secretKeyRef:
                    name: my-rabbitmq-secret
                    key: host
              - name: RABBITMQ_PORT
                valueFrom:
                  secretKeyRef:
                    name: my-rabbitmq-secret
                    key: port
              - name: RABBITMQ_USER
                valueFrom:
                  secretKeyRef:
                    name: my-rabbitmq-secret
                    key: user
              - name: RABBITMQ_PASS
                valueFrom:
                  secretKeyRef:
                    name: my-rabbitmq-secret
                    key: password
              - name: RABBITMQ_VHOST
                valueFrom:
                  secretKeyRef:
                    name: my-rabbitmq-secret
                    key: vhost
              - name: APP_MIN_MSG_COUNT_TO_PUBLISH
                value: "0"
              - name: APP_MAX_MSG_COUNT_TO_PUBLISH
                value: "100"

Para o microservice-1

apiVersion: apps/v1
kind: Deployment
metadata:
  name: microservice-1
spec:
  replicas: 1
  selector:
    matchLabels:
      app: microservice-1
  template:
    metadata:
      labels:
        app: microservice-1
    spec:
      containers:
        - name: my-container
          image: ematos/bypass
          imagePullPolicy: Always
          env:
            - name: RABBITMQ_SOURCE_QUEUE_NAME
              value: queue-1
            - name: RABBITMQ_DESTINATION_QUEUE_NAME
              value: queue-2
            - name: RABBITMQ_HOST
              valueFrom:
                secretKeyRef:
                  name: my-rabbitmq-secret
                  key: host
            - name: RABBITMQ_PORT
              valueFrom:
                secretKeyRef:
                  name: my-rabbitmq-secret
                  key: port
            - name: RABBITMQ_USER
              valueFrom:
                secretKeyRef:
                  name: my-rabbitmq-secret
                  key: user
            - name: RABBITMQ_PASS
              valueFrom:
                secretKeyRef:
                  name: my-rabbitmq-secret
                  key: password
            - name: APP_MIN_DURATION_IN_MILLISECONDS
              value: "0"
            - name: APP_MAX_DURATION_IN_MILLISECONDS
              value: "1000"
            - name: APP_ERROR_RATE
              value: "0.1"

Para o microservice-2

apiVersion: apps/v1
kind: Deployment
metadata:
  name: microservice-2
spec:
  replicas: 1
  selector:
    matchLabels:
      app: microservice-2
  template:
    metadata:
      labels:
        app: microservice-2
    spec:
      containers:
        - name: my-container
          image: ematos/bypass
          imagePullPolicy: Always
          env:
            - name: RABBITMQ_SOURCE_QUEUE_NAME
              value: queue-2
            - name: RABBITMQ_DESTINATION_QUEUE_NAME
              value: queue-3
            - name: RABBITMQ_HOST
              valueFrom:
                secretKeyRef:
                  name: my-rabbitmq-secret
                  key: host
            - name: RABBITMQ_PORT
              valueFrom:
                secretKeyRef:
                  name: my-rabbitmq-secret
                  key: port
            - name: RABBITMQ_USER
              valueFrom:
                secretKeyRef:
                  name: my-rabbitmq-secret
                  key: user
            - name: RABBITMQ_PASS
              valueFrom:
                secretKeyRef:
                  name: my-rabbitmq-secret
                  key: password
            - name: APP_MIN_DURATION_IN_MILLISECONDS
              value: "100"
            - name: APP_MAX_DURATION_IN_MILLISECONDS
              value: "700"
            - name: APP_ERROR_RATE
              value: "0.4"

Para o microservice-persister

apiVersion: apps/v1
kind: Deployment
metadata:
  name: microservice-persister
spec:
  replicas: 1
  selector:
    matchLabels:
      app: microservice-persister
  template:
    metadata:
      labels:
        app: microservice-persister
    spec:
      containers:
        - name: my-container
          image: ematos/persister
          imagePullPolicy: Always
          env:
            - name: RABBITMQ_SOURCE_QUEUE_NAME
              value: queue-3
            - name: RABBITMQ_HOST
              valueFrom:
                secretKeyRef:
                  name: my-rabbitmq-secret
                  key: host
            - name: RABBITMQ_PORT
              valueFrom:
                secretKeyRef:
                  name: my-rabbitmq-secret
                  key: port
            - name: RABBITMQ_USER
              valueFrom:
                secretKeyRef:
                  name: my-rabbitmq-secret
                  key: user
            - name: RABBITMQ_PASS
              valueFrom:
                secretKeyRef:
                  name: my-rabbitmq-secret
                  key: password
            - name: RABBITMQ_VHOST
              valueFrom:
                secretKeyRef:
                  name: my-rabbitmq-secret
                  key: vhost
            - name: APP_CONCURRENCY
              value: "1"
            - name: APP_MIN_DURATION_IN_MILLISECONDS
              value: "50"
            - name: APP_MAX_DURATION_IN_MILLISECONDS
              value: "800"
            - name: APP_ERROR_RATE
              value: "0.3"
            - name: MONGODB_URL
              valueFrom:
                secretKeyRef:
                  name: my-mongo-secret
                  key: url
            - name: MONGODB_DATABASE_NAME
              valueFrom:
                secretKeyRef:
                  name: my-mongo-secret
                  key: db_name
            - name: MONGODB_COLLECTION_NAME
              valueFrom:
                secretKeyRef:
                  name: my-mongo-secret
                  key: collection_name

Aplicando os arquivos no Kubernetes:

kubectl apply -f microservice-publisher.yaml
# cronjob.batch/microservice-publisher configured

kubectl apply -f microservice-1.yaml
# deployment.apps/microservice-1 configured

kubectl apply -f microservice-2.yaml
# deployment.apps/microservice-2 configured

kubectl apply -f microservice-persister.yaml
# deployment.apps/microservice-persister configured

O Kubernetes finalizará os pods antigos e levantará novos carregando as variáveis de ambiente a partir das secrets. Pra garantir que a aplicação realmente está carregando as variáveis de ambiente a partir das secrets, vamos acessar alguns pods e resgatar valores de variáveis de ambiente:

kubectl get pods
# NAME                                      READY   STATUS      RESTARTS   AGE
# microservice-1-58468f86b-6ps5h            1/1     Running     0          4m
# microservice-2-748d69b4d9-ks6j4           1/1     Running     0          4m
# microservice-persister-68899c498c-m6v5z   1/1     Running     0          44m
# microservice-publisher-1561136100-cvkxl   0/1     Completed   0          2m
# microservice-publisher-1561136160-ngtpd   0/1     Completed   0          1m
# microservice-publisher-1561136220-b9kmp   0/1     Completed   0          12s
# my-mongo-mongodb-6844fb7558-t6gf9         1/1     Running     0          4h
# my-rabbitmq-0                             1/1     Running     0          4h

# Semelhante a `docker exec`
kubectl exec -it microservice-persister-68899c498c-m6v5z -- sh -c "echo \$MONGODB_URL"
# mongodb://dummy:spam@my-mongo-mongodb:27017

kubectl exec -it microservice-1-58468f86b-6ps5h -- sh -c "echo \$RABBITMQ_USER"
# guest

kubectl exec -it microservice-2-748d69b4d9-ks6j4 -- sh -c "echo \$RABBITMQ_HOST"
# my-rabbitmq

Exercício 1

Uma vantagem do Kubernetes que já tratei em meu tutorial anterior é a facilidade de escalar aplicações horizontalmente. Nesse exercício você deve configurar a variável APP_MAX_MSG_COUNT_TO_PUBLISH=1000 no microservice-publisher. Isso deve fazer uma das filas começar a encher indefinidamente. Sua missão é alterar a quantidade de réplicas do microservice-1 e/ou microservice-2 e/ou microservice-persister de modo que consigam estabilizar o fluxo de mensagens, ou seja, nenhuma fila deve crescer indefinidamente. O desafio é encontrar o menor número de réplicas que torna isso possível. Boa sorte!

Dica: Se precisar parar o microservice-publisher porque a fila está enchendo muito rápido, mude a opção spec.suspend para false e dê um kubectl apply -f microservice-publisher.yaml.

Exercício 2

No caso do microservice-persister você pode alterar a quantidade de réplicas do deployment e/ou incrementar a variável APP_CONCURRENCY. Qual dos dois traz melhores resultados? Qual consome menos memória e cpu total? (use o comand kubectl top pods -l app=microservice-persister para consultar o consumo de memória e cpu do microservice-persister)

Uma curiosidade

Microsserviços são poliglotas. Os projetos ematos/publisher, ematos/bypass e ematos/persister são escritos em linguagens diferentes um do outro. O primeiro em Python, o segundo em Ruby, e o terceiro em JavaScript.

Do ponto de vista da criação das configurações pro Kubernetes isso ficou transparente. Essa é uma grande vantagem que nos possibilita desenvolver aplicações usando a tecnologia mais apropriada pro problema que precisamos resolver sem criar empecilhos que dificultam o deploy de aplicações.

Conclusão

Nesse tutorial demos um grande salto no Kubernetes. Criamos aplicações stateful (RabbitMQ e MongoDB), conectamos aplicações através de um message broker e criamos um cronjob. Também configuramos as variáveis de ambiente de forma segura usando secrets, e simulamos uma situação real onde tivemos de estabilizar um sistema escalando as aplicações horizontalmente. Com o que vimos aqui nos tornamos capazes de desenvolver sistemas mais desacoplados, poliglotas e escaláveis.

Arquivos do tutorial (incluindo secrets): github.com/eduardo-matos/tutorial-kubernetes-microservices