Múltiplas aplicações em um cluster Kubernetes
Nesse artigo mostro como preparei múltiplas aplicações para deploy em um único cluster kubernetes e também: o motivo da escolha do kubernetes, os benefícios, as dificuldades enfrentadas e os próximos passos.
O problema inicial
Temos pelo menos 5 aplicações principais rodando no Google Cloud Platform (GCP) e também algumas funções que foram desacopladas de uma API e hoje são executadas a partir de google functions, acessadas pelas aplicações principais.
Três dessas aplicações principais rodavam individualmente (uma aplicação por VM) em VMs do tipo n1-standard-1 (1vCPU e 3,75GB de RAM). Todas as aplicações containerizadas e o deploy em produção realizado por um simples:
| |
Mesmo com todo o processo de deploy automatizado, sem gerar dores de cabeça, o desperdício de recursos por parte de duas aplicações e a falta de recursos disponíveis para uma das aplicações me incomodava:
- Uma das aplicações (painel de administração, poucas pessoas com acesso) consumia no máximo 20% da CPU somando todos os containers (nginx, app, schedule, queue, redis, certbot) e no máximo 2 dos 3,75GB de RAM.
- Uma segunda aplicação com métricas e situação bem parecida à mencionada acima.
- Uma terceira aplicação, com situação oposta, com métricas recomendando o upgrade da VM para pelo menos 6GB de RAM. Essa aplicação executa jobs em queues, pode ficar alguns minutos sem receber nenhum novo job, porém, quando recebe um novo job ela deve executar todos rapidamente, além de poder receber novos jobs enquanto executa o antigo e já ter que iniciar a execução desse novo job sem espera. No framework utilizado nessa aplicação (Laravel), cada worker utiliza pelo menos 32MB de RAM, se configurarmos um valor máximo de 120 workers, já são necessários pelo menos 3840MB de memória RAM, excedendo os 3,75GB de RAM dessa VM. Além do fato de muitas vezes os 120 workers não serem suficientes para uma entrega rápida, ocasionando em um wait time longo para executar os jobs nas queues:

Essa aplicação definivamente precisava de mais recursos enquanto as outras duas citadas anteriormente não utilizavam todos os recursos disponíveis.
Uma quarta aplicação, um MVP (Minimum viable product) rodando em um único container no cloud.run, já estava validada e precisava evoluir com implementação de queues e cache. Como o cloud.run é feito para conteúdo stateless e não possui acesso a redis (pelo menos não de forma fácil, sem ter que expor o redis de alguma VM por exemplo), era necessário tirá-lo dali.
Uma quinta aplicação, também em container único, rodava bem no cloud.run e, diferentemente da anterior não precisa de queues. Porém, como possui muitos acessos no cloud.run e o tempo de execução de CPU de cada request dessa aplicação é alto, os custos no cloud.run começaram a incomodar (abaixo os preços do cloud.run com e sem free tier):

Uma solução viável para otimização da utilização de recursos seria executar as aplicações em um cluster, possuindo assim o controle de quanto hardware dedicar a cada aplicação e abrindo possibilidade para escalabilidade da terceira aplicação mencionada anteriormente. Para orquestrar os contâiners no cluster, dentre as opções disponíveis eu teria que escolher bem entre duas: Swarm ou Kubernetes, pois possuía um pouco de conhecimento prévio em ambas as ferramentas.
Por que Kubernetes?
Gerenciar um cluster é difícil, aplicar patches de segurança, auto reparo, auto upgrade, auto scaling e garantir disponibilidade são só alguns dos exemplos do que teríamos que manter caso optássemos por gerenciá-lo.
Dentre as opções de cluster citadas anteriormente (Swarm e Kubernetes), nosso cloud provider disponibiliza apenas o serviço de gerenciamento de Kubernetes (na minha opinião, um dos melhores e mais robustos, talvez porque eles projetaram o Kubernetes e a maioria dos seus serviços rodam no mesmo). O serviço é o Google Kubernetes Engine (GKE) e oferece um plano gratuito para um cluster zonal, atendendo nossas necessidades.
Os custos previstos:
- 2 VMs e2-standard-2 com 2vCPU e 8GB de ram cada, totalizando um cluster com 4vCPU e 16GB de RAM. Com um contrato de desconto por uso contínuo de 3 anos, a previsão mensal de custo é de aproximadamente
45 USD. - Loadbalancer http/https, 1 até 5 regras de forwarding custam aproximadamente
18 USD.
Bancos de dados, buckets e outros serviços gerenciados do google não entram na conta pois não foram alterados e seus custos continuaram os mesmos.
Criando o cluster Kubernetes
Podemos criar o cluster no GKE pelo Google Cloud Console ou via linha de comando a partir de nossa máquina:
Tenha o gcloud e kubectl previamente instalados.
Substitua
yourclustername,yourprojectname,yourregion(ex para São Paulo: southamerica-east1),yourregion-zone(ex para São Paulo e zona a: southamerica-east1-a) eyournetworkpelos devidos valores.
| |
Definimos que o cluster terá no mínimo 2 nodes com máquinas do tipo e2-standard-2 (máquinas contratadas no commitment discount mencionado anteriormente), e no máximo 3 nodes.
Altere o contexto do kubectl para o GKE:
| |
Preparando os containers
Como dito no primeiro tópico, as aplicações já rodavam containerizadas. Um container dedicado para a aplicação web, um para as queues, um para a execução de schedules, um container redis e um nginx. O container certbot foi descartado pois foi adotada outra abordagem para gerenciamento dos certificados SSL.
Caso você ainda não tenha containerizado sua aplicação, prepare-a de modo que respeite o Twelve-Factor App.
Preparando os manifestos
Aqui vem o primeiro susto para quem era acostumado a subir o ambiente de produção inteiro com um único arquivo docker-compose.yaml 🙃

Mostrarei o propósito de cada arquivo. Veja detalhes e conceitos do Kubernetes em sua documentação.
A infraestrutura da aplicação é definida como código, os controllers do Kubernetes checam em loop se o estado atual da aplicação é igual ao estado definido via código, e caso não seja, aplica as modificações necessárias.
Os manifestos podem ser definidos em YAML ou JSON, as extensões .yaml, .yml e .json são aceitas. Coloco todos no mesmo diretório para facilitar o deploy com o comando:
| |
01-namespace.yaml
| |
Nesse arquivo defino o Namespace da aplicação. Com namespaces é possível definir o escopo das aplicações. Assim é possível executar várias aplicações diferentes no mesmo cluster sem que interfiram uma na outra (a comunicação entre namespaces ainda é possível através de serviços expostos como mostrarei). Outra utilidade de namespaces é separar ambientes de staging e production por exemplo. Por padrão, caso namespaces não sejam definidos os deploys são realizados no namespace default.
No mesmo arquivo defino um deploy do tipo Resource Quota, nele é possível definir os recursos e limites de recursos solicitados pelo namespace. No exemplo, defino que:
requests.cpu: 300m- todos os componentes do namespace somados podem requisitar (somados) no máximo 300 millicores de cpu (1vCPU = 1000m, valores de cpu podem ser definidos a partir de 1m).requests.memory: 1536Mi- todos os componentes do namespace somados podem requisitar (somados) no máximo 1536Mi de memória (1 Mebibyte (MiB) = (1024)^2 bytes = 1048576 bytes).limits.cpu: 500m- todos os componentes do namespace somados (apesar de poderem requisitar 300m de cpu definidos anteriormente) podem utilizar o máximo 500 millicores de cpu.limits.memory: 2048Mi- todos os componentes do namespace somados (apesar de poderem requisitar 1536Mi de memória definidos anteriormente) podem utilizar no máximo 2048Mi de memória.
Quando os limites de cpu definidos são atingidos, a aplicação começa a sofrer throttled de cpu, ou seja, sua performance é afetada.
Quando os limites de memória são atingidos, não é possível “comprimir” a memória como é feito com cpu, e seu Pod é terminado.
Quando um deploy de uma nova versão da sua aplicação é feita, caso o limite de cpu ou memória seja excedido, os pods não serão executados e ficarão com o estado Evicted.
A definição de ResourceQuota para um namespace é opcional, porém garante que uma aplicação não consuma recursos demasiadamente.
02-nfs-server-deployment.yaml
| |
Nesse arquivo defino o deployment de um volume NFS, já que o padrão GCEPersistentDisk do GKE não suporta o tipo de acesso ReadWriteMany para ser lido e escrito por vários nodes ao mesmo tempo.
Este volume será usado para persistir os dados do redis e também as páginas estáticas que são geradas a partir do container app e compartilhadas com o container nginx.
Alguns detalhes do arquivo:
- O namespace deve ser o mesmo definido anteriormente para que o escopo do deploy seja o mesmo namespace.
- Ele possui apenas uma réplica.
- As requests e limits de cpu desse deployment podem ser bem pequenas (mostro mais a frente como analisar).
- O volume é montado no path /exports do disco.
- O disco definido em
pdNamenos volumes deve ser criado anteriormente com:
| |
03-nfs-server-service.yaml
| |
O service acima é o responsável por expor o deployment criado anteriormente para ser acessado pelos outros pods. O serviço é exposto com ClusterIp, apenas para outros pods, e não para a internet.
Lembre-se de definir o selector do service com o mesmo valor definido nas labels do selector no deployment.
04-redis-statefulset.yaml
| |
O deploy do redis é do tipo StatefulSet, o volume pode acessar diretamente o serviço deployado anteriormente via <service>.<namespace>.svc.cluster.local.
05-redis-service.yaml
| |
Expõe o redis com ClusterIp para ser acessado pelos outros pods.
06-app-deployment.yaml
| |
Responsável pelo deploy da aplicação laravel com fpm, em annotations temos uma anotação secret.reloader.stakater.com/reload: "env" que irá realizar o deploy de um novo pod sempre que a secret com nome env for atualizada. Esse comportamento não é padrão do kubernetes e para habilitá-lo instalamos um controller chamado Reloader com o comando:
| |
replicas: 2- definimos que o deploy irá criar 2 pods.revisionHistoryLimit: 1- só teremos acesso a uma versão anterior a atual para rollback.
Em strategy:
type: RollingUpdate- Nosso deploy é do tipo Rolling Update.maxSurge: 1- Só pode surgir um novo pod por vez.maxUnavailable: 50%- No mínimo metade dos pods devem estar disponíveis durante o deploy, ou seja, no exemplo comreplicas: 2emaxSurge: 1, um novo pod surgirá, então um pod antigo será terminado (respeitando omaxUnavailable: 50%), então um novo pod surgirá, e o último pod antigo será terminado.
Em containers:
Esse pod possui 2 containers, o app principal e um sidecar (um proxy para conexão com o banco de dados no Google Cloud SQL).
No primeiro container app temos:
commands:eargs:- são os comandos que serão executados pelo nosso container, osleepinicial serve para aguardar até que o container sidecar esteja disponível. Na versão1.18em diante esse sleep não será mais necessário.envFrom:- injetamos nossas variáveis de ambiente (mostrarei como criá-las no tópico Automatizando o processo de teste e deploy com um pipeline CI/CD).readinessProbe:- O container só receberá tráfego após uma conexão TCP bem sucedida com o container na porta 9000.volumeMounts:- O volume static compartilha assets entre o app e o nginx. O volume cache-html armazena algumas páginas de forma estática geradas a partir do conteúdo dinâmico, evitando a renderização a todo momento.
No sidecar container cloudsql-proxy fazemos a autenticação com o uso de um secret que também mostro como criá-la no tópico Automatizando o processo de teste e deploy com um pipeline CI/CD.
07-app-hpa.yaml
| |
O Horizontal Pod Autoscaler (HPA) escala automaticamente o número de pods do nosso deployment. No exemplo em 06-app-deployment.yaml, nosso deployment foi feito com duas réplicas, o HPA acima irá escalar esse deployment para 1 ou 3 réplicas baseado na média de utilização de cpu e memória do deployment.
08-app-service.yaml
| |
Expõe o app com ClusterIp para ser acessado pelos outros pods.
09-queue-deployment.yaml
| |
Os conceitos são os mesmos do 06-app-deployment.yaml, porém iniciamos com apenas uma réplica e o HPA a seguir controla a necessidade de outras réplicas.
10-queue-hpa.yaml
| |
Os conceitos são os mesmos do 07-app-hpa.yaml, porém para a queue.
11-schedule-deployment.yaml
| |
Se você já usa kubernetes provavelmente pensou “existe um controller CronJob para isso”. Sim, porém meus crons são executados a cada minuto, o processo de um controller disparar um job, subir um pod, executar o container, matar o pod, e repetir de novo alguns segundos depois não me parece legal. Não sou o único a adotar essa abordagem para conjobs a cada minuto.
12-nginx-configmap.yaml
| |
O ConfigMap com a configuração nginx utilizada para essa aplicação.
13-nginx-deployment.yaml
| |
O deployment do nginx utiliza o nfs-server criado no passo 2 e exposto no passo 3, ele terá acesso aos conteúdos compartilhados pelo passo 6. Além de utilizar o nginx-configmap criado no passo anterior.
14-nginx-hpa.yaml
| |
Os conceitos são os mesmos do 07-app-hpa.yaml, porém para o nginx.
15-nginx-service.yaml
| |
Expõe o nginx com o tipo NodePort para que seja acessado pela internet.
16-managedcerts.yaml
| |
Os certificados SSL auto gerenciados pelo Google. Mais detalhes no tópico Adicionando certificados SSL auto gerenciados
17-ingress.yaml
| |
Criaremos um ip global para ser utilizado pela aplicação:
| |
O ingress irá expor a aplicação para a internet. Em annotations adicionamos o nome do IP global criado acima e o nome dos certificados gerenciados criados no passo anterior. Em spec definimos que todas as requests serão encaminhadas para o serviço nginx na porta 80, criado em 15-nginx-service.yaml.
Por trás dos panos, um load balancer é criado com duas forwarding rules (http e https) apontando para esse ingress e sua spec.
Adicionando certificados SSL auto gerenciados
Crie uma zona DNS
https://cloud.google.com/dns/docs/
Aponte o NS em seu register domain para os mostrados na zona DNS criada acima.
Aponte o record A para o IP criado anteriormente em 17-ingress.yaml. Obtenha o IP com o comando:
| |
Veja com o comando host yourapp1.com se ele já está apontando para o IP criado anteriormente.
No processo de deploy, quando o arquivo 16-managedcerts.yaml for aplicado (kubectl apply -f 16-managedcerts.yaml), o processo de geração do certificado irá levar de 15 a 30 minutos.
Realizando o deploy dos manifestos
O deploy pode ser realizado por arquivo:
| |
Ou todos os arquivos da pasta
| |
O mesmo se aplica para o delete com o kubectl delete.
Automatizando o processo de testes e deploy com um pipeline de CI/CD
O Google KMS é um serviço quer permite gerenciar chaves criptográficas. Com ele podemos criptografar arquivos de configuração/environment variables e até adicioná-los ao controle de versão de forma segura, pois estarão criptografados.
Após ativar a API do KMS, crie um grupo de chaves:
| |
Crie uma chave:
| |
Criptografe os arquivos de configuração/environment variables que deseja:
| |
| |
O exemplo abaixo é necessário apenas se possui algum pacote privado como o Laravel Nova.
| |
Agora ambos podem ser commitados de forma segura :)
Utilizo o Cloud Build para o processo de CI/CD, separo os processos em duas triggers e dois arquivos: cloudbuild.ci.yaml e cloudbuild.cd.yaml. Lembre-se de adicionar as variáveis de substituição nas triggers criadas no Cloud Build:
_CLUSTER- o nome do seu cluster_KEY- sua chave kms criada anteriormente_KEYRING- seu keyring criado anteriormente_ZONE- yourregion-zone
cloudbuild.ci.yaml
| |
cloudbuild.cd.yaml
| |
Não vou entrar em detalhes sobre o processo de CI em cloudbuild.ci.yaml pois não é o propósito. Em resumo sobre o processo de CD em cloudbuild.cd.yaml:
- Os arquivos de configuração/environment variables são descriptografados.
- A criação secret
envutilizada nos manifestos de06-app-deployment,09-queue-deploymente11-schedule-deploymenté simulada com--dry-runa partir do arquivo .env que foi descriptografado e seu output é salvo emk8s/06.0-app-secret.yaml. - O mesmo processo ser repete para a service account utilizada para que o cloudsqlproxy tenha acesso ao Cloud SQL.
- A imagem docker é criada e tageada com a release tag do repositório e também com a tag latest.
- A palavra TAG_NAME é substituída pela release tag do repositório nos arquivos de manifesto.
- O deploy dos manifestos é realizado.
- Os assets da aplicação são copiados para um bucket.
Dica: Caso queira que um commit não passe pelo processo de CI/CD, adicione
[skip cd]à mensagem de commit.
Monitorando o cluster com Kontena Lens e métricas Prometheus
Usando a api do kubernetes é possível obter vários dados para monitorar o cluster, porém para uma visão gráfica geral de todos os namespaces utilizo o Kontena Lens, uma ferramenta grátis e opensource:

Após instalá-lo e conectá-lo ao cluster, clique com o botão direito no ícone do cluster e habilite as métricas prometheus.
Analise ao longo do tempo se as requests e os limits definidos nos deployments estão sendo suficientes ou até mesmo se estão desperdiçando recurso e redefina-os caso necessário.
Realizando o deploy de outras aplicações
O processo de deploy das outras aplicações é o mesmo, o namespace é diferente, mas os conceitos são os mesmos. Como um rápido exemplo, a quinta aplicação mencionada no início (executada no cloud.run), desta vez com um container apache:

Exemplo do app-deployment:
| |
Problemas identificados
Aqui vão algumas dicas para que alguém que esteja lendo não passe pelas mesmas situações que passei:
Ative o Network Plugin do GKE
Durante as primeiras semanas me deparava com uma mensagem “network is not ready: runtime network not ready: NetworkReady=false reason:NetworkPluginNotReady” e os pods reinicializando uma ou duas vezes na semana. A solução é ativar o Network plugin do GKE, que utiliza o Calico por trás dos panos.
Reduza os recursos utilizado pelo namespace kube-system
Por padrão o GKE declara um limit de 1 vCPU para o fluentd utilizado na captura de logs, esse limite está exagerado para um cluster pequeno como o nosso. Abaixo uma ScalingPolicy das requests and limits do fluentd:
| |
980m de cpu a menos do que o padrão definido pelo GKE 😅
Aplique o arquivo acima:
| |
Outras dicas para reduzir recursos consumidos pelo namespace kube-system podem ser encontradas aqui.
Custo de forwading rules no load balancer
Como dito anteriormente, entre 1 e 5 forwarding rules o custo é o mesmo, aproximadamente 18 USD. Porém, após o deploy de 5 aplicações temos 10 fowarding rules, totalizando mais de 50 USD…
Possíveis soluções:
Com custo aproximado de
18 USD: Utilizar apenas um load balancer no namespace default com nginx realizando o proxy para os serviços em diferentes namespaces, gerando assim apenas duas forwarding rules. Exemplo de um nginx-configmap para atingir esse objetivo:1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41apiVersion: v1 kind: ConfigMap metadata: name: nginx-configmap namespace: default data: default.conf: | server { listen 80; listen [::]:80; server_name _; location / { add_header Content-Type text/plain; return 200 "OK."; } } server { listen 80; listen [::]:80; server_name yourapp1.com www.yourapp1.com; location / { proxy_set_header Host $host; proxy_set_header X-Forwarded-For $remote_addr; proxy_pass http://nginx.yourapp1.svc.cluster.local:80; } } server { listen 80; listen [::]:80; server_name yourapp2.com www.yourapp2.com; location / { proxy_set_header Host $host; proxy_set_header X-Forwarded-For $remote_addr; proxy_pass http://app.yourapp2.svc.cluster.local:8080; } }Vantagens:
- Sua aplicação continua utilizando o load balancer do Google.
- Os certificados SSL auto gerenciados pelo Google ainda funcionam.
Desvantagens:
18 USD😅
Com custo
0 USD: Nginx Ingress, Traefik, Istio ou outro ingress.Vantagens:
0 USD- Features não disponíveis no ingress padrão do Google.
Desvantagens:
- Aparentemente, bem mais trabalhoso (minha opinião).
Próximos passos
O processo ao longo dos dias foi bem divertido, agora continuarei monitorando e aprimorando o conhecimento na ferramenta. Se lhe ajudou ou se tem sugestões, deixe um comentário abaixo :)
Conteúdos que me ajudaram no processo:
Último recado:

Só se aventure com Kubernetes para aprendizado ou se fizer sentido para seu projeto e resolver algum problema existente como foi mostrado nesse artigo. Não complique o que está funcionando perfeitamente.