Artigos Técnicos30 de junho de 202617 min de leitura

HPA por CPU não basta: autoscaling por sinal com KEDA, do laboratório à produção

Wallacy Santos Ferreira

Nuvem Online

Banner - HPA por CPU não basta: autoscaling por sinal com KEDA, do laboratório à produção

Toda equipe que opera Kubernetes em produção um dia liga o autoscaling, escolhe "70% de CPU" e segue em frente. Funciona — até o dia em que o workload que precisava escalar não é CPU-bound. Aí o gráfico de CPU fica plano enquanto a fila transborda, a latência sobe e o autoscaler, fiel ao que mandaram medir, não faz nada.

Este artigo é um guia prático de ponta a ponta. Partimos de uma arquitetura real que operamos — uma plataforma com três workloads de perfis bem diferentes — e mostramos como saímos do "escala por CPU" para o autoscaling orientado a sinal com KEDA. Mas não paramos no "funcionou no laboratório": a segunda metade é sobre tornar o próprio autoscaler resiliente e observável, que é o que separa um lab de produção.

Tudo é aditivo e reversível, feito sobre o chart upstream do KEDA via overlay de values — sem fork, sem lock-in.

Por que o HPA por CPU não entrega HA de verdade?

O HorizontalPodAutoscaler nativo do Kubernetes escala um Deployment comparando uma métrica de recurso (tipicamente CPU) com um alvo, como porcentagem sobre o request do container. É elegante e suficiente para um caso: serviço cujo gargalo é, de fato, CPU.

O problema é que a maioria das cargas que mais sofrem com pico não é limitada por CPU. Na arquitetura que serve de base aqui, três workloads convivem — e só um deles escala bem por CPU:

Workload O que faz Por que CPU falha como gatilho Sinal que importa
API (BFF) Camada HTTP, I/O-bound Espera I/O de um backend mais lento; acumula requests sem queimar CPU Requests in-flight (saturação real)
Consumidor Lê eventos de um tópico Kafka Decodifica e grava; o gargalo é o broker/destino, não a CPU do pod Lag do consumer group
Worker Processa jobs em rajada Fica em espera/streaming; a fila cresce com CPU baixa Profundidade da fila (no banco)

O padrão é o mesmo nos três: a carga que deveria disparar a escala não aparece no gráfico de CPU. Pior, mesmo quando a CPU acaba subindo, ela sobe depois — é um sintoma tardio do problema, não o problema. Escalar por CPU nesses casos é escalar pelo sinal errado e chegar atrasado. O resultado é o oposto de alta disponibilidade: backlog que vira incidente.

A pergunta certa não é "quanta CPU?", e sim "o que, neste workload, indica que ele está ficando para trás?". Para um consumidor Kafka, é o lag. Para um worker de jobs, é o tamanho da fila. Para um BFF, é quantas requisições estão em voo. É disso que o KEDA trata.

O que o KEDA adiciona ao HPA (e o que ele não substitui)

Vale dizer logo, porque é a confusão mais comum: o KEDA não substitui o HPA. Ele o alimenta.

O KEDA é um projeto da CNCF (graduado) que estende o autoscaling do Kubernetes com três peças no cluster:

  • Operator — observa os objetos ScaledObject e, para cada um, cria e mantém um HPA nativo por baixo. Ele também ativa/desativa o Deployment para o scale-to-zero.
  • Metrics apiserver — registra-se como um external metrics adapter (external.metrics.k8s.io) e é quem entrega ao HPA o valor do sinal (lag, profundidade de fila, resultado de uma query). É a peça no caminho das decisões de escala.
  • Admission webhooks — validam os ScaledObject na criação.

Você descreve a intenção num ScaledObject; o KEDA traduz para um HPA com métrica externa:

apiVersion: keda.sh/v1alpha1
kind: ScaledObject
metadata:
  name: consumer
spec:
  scaleTargetRef:
    name: consumer            # o Deployment alvo, no mesmo namespace
  minReplicaCount: 2
  maxReplicaCount: 6
  pollingInterval: 30         # de quanto em quanto tempo o KEDA consulta o sinal (s)
  cooldownPeriod: 300         # quanto esperar sem atividade antes de reduzir (s)
  triggers:
    - type: kafka
      metadata: { ... }

Os ganhos sobre o HPA nativo:

  1. Mais de 70 scalers prontos — Kafka, RabbitMQ, Prometheus, PostgreSQL, Redis, filas de nuvem, e mais.
  2. Scale-to-zero — workloads event-driven podem cair a zero réplicas quando não há trabalho (o HPA nativo só vai até 1). Ganho direto de custo.
  3. Múltiplos triggers num só objeto, com o KEDA escalando pelo maior número de réplicas desejado.

O que ele não faz: substituir o HPA nativo onde CPU/memória já resolvem. Se o gargalo é mesmo CPU, o HPA nativo é mais simples e tem uma peça a menos para falhar. KEDA entra quando o sinal certo está fora do que o HPA nativo enxerga.

Laboratório: escalando pelo sinal certo

Vamos ao hands-on. Você precisa de um cluster Kubernetes (gerenciado, on-prem ou local com kind/k3d/minikube), além de kubectl e helm. Os manifestos abaixo rodam em qualquer cluster.

Instalando o KEDA pelo chart upstream

Nada de fork: consumimos o chart oficial da comunidade e sobrepomos só o que muda, num arquivo de values.

helm repo add kedacore https://kedacore.github.io/charts
helm repo update

# disciplina de plataforma: renderize e revise ANTES de aplicar
helm template keda kedacore/keda -n keda -f values-keda.yaml | less

helm upgrade --install keda kedacore/keda \
  -n keda --create-namespace -f values-keda.yaml

No laboratório, values-keda.yaml pode estar vazio — os defaults sobem. Guardamos o overlay de produção para a segunda metade.

Exemplo A — consumidor Kafka que escala pelo lag

Este é o caso canônico de "sinal ≠ CPU". O consumidor lê de um tópico; o que indica atraso é o lag do consumer group — quantas mensagens já foram produzidas mas ainda não consumidas.

apiVersion: keda.sh/v1alpha1
kind: ScaledObject
metadata:
  name: consumer
spec:
  scaleTargetRef:
    name: consumer
  minReplicaCount: 2
  maxReplicaCount: 6          # escala útil ≤ nº de partições do tópico
  triggers:
    - type: kafka
      metadata:
        bootstrapServers: kafka-bootstrap.mensageria.svc:9092
        consumerGroup: meu-consumer-group   # o MESMO group que o app consome
        topic: eventos
        lagThreshold: "1000"                 # mensagens de lag por réplica
        offsetResetPolicy: "earliest"

Dois detalhes que separam o tutorial de YouTube de algo que sobrevive a produção:

  • maxReplicaCount ≤ número de partições. Num consumer group, cada partição é consumida por no máximo um membro. Escalar além do número de partições só cria pods ociosos — o paralelismo do Kafka tem teto na partição.
  • offsetResetPolicy: earliest. Parece detalhe, mas morde. Com latest, num grupo novo ou com offsets expirados (um backfill, ou a volta depois de um outage), o KEDA enxergaria lag ≈ 0 e não escalaria justamente quando há mais backlog para processar. Com earliest, o cálculo do lag enxerga o backlog acumulado e a escala reage. Casa, também, com o comportamento de quem lê do começo da partição.

RabbitMQ é equivalente conceitual: troque o trigger por type: rabbitmq e escale pela profundidade da fila (queueLength). A lógica de "escale pelo que se acumula" é a mesma.

Gere carga (produza mensagens mais rápido do que o consumidor processa) e observe:

kubectl get scaledobject consumer
kubectl get hpa                       # o KEDA cria um "keda-hpa-consumer"
kubectl get pods -l app=consumer -w   # réplicas subindo conforme o lag passa do threshold

Exemplo B — saturação real via PromQL

Nem todo sinal vem de um broker. Para o BFF HTTP, o gargalo é I/O: ele acumula requisições em voo esperando um backend mais lento, sem queimar CPU. A métrica certa é a de saturação, que a aplicação expõe ao Prometheus — e o KEDA lê via PromQL. Aqui combinamos dois triggers no mesmo objeto:

apiVersion: keda.sh/v1alpha1
kind: ScaledObject
metadata:
  name: api
spec:
  scaleTargetRef:
    name: api
  minReplicaCount: 2
  maxReplicaCount: 6
  advanced:
    horizontalPodAutoscalerConfig:
      behavior:
        scaleDown:
          stabilizationWindowSeconds: 300   # sobe rápido, desce devagar
  triggers:
    - type: cpu                              # mantém o gatilho clássico...
      metricType: Utilization
      metadata:
        value: "70"
    - type: prometheus                       # ...e soma a saturação real
      metadata:
        serverAddress: http://prometheus.monitoring.svc:9090
        query: sum(http_requests_in_flight{namespace="minha-app",container="api"})
        threshold: "50"                      # in-flight médio alvo por réplica

A query soma o total de requisições em voo; o HPA divide pelo threshold para achar o número de réplicas. Como há dois triggers, o KEDA escala pelo maior desejado — se a CPU está tranquila mas há 600 requisições em voo, ele sobe para atender a saturação; se a CPU dispara primeiro, sobe por ela. Você não escolhe um ou outro: cobre os dois.

Se o seu Prometheus é multi-tenant (Mimir/Cortex, por exemplo), adicione customHeaders: X-Scope-OrgID=<tenant> no metadata para a query cair no tenant certo.

Exemplo C — worker que escala pela fila no banco

Quando não há broker, a "fila" muitas vezes é uma tabela. Um worker reivindica jobs de uma tabela jobs e o sinal de pressão é quantos estão pendentes. O KEDA tem scaler de PostgreSQL, e a credencial vem por um TriggerAuthentication:

apiVersion: keda.sh/v1alpha1
kind: TriggerAuthentication
metadata:
  name: worker-pg
spec:
  secretTargetRef:
    - parameter: connection
      name: worker-db          # secret com a connection string
      key: dsn
---
apiVersion: keda.sh/v1alpha1
kind: ScaledObject
metadata:
  name: worker
spec:
  scaleTargetRef:
    name: worker
  minReplicaCount: 1
  maxReplicaCount: 4
  advanced:
    horizontalPodAutoscalerConfig:
      behavior:
        scaleDown:
          stabilizationWindowSeconds: 600   # jobs longos: não cortar trabalho em voo
  triggers:
    - type: postgresql
      metadata:
        query: "SELECT count(*) FROM jobs WHERE status IN ('queued','running')"
        targetQueryValue: "5"               # jobs por réplica
      authenticationRef:
        name: worker-pg

Três decisões que só aparecem quando isso roda de verdade:

  • Conte running, não só queued. Se os workers reivindicam jobs com SELECT ... FOR UPDATE SKIP LOCKED, o job vira running ao ser pego e sairia da contagem — o KEDA veria a fila esvaziar e derrubaria pods com job em andamento. Contar queued + running evita o corte prematuro.
  • SKIP LOCKED é o que torna seguro escalar. Várias réplicas competem pela mesma tabela sem reivindicar o mesmo job duas vezes. Sem isso, mais réplicas = trabalho duplicado.
  • Least-privilege no scaler. A credencial do TriggerAuthentication é usada só para um SELECT count(*). Aponte-a para um role somente-leitura, não para o DSN de leitura/escrita da aplicação. Parametrizar o secret também evita que renomear o segredo da app quebre o scaler em silêncio.

HPA e KEDA juntos: como dividir o trabalho?

A regra prática que usamos:

  • HPA nativo (CPU/mem) quando o gargalo é mesmo recurso e o workload é stateless e CPU-bound. Menos peças, menos o que dar errado.
  • KEDA com trigger externo quando o sinal que importa está fora do CPU/mem — fila, evento, saturação, qualquer PromQL.
  • KEDA com cpu + trigger externo quando os dois importam (o caso do BFF acima): um só ScaledObject, escala pelo maior.

Para evitar flapping (subir e descer sem parar quando a carga oscila), use o behavior do HPA — sobe rápido, desce devagar. Uma stabilizationWindowSeconds no scale-down segura a redução; workloads com trabalho longo em voo (como o worker) pedem janelas mais largas para não cortar um job no meio.

E para o caso em que o próprio sinal some — o Prometheus cai, o broker fica inacessível —, configure o fallback:

spec:
  fallback:
    failureThreshold: 3     # após 3 falhas seguidas do scaler...
    replicas: 4             # ...fixa em 4 réplicas (um patamar seguro)

Sem fallback, quando o scaler falha o HPA simplesmente congela a última decisão — o que pode ser bom ou péssimo, dependendo do momento. Com fallback, você define explicitamente o patamar seguro de degradação.

Do lab à produção: tornando o autoscaler resiliente

Aqui está o que quase nenhum tutorial cobre. Você acabou de delegar as decisões de escala a um componente novo — então a saúde dele virou parte da sua disponibilidade. Se o autoscaler cai, a escala para.

E os componentes do KEDA têm perfis de risco diferentes:

  • Operatornão está no caminho de tráfego. Usa leader election; se cai, os workloads seguem servindo e apenas não reescalam até voltar. HA é opcional.
  • Metrics apiserverestá no caminho das decisões de escala. É quem serve a external.metrics.k8s.io ao HPA, e não sai de cena graciosamente: enquanto está fora (evicção, OOM, drain de nó), o HPA recebe FailedGetExternalMetric e congela a escala dos workloads que dependem de métrica externa.

Seja honesto sobre o que dá para conseguir: a própria documentação do KEDA afirma que não há suporte a HA pleno por limitação upstream. Múltiplas réplicas do operator e do metrics apiserver são active/standby — só uma serve por vez; as outras reduzem o downtime no failover. O ganho é failover rápido, não escala horizontal do autoscaler. Saber disso muda a expectativa e o desenho.

Com isso em mente, o overlay de values do chart upstream — aditivo, sem fork:

# values-keda.yaml — sobrepõe só o necessário ao chart kedacore/keda

# 2 réplicas do metrics apiserver: active/standby reduz o downtime no failover
metricsServer:
  replicaCount: 2

# o drain de um nó não pode levar as duas réplicas e congelar o HPA
podDisruptionBudget:
  metricServer:
    minAvailable: 1

# espalha as 2 réplicas em nós distintos para a queda de um nó não derrubar ambas
topologySpreadConstraints:
  metricsServer:
    - maxSkew: 1
      topologyKey: kubernetes.io/hostname
      whenUnsatisfiable: ScheduleAnyway     # soft: o PDB já cobre a disrupção voluntária
      labelSelector:
        matchLabels:
          app: keda-operator-metrics-apiserver

# (opcional) HA do operator — leader election, 1 ativo + standby
# operator:
#   replicaCount: 2

Algumas escolhas merecem nota:

  • ScheduleAnyway (soft) vs DoNotSchedule (hard). Soft é uma preferência — em um cluster com poucos nós elegíveis, as réplicas podem cair no mesmo nó, então o spread é best-effort e não garante sobreviver à perda de um nó. Hard garante o espalhamento, ao custo de pods Pending quando faltar nó. Em cluster pequeno, soft + PDB costuma ser o equilíbrio certo.
  • topologyKey. Em cluster multi-zona, espalhe por topology.kubernetes.io/zone. Em cluster de zona única, o melhor que dá é espalhar por kubernetes.io/hostname (nó).

Os nomes de campo do chart do KEDA têm uma inconsistência histórica (metricsServer para o componente, metricServer sob podDisruptionBudget/prometheus). Não decore — confirme com helm show values kedacore/keda na sua versão e valide com helm template antes de aplicar.

Como saber que a escala está saudável?

Um trigger quebrado — a auth do Kafka expira, o role do Postgres perde permissão, o Prometheus muda de endereço — faz a escala parar em silêncio. Sem observabilidade do autoscaler, você descobre pela latência, no pior momento. Por isso, em produção, ligar as métricas do KEDA não é opcional.

O chart expõe as métricas Prometheus dos componentes (desligadas por default) e cria os objetos de coleta:

# continuação do values-keda.yaml
prometheus:
  operator:
    enabled: true
    podMonitor:
      enabled: true
  metricServer:
    enabled: true
    podMonitor:
      enabled: true

O que observar:

  • keda_scaled_object_errors_total — erros por ScaledObject. Qualquer taxa > 0 sustentada é trigger quebrado.
  • keda_scaler_detail_errors_total — erros por scaler (qual broker/banco/endpoint está falhando).
  • keda_scaler_metrics_value — o valor que o KEDA está entregando ao HPA. Compare com o esperado.
  • keda_scaler_active — se o scaler está ativo (1) ou não (0). Útil para scale-to-zero.
  • Do lado do HPA, fique de olho em FailedGetExternalMetric (via eventos do HPA / kube-state-metrics) — é o sintoma do metrics apiserver fora.

E um alerta mínimo, como PrometheusRule:

apiVersion: monitoring.coreos.com/v1
kind: PrometheusRule
metadata:
  name: keda-health
spec:
  groups:
    - name: keda
      rules:
        - alert: KedaScaledObjectErrors
          expr: sum by (scaledObject) (rate(keda_scaled_object_errors_total[5m])) > 0
          for: 10m
          labels: { severity: warning }
          annotations:
            summary: "ScaledObject {{ $labels.scaledObject }} com erros"
            description: "O KEDA está falhando ao avaliar triggers — a escala pode estar congelada."

Os nomes exatos das métricas variam entre versões do KEDA (o agregador antigo usava keda_metrics_adapter_*). Confirme no endpoint /metrics da sua instalação antes de fechar os alertas.

Disciplina de plataforma: aditivo, reversível e sem fork

Repare no que não fizemos em nenhum momento: forkar o chart, editar manifesto renderizado à mão, ou criar um operator caseiro. Tudo entrou como overlay de values sobre o chart upstream da comunidade — a mesma disciplina que defendemos para substituir o ingress-nginx pelo Gateway API sem reescrever o mundo e para manter um cluster Kubernetes de produção 100% open source, sem lock-in.

Três propriedades fazem isso valer a pena:

  1. Aditivo. Um ScaledObject só existe quando você o cria; o HPA nativo e as réplicas fixas seguem como estavam até você optar pelo KEDA naquele workload.
  2. Reversível. Tirou o ScaledObject? O Deployment volta a ser governado pelo replicas fixo ou por um HPA nativo. Sem migração de mão única.
  3. Verificável. helm template ... | git diff no CI mostra exatamente o que vai mudar no cluster, antes de mudar. Autoscaling é mudança de produção como qualquer outra — merece o mesmo gate.

A recompensa do KEDA — escalar pelo sinal certo, inclusive a zero — vem sem acoplar a plataforma a um fornecedor. O autoscaler é um componente da comunidade, roda em qualquer cluster conforme, e sai tão limpo quanto entrou.

Quando usar o quê

Cenário Ferramenta Por quê
Serviço stateless CPU-bound HPA nativo (CPU) Mais simples, uma peça a menos para falhar
Saturação de I/O (requests in-flight, latência, RPS) KEDA + Prometheus O sinal está numa métrica, não no CPU
Consumidor de fila/evento (Kafka, RabbitMQ, SQS…) KEDA + scaler da fila Escala pelo lag/profundidade; permite scale-to-zero
Worker de jobs (fila numa tabela) KEDA + PostgreSQL/Redis Escala pela pressão real da fila; SKIP LOCKED evita duplicação
Ajuste de requests/limits (sizing vertical) VPA É outro eixo — complementa, não substitui o horizontal
Falta nó para os pods que o HPA pediu Cluster Autoscaler / Karpenter Escala o cluster, não o workload — camada abaixo, complementar

A regra de ouro: escale pelo sinal que reflete a demanda real do workload, não pelo sinal mais fácil de medir. O HPA por CPU continua ótimo onde CPU é o gargalo. Para todo o resto, o KEDA traz o sinal certo — e, com um pouco de disciplina, traz junto um autoscaler que você consegue manter no ar e enxergar quando ele tropeça.

Perguntas Frequentes

O KEDA substitui o HPA do Kubernetes?

Não. O KEDA cria e alimenta um HPA por baixo dos panos: cada ScaledObject gera um HorizontalPodAutoscaler que consome métricas externas servidas pelo metrics apiserver do KEDA. Você continua usando o HPA — só que agora ele enxerga sinais que o HPA nativo não alcança (lag de fila, profundidade no banco, qualquer PromQL).

Posso usar HPA por CPU e KEDA no mesmo workload ao mesmo tempo?

Não no mesmo Deployment com dois objetos. Ter um HPA nativo e um ScaledObject apontando para o mesmo alvo gera conflito de controle de réplicas. O caminho certo é um único ScaledObject com dois triggers (ex.: cpu + prometheus): o KEDA escala pelo maior número de réplicas desejado entre eles.

O KEDA vira um ponto único de falha do meu autoscaling?

O metrics apiserver do KEDA está no caminho das decisões de escala: se ele sai do ar, o HPA recebe FailedGetExternalMetric e congela a escala. Mitigue com réplicas (active/standby), PodDisruptionBudget, topology spread e um fallback de réplicas fixas. A própria documentação do KEDA admite que não há HA pleno por limitação upstream — o ganho é failover, não ativo-ativo.

O que acontece se a fila, o banco ou o Prometheus do scaler ficarem indisponíveis?

Sem fallback, o HPA mantém a última decisão de escala (congela). Configure o bloco fallback do ScaledObject para cair em um número fixo e seguro de réplicas quando o scaler falhar N vezes seguidas, e alerte em cima das métricas de erro do KEDA para não descobrir o problema pela latência.

Scale-to-zero é seguro em produção?

Para workloads orientados a evento que toleram cold start, sim — é um dos maiores ganhos de FinOps do KEDA. Cuidado com a latência do primeiro evento depois de escalar de zero e, no Kafka, com a política de offset: use earliest para não ignorar o backlog acumulado enquanto o consumidor estava em zero.


Fontes:

Gostou? Compartilhe:
Precisa de ajuda?Fale com nossos especialistas 👋
Avatar Walcew - Headset