Autoscaling in Kubernetes: HPA, VPA, KEDA e Karpenter
Uno dei principali vantaggi di Kubernetes in produzione e la capacita di scalare automaticamente i workload in risposta alla domanda. Eppure, la maggior parte dei team usa solo una frazione delle capacita di autoscaling disponibili: configurano un HPA con scaling su CPU e si fermano li. Il risultato? Pod sotto-provisionati che rallentano sotto carico, o nodi sovra-provisionati che bruciano budget senza motivo.
Kubernetes offre quattro livelli di autoscaling complementari: HPA scala orizzontalmente i Pod su metriche CPU/memoria/custom, VPA aggiusta automaticamente i resource request, KEDA abilita scaling event-driven su qualsiasi sorgente (code, database, metriche Prometheus), e Karpenter provisiona nodi in meno di 30 secondi, 40% piu veloce del Cluster Autoscaler tradizionale secondo i benchmark CNCF 2025. Questo articolo mostra come usarli insieme in produzione.
Cosa Imparerai
- Come funziona l'Horizontal Pod Autoscaler (HPA) con metriche custom e external
- Configurare il Vertical Pod Autoscaler (VPA) per rightsizing automatico
- KEDA: autoscaling event-driven su code SQS, Kafka, Redis e Prometheus
- Karpenter: provisioning di nodi just-in-time con NodePool e NodeClass
- Pattern di combinazione: usare HPA e KEDA insieme senza conflitti
- Troubleshooting: perche il tuo HPA non scala come ti aspetti
- Best practices per evitare i flap loop e i cold start
Horizontal Pod Autoscaler (HPA)
L'HPA e il componente Kubernetes che scala il numero di repliche di un Deployment, StatefulSet o ReplicaSet in base a metriche osservate. Il controller HPA interroga le metriche ogni 15 secondi (configurabile) e calcola il numero desiderato di repliche con la formula:
desiredReplicas = ceil(currentReplicas * (currentMetricValue / desiredMetricValue))
Per evitare flapping (scaling su e giu continuo), l'HPA ha un periodo di stabilizzazione: 5 minuti per lo scale-down e 0 secondi per lo scale-up di default.
HPA su CPU e Memoria
La configurazione base con CPU e memoria. Nota che per scalare su memoria, l'applicazione deve liberare memoria quando il carico diminuisce, altrimenti lo scale-down non avviene mai:
# hpa-basic.yaml
apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
metadata:
name: api-server-hpa
namespace: production
spec:
scaleTargetRef:
apiVersion: apps/v1
kind: Deployment
name: api-server
minReplicas: 2
maxReplicas: 20
metrics:
- type: Resource
resource:
name: cpu
target:
type: Utilization
averageUtilization: 60 # scala quando CPU media > 60%
- type: Resource
resource:
name: memory
target:
type: AverageValue
averageValue: "512Mi" # scala quando memoria media > 512Mi
behavior:
scaleUp:
stabilizationWindowSeconds: 0 # scala su immediatamente
policies:
- type: Percent
value: 100
periodSeconds: 60 # max raddoppio delle repliche al minuto
- type: Pods
value: 4
periodSeconds: 60 # o max 4 pod al minuto
selectPolicy: Max # usa la policy piu aggressiva
scaleDown:
stabilizationWindowSeconds: 300 # 5 minuti prima di scalare giu
policies:
- type: Percent
value: 25
periodSeconds: 60 # riduce max 25% delle repliche al minuto
selectPolicy: Min
HPA con Metriche Custom via Prometheus Adapter
Per scalare su metriche applicative (richieste al secondo, lunghezza queue, etc.), serve il Prometheus Adapter che espone le metriche Prometheus come Custom Metrics API Kubernetes:
# Installa Prometheus Adapter
helm repo add prometheus-community https://prometheus-community.github.io/helm-charts
helm install prometheus-adapter prometheus-community/prometheus-adapter \
--namespace monitoring \
--set prometheus.url=http://kube-prometheus-stack-prometheus.monitoring.svc \
--set prometheus.port=9090
# prometheus-adapter-config.yaml - regola di mapping metrica
rules:
custom:
- seriesQuery: 'http_requests_total{namespace!="",pod!=""}'
resources:
overrides:
namespace: {resource: "namespace"}
pod: {resource: "pod"}
name:
matches: "^(.*)_total$"
as: "${1}_per_second"
metricsQuery: 'sum(rate(<<.Series>>{<<.LabelMatchers>>}[2m])) by (<<.GroupBy>>)'
---
# hpa-custom-metric.yaml
apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
metadata:
name: api-server-rps-hpa
namespace: production
spec:
scaleTargetRef:
apiVersion: apps/v1
kind: Deployment
name: api-server
minReplicas: 2
maxReplicas: 50
metrics:
- type: Pods
pods:
metric:
name: http_requests_per_second
target:
type: AverageValue
averageValue: "1000" # 1000 req/s per Pod
HPA con Metriche External
Le metriche External permettono di scalare su fonti esterne al cluster, come la lunghezza di una coda SQS o il numero di messaggi Kafka non consumati:
# hpa-external-metric.yaml
# Scala in base alla lunghezza di una coda SQS
apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
metadata:
name: worker-queue-hpa
namespace: production
spec:
scaleTargetRef:
apiVersion: apps/v1
kind: Deployment
name: queue-worker
minReplicas: 1
maxReplicas: 100
metrics:
- type: External
external:
metric:
name: sqs_approximate_number_of_messages_visible
selector:
matchLabels:
queue: "job-queue-prod"
target:
type: AverageValue
averageValue: "10" # 10 messaggi per worker
# Verifica stato HPA
kubectl get hpa -n production -w
kubectl describe hpa api-server-hpa -n production
Vertical Pod Autoscaler (VPA)
Il VPA monitora l'utilizzo effettivo di CPU e memoria dei tuoi Pod e aggiusta automaticamente
i resources.requests e limits. E la soluzione al problema del
"garbage in, garbage out" dei resource request: se non sai quante risorse ha bisogno il tuo
Pod, il VPA lo scopre per te.
VPA e HPA: Attenzione ai Conflitti
Non usare VPA in modalita Auto insieme a HPA che scala su CPU o memoria:
i due controller entreranno in conflitto. La combinazione corretta e: VPA in modalita
Off o Initial per i resource request, e HPA per le repliche
su metriche custom. Oppure usa KEDA invece di HPA per evitare il problema.
Installazione e Configurazione VPA
# Installa VPA
git clone https://github.com/kubernetes/autoscaler.git
cd autoscaler/vertical-pod-autoscaler
./hack/vpa-install.sh
# oppure con Helm
helm repo add fairwinds-stable https://charts.fairwinds.com/stable
helm install vpa fairwinds-stable/vpa --namespace vpa --create-namespace
---
# vpa-recommendation.yaml - modalita Off (solo raccomandazioni)
apiVersion: autoscaling.k8s.io/v1
kind: VerticalPodAutoscaler
metadata:
name: api-server-vpa
namespace: production
spec:
targetRef:
apiVersion: apps/v1
kind: Deployment
name: api-server
updatePolicy:
updateMode: "Off" # Off|Initial|Recreate|Auto
resourcePolicy:
containerPolicies:
- containerName: api-server
minAllowed:
cpu: "100m"
memory: "128Mi"
maxAllowed:
cpu: "4"
memory: "4Gi"
controlledResources: ["cpu", "memory"]
controlledValues: RequestsAndLimits
# Leggi le raccomandazioni VPA
kubectl describe vpa api-server-vpa -n production
# Output tipico:
# Recommendation:
# Container Recommendations:
# Container Name: api-server
# Lower Bound: cpu: 100m, memory: 256Mi
# Target: cpu: 450m, memory: 512Mi
# Uncapped Target: cpu: 450m, memory: 512Mi
# Upper Bound: cpu: 2000m, memory: 2Gi
VPA in Modalita Auto
# vpa-auto.yaml - aggiorna automaticamente i resource (riavvia i Pod)
apiVersion: autoscaling.k8s.io/v1
kind: VerticalPodAutoscaler
metadata:
name: background-worker-vpa
namespace: production
spec:
targetRef:
apiVersion: apps/v1
kind: Deployment
name: background-worker
updatePolicy:
updateMode: "Auto" # Riavvia i Pod con i nuovi resource
minReplicas: 2 # Non aggiornare se le repliche sono meno di 2
resourcePolicy:
containerPolicies:
- containerName: worker
minAllowed:
cpu: "200m"
memory: "256Mi"
maxAllowed:
cpu: "2"
memory: "2Gi"
KEDA: Event-Driven Autoscaling
KEDA (Kubernetes Event-Driven Autoscaling) e un operatore CNCF che estende l'HPA con oltre 60 scaler predefiniti: AWS SQS, Azure Service Bus, Kafka, RabbitMQ, Redis, Prometheus, Datadog, e molti altri. KEDA puo scalare un deployment a 0 repliche quando non ci sono eventi, e riportarlo a 1 quando arriva il primo evento.
Installazione KEDA
# Installa KEDA via Helm
helm repo add kedacore https://kedacore.github.io/charts
helm repo update
helm install keda kedacore/keda \
--namespace keda \
--create-namespace \
--version 2.14.0
# Verifica
kubectl get pods -n keda
ScaledObject per Kafka
Un worker che consuma da un topic Kafka, scala in base al consumer group lag:
# keda-kafka-scaledobject.yaml
apiVersion: keda.sh/v1alpha1
kind: ScaledObject
metadata:
name: kafka-consumer-scaler
namespace: production
spec:
scaleTargetRef:
apiVersion: apps/v1
kind: Deployment
name: kafka-consumer
pollingInterval: 15 # controlla ogni 15 secondi
cooldownPeriod: 30 # aspetta 30s prima di scalare a 0
minReplicaCount: 0 # scala a zero se non ci sono messaggi
maxReplicaCount: 50
advanced:
restoreToOriginalReplicaCount: true
horizontalPodAutoscalerConfig:
behavior:
scaleDown:
stabilizationWindowSeconds: 30
triggers:
- type: kafka
metadata:
bootstrapServers: kafka-broker.kafka.svc:9092
consumerGroup: my-consumer-group
topic: orders-topic
lagThreshold: "100" # 100 messaggi per replica
offsetResetPolicy: latest
allowIdleConsumers: "false"
scaleToZeroOnInvalidOffset: "false"
authenticationRef:
name: kafka-auth # TriggerAuthentication con credenziali Kafka
ScaledObject per AWS SQS
# keda-sqs-scaledobject.yaml
apiVersion: v1
kind: Secret
metadata:
name: aws-credentials
namespace: production
data:
AWS_ACCESS_KEY_ID: BASE64_KEY
AWS_SECRET_ACCESS_KEY: BASE64_SECRET
---
apiVersion: keda.sh/v1alpha1
kind: TriggerAuthentication
metadata:
name: aws-trigger-auth
namespace: production
spec:
secretTargetRef:
- parameter: awsAccessKeyID
name: aws-credentials
key: AWS_ACCESS_KEY_ID
- parameter: awsSecretAccessKey
name: aws-credentials
key: AWS_SECRET_ACCESS_KEY
---
apiVersion: keda.sh/v1alpha1
kind: ScaledObject
metadata:
name: sqs-worker-scaler
namespace: production
spec:
scaleTargetRef:
apiVersion: apps/v1
kind: Deployment
name: sqs-worker
minReplicaCount: 0
maxReplicaCount: 100
triggers:
- type: aws-sqs-queue
authenticationRef:
name: aws-trigger-auth
metadata:
queueURL: https://sqs.eu-west-1.amazonaws.com/123456789/job-queue
queueLength: "5" # 5 messaggi per replica
awsRegion: eu-west-1
identityOwner: pod # usa IRSA se disponibile
ScaledObject su Prometheus
# keda-prometheus-scaledobject.yaml
# Scala in base a una query Prometheus personalizzata
apiVersion: keda.sh/v1alpha1
kind: ScaledObject
metadata:
name: api-latency-scaler
namespace: production
spec:
scaleTargetRef:
apiVersion: apps/v1
kind: Deployment
name: api-server
minReplicaCount: 2
maxReplicaCount: 30
triggers:
- type: prometheus
metadata:
serverAddress: http://kube-prometheus-stack-prometheus.monitoring.svc:9090
metricName: http_request_duration_p99
threshold: "0.5" # scala se P99 latency > 500ms
query: histogram_quantile(0.99, sum(rate(http_request_duration_seconds_bucket{job="api-server"}[2m])) by (le))
# Verifica stato KEDA
kubectl get scaledobject -n production
kubectl describe scaledobject kafka-consumer-scaler -n production
Karpenter: Node Provisioning Just-in-Time
Karpenter e il provisioner di nodi di nuova generazione creato da AWS e ora CNCF project. A differenza del Cluster Autoscaler, che lavora con node group predefiniti, Karpenter provisiona nodi con le esatte caratteristiche richieste dai Pod pending: tipo di istanza, zona, capacita on-demand o spot, CPU/GPU. Il risultato: provisioning in 30-60 secondi contro i 3-5 minuti del Cluster Autoscaler.
Architettura Karpenter
Karpenter sostituisce completamente il Cluster Autoscaler. Ha due CRD principali:
- NodePool: definisce i requisiti dei nodi che Karpenter puo creare (tipi di istanza, zone, taints, labels, limiti)
- NodeClass (EC2NodeClass su AWS): configurazione cloud-provider-specifica (AMI, subnet, security groups, user data)
Installazione Karpenter su EKS
# Prerequisiti: IRSA configurata per Karpenter
export CLUSTER_NAME="my-production-cluster"
export AWS_ACCOUNT_ID=$(aws sts get-caller-identity --query Account --output text)
export AWS_REGION=eu-west-1
# Installa Karpenter con Helm
helm repo add karpenter https://charts.karpenter.sh/
helm repo update
helm upgrade --install karpenter karpenter/karpenter \
--namespace karpenter \
--create-namespace \
--version 1.0.0 \
--set serviceAccount.annotations."eks.amazonaws.com/role-arn"=arn:aws:iam::${AWS_ACCOUNT_ID}:role/KarpenterControllerRole \
--set settings.clusterName=${CLUSTER_NAME} \
--set settings.interruptionQueue=${CLUSTER_NAME} \
--set controller.resources.requests.cpu=1 \
--set controller.resources.requests.memory=1Gi
NodePool e EC2NodeClass per Produzione
# karpenter-nodepool.yaml
apiVersion: karpenter.sh/v1
kind: NodePool
metadata:
name: general-purpose
spec:
template:
metadata:
labels:
node-type: general-purpose
spec:
nodeClassRef:
apiVersion: karpenter.k8s.aws/v1
kind: EC2NodeClass
name: default
requirements:
- key: karpenter.sh/capacity-type
operator: In
values: ["on-demand", "spot"] # preferisce spot, fallback on-demand
- key: kubernetes.io/arch
operator: In
values: ["amd64"]
- key: karpenter.k8s.aws/instance-category
operator: In
values: ["c", "m", "r"] # compute, memory, general
- key: karpenter.k8s.aws/instance-generation
operator: Gt
values: ["2"] # solo istanze di generazione 3+
- key: karpenter.k8s.aws/instance-cpu
operator: In
values: ["4", "8", "16", "32"]
taints: []
expireAfter: 720h # ricicla nodi ogni 30 giorni
terminationGracePeriod: 48h
limits:
cpu: "500" # max 500 vCPU in questo NodePool
memory: 2000Gi
disruption:
consolidationPolicy: WhenEmptyOrUnderutilized
consolidateAfter: 1m # consolida nodi vuoti dopo 1 minuto
budgets:
- nodes: "20%" # non drainare piu del 20% dei nodi alla volta
---
apiVersion: karpenter.k8s.aws/v1
kind: EC2NodeClass
metadata:
name: default
spec:
amiFamily: AL2023
amiSelectorTerms:
- alias: al2023@latest # usa sempre la AMI piu recente
subnetSelectorTerms:
- tags:
karpenter.sh/discovery: "my-production-cluster"
securityGroupSelectorTerms:
- tags:
karpenter.sh/discovery: "my-production-cluster"
instanceProfile: KarpenterNodeInstanceProfile
blockDeviceMappings:
- deviceName: /dev/xvda
ebs:
volumeSize: 100Gi
volumeType: gp3
iops: 3000
encrypted: true
metadataOptions:
httpEndpoint: enabled
httpProtocolIPv6: disabled
httpPutResponseHopLimit: 1 # sicurezza: blocca access IMDSv1 da container
httpTokens: required # richiede IMDSv2
NodePool per Workload GPU
# karpenter-gpu-nodepool.yaml
apiVersion: karpenter.sh/v1
kind: NodePool
metadata:
name: gpu-nodes
spec:
template:
metadata:
labels:
node-type: gpu
spec:
nodeClassRef:
apiVersion: karpenter.k8s.aws/v1
kind: EC2NodeClass
name: gpu-nodeclass
requirements:
- key: karpenter.sh/capacity-type
operator: In
values: ["on-demand"] # GPU spot non disponibile in tutte le zone
- key: karpenter.k8s.aws/instance-family
operator: In
values: ["g5", "p3", "p4d"] # GPU instance families
taints:
- key: nvidia.com/gpu
effect: NoSchedule # solo Pod che tollerano questo taint
limits:
cpu: "128"
memory: 1024Gi
nvidia.com/gpu: "32" # max 32 GPU in questo NodePool
Consolidazione e Cost Optimization
# Forza la consolidazione immediata (utile per test)
kubectl annotate node karpenter.sh/do-not-disrupt-
# Vedi i nodi creati da Karpenter
kubectl get nodes -l karpenter.sh/nodepool=general-purpose -o wide
# Vedi le decisioni di Karpenter in tempo reale
kubectl logs -n karpenter -l app.kubernetes.io/name=karpenter -f | grep -E "launched|terminated|consolidated"
# Vedi quanto sta costando ogni nodo (con Kubecost)
kubectl get nodeclaims -o json | jq '.items[] | {name: .metadata.name, type: .status.providerID, price: .metadata.annotations["karpenter.sh/nodepool"]}'
Combinare HPA, KEDA e Karpenter
In un cluster di produzione maturi, questi tre componenti lavorano in sinergia:
- KEDA scala i Pod da 0 a N in base agli eventi (Kafka lag, SQS depth, Prometheus query)
- Karpenter rileva i Pod pending e provisiona nodi con le caratteristiche esatte richieste in 30-60 secondi
- VPA (in modalita Off) fornisce raccomandazioni sui resource request che applichi manualmente o tramite pipeline CI/CD
Pattern Consigliato per Produzione
- API server stateless: KEDA su Prometheus (latenza P99) + Karpenter general-purpose NodePool
- Worker di code: KEDA su SQS/Kafka con minReplicas=0 + Karpenter con mix on-demand/spot
- Database/StatefulSet: VPA in modalita Auto con minReplicas >= 2, niente HPA su memoria
- Batch job: KEDA ScaledJob (non ScaledObject) per Job K8s che terminano
- Non usare HPA su CPU insieme a KEDA: porta a conflitti su targetMetrics
KEDA ScaledJob per Batch
# keda-scaledjob.yaml - per batch job che terminano
apiVersion: keda.sh/v1alpha1
kind: ScaledJob
metadata:
name: ml-training-job
namespace: production
spec:
jobTargetRef:
template:
spec:
containers:
- name: trainer
image: my-registry/ml-trainer:latest
resources:
requests:
cpu: "2"
memory: "4Gi"
nvidia.com/gpu: "1"
limits:
nvidia.com/gpu: "1"
tolerations:
- key: nvidia.com/gpu
operator: Exists
effect: NoSchedule
restartPolicy: Never
pollingInterval: 30
maxReplicaCount: 20
successfulJobsHistoryLimit: 5
failedJobsHistoryLimit: 3
triggers:
- type: aws-sqs-queue
authenticationRef:
name: aws-trigger-auth
metadata:
queueURL: https://sqs.eu-west-1.amazonaws.com/123456789/ml-jobs
queueLength: "1" # 1 job per task
awsRegion: eu-west-1
Troubleshooting HPA e KEDA
# HPA non scala? Controlla lo stato
kubectl describe hpa api-server-hpa -n production
# Cerca: "AbleToScale", "ScalingActive", "DesiredReplicas"
# Errore comune: "failed to get cpu utilization" = metrics-server non installato
# Verifica che metrics-server funzioni
kubectl top pods -n production
kubectl top nodes
# KEDA non scala a zero? Controlla il cooldownPeriod
kubectl get scaledobject kafka-consumer-scaler -n production -o yaml | grep -A5 "conditions"
# Karpenter non provisiona?
kubectl get pods --field-selector=status.phase=Pending -A
kubectl describe pod | grep "Events" -A20
# Cerca: "0/N nodes are available" + il motivo del pending
# Vedi log Karpenter
kubectl logs -n karpenter -l app.kubernetes.io/name=karpenter --tail=50 | grep -i "error\|warning\|launched"
# Simula provisioning senza applicare
kubectl annotate pods karpenter.sh/do-not-disrupt=true
Best Practices e Anti-Pattern
Best Practices per l'Autoscaling
- Imposta sempre minReplicas >= 2 per servizi critici: lo scaling da 0 richiede un cold start; per API in produzione, mantieni almeno 2 repliche minime
- Usa PodDisruptionBudget: previeni che Karpenter/HPA drainino troppi Pod durante la consolidazione
- Configura resource request accurati: HPA calcola l'utilizzo percentuale sui resource.requests; se sono troppo bassi, non scala mai
- Readiness probe rigorosa: Kubernetes aspetta che il Pod sia Ready prima di inviare traffico; senza readiness probe, i Pod appena scalati ricevono traffico prima di essere pronti
- Monitora i flap: se l'HPA scala su e giu ogni pochi minuti, aumenta il
stabilizationWindowSecondsdi scale-down - Usa topology spread constraints con Karpenter: distribuisci i Pod su zone per alta disponibilita anche durante provisioning
Anti-Pattern da Evitare
- HPA senza resource requests definiti: l'HPA non puo calcolare l'utilizzo percentuale senza requests nel container spec
- VPA Auto + HPA su CPU/memoria: i due controller si contendono i resource e causano scaling incoerente; usa KEDA su metriche custom se vuoi entrambi
- maxReplicas troppo basso: se il tuo picco di traffico richiede 100 Pod ma maxReplicas e 20, l'autoscaling non basta e il servizio degrada
- Karpenter senza budget di disruption: senza
disruption.budgets, Karpenter puo drainare il 100% dei nodi durante la consolidazione notturna - Polling interval troppo basso su KEDA: un
pollingIntervaldi 5 secondi su sorgenti esterne (SQS, external APIs) genera troppe chiamate API e possibili throttling
Conclusioni e Prossimi Passi
L'autoscaling efficace in Kubernetes non e una singola soluzione ma una strategia a piu livelli: KEDA per l'event-driven scaling dei Pod, HPA per lo scaling basato su utilizzo delle risorse, VPA per ottimizzare i resource request, e Karpenter per il provisioning rapido dei nodi. Usati insieme, questi strumenti permettono di ridurre i costi del 30-50% rispetto a cluster staticamente provisionati, mantenendo SLA elevati.
La chiave del successo e la configurazione accurata dei resource request (VPA aiuta qui), la scelta delle metriche giuste su cui scalare (non sempre la CPU e la risposta), e la configurazione del comportamento di scaling (periodi di stabilizzazione, rate limiting) per evitare oscillazioni che possono peggiorare le performance invece di migliorarle.

Commenti
Caricamento commenti...
Accedi per commentare