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
ScaledObjecte, 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
ScaledObjectna 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:
- Mais de 70 scalers prontos — Kafka, RabbitMQ, Prometheus, PostgreSQL, Redis, filas de nuvem, e mais.
- 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.
- 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. Comlatest, 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. Comearliest, 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: rabbitmqe 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>nometadatapara 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 comSELECT ... FOR UPDATE SKIP LOCKED, o job virarunningao ser pego e sairia da contagem — o KEDA veria a fila esvaziar e derrubaria pods com job em andamento. Contarqueued+runningevita 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 umSELECT 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:
- Operator — nã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 apiserver — está no caminho das decisões de escala. É quem serve a
external.metrics.k8s.ioao HPA, e não sai de cena graciosamente: enquanto está fora (evicção, OOM, drain de nó), o HPA recebeFailedGetExternalMetrice 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) vsDoNotSchedule(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 podsPendingquando faltar nó. Em cluster pequeno, soft + PDB costuma ser o equilíbrio certo.topologyKey. Em cluster multi-zona, espalhe portopology.kubernetes.io/zone. Em cluster de zona única, o melhor que dá é espalhar porkubernetes.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 porScaledObject. 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/metricsda 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:
- Aditivo. Um
ScaledObjectsó existe quando você o cria; o HPA nativo e as réplicas fixas seguem como estavam até você optar pelo KEDA naquele workload. - Reversível. Tirou o
ScaledObject? O Deployment volta a ser governado peloreplicasfixo ou por um HPA nativo. Sem migração de mão única. - Verificável.
helm template ... | git diffno 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:
- KEDA — Documentação oficial — CNCF
- KEDA — Apache Kafka scaler — CNCF
- KEDA — Prometheus scaler — CNCF
- KEDA — PostgreSQL scaler — CNCF
- KEDA — Operate: Cluster & High Availability — CNCF
- KEDA — Integrate with Prometheus — CNCF
- Kubernetes — Horizontal Pod Autoscaling — Kubernetes
- kedacore/charts — values.yaml do chart oficial — KEDA