Planejamento de Capacidade
O negócio faz uma pergunta simples: "Podemos abrir a venda do show no estádio em 14 de março às 10:00?" Por trás dela há perguntas de engenharia com respostas numéricas: quantas réplicas de cada serviço, que tamanho de banco, quantas conexões, que taxa de admissão para a sala de espera, com quanta antecedência escalar — e quanto custa. O planejamento de capacidade2 transforma as medições das páginas anteriores nesses números, com margem suficiente para sobreviver ao que você não previu.
De medições a réplicas
Planejamento de capacidade é aritmética sobre três entradas que você já sabe obter:
- Demanda — a taxa de chegada de pico por endpoint, do modelo de carga (previsão × fator de segurança).
- Capacidade por unidade — o joelho de um pod (ou de um banco), de um teste breakpoint: a maior taxa em que ele ainda cumpre o SLO.
- Folga (headroom) — quão abaixo do joelho você quer operar, e a quais falhas precisa sobreviver.
A Lei de Little dimensiona tudo o que segura uma requisição
Réplicas não são o único recurso. Todo componente que segura uma requisição enquanto ela está em andamento — threads, conexões, sockets, VUs, posições de fila — precisa ser dimensionado para a concorrência, e a Lei de Little a dá diretamente: concorrência = taxa de chegada × tempo em que é segurada1.
| Recurso | Taxa | Tempo segurado | Concorrência necessária |
|---|---|---|---|
Threads de requisição do inventory | 3 000 reservas/s | 120 ms (p50 da resposta) | 360 threads somando todos os pods — mais para a cauda |
| Conexões do PostgreSQL | 3 000 reservas/s | 6 ms por transação | 18 conexões ativas (atrás do PgBouncer) |
| VUs do k6 para o teste de carga | 1 000 iterações de hold()/s | 3,5 s por iteração | 3 500 VUs |
| Sala de espera | 500 fãs admitidos/s | posição 600 000 na fila | 600 000 / 500 = 1 200 s: uma espera de 20 minutos para mostrar ao fã |
Um modelo de capacidade para a venda
O modelo abaixo combina as taxas de pico do modelo de carga com os joelhos por pod medidos em testes breakpoint (todos os valores são premissas ilustrativas — troque-os pelas suas medições). Ele planeja operar no máximo a 75 % do joelho e espalha as réplicas por três zonas de disponibilidade, para que a perda de uma zona ainda deixe capacidade suficiente:
| Service | Knee per pod (req/s) | Peak, no waiting room | Pods (load → +zone loss) | Peak, waiting room | Pods (load → +zone loss) |
|---|---|---|---|---|---|
gateway | 4,000 | 30,000 | 10 → 15 | 30,000 | 10 → 15 |
catalog | 1,500 | 19,500 | 18 → 27 | 19,500 | 18 → 27 |
search | 600 | 3,000 | 7 → 12 | 3,000 | 7 → 12 |
inventory | 275 | 4,500 | 22 → 33 | 3,000 | 15 → 24 |
order | 150 | 3,000 | 27 → 42 | 2,000 | 18 → 27 |
| PostgreSQL client connections | pool 10/pod | 1,020 | 780 |
O que o modelo diz:
- A sala de espera economiza capacidade onde ela é mais cara. Limitar as reservas admitidas a 3 000/s (abaixo do joelho do banco) corta cerca de um terço das réplicas de
inventoryeorder— e, mais importante, mantém limitada a carga sobre o único banco primário. - Resiliência a zonas custa 50 % mais réplicas no pico. Esse é o preço de "uma queda de zona às 10:01 não para a venda". Pode valer a pena no dia da venda e não em uma terça-feira — os limites do autoscaling podem variar por dia.
- O número de conexões é um sinal de alerta. Perto de mil conexões de clientes não podem ir direto ao PostgreSQL; o PgBouncer (transaction pooling) as multiplexa em algumas dezenas de conexões de servidor — o número que a Lei de Little de fato exige.
- Pods viram nós, que viram dólares. Cerca de cem pods no pico é aproximadamente o "+24 nós por 2 horas" do modelo de custo de FinOps.
Folga: por que não operar a 100 % do joelho?
| Motivo para a folga | O que acontece sem ela |
|---|---|
| Erro de previsão | O show é mais popular do que o esperado; a demanda passa do plano |
| Rajadas de menos de um minuto | Dashboards fazem médias por minuto; dentro dele, alguns segundos carregam 2–3× a média |
| Falhas | Uma zona, um nó ou um pod se perde; os sobreviventes absorvem sua carga |
| Deploys | Rolling updates e canaries removem ou desviam capacidade temporariamente |
| Atraso do autoscaling | A nova capacidade chega minutos depois da demanda |
| Vizinhos barulhentos, GC, compactação | A capacidade por pod varia de execução para execução |
Metas típicas operam os serviços entre 50 e 75 % do joelho no pico esperado; o número certo depende de quão rápido você consegue adicionar capacidade e de quão caro é violar o SLO. Para a venda do BoxOffice — que dura minutos, é muito visível e não pode ser repetida —, uma folga generosa é um seguro barato.
Atraso do autoscaling: os minutos que importam
O autoscaling reage à demanda; ele não a antecipa. Uma linha do tempo aproximada para um serviço JVM no Kubernetes quando o pico começa:
| Etapa | Duração típica |
|---|---|
| O pipeline de métricas percebe a mudança (scrape + agregação) | 15–60 s |
| Avaliação e decisão do HPA | até 15 s |
| Pod escalonado — se um nó tiver espaço; senão, provisionamento de nó | segundos / 1–3 min |
| Download da imagem em um nó novo | segundos a um minuto |
| Início da JVM, contexto Spring, readiness, aquecimento do JIT | 20–60 s |
| Total | ≈ 1–4 minutos |
Durante essa janela, o backlog cresce na diferença entre demanda e capacidade. Se as reservas saltam de 500/s para 4 500/s enquanto o deployment consegue atender 1 100/s por dois minutos, o backlog é \((4\,500 - 1\,100) \times 120 \approx 408\,000\) requisições — muito além de qualquer timeout. Elas viram erros, os retries pioram tudo, e os fãs que chegaram primeiro têm a pior experiência. Daí as regras para picos conhecidos:
- Pré-escale antes do evento (um aumento agendado às 09:45; o scaler de cron do KEDA;
minReplicaselevado) e mantenha capacidade livre nos nós. - Controle de admissão (sala de espera, rate limiting), para que a demanda excedente espere fora do sistema, em ordem, em vez de expirar dentro dele3.
- Aqueça a nova capacidade: caches pré-carregados com o evento, pools de conexão cheios (
minimum-idle = maximum-pool-size), uma requisição de aquecimento a cada pod novo antes de ele ficar pronto.
Plataformas serverless encurtam o atraso, mas não o eliminam: novas instâncias de função têm cold starts, e os provedores limitam quão rápido a concorrência pode crescer e quanta concorrência uma conta pode usar (verifique as cotas atuais do seu provedor).
Experimente: o cenário abaixo envia uma rajada a um componente serverless. Rode-o com as configurações padrão e observe a latência de cold start no início da rajada e as requisições estranguladas (rejeitadas) quando o limite de concorrência é atingido. Depois aumente o limite ou adicione concorrência provisionada (pré-aquecida) e compare os primeiros segundos da rajada — a versão serverless da pré-escala.
Previsão e a revisão de capacidade
Planejamento de capacidade não se faz uma vez só. Um processo simples e sustentável:
- Preveja a demanda a partir do histórico: tendência (crescimento por mês), sazonalidade (fins de semana, feriados) e eventos (vendas são anunciadas com antecedência — use o calendário, não só a tendência).
- Meça de novo a capacidade por unidade depois de cada mudança significativa; uma nova funcionalidade pode mover o joelho mais do que qualquer mudança de infraestrutura. O orçamento de desempenho na CI (abaixo) pega a maioria das regressões antes que cheguem à produção.
- Revise mensalmente: utilização de pico vs plano, folga restante, custo por unidade de demanda, eventos futuros. Capacidade, SLOs e custo pertencem à mesma reunião.
- Prefira elasticidade a precisão: um sistema que escala em um minuto precisa de uma previsão menos precisa do que um que precisa de uma semana para comprar hardware.
Orçamentos de desempenho na CI
Uma regressão de desempenho que chega à produção é descoberta pelos fãs. Um orçamento de desempenho (performance budget) é um limite — por endpoint, por página, por build — que o pipeline impõe a cada mudança, da mesma forma que os testes impõem a correção4. Orçamentos úteis misturam medidas ruidosas e determinísticas:
| Orçamento | Exemplo para o BoxOffice | Ruído | Como impor |
|---|---|---|---|
| Latência a uma taxa moderada fixa | POST /holds p95 < 150 ms a 20 reservas/s | Médio–alto em runners de CI compartilhados | Thresholds do k6 com margem + comparação com a linha de base da main |
| Taxa de erros | < 0,1 % | Baixo | Threshold do k6 |
| Consultas por requisição | GET /orders/{id}: ≤ 3 statements SQL | Nenhum (determinístico) | Teste de integração que conta statements (pega N+1 com confiabilidade) |
| Tamanho do payload | JSON do mapa de assentos ≤ 60 KB | Nenhum | Teste de contrato |
| Alocações / CPU por requisição | ≤ linha de base + 10 % | Baixo (com JMH ou um profiler) | Job de micro-benchmark |
| Tamanho da imagem e tempo de início | Imagem ≤ 250 MB; pronta em ≤ 30 s | Baixo | Passo do pipeline sobre a imagem construída |
O orçamento do k6 é um teste curto, de modelo aberto, contra uma stack parecida com a produção iniciada dentro do job. Não é um teste de capacidade — runners de CI são pequenos e ruidosos —, mas pega com confiabilidade as grandes regressões: um índice ausente, um N+1, uma chamada síncrona adicionada ao caminho quente.
import { textSummary } from 'https://jslib.k6.io/k6-summary/0.0.2/index.js';
import { browse, hold } from './lib/boxoffice.js';
export { browse, hold };
export const options = {
scenarios: {
browse: { executor: 'constant-arrival-rate', exec: 'browse', rate: 100, timeUnit: '1s',
duration: '3m', preAllocatedVUs: 50, maxVUs: 200 },
hold: { executor: 'constant-arrival-rate', exec: 'hold', rate: 20, timeUnit: '1s',
duration: '3m', preAllocatedVUs: 100, maxVUs: 300 },
},
thresholds: {
'http_req_duration{name:GET /events/{id}}': ['p(95)<80'],
'http_req_duration{name:POST /holds}': ['p(95)<150'],
http_req_failed: ['rate<0.001'],
dropped_iterations: ['count==0'],
},
};
export function handleSummary(data) {
return {
stdout: textSummary(data, { indent: ' ', enableColors: false }),
'results/summary.json': JSON.stringify(data),
};
}
name: perf-budget
on:
pull_request:
push: { branches: [ main ] } # main's runs become the baseline
jobs:
k6:
runs-on: ubuntu-latest
permissions: { contents: read, actions: read }
steps:
- uses: actions/checkout@v4
- name: Start BoxOffice with production-like settings
run: docker compose -f compose.yaml -f compose.perf.yaml up -d --build --wait
- name: Seed a 60 000-seat event
run: docker compose exec -T db psql -U postgres -d boxoffice -f /seed/onsale.sql
- uses: grafana/setup-k6-action@v1
- name: Warm up (not measured)
run: k6 run --quiet -e BASE_URL=http://localhost:8080 --vus 5 --duration 30s k6/smoke.js
- name: Performance budget (fails on threshold breach)
run: mkdir -p results && k6 run -e BASE_URL=http://localhost:8080 k6/budget.js
- name: Compare with the latest baseline from main
if: github.event_name == 'pull_request'
env: { GH_TOKEN: "${{ github.token }}" }
run: |
id=$(gh run list --workflow perf-budget --branch main --status success --limit 1 \
--json databaseId -q '.[0].databaseId')
gh run download "$id" --name k6-summary --dir baseline || echo "no baseline yet"
python3 k6/compare.py results/summary.json baseline/summary.json --max-regression 0.15
- uses: actions/upload-artifact@v4
if: always()
with: { name: k6-summary, path: results/summary.json }
import argparse
import json
import os
import sys
parser = argparse.ArgumentParser(description="Compare k6 p95 per endpoint with a baseline")
parser.add_argument("current")
parser.add_argument("baseline")
parser.add_argument("--max-regression", type=float, default=0.15)
args = parser.parse_args()
def p95_by_endpoint(path):
metrics = json.load(open(path))["metrics"]
return {name: m["values"]["p(95)"] for name, m in metrics.items()
if name.startswith("http_req_duration{name:")}
current = p95_by_endpoint(args.current)
baseline = p95_by_endpoint(args.baseline) if os.path.exists(args.baseline) else {}
failed = False
for name, value in sorted(current.items()):
ref = baseline.get(name)
if ref is None:
print(f"{name}: {value:.1f} ms (no baseline)")
continue
change = (value - ref) / ref
regression = change > args.max_regression
failed |= regression
print(f"{name}: {ref:.1f} -> {value:.1f} ms ({change:+.0%}){' REGRESSION' if regression else ''}")
sys.exit(1 if failed else 0)
Dois gates protegem o orçamento: os thresholds absolutos em budget.js (derivados dos SLOs, com margem para o runner pequeno) e a comparação relativa com a main (uma desaceleração de 15 % falha mesmo que o threshold absoluto ainda passe). Para manter o gate confiável em vez de ignorado:
- Combata o ruído: taxa de chegada fixa, passo de aquecimento, o mesmo tipo de runner, e uma repetição automática antes de falhar; um runner self-hosted dedicado reduz ainda mais a variância.
- Prefira orçamentos determinísticos quando possível: uma asserção de contagem de consultas em um teste de integração nunca é instável e pega a regressão mais comum (N+1).
- Torne as falhas acionáveis: publique o resumo, aponte o trace da requisição mais lenta e diga qual endpoint regrediu e quanto.
- Rode a campanha completa em outro lugar: o gate do PR é um alarme de fumaça; os testes load, spike e breakpoint rodam todas as noites ou antes de grandes eventos no ambiente de desempenho.
No código do BoxOffice: o gate de orçamento de desempenho (lab 11)
O Hands-on 11 — Gate de desempenho monta uma versão menor deste gate, de propósito: um endpoint (GET /catalog/events/{id}), um cenário de aquecimento de 20 s que os thresholds ignoram, depois 40 s a 50 req/s no modelo aberto, só com thresholds absolutos — p95 < 300 ms, p99 < 800 ms, falhas < 1 %, checks > 99 %. Ainda sem comparação com uma linha de base: o orçamento é folgado (o lab mediu p95 ≈ 7 ms num laptop) para que só regressões reais falhem, porque um gate que falha ao acaso ensina as pessoas a rodar de novo até ficar verde. Ele roda como o job performance budget do ci.yaml, e o job da imagem depende dele (needs): uma mudança que estoura o orçamento nunca é construída, escaneada nem implantada. O lab prova isso acrescentando um Thread.sleep de 400 ms ao endpoint de evento e vendo o p(95)<300 ficar vermelho.
export const options = {
scenarios: {
warmup: { // JIT, connection pools and cache: not measured
executor: 'constant-arrival-rate',
rate: 20,
timeUnit: '1s',
duration: '20s',
preAllocatedVUs: 20,
},
budget: { // the measured part: an open-model load the service must sustain
executor: 'constant-arrival-rate',
startTime: '20s',
rate: Number(__ENV.RATE || 50),
timeUnit: '1s',
duration: '40s',
preAllocatedVUs: 50,
maxVUs: 200,
},
},
summaryTrendStats: ['avg', 'med', 'p(95)', 'p(99)', 'max'],
thresholds: {
'http_req_duration{scenario:budget}': ['p(95)<300', 'p(99)<800'],
'http_req_failed{scenario:budget}': ['rate<0.01'],
'checks{scenario:budget}': ['rate>0.99'],
},
};
performance:
name: performance budget
needs: test
runs-on: ubuntu-latest
timeout-minutes: 20
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Start PostgreSQL, Redis and catalog
run: docker compose -f .github/ci/compose.perf.yaml up -d --build --wait --wait-timeout 300
- uses: grafana/setup-k6-action@43b9fc21641a76002687994433dd586f56e791b1 # v1.2.2
with:
k6-version: "1.8.2"
- name: k6 budget (fails the job when a threshold is crossed)
uses: grafana/run-k6-action@4082a08c7e40cf6d65863911077dfb60ae0a4f04 # v1.5.0
env:
BASE_URL: http://localhost:8082
with:
path: load/budget.js
Na entrevista
Feche a parte de capacidade de um design com um cálculo explícito: "Pico de 4 500 reservas/s; um pod sustenta ~275 dentro do SLO; a 75 % do joelho são 22 pods, 33 para sobreviver à perda de uma zona; as conexões pela Lei de Little são 4 500 × 6 ms ≈ 27 ativas, então PgBouncer na frente do primário; o autoscaling leva minutos, então pré-escalamos às 09:45 e a sala de espera admite no máximo o que o banco aguenta." Poucos candidatos conectam joelho medido → réplicas → folga → controle de admissão; fazer isso de uma vez é um sinal forte.
Boas práticas
- Mantenha o modelo de capacidade como código e rode-o de novo a cada revisão de capacidade — demanda, joelho por réplica e folga como entradas; uma planilha que ninguém roda de novo vira o plano do trimestre passado.
- Defina a taxa de admissão da sala de espera a partir do joelho do componente mais estreito, e alerte quando a taxa admitida se aproximar dele — a admissão é o único limite que você escolhe; todos os outros são descobertos.
- Acompanhe o custo por unidade de demanda ao lado da utilização — USD por mil ingressos vendidos mostra se uma mudança de capacidade valeu a pena. Veja Cloud-native — FinOps.
- Defina um orçamento a partir de números medidos com margem larga, e aperte-o só depois de observar a tendência — um gate precisa falhar em regressões, não no ruído do runner. No BoxOffice: p95 < 300 ms contra ~7 ms medidos, no
load/budget.jsdo lab 11. - Torne o orçamento um passo obrigatório antes de a imagem ser construída — uma mudança que o estoura nunca é escaneada, publicada nem implantada. No BoxOffice: o job da imagem
needs: [test, performance]noci.yaml(lab 11).
Armadilhas
- Planejar com a vazão máxima em vez do joelho: o sistema "aguenta" essa taxa com o SLO quebrado.
- Esquecer os recursos compartilhados: 30 pods a mais são 300 conexões de banco a mais.
- Confiar no autoscaling reativo para um pico conhecido: o atraso é maior que os minutos críticos do pico.
- Folga que some em uma falha: calcule a capacidade depois de perder uma zona, não antes.
- Um gate de desempenho instável: se ele falha ao acaso, as pessoas aprendem a rodá-lo de novo até ficar verde — reduza o ruído e prefira orçamentos determinísticos.
- Nunca medir de novo: o joelho se move a cada release; o plano de capacidade de ontem é uma hipótese.
Como se conecta
flowchart LR
KNEE["measured knee per pod<br/>← Load testing"] --> REP["replicas = peak ÷<br/>(knee × target utilization)"]
LL["Little's Law<br/>N = X × R"] --> CONN["threads, connections,<br/>PgBouncer"]
REP --> HEAD["headroom:<br/>zone loss, 75 % of the knee"]
HEAD --> LAG["autoscaling lag, cold starts<br/>→ pre-scale before 10:00"]
LAG --> WR["waiting room admits what<br/>the database can take"]
REP --> COST["pods → nodes → dollars<br/>→ Cloud-native FinOps"]
FC["forecast and<br/>capacity review"] --> REP
BUD["performance budget in CI<br/>guards the knee"] --> KNEE Componentes da paleta: app (réplicas por serviço) · sql (conexões e o limite do primário) · queue (a taxa de admissão da sala de espera) · serverless (cold starts e limites de concorrência) · metrics (utilização, o sinal do autoscaler). Cenários: serverless-burst, scale-out, flash-sale. Continua em: Estimativas — filas, Compute — serverless e autoscaling, Cloud-native — Kubernetes, Cloud-native — FinOps, Estudo de caso — ingressos.
-
LITTLE, J. D. C. A Proof for the Queuing Formula: L = λW. Operations Research, v. 9, n. 3, p. 383–387, 1961. ↩
-
ALLSPAW, J.; KEJARIWAL, A. The Art of Capacity Planning: Scaling Web Resources in the Cloud. 2nd ed. O'Reilly, 2017. ↩
-
BEYER, B. et al. Site Reliability Engineering, cap. 21 "Handling Overload". O'Reilly, 2016. ↩
-
Grafana Labs. Automated performance testing. Documentação do k6. ↩