Escalando o Kubernetes a partir de eventos com o KEDA
Por padrão, o Kubernetes nos dá a opção de escalarmos automaticamente as aplicações com base em métricas como consumo de memória e de CPU. A lógica por trás disso é: se estamos tendo maior consumo desses recursos, alguma coisa aconteceu que faz com que seja necessário temporariamente mais poder de processamento.
No entanto, nem sempre a curva de resposta é a correta. Imaginemos um cenário onde é necessário o processamento de mensagens que chegam em uma fila (Rabbit, Kafka, Nats, etc). Sua aplicação não necessariamente irá consumir mais memória/CPU por haver mais mensagens para processar ou, no mínimo, a resposta não será proporcional. Então você pode acabar em uma situação onde o pod está “tranquilão” mas o broker está com muitas mensagens aguardando para serem processadas. Ou, ainda, podemos ter o cenário contrário: cada mensagem exige muito processamento e o k8s começar a escalar pods de forma exagerada sem necessidade.
Para esses cenários, que arrisco dizer são uma parcela bem grande das aplicações, precisamos ter a possibilidade de escalar as aplicações de forma estimulada por eventos. Esses eventos podem ser dos mais diversos tipos: uma nova mensagem no broker, um novo vídeo que chegou no Blob Storage para processar, um registro que entrou em uma tabela do banco de dados, etc.
Com o objetivo de atender esse tipo de cenário surgiu o KEDA. O KEDA foi criado em parceria pela Microsoft e a Red Hat e hoje é um projeto FOSS debaixo do guarda-chuva da CNCF (Cloud Native Computing Foundation), o que garante a governança necessária para que seja um projeto sério que podemos colocar em produção.
Sua finalidade está explícita em seu nome: Kubernetes Event-Drive Autoscaling.
O que o KEDA faz é simples: informa ao k8s as métricas de escalonamento, ou seja, é usada toda a estrutura de escalonamento já existente no k8s.
Para calcular os valores das métricas o KEDA utiliza o que ele chama de Scalers que nada mais são do que blocos de código específicos para cada originador de eventos. O KEDA possui uma lista enorme de scalers prontos: RabbitMQ, Kafka, Nats, Azure Service Bus, AWS SQS, etc. E o mais interessante: você pode criar o seu próprio scaler customizado!
Nesse post irei fazer uma breve demonstração de sua utilização com uma aplicação em Python acessando o RabbitMQ. Mas obviamente ele é agnóstico do que está rodando dentro do pod. De fato, a primeira utilização real que tive do KEDA foi uma aplicação em C# da qual fui arquiteto em uma empresa anterior.
Todo o código demonstrado está nesse repositório: https://github.com/cassioeskelsen/keda-python-rabbit
Irei utilizar como K8s o Microk8s, que se tornou meu ambiente preferido para testar aplicações localmente. Mas, obviamente você pode utilizar o KEDA em qualquer ambiente.
Preparando o ambiente
A instalação do KEDA pode ser feita com HELM ou arquivo de deploy yaml. Mas o importante é que você tenha permissões para configuração do cluster já que o KEDA precisa se registrar na API do Cluster.
A documentação do KEDA lista o procedimento necessário para instalação e garanto que, tendo as permissões necessárias, o processo é muito simples e tranquilo.
Como usaremos o microk8s para o ambiente de testes, verifique como instalá-lo em sua página principal: https://microk8s.io . Neste link estão listados os passos para cada ambiente: Linux, Windows e Mac.
A instalação do KEDA no Microk8s pode ser feita com HELM/YAML também, mas vamos usar as facilidades do Microk8s e digitar simplesmente:
microk8s enable keda
(Creio que esteja ficando claro porque estou preferindo o microk8s!!)
Precisaremos também um “container registry” onde iremos publicar nossa imagem docker para que o k8s possa baixá-la. O registry mais simples para nossos testes é o Docker Hub, onde você pode criar uma conta gratuita. Após criada a conta, é necessário fazer o login localmente nela para permitir a publicação, o que é feito com o comando:
docker login
Obs: para facilitar, você pode pular essa etapa e usar o meu container de exemplo que já estará publicado (conta “sricanesh”).
Por último, precisaremos de um RabbitMQ. A forma mais fácil é utilizarmos um arquivo Docker Compose para isso. Deixarei um exemplo na pasta /rabbit do repositório com o código do post.
Relembrando, para rodar um docker compose:
docker compose up -d
Para derrubar:
docker compose down
O servidor Rabbit irá rodar nessas configurações:
host: localhost
porta: 5672
porta da UI: 15672
usuario: user
senha: password
Não irei criar pipeline de deploy nesse post. Faremos todo deploy “na mão”.
O exemplo em Python
O código de exemplo será muito simples: um publisher que irá criar as mensagens no Rabbit e um consumidor dessas mensagens, que será o nosso worker rodando no k8s.
A única dependência para esse código é o Kombu que é utilizado para acessar o Rabbit.
Você pode instalar a dependência da forma padrão do python:
pip3 install kombuoupip3 install -r requirements.txt
O código do publicador:
Por padrão o publicador irá criar uma única mensagem, mas você pode passar como parâmetro a quantidade de mensagens que quer. Não execute o publicador agora, iremos executar o consumidor primeiro para que a fila/exchange sejam corretamente criadas.
O código do consumidor:
Esse código irá apenas logar na tela a mensagem recebida do RabbitMQ. Adicionei um pequeno sleep para que o processamento não ocorra muito rápido e não vejamos o KEDA entrando em ação.
Vamos executar o que temos até aqui para testar se está tudo certo. Assumindo que você tenha clonado o projeto de exemplo, na raíz do projeto digite:
python3 -m app.rabbit_consumer
Acessando a UI do Rabbit (http://localhost:15672/#/queues) veremos que a fila foi criada:
Interrompa a execução (CTRL+C) e execute o publicador de mensagens (nessa primeira vez apenas iremos enviar uma mensagem):
python3 -m app.rabbit_publisher
Volte na interface do RabbitMQ e verifique que em Ready agora há uma mensagem.
Execute novamente o consumidor e veja a mensagem sendo logada na tela:
Se você quiser, pode repetir o processo enviando uma série de mensagens, dessa forma:
python3 -m app.rabbit_publisher 100
Publicando o consumidor no k8s
Vamos primeiramente definir algumas variáveis de ambiente para garantir utilizar sempre os mesmos parâmetros. No linux/Mac faremos:
export REGISTRY=sricanesh
export IMAGE_NAME=keda_python-rabbit
export VERSION=latest
export CONTAINER=$(IMAGE_NAME)_container
REGISTRY é o nome da sua conta no Docker Hub. Como mencionado, você pode utilizar o container que eu estou publicando, nesse caso deixe o nome da minha conta “sricanesh”.
As demais envs você pode deixar como está ou alterar a gosto.
O básico para publicação no k8s é o Dockerfile, que não tem muito mistério em nosso caso:
Se você for usar seu próprio Docker Hub, precisará rodar os comandos abaixo:
docker build -t queue-consumer . #1
docker tag queue-consumer $REGISTRY/queue-consumer #2
docker push $REGISTRY/queue-consumer #3
Linha 1: constrói a imagem
Linha 2: cria a tag com nome de queue-consumir
Linha 3: envia para o Docker Hub
Se tudo der certo, sua imagem estará publicada no Docker Hub como a minha:
O segundo ítem que precisamos é o yaml de deploy no k8s:
Até aqui tudo normal, só observe na linha 19 que o nome da imagem precisa ser mudado caso você esteja usando a própria conta do Docker Hub ao invés da minha. O nome deverá ser: <seu username>/queue-consumer.
microk8s kubectl apply -f infrastructure/keda-python-rabbit.yaml
Vamos verificar se foi publicado corretamente com kubectl get deployments/get pods:
Como não subimos o scaler do Keda, o comportamento foi o padrão que é o de subir ao menos um pod.
Agora vamos usar o comando abaixo para ter o live tail dos logs:
microk8s kubectl logs -f -l app=keda-python-rabbit
Abra um segundo terminal e execute o publicador (python3 -m app.rabbit_publisher) para enviar algumas mensagens. Depois volte no primeiro terminal e veja se deu tudo certo:
Nesse caso enviei 3 mensagens, que foram mostradas no terminal pelo log. Tudo certo, agora podemos partir para a mágica do KEDA.
Ativando o KEDA
Para ativar o KEDA para um deployment você precisa de um ScaledObject que nada mais é do que um yaml onde você descreve como quer que o KEDA se comporte. Ele vai no lugar do yaml onde você especificava o escalonamento por memória/CPU.
No nosso exemplo ele ficará dessa forma:
Alguns comentários importantes:
Linha 8: tome cuidado para especificar corretamente qual deployment deve ser escalado por essa regra.
Linha 15: aqui especifiquei apenas um trigger, mas você pode ter vários triggers em um mesmo deployment, por exemplo, pode ouvir várias filas.
Linha 18: aqui deixei fixo o nome do host do RabbitMQ. Mas em produção é você pegar essa info de uma env utilizando o parâmetro hostFromEnv ao invés de host.
Veja em https://keda.sh/docs/2.2/scalers/rabbitmq-queue/ a especificação detalhada de cada parâmetro.
Agora podemos fazer o deploy do yaml com o comando abaixo
microk8s kubectl apply -f infrastructure/rabbit_python_scaler.yaml
Assim que o deploy for concluído ele já irá entrar em ação, o que em nosso caso significa que o pod existente será encerrado pois não temos nenhuma mensagem na fila.
Se enviarmos uma única mensagem o KEDA já irá escalar um pod:
Decorridos os 30 segundos especificados em cooldownPeriod o pod será derrubado.
Se enviarmos mais mensagens de uma vez, por exemplo, 1000, veremos que o KEDA irá escalar mais pods para nós:
Consumidas todas mensagens, os pods serão encerrados. Mágica feita!
Concluindo
Como podemos ver, o KEDA é uma tecnologia fantástica para quando precisamos escalar nossos pods reagindo a eventos.
Antes de concluir duas questões importantes:
- Uma dúvida comum, principalmente para quem está migrando Azure Functions para o Keda é se podemos dizer ao pod qual mensagem ele deve processar. Isso não é possível, o KEDA apenas “liga” sua aplicação é função dela procurar o que deve ser feito (no nosso exemplo, o consumer do RabbitMQ é ativado e começa a consumir mensagens).
- Quem trabalha com o Rabbit sabe que temos as “unacked messages”, ou seja, as mensagens que o consumidor pegou e ainda não deu ack.
Isso pode causar um problema se nosso cooldownPeriod for muito baixo. Por exemplo, no nosso caso, se após o 30 segundos o KEDA ver que não tem mais mensagens na fila (as unacked ele não vê), os pods acabam sendo derrubados. Para resolver isso você pode usar a interface http ao invés da ampq pois nesse caso o scaler conseguirá enxergar as mensagens unacked também. Não usei essa opção aqui pois nosso Rabbit de testes está publicado em “localhost” e o pod dentro do Kubernates não irá ver esse “localhost”.