Métricas customizadas com AKS

Quando trabalhamos com microsserviços, sempre ouvimos falar que o monitoramento e a observabilidade são métricas chave para que possamos ter sucesso em manter nosso ecossistema coeso, funcional e não ficarmos loucos com o que está acontecendo. Falamos isto no podcast #FalaDev que participei junto de vários convidados.

Afinal, em sistemas distribuídos, a complexidade não está na unidade, mas sim em como essas unidades interagem umas com as outras. E, se não soubermos o que está acontecendo no nosso ecossistema, não podemos diagnosticar, entender e muito menos responder a incidentes em tempo hábil.

Mas como podemos resolver estes problemas? A resposta é bem simples, temos que começar o monitoramento de nossas aplicações.

Azure Monitor For Containers

Para esse tipo de situação temos ferramentas como o Azure Monitor. Essa é uma ferramenta disponibilizada pela Azure para o monitoramento de diversos de seus produtos, um deles é o AKS.

A ideia deste artigo é entender um pouco mais do Azure Monitor primeiro, depois vamos criar uma pequena aplicação que irá extrair algumas métricas customizadas de alguns de nossos serviços. Então vamos começar entendendo como ele funciona!

Como funciona o Azure Monitor For Containers?

Conforme este artigo incrível do Thomas Stringer diz, o Azure Monitor é uma solução que inclui várias facetas, uma delas é a visualização de dados que é obtida através da captura de métricas pelo que chamamos de agent.

Diagrama de funcionamento do Azure Monitor (Fonte: Microsoft Docs)

Um agent é um pequeno serviço que roda dentro do nosso cluster como um daemonset, ou seja, um serviço que cria um pod em cada nó do nosso cluster para poder capturar as métricas de recursos de máquina, como CPU, RAM e etc.

Por padrão, este recurso vem desabilitado quando criamos um novo cluster do AKS, então temos que habilitá-lo – vamos aprender como fazer isso no próximo capítulo.

Uma vez que o recurso está habilitado, o DaemonSet chamado omsagent é instalado no cluster e um deployment chamado omsagent-rs é criado em cada um dos nós do cluster. Este deployment é o responsável por agregar métricas e enviá-las ao que chamamos de Log Analytics workspace, ou seja, o local onde todas as nossas métricas vão ficar armazenadas para podermos lê-las.

Fluxo de métricas do Azure Monitor (Fonte: Thomas Stringer)

Uma vez que temos todos os serviços rodando, vamos ser capazes de obter as métricas do nosso cluster acessando ou o próprio painel da Azure, ou então uma ferramenta chamada Azure Data Explorer.

Tela do Azure Monitor for Containers

Monitorando um cluster

Primeiramente, precisamos criar um cluster AKS para que possamos monitorar, para isso, precisamos registrar duas extensões em nosso Azure CLI (se você ainda não possui o Azure CLI instalado, então instale ele na sua máquina).

Vamos verificar se já temos os providers instalados com os seguintes comandos:

az provider show -n Microsoft.OperationsManagement -o table && \
az provider show -n Microsoft.OperationalInsights -o table
Verificando se já temos os providers instalados

Se tivermos saídas dese tipo:

Namespace                       RegistrationPolicy    RegistrationState
------------------------------  --------------------  -------------------
Microsoft.OperationsManagement  RegistrationRequired  Registered

Namespace                      RegistrationPolicy    RegistrationState
-----------------------------  --------------------  -------------------
Microsoft.OperationalInsights  RegistrationRequired  Registered

Significa que os provedores estão instalados e funcionando (veja o Registered), porém, se precisarmos instala-los, vamos ter que rodar os seguintes comandos:

az provider register --namespace Microsoft.OperationsManagement && \
az provider register --namespace Microsoft.OperationalInsights

O processo pode demorar alguns minutos para completar, rode o primeiro comando novamente para ter certeza de que o provedor foi registrado. Vamos agora criar um novo Resource Group e armazenar o valor tanto dele quando o nome do nosso novo cluster em uma variável:

export RESOURCE_GROUP=aksmonitor
export CLUSTER_NAME=aksmonitor
az group create -n $RESOURCE_GROUP -l eastus

Então podemos criar um novo cluster do AKS habilitado para monitoramento através do comando:

az aks create \
  -g $RESOURCE_GROUP \
  -n $CLUSTER_NAME \
  --node-count 1 \
  --generate-ssh-keys \
  --enable-addons monitoring,http_application_routing
Se você já tem um cluster AKS criado, pode habilitar o monitoramento através do comando az aks enable-addons -a monitoring -n $CLUSTER_NAME -g $RESOURCE_GROUP

Criando um teste

Vamos criar alguns pods de teste para que possamos monitorar o uso do sistema, primeiramente vamos criar um deployment simples que vai expor uma pequena API em Node.js. Para isso vamos criar um novo arquivo simple_api.yaml e criar nossas instruções:

apiVersion: apps/v1
kind: Deployment
metadata:
  name: simple-api
spec:
  selector:
    matchLabels:
      app: simple-api
  template:
    metadata:
      labels:
        app: simple-api
    spec:
      containers:
      - name: simple-api
        image: khaosdoctor/scalable-node-api:2.0.0
        resources:
          limits:
            memory: "128Mi"
            cpu: "100m"
        ports:
        - containerPort: 8080
          name: http
        env:
          - name: PORT
            value: "8080"
---
apiVersion: v1
kind: Service
metadata:
  name: simple-api
spec:
  type: LoadBalancer
  selector:
    app: simple-api
  ports:
  - port: 80
    targetPort: http

Este serviço vai nos dar um endereço externo de IP que poderá ser obtido usando kubectl get svc na coluna EXTERNAL-IP:

NAME         TYPE           CLUSTER-IP    EXTERNAL-IP     PORT(S)        
simple-api   LoadBalancer   10.0.50.182   xxx.xxx.xxx.xxx   80:30287/TCP

Acesse o endereço após alguns instantes para ver o pod sendo executado. Nesta versão teremos um delay de 2 segundos gerando uma pequena carga para podermos ver o consumo de recursos. Vamos ao nosso portal da Azure e selecionaremos nosso cluster AKS, então vamos no menu "Insights":

Vamos utilizar o comando scale do kubectl para poder escalar os pods e ver que temos monitoramento constante:

Numero de pods pendentes e executando

Se rodarmos um teste de stress, veremos que teremos um aumento gradativo do uso de memória e CPU:

Além disso temos diversos workbooks que são dashboards já prontos que nos permitem ver alguns dados:

Workbook de deployments

Podemos também obter os logs dos nossos containers através do menu logs no canto esquerdo do painel:

Aqui iremos ter uma visão inicial com um modal que irá nos perguntar se queremos ver algumas queries prontas, vamos para a aba "Audit" e então vamos clicar em "Run" abaixo de "List containers logs per namespace":

Veja um exemplo de uma query no formato Kusto:

// List container logs per namespace 
// View container logs from all the namespaces in the cluster. 
ContainerLog
| join(KubePodInventory
    | where TimeGenerated > startofday(ago(1h)))//KubePodInventory Contains namespace information
    on ContainerID
| where TimeGenerated > startofday(ago(1h))
| project TimeGenerated, Namespace, LogEntrySource, LogEntry

Vamos ter uma lista de todos os logs gerados pela nossa aplicação:

Mas e se quisermos gerar mais informações e buscar mais dados? Como fazemos para criar uma métrica customizada no Azure Monitor?

Criando métricas customizadas

Para podermos entender um pouco do que vamos fazer a seguir, precisamos entender sobre como o Kubernetes trabalha com métricas.

Prometheus

Atualmente, o Prometheus é o líder de mercado quando se trata de armazenamento e coleta de métricas para aplicações distribuídas. O Prometheus funciona em um modelo de scrapping, ou seja, de tempos em tempos ele acessa uma URL definida em seus endpoints cadastrados e busca os dados em um formato específico. A camada de abstração que roda entre um serviço e o Prometheus é chamado de exporter.

Os exporters buscam as métricas das APIs e aplicações e as formatam para que sejam consumidas pelo Prometheus de forma correta, por isso eles geralmente são executados dentro do mesmo pod, em outro container, acessando a aplicação localmente. Como esta imagem do blog do Thomas Stringer nos mostra:

Diagrama de funcionamento de um exporter (Fonte: Thomas Stringer)

Exporters

Quando estamos trabalhando com uma tecnologia que ainda não tem um exporter pronto, ou então quando queremos fazer o scrapping de métricas de aplicações que nós mesmos escrevemos (que é o nosso caso aqui) nós podemos escrever o nosso próprio através de uma lista de clientes disponíveis.

O trabalho de um exporter é basicamente rodar um loop onde ele:

  1. Inicia um servidor HTTP
  2. Busca as métricas da aplicação alvo
  3. Trata as métricas e as formata
  4. Devolve as métricas para o prometheus quando forem necessárias
  5. Dorme por um determinado tempo antes de começar de novo

A aplicação que vamos estar utilizando é uma simples aplicação de votação feita em Go, você pode ver o código fonte neste repositório. Temos uma imagem hospedada em meu Docker Hub pessoal.

Como criei a aplicação do zero, construi um pequeno exporter em Node.js para ela que você pode checar neste repositório com esta imagem. Basicamente a aplicação só possui um arquivo index.js que inicia um servidor do Koa usando uma biblioteca de cliente do Prometheus.

const Koa = require('koa')
const app = new Koa()
const axios = require('axios').default

const prometheus = require('prom-client')
const PrometheusRegistry = prometheus.Registry
const registry = new PrometheusRegistry()

const PREFIX = `go_vote_api_`
const pollingInterval = process.env.POLLING_INTERVAL_MS || 5000
registry.setDefaultLabels({ service: 'go_vote_api', hostname: process.env.POD_NAME || process.env.HOSTNAME || 'unknown' })

// METRICS START

const totalScrapesCounter = new prometheus.Counter({
  name: `${PREFIX}total_scrapes`,
  help: 'Number of times the service has been scraped for metrics'
})
registry.registerMetric(totalScrapesCounter)

const scrapeResponseTime = new prometheus.Summary({
  name: `${PREFIX}scrape_response_time`,
  help: 'Response time of the scraped service in ms'
})
registry.registerMetric(scrapeResponseTime)

const localResponseTime = new prometheus.Summary({
  name: `${PREFIX}exporter_response_time`,
  help: 'Response time of the exporter in ms'
})
registry.registerMetric(localResponseTime)

const totalVotes = new prometheus.Gauge({
  name: `${PREFIX}total_votes`,
  help: 'Total number of votes computed until now',
  async collect () {
    const total = await scrapeApplication()
    this.set(total)
  }
})
registry.registerMetric(totalVotes)

// --Utility Function-- //

async function scrapeApplication () {
  const id = Date.now().toString(16)
  console.log(`Scraping ${process.env.SCRAPE_URL}:${process.env.SCRAPE_PORT}/${process.env.SCRAPE_PATH} [scrape id: ${id}]`)
  const start = Date.now()
  const metrics = await axios.get(`${process.env.SCRAPE_URL}:${process.env.SCRAPE_PORT}/${process.env.SCRAPE_PATH}`)
  scrapeResponseTime.observe(Date.now() - start)
  totalScrapesCounter.inc()
  console.log(`Scraped data [scrape id: ${id}]`)
  return metrics.data.total
}

// --Servers start-- //

app.use(async (ctx, next) => {
  console.log(`Received scrape request: ${ctx.method} ${ctx.url} @ ${new Date().toUTCString()}`)
  const start = Date.now()
  await next()
  localResponseTime.observe(Date.now() - start)
})

app.use(async ctx => {
  ctx.set('Content-Type', registry.contentType)
  ctx.body = await registry.metrics()
})

// start loop
if (pollingInterval > 0) {
  setInterval(async () => {
    const total = await scrapeApplication()
    totalVotes.set(total)
  }, pollingInterval)
}

console.log(`Listening on ${process.env.SCRAPER_PORT || 9837}`)
app.listen(process.env.SCRAPER_PORT || 9837)

O que esta aplicação está fazendo é registrar uma série de métricas em um registrador padrão do Prometheus, as métricas que estamos pegando são:

  • Número total de votos
  • Quantidade vezes que buscamos as métricas
  • Tempo de resposta do exporter e também da API na rota /total
Obviamente que estas métricas não são tão importantes quanto outras métricas que podem ser obtidas através da instrumentação direta na aplicação, para isso uma boa prática é ter uma rota /metrics que serve as métricas de instrumentação da aplicação, como CPU, RAM e outras, além do exporter.

Se acessarmos o exporter, teremos uma saída como esta:

Extraindo métricas da aplicação

Para podermos extrair as métricas da aplicação, o que vamos fazer é rodar os dois containers lado a lado, dessa forma não oneramos a aplicação original com a busca e parsing de métricas e ainda mantemos a velocidade de conexão por estarem na mesma rede. Nosso arquivo de deployment anterior vai mudar um pouco:

apiVersion: apps/v1
kind: Deployment
metadata:
  name: vote-api
spec:
  selector:
    matchLabels:
      app: vote-api
  template:
    metadata:
      labels:
        app: vote-api
    spec:
      containers:
      - name: vote-api
        image: khaosdoctor/go-vote-api
        resources:
          limits:
            memory: "128Mi"
            cpu: "200m"
        ports:
        - containerPort: 8080
          name: http
      - name: vote-api-exporter
        image: khaosdoctor/go-vote-api-exporter
        resources:
          limits:
            memory: "128Mi"
            cpu: "100m"
        ports:
        - containerPort: 9837
          name: exporter
        env:
          - name: SCRAPE_PORT
            value: "8080"
          - name: SCRAPE_PATH
            value: total
          - name: SCRAPE_URL
            value: "http://localhost"
      - name: vote-api-voter
        image: curlimages/curl
        command: ["/bin/sh"]
        args: [
          "-c",
          "while true; do wget -O- http://localhost:8080/votes/Lucas; sleep 3; done"
        ]
        resources:
          limits:
            memory: "128Mi"
            cpu: "100m"
---
apiVersion: v1
kind: Service
metadata:
  name: vote-api
spec:
  type: LoadBalancer
  selector:
    app: vote-api
  ports:
  - port: 80
    targetPort: http
---
apiVersion: v1
kind: Service
metadata:
  name: vote-api-exporter
spec:
  selector:
    app: vote-api
  ports:
  - port: 9837
    targetPort: exporter

O que estamos fazendo é subir três containers junto com a aplicação, um deles é o exporter e o outro é uma simples aplicação que ficará votando a cada 3 segundos para simular o aumento dos votos. Note que estamos definindo localhost como a url de scrapping porque todos os containers estão na mesma rede local.

Podemos verificar os logs de cada container criado depois com o comando kubectl  logs deploy/vote-api -c <nome-do-container>, se quisermos ver nosso exporter em ação basta executarmos kubectl port-forward svc/vote-api-exporter 9837:9837 e acessarmos localhost:9837 em nossa máquina:

Veja que agora temos mais labels, como o hostname que antes não era buscado.

Preparando o Azure Monitor

Agora que temos nossa API pronta, vamos preparar o Azure Monitor para poder buscar as métricas. Para isso, vamos criar um simples ConfigMap que irá configurar o nosso agente dentro do Node. A própria Microsoft tem um modelo padrão de configuração que podemos baixar com o comando abaixo

$ curl -Lo agent-config.yaml https://aka.ms/container-azm-ms-agentconfig

Salve o arquivo e veja que ele está bastante comentado, é um arquivo longo, mas a parte que nos interessa é esta aqui:

        # When monitor_kubernetes_pods = true, replicaset will scrape Kubernetes pods for the following prometheus annotations:
        # - prometheus.io/scrape: Enable scraping for this pod
        # - prometheus.io/scheme: If the metrics endpoint is secured then you will need to
        #     set this to `https` & most likely set the tls config.
        # - prometheus.io/path: If the metrics path is not /metrics, define it with this annotation.
        # - prometheus.io/port: If port is not 9102 use this annotation
        monitor_kubernetes_pods = false

Vamos alterar monitor_kubernetes_pods para true, isto fará com que quaisquer deployments que tiverem as annotations prometheus.io/scrape e prometheus.io/scheme sejam buscados pelo agent como se fosse o Prometheus buscando métricas. Agora vamos criar a configuração com kubectl apply -f agent.yaml.

Vamos agora adicionar as annotations no nosso arquivo de deployment:

apiVersion: apps/v1
kind: Deployment
metadata:
  name: vote-api
spec:
  selector:
    matchLabels:
      app: vote-api
  template:
    metadata:
      labels:
        app: vote-api
      annotations:
        prometheus.io/scrape: "true"
        prometheus.io/path: /
        prometheus.io/port: "9837"
    spec:
      containers:
      - name: vote-api
        image: khaosdoctor/go-vote-api
        resources:
          limits:
            memory: "128Mi"
            cpu: "200m"
        ports:
        - containerPort: 8080
          name: http
      - name: vote-api-exporter
        image: khaosdoctor/go-vote-api-exporter
        resources:
          limits:
            memory: "128Mi"
            cpu: "100m"
        ports:
        - containerPort: 9837
          name: exporter
        env:
          - name: SCRAPE_PORT
            value: "8080"
          - name: SCRAPE_PATH
            value: total
          - name: SCRAPE_URL
            value: "http://localhost"
      - name: vote-api-voter
        image: curlimages/curl
        command: ["/bin/sh"]
        args: [
          "-c",
          "while true; do wget -O- http://localhost:8080/votes/Lucas; sleep 3; done"
        ]
        resources:
          limits:
            memory: "128Mi"
            cpu: "100m"
---
apiVersion: v1
kind: Service
metadata:
  name: vote-api
spec:
  type: LoadBalancer
  selector:
    app: vote-api
  ports:
  - port: 80
    targetPort: http
---
apiVersion: v1
kind: Service
metadata:
  name: vote-api-exporter
spec:
  selector:
    app: vote-api
  ports:
  - port: 9837
    targetPort: exporter

Agora podemos buscar as métricas através do nosso portal, vamos clicar em Logs que nem fizemos antes, e agora podemos buscar dentro da tabela de InsightsMetrics, pela seguinte query:

InsightsMetrics
| where Namespace == "prometheus"
| where Name in ("go_vote_api_total_votes")
| summarize sum(Val) by TimeGenerated, Name
| order by TimeGenerated asc

Isso vai nos dar todos os votos que foram computados separados por horário que eles foram gerados:

Se clicarmos em Chart teremos um grafico que pode ser configurado para podermos ver o crescimento da métrica:

Conclusão

Extrair métricas é importante e necessário para que possamos ter uma melhor visão do nosso sistema. Com o Azure Monitor fica muito mais fácil de fazermos essas medições porque não precisamos instalar nada externo como o Prometheus e também não precisamos gerenciar bancos de dados ou qualquer outra coisa.