Pular para conteúdo

3. Autoscaling

O lab 3 escalou o catalog à mão: três réplicas atrás do Nginx, qualquer que fosse a carga. O Horizontal Pod Autoscaler (HPA) fecha o laço — mede, compara com um alvo e muda o número de réplicas —: é o laço de controle de autoscaling da aula de computação, rodando a cada 15 segundos.

A regra

k8s/base/catalog-hpa.yaml
apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
metadata:
  name: catalog
spec:
  scaleTargetRef:
    apiVersion: apps/v1
    kind: Deployment
    name: catalog
  minReplicas: 2
  maxReplicas: 8
  metrics:
    - type: Resource
      resource:
        name: cpu
        target:
          type: Utilization
          averageUtilization: 60     # % of the CPU *request* (250m): scale when pods average > 150m
  behavior:
    scaleUp:
      stabilizationWindowSeconds: 0  # react at once to load
      selectPolicy: Max
      policies:
        - type: Percent
          value: 100                 # at most double...
          periodSeconds: 15
        - type: Pods
          value: 2                   # ...or +2 pods, whichever is more, every 15 s
          periodSeconds: 15
    scaleDown:
      stabilizationWindowSeconds: 120  # wait 2 min of lower load before removing pods
      policies:
        - type: Pods
          value: 1
          periodSeconds: 30

A fórmula central do HPA é simples:

\[ \text{réplicas desejadas} = \left\lceil \text{réplicas atuais} \times \frac{\text{utilização atual}}{\text{utilização alvo}} \right\rceil \]

Com 2 pods a 150% do seu request de CPU e um alvo de 60%: ⌈2 × 150 / 60⌉ = 5 pods. A utilização é uma porcentagem do request (250m), não do nó: um pod usando 150m está a 60%.

Campo do behavior Por que existe
scaleUp.stabilizationWindowSeconds: 0 escalar para cima assim que a carga aparece — scale-up lento é requisição perdida
scaleUp.policies (100% ou +2 pods a cada 15 s) crescimento limitado: no máximo dobra por período, então uma leitura ruidosa não explode o deployment
scaleDown.stabilizationWindowSeconds: 120 usa a recomendação mais alta dos últimos 2 min — sem oscilação quando a carga oscila
scaleDown.policies (1 pod a cada 30 s) libera capacidade devagar; escalar para baixo rápido demais dispara o scale-up de novo
Por que CPU, e quando ela é o sinal errado?

O catalog é limitado por CPU quando a taxa de acerto do cache é alta: mais requisições → mais CPU, quase linearmente, então a CPU é um bom indicador da carga. Ela é o sinal errado quando o gargalo está em outro lugar: um serviço esperando um banco de dados (CPU baixa, latência alta), um consumidor Kafka (escale pelo lag), um servidor WebSocket (escale por conexões). Para esses, o HPA aceita métricas customizadas e externas (requisições por segundo, tamanho de fila) por meio de um adaptador como o KEDA ou o Prometheus adapter.

Coloque carga

O script do k6 sobe a taxa de chegada em rampa (modelo aberto) pelo Ingress, segura e desce de volta. O observador registra o que o HPA vê e decide a cada 5 segundos.

load/catalog-hpa.js
import http from 'k6/http';
import { check } from 'k6';

const BASE_URL = __ENV.BASE_URL || 'http://localhost'; // the Ingress
const PEAK = Number(__ENV.PEAK || 300);

export const options = {
  scenarios: {
    ramp: {
      executor: 'ramping-arrival-rate', // open model: arrivals keep coming even if pods are slow
      startRate: 20,
      timeUnit: '1s',
      preAllocatedVUs: 100,
      maxVUs: 1000,
      stages: [
        { target: PEAK, duration: '3m' }, // ramp up: watch the HPA react
        { target: PEAK, duration: '4m' }, // hold: does the latency come back down?
        { target: 20, duration: '1m' },   // ramp down
        { target: 20, duration: '3m' },   // quiet: watch the scale-down stabilization
      ],
    },
  },
  summaryTrendStats: ['avg', 'med', 'p(95)', 'p(99)', 'max'],
  thresholds: {
    http_req_failed: ['rate<0.01'],
    http_req_duration: ['p(95)<500'],
  },
};

export default function () {
  const id = 1 + Math.floor(Math.random() * 200000);
  const res = http.get(`${BASE_URL}/catalog/events/${id}`, { tags: { name: 'GET /catalog/events/{id}' } });
  check(res, { 'status 200': (r) => r.status === 200 });
}
scripts/watch-hpa.sh
#!/bin/sh
# Logs time, desired/current replicas, CPU and ready pods every 5 s. Usage: sh scripts/watch-hpa.sh > results/lab10-hpa.csv
echo "time,cpu,desired,current,ready"
while true; do
  HPA=$(kubectl -n boxoffice get hpa catalog -o jsonpath='{.status.currentMetrics[0].resource.current.averageUtilization},{.status.desiredReplicas},{.status.currentReplicas}')
  READY=$(kubectl -n boxoffice get deploy catalog -o jsonpath='{.status.readyReplicas}')
  echo "$(date +%T),$HPA,$READY"
  sleep 5
done

Comece com pods aquecidos. Uma JVM que acabou de iniciar queima CPU compilando código, e o HPA escala com essa CPU também: numa execução de teste iniciada logo depois de um rollout, PEAK=250 levou o catalog a 8 réplicas, as liveness probes estouraram o timeout com as CPUs ocupadas, pods reiniciaram e o k6 viu 26% de erros. A mesma execução com pods aquecidos nunca saiu de 2 réplicas. Aqueça-os e espere o HPA voltar a 2 réplicas:

k6 run -q -e RATE=200 -e DURATION=1m load/catalog-otel.js > /dev/null   # lab 8's script
kubectl -n boxoffice get hpa catalog -w                    # wait for REPLICAS 2 and a low cpu, then Ctrl+C

Três terminais:

mkdir -p results && sh scripts/watch-hpa.sh | tee results/lab10-hpa.csv   # 1: the control loop
kubectl -n boxoffice get pods -l app=catalog -w            # 2: pods appearing
k6 run -e PEAK=1000 load/catalog-hpa.js                    # 3: the load (≈ 11 min)

Ajuste o PEAK à sua máquina. O HPA reage à CPU, não à latência: o PEAK deve levar dois pods aquecidos claramente acima de 60% do request de CPU (150m cada, kubectl -n boxoffice top pods), mas ficar abaixo do que o seu laptop consegue gerar. Num laptop de teste (8 CPUs para o Docker), dois pods aquecidos ficaram em cerca de 40% com 250 req/s e passaram de 60% por volta de 600 req/s; daí o PEAK=1000.

Forma esperada (ilustrativa)

A CPU sobe durante a rampa; o HPA adiciona pods em degraus (2 → 4 → 6 …) tão rápido quanto a política de scaleUp permite; cada pod novo precisa de dezenas de segundos antes de ficar pronto (inicialização da JVM + probes), então o p95 sobe durante a rampa e só volta a cair quando os pods novos ficam prontos — num laptop, talvez só depois do fim do patamar. Depois que a carga cai, as réplicas ficam de pé durante a janela de estabilização de 2 minutos e então saem uma a uma.

O atraso entre "a carga chega" e "a capacidade está pronta" é o preço do autoscaling reativo. Para uma venda relâmpago com horário conhecido, você faria pré-escalonamento (aumentar o minReplicas antes das 10:00) em vez de torcer para o HPA ser rápido o bastante — o escalonamento reativo cuida do inesperado, o agendamento cuida do esperado.

Armadilhas

  • Sem requests, sem HPA. A utilização é relativa a resources.requests.cpu; sem requests o HPA reporta <unknown> e não faz nada.
  • O aquecimento da JVM parece carga. Uma JVM iniciando queima CPU (carregamento de classes, JIT): pods novos podem empurrar a média para cima e disparar mais scale-up. A startup probe mantém os pods não prontos fora do tráfego, mas a CPU deles ainda conta; a janela de estabilização e a política de scale-up limitam o estrago.
  • O banco de dados não escala sozinho. Oito pods do catalog × 10 conexões = 80 conexões a um único PostgreSQL. Escalar a camada stateless leva o gargalo para a stateful — confira o max_connections (ou use o PgBouncer do lab 6).
  • Capacidade dos nós. Se os pods pedidos não cabem nos nós, eles ficam em Pending. Na nuvem um cluster autoscaler (ou o Karpenter) adiciona nós, o que leva minutos, não segundos.

Próximo: Ingress