Kubernetes HPA escalando por requisições http

Ola. Nesse post, vamos tratar como fazer o HPA do Kubernetes conseguir identificar a quantidade de requisições http que o POD esta recebendo e assim escalar a quantidade de PODs de acordo com a demanda.

Essa é uma ótima alternativa do que utilizar HPA por CPU ou memória, principalmente se for aplicações Spring Boot (Java) em que muitas vezes o consumo de CPU ou memória não são indicativos de capacidade de resposta.

Você pode acompanhar e pegar o fonte de tudo o que eu criei nesse post no github:

https://github.com/escovabit-tec-br/spring-boot-kubernetes-hpa-prometheus

Esse post, vai ter uma lista de pré-requisitos, infelizmente não tenho post falando de cada um deles, mas vou ir colocando as documentações oficiais.

Pré requisitos


Arquitetura da solução

Nossa solução depende de um conjunto de “pequenos” fatores para funcionar.

Arquitetura – Kubernetes HPA + Prometheus

Vamos fazer um breve resumo de cada um deles, e depois tratar cada elemento em sequencia.

  • Spring Boot Actuator – Prometheus:

Precisamos fazer nossa aplicação exportar as métricas de utilização no formato que o Prometheus espera. É assim que conseguimos os dados de “estado” da aplicação, para saber quantas requisições ela esta recebendo.

  • Prometheus – Service Monitor:

Depois, devemos instruir o Prometheus, que ele precisa fazer a coleta das informações de nossa aplicação. Com isso vamos ter uma “base de dados” com as métricas para consultar.

  • Prometheus Adapter – Custom Query e Custom Metrics:

Agora que temos a informação em “banco” com o Prometheus, devemos criar uma “Custom Query” que ira fazer a consulta no Prometheus e exportar a informação diretamente para o Kubernetes em uma “Custom Metrics”.

  • Kubernetes HPA

Por fim, devemos criar um HPA no Kubernetes que consulte a “Custom Metrics” e calcule o estado do HPA.


Configurando a aplicação Spring Boot

Fiz um Controller (PingController) somente para servir de rebatedor e simular a operação da aplicação.

package br.tec.escovabit.apphpa.controller;

import java.time.OffsetDateTime;

import lombok.SneakyThrows;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

import br.tec.escovabit.apphpa.controller.model.response.ThreadWaitModel;
import lombok.extern.slf4j.Slf4j;

@RestController
@RequestMapping("/ping")
@Slf4j
public class PingController {

    @SneakyThrows
    @GetMapping("/thread-wait/{time}")
    public ResponseEntity<ThreadWaitModel> threadWait(@PathVariable("time") Long time) {
        ThreadWaitModel model = new ThreadWaitModel();
        model.setStartDate(OffsetDateTime.now());
        log.info("Thread wait start: {}", model.getStartDate());
        try {
            Thread.sleep(time);
        } catch (InterruptedException e) {
            log.error("Thread InterruptedException", e);
            throw e;
        }
        model.setEndDate(OffsetDateTime.now());
        log.info("Thread wait end: {}", model.getEndDate());
        return ResponseEntity.ok().body(model);
    }

}

Já falamos sobre o Actuator dentro do Spring Boot em outros posts:

http://3.139.95.241/2021/05/04/spring-boot-actuator-o-que-e-como-melhorar/
http://3.139.95.241/2021/05/05/spring-boot-actuator-micrometer-e-prometheus/

Vamos tratar aqui, somente o que não foi passado nos post anteriores.


Começamos criando a classe de configuração MicrometerConfiguration.

Essa classe, basicamente configura o Micrometer.io para exportar o nome da aplicação junto com os dados para o Prometheus. Isso é importante para conseguimos distinguir qual aplicação esta recebendo as requisições, mesmo sendo o mesmo contexto na URL.

package br.tec.escovabit.apphpa.configuration;

import java.net.InetAddress;
import java.net.UnknownHostException;
import java.util.Arrays;
import java.util.List;
import java.util.logging.Level;
import java.util.logging.Logger;

import org.springframework.beans.factory.annotation.Value;
import org.springframework.boot.actuate.autoconfigure.metrics.MeterRegistryCustomizer;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

import io.micrometer.core.instrument.MeterRegistry;
import io.micrometer.core.instrument.config.MeterFilter;

@Configuration
public class MicrometerConfiguration {

    private static final String UNKNOW = "unknow";
    private static final List<String> NON_APPLICATION_ENDPOINTS = Arrays.asList(
            "/swagger", "/**", "/v2/api-docs", "/webjars");
    private static final Logger LOGGER = Logger.getLogger(MicrometerConfiguration.class.getName());
    private static final String TAG_URI = "uri";

    @Bean
    public static MeterRegistryCustomizer<MeterRegistry> metricsCommonTags(
            @Value("${spring.application.name}") String applicationName) {

        return registry -> registry.config()
                .commonTags(
                        "host", getHostName(),
                        "instance", getHostName(),
                        "ip", getHostAddress(),
                        "application", applicationName)
                .meterFilter(denyFrameworkURIsFilter());
    }

    private static MeterFilter denyFrameworkURIsFilter() {
        return MeterFilter.deny(id -> isNonApplicationEndpoint(id.getTag(TAG_URI)));
    }

    private static boolean isNonApplicationEndpoint(String uri) {
        return uri != null
                && NON_APPLICATION_ENDPOINTS.stream()
                        .map(uri::startsWith)
                        .filter(i -> i)
                        .findFirst()
                        .orElse(false);
    }

    private static String getHostName() {
        try {
            return InetAddress.getLocalHost().getHostName();
        } catch (UnknownHostException e) {
            LOGGER.log(Level.INFO, e.getMessage(), e);
            return UNKNOW;
        }
    }

    private static String getHostAddress() {
        try {
            return InetAddress.getLocalHost().getHostAddress();
        } catch (UnknownHostException e) {
            LOGGER.log(Level.INFO, e.getMessage(), e);
            return UNKNOW;
        }
    }
}

Outra pequena configuração, é no arquivo application.yml, definir o nome da aplicação:

spring:
  application:
    name: app-hpa

Você pode comparar a resposta da url /actuator/prometheus no post anterior com essa que acabamos de fazer:


Configurando o Prometheus Adapter

Estou considerando que o seu ambientes já tem o Prometheus rodando, só vou tratar da configuração especifica da “Custom Metrics”.

Fiz um deve README.md sobre a instalação do Prometheus: https://github.com/escovabit-tec-br/spring-boot-kubernetes-hpa-prometheus/tree/main/helm/prometheus

A customização que precisamos fazer no Prometheus Adaptar esta em criar uma “Custom Metrics” conforme mostrada a baixo.

rules:
  default: true
  custom:
    - seriesQuery: '{__name__=~"^http_server_requests_seconds_.*",container!="POD",namespace!="",pod!=""}'
      seriesFilters: []
      resources:
        overrides:
          namespace:
            resource: namespace
          pod:
            resource: pod
      name:
        matches: '^http_server_requests_seconds_count$'
        as: 'sum_http_server_requests_seconds_count_filted'
      metricsQuery: sum(rate(<<.Series>>{<<.LabelMatchers>>,uri!~"UNKNOWN|^/actuator/.*"}[5m])) by (<<.GroupBy>>)

Os principais elementos dessa configuração que precisamos conhecer:

  • seriesQuery

Corresponde a query que sera executada no Prometheus. O http_server_requests_seconds_* é o nome do atributo que o Spring Boot expõem as informações.

  • name.as

Indica o nome que definimos para está “Custom Metrics”, é ele que vamos utilizar no HPA.

  • metricsQuery

Representa como o Prometheus Adapter filtra a query por aplicação, para que o HPA calcule o resultado por POD.

Também é importante a configuração de uri que remove o actuator do calculo, para que somente uri de “negocio” sejam calculadas.


Publicando nossa aplicação

Por fim, vamos fazer a publicação da nossa aplicação.

Fiz um helm para facilitar a publicação a nossa aplicação de exemplo. Nos arquivos de publicação, não tem nenhuma peculiaridade, o que temos que tratar são dois yamls que vou mostrar a baixo. Ele são publicados em conjunto com a aplicação.

O primeiro é o ServiceMonitor, este yaml tem como objetivo configura o Prometheus a identificar e coletar as informações da nossa aplicação.

apiVersion: monitoring.coreos.com/v1
kind: ServiceMonitor
metadata:
  labels:
    app.kubernetes.io/instance: app-hpa
    app.kubernetes.io/name: app-hpa
  name: app-hpa
  namespace: l2-app-exemplos
spec:
  endpoints:
    - honorLabels: true
      interval: 1m
      path: /actuator/prometheus
      port: http
      scheme: http
      scrapeTimeout: 30s
  jobLabel: app-hpa-l2-app-exemplos
  namespaceSelector:
    matchNames:
      - l2-app-exemplos
  selector:
    matchLabels:
      app.kubernetes.io/instance: app-hpa
      app.kubernetes.io/name: app-hpa

O que se precisa de atenção nessa configuração são os itens:

  • spec.endpoints.path

Que representa o endeço que o prometheus ira chamar para obter os dados para a coleta

  • spec.endpoints.port

Que representa o nome da porta do Service/POD estão exportando.

Precisa que o Service e o POD estejam usando portas com o atributo “name“, porque o Prometheus localiza a porta pelo nome atribuído a ela.


Em sequencia, temos as configuração do HPA, nela devemos configura o uso da nossa “Custom Metrics”

apiVersion: autoscaling/v2beta2
kind: HorizontalPodAutoscaler
metadata:
  name: app-hpa
  namespace: l2-app-exemplos
  labels:
    app.kubernetes.io/instance: app-hpa
    app.kubernetes.io/name: app-hpa
spec:
  scaleTargetRef:
    kind: Deployment
    name: app-hpa
    apiVersion: apps/v1
  minReplicas: 1
  maxReplicas: 100
  metrics:
    - type: Pods
     # Dependendo da versão do K8S pode ser assim
      pods:
        metric:
          name: sum_http_server_requests_seconds_count_filted
        target:
          type: AverageValue
          averageValue: 20m
    # Ou assim
    # pods:
    #   metricName: sum_http_server_requests_seconds_count_filted
    #   targetAverageValue: 20m
  behavior:
    scaleDown:
      stabilizationWindowSeconds: 10
      policies:
      - type: Pods
        value: 2
        periodSeconds: 2
    scaleUp:
      stabilizationWindowSeconds: 10
      policies:
      - type: Pods
        value: 2
        periodSeconds: 2
      selectPolicy: Max

A configuração fica sobre o elemento:

  • spec.metrics.pods.metricName: sum_http_server_requests_seconds_count_filted

Que representa o nome da Custom Metrics que criamos no Prometheus Adapter.


Com tudo pronto, podemos ver nosso HPA em ação.

HPA com aplicação sem requisições
HPA com a aplicação recebendo requisições e subindo o numero de replicas

Para simular as requisições, basta rodar um loop como este:

while true; do curl http://$NODE_IP:$NODE_PORT/ping/thread-wait/1000; done;

Para saber como o Kubernetes esta “enxergando” a métrica, execute o comando:

kubectl get --raw "/apis/custom.metrics.k8s.io/v1beta1/namespaces/l2-app-exemplos/pods/*/sum_http_server_requests_seconds_count_filted?labelSelector=app.kubernetes.io%2Finstance%3Dapp-hpa%2Capp.kubernetes.io%2Fname%3Dapp-hpa" | jq .

O resultado, deve ser parecido com este:

{
  "kind": "MetricValueList",
  "apiVersion": "custom.metrics.k8s.io/v1beta1",
  "metadata": {
    "selfLink": "/apis/custom.metrics.k8s.io/v1beta1/namespaces/l2-app-exemplos/pods/%2A/sum_http_server_requests_seconds_count_filted"
  },
  "items": [
    {
      "describedObject": {
        "kind": "Pod",
        "namespace": "l2-app-exemplos",
        "name": "app-hpa-688b6958bd-2zwv2",
        "apiVersion": "/v1"
      },
      "metricName": "sum_http_server_requests_seconds_count_filted",
      "timestamp": "2021-11-25T09:32:57Z",
      "value": "62m",
      "selector": null
    },
    {
      "describedObject": {
        "kind": "Pod",
        "namespace": "l2-app-exemplos",
        "name": "app-hpa-688b6958bd-mkszm",
        "apiVersion": "/v1"
      },
      "metricName": "sum_http_server_requests_seconds_count_filted",
      "timestamp": "2021-11-25T09:32:57Z",
      "value": "79m",
      "selector": null
    },
    {
      "describedObject": {
        "kind": "Pod",
        "namespace": "l2-app-exemplos",
        "name": "app-hpa-688b6958bd-v6kts",
        "apiVersion": "/v1"
      },
      "metricName": "sum_http_server_requests_seconds_count_filted",
      "timestamp": "2021-11-25T09:32:57Z",
      "value": "50m",
      "selector": null
    }
  ]
}

Para o valor do HPA, o Kubernetes soma todos os values e divide pela quantidade de PODs


Conclusão

Fazendo essa estrutura, conseguimos ter uma forma de crescer o numero de PODs com base em uma informação mais calculável do que CPU e memória.

Pois conseguimos fazer testes de carga para saber quanto um único POD consegue responder de forma aceitável e escalar para a estimativa de requisições que precisamos responde.

Nos vemos no próximo post. SYL