A fatura de Kubernetes de uma empresa SaaS europeia tinha passado de 15 000 para 38 000 dólares por mês em 18 meses, enquanto a sua base de utilizadores apenas duplicava. Em cinco semanas baixámo-la para 19 610 dólares, um corte de 48%, sem alterações ao código da aplicação, sem menos réplicas em produção e sem indisponibilidade. A capacidade spot foi o que mais poupou, 7400 dólares por mês, mas veio em segundo lugar: primeiro o right-sizing e, no fim, os compromissos de utilização.
A seguir estão as cinco fases por ordem, cada uma com o manifesto, a consulta ou o comando para a repetir. O nosso guia de otimização de custos em Kubernetes com práticas SRE explica como medir o desperdício em geral. Este artigo aprofunda o que esse guia só esboça: correr cargas em capacidade spot sem que os utilizadores deem por isso.
O ponto de partida: três clusters a 18% de CPU
A empresa usava Kubernetes gerido numa grande cloud pública, com três clusters: produção, staging e desenvolvimento. Todos funcionavam dia e noite em instâncias on-demand de uso geral com 8 vCPU e 32 GiB. A nossa auditoria encontrou:
- Utilização média dos nós: 18% de CPU e 34% de memória.
- Não produção ligada 24/7: staging e desenvolvimento só eram usados em horário laboral, das 09:00 às 19:00 CET.
- Sem autoscaling: o número de nós era fixo.
- Requests inflacionados: a maioria dos pods pedia entre duas e quatro vezes o que usava.
- Armazenamento esquecido: 23 volumes órfãos de PVC antigos, que custavam 420 dólares por mês.
Juntos, estes dados dizem que os nós estavam dimensionados para os requests, não para a carga. Isso definiu a ordem: reduzir os requests, deixar que menos nós os suportem e, depois, tornar cada nó mais barato.
Fase 1: right-sizing dos requests antes de tudo (semanas 1-2)
Corremos o Vertical Pod Autoscaler em modo de recomendação em todos os namespaces durante 14 dias. O VPA precisa de um objeto por carga; este ciclo cria um para cada Deployment de um namespace:
NS=orders
for d in $(kubectl -n "$NS" get deployments -o jsonpath='{.items[*].metadata.name}'); do
kubectl apply -f - <<EOF
apiVersion: autoscaling.k8s.io/v1
kind: VerticalPodAutoscaler
metadata:
name: $d
namespace: $NS
spec:
targetRef:
apiVersion: apps/v1
kind: Deployment
name: $d
updatePolicy:
updateMode: "Off"
EOF
done
kubectl -n "$NS" get vpaDefine updateMode: "Off" de forma explícita: o valor por omissão da API, Recreate, despeja pods para aplicar os novos valores.
Depois, os requests e os limits foram definidos a partir do P95 de utilização de cada contentor mais uma margem de 30%. O target por omissão do VPA é o percentil 90 mais 15%; as flags do recommender --target-cpu-percentile=0.95, --target-memory-percentile=0.95 e --recommendation-margin-fraction=0.3 fazem-no aplicar a regra do P95 mais 30%. As consultas de Prometheus do nosso guia de otimização de custos fazem o mesmo a partir das métricas em bruto.
Exemplo prático. Uma API em Java pedia 2 CPU e 4Gi por réplica, mas usava cerca de 400m e 800Mi. Tinha 6 réplicas. Tomando esses valores de utilização como o P95:
- Request de CPU: 400m × 1,3 = 520m, em vez de 2000m.
- Request de memória: 800Mi × 1,3 = 1040Mi, em vez de 4096Mi.
- Nas 6 réplicas: 3,12 cores em vez de 12, e cerca de 6,1Gi em vez de 24Gi.
São quase 9 cores libertados por um único serviço. Nos 47 serviços, a produção passou de 24 nós worker para 14. Poupança: 5200 dólares por mês.
Fase 2: decidir que cargas podem ir para spot (semana 3)
A capacidade spot é capacidade sobrante de um fornecedor, vendida com desconto e recuperada quando o fornecedor precisa dela. O aviso antes de uma recuperação vai de dois minutos a 30 segundos ou menos, conforme o fornecedor e a sua configuração. Desenha para o aviso mais curto que o teu dá.
Uma carga só é candidata a spot se cumprir todas estas condições:
- Sem estado: sem PersistentVolumeClaim e sem dados locais que tenham de sobreviver ao pod.
- Duas ou mais réplicas, distribuídas por zonas: uma recuperação leva uma parte da capacidade, não toda.
- Desliga-se dentro do aviso: deixa de aceitar trabalho, termina ou devolve o que tem em curso e sai antes de o nó desaparecer.
- Trabalho idempotente: um consumidor ou um job aguenta uma mensagem entregue de novo ou retoma a partir de um checkpoint.
- Arranque rápido: uma réplica nova fica pronta antes de as restantes ficarem sem margem.
Isto lista as réplicas, os volume claims e o grace period de cada carga:
kubectl get deployments,statefulsets -A -o json | jq -r '
.items[] | [
.kind,
.metadata.namespace + "/" + .metadata.name,
"replicas=\(.spec.replicas)",
"pvcs=\(([.spec.template.spec.volumes[]? | select(.persistentVolumeClaim)] | length) + ((.spec.volumeClaimTemplates // []) | length))",
"grace=\(.spec.template.spec.terminationGracePeriodSeconds // 30)s"
] | @tsv'Uma só réplica, um volume claim ou um grace period maior do que o aviso deixam a carga em on-demand. Aqui, a divisão foi:
- Em spot: as APIs sem estado (12 serviços, 34 pods), os frontends web e os workers em segundo plano e consumidores de filas.
- Em on-demand: as bases de dados e os serviços com estado, ou seja, PostgreSQL, Redis e o cluster de pesquisa.
Um nó de base de dados recuperado significa um failover e uma réplica reconstruída do zero. Além disso, os volumes de armazenamento em bloco são zonais, por isso o pod só pode reiniciar na zona do seu volume, que pode ser precisamente a que acabou de ficar sem capacidade spot.
Dois NodePools: spot primeiro para o que não tem estado, on-demand para o que tem
O Karpenter substituiu o cluster autoscaler e podia escolher entre 15 ou mais tipos de instância em 3 zonas de disponibilidade. Dois NodePools do Karpenter v1 que implementam esta divisão têm este aspeto:
apiVersion: karpenter.sh/v1
kind: NodePool
metadata:
name: stateless
spec:
template:
metadata:
labels:
workload-class: stateless
spec:
nodeClassRef:
group: PROVIDER_GROUP
kind: PROVIDER_NODECLASS
name: default
requirements:
- key: karpenter.sh/capacity-type
operator: In
values: ["spot", "on-demand"]
- key: kubernetes.io/arch
operator: In
values: ["amd64"]
- key: node.kubernetes.io/instance-type
operator: Exists
minValues: 15
expireAfter: 720h
disruption:
consolidationPolicy: WhenEmptyOrUnderutilized
consolidateAfter: 5m
budgets:
- nodes: "10%"
limits:
cpu: "400"
---
apiVersion: karpenter.sh/v1
kind: NodePool
metadata:
name: stateful
spec:
template:
metadata:
labels:
workload-class: stateful
spec:
nodeClassRef:
group: PROVIDER_GROUP
kind: PROVIDER_NODECLASS
name: default
taints:
- key: workload-class
value: stateful
effect: NoSchedule
requirements:
- key: karpenter.sh/capacity-type
operator: In
values: ["on-demand"]
- key: kubernetes.io/arch
operator: In
values: ["amd64"]
expireAfter: Never
disruption:
consolidationPolicy: WhenEmpty
consolidateAfter: 30m
budgets:
- nodes: "1"
limits:
cpu: "96"O que faz cada parte:
nodeClassRefaponta para uma NodeClass, que é específica da tua cloud. Usa o group e o kind que a versão do Karpenter do teu fornecedor instala.- Os dois tipos de capacidade no pool sem estado. O Karpenter lança spot e só recorre a on-demand quando não há nenhuma oferta spot disponível. Um pool só de spot deixaria os pods em Pending.
minValues: 15mantém pelo menos 15 tipos de instância em cada decisão de lançamento, para que o pool nunca dependa de um só tipo numa só zona. O campo é alpha e, com a políticaStrictpor omissão, o pool não lança nada se os outros requisitos deixarem menos de 15 tipos. Limita tamanhos ou famílias com as labels de instância do teu fornecedor eGteouLte, não com uma lista de nomes de tipos.- Consolidação e spot. Por omissão, o Karpenter só elimina nós spot subutilizados. Substituir um por um nó spot mais barato exige a feature gate
SpotToSpotConsolidation(alpha, desligada por omissão), e uma substituição de um único nó exige essa mesma flexibilidade de 15 tipos. - O pool com estado tem um taint para que nada mais lá aterre. Só consolida nós vazios e os seus nós nunca expiram por tempo. São substituídos quando algo muda, como uma nova imagem de nó, um de cada vez.
limitslimitam o que cada pool pode lançar. A fase 3 usa esse limite como interruptor.
Agendar os pods sem estado para que perder um nó não seja notícia
Os NodePools decidem o que pode ser lançado; a spec do pod decide onde cai cada réplica e como sai. Um Deployment sem estado para spot, com o seu PodDisruptionBudget:
apiVersion: apps/v1
kind: Deployment
metadata:
name: orders-api
namespace: orders
spec:
replicas: 6
selector:
matchLabels:
app: orders-api
template:
metadata:
labels:
app: orders-api
spec:
terminationGracePeriodSeconds: 45
affinity:
nodeAffinity:
preferredDuringSchedulingIgnoredDuringExecution:
- weight: 100
preference:
matchExpressions:
- key: karpenter.sh/capacity-type
operator: In
values: ["spot"]
topologySpreadConstraints:
- maxSkew: 1
topologyKey: topology.kubernetes.io/zone
whenUnsatisfiable: DoNotSchedule
labelSelector:
matchLabels:
app: orders-api
- maxSkew: 1
topologyKey: kubernetes.io/hostname
whenUnsatisfiable: ScheduleAnyway
labelSelector:
matchLabels:
app: orders-api
containers:
- name: api
image: registry.example.com/orders-api:1.4.2
ports:
- containerPort: 8080
readinessProbe:
httpGet:
path: /ready
port: 8080
periodSeconds: 5
failureThreshold: 2
lifecycle:
preStop:
sleep:
seconds: 10
resources:
requests:
cpu: 500m
memory: 1Gi
limits:
memory: 1Gi
---
apiVersion: policy/v1
kind: PodDisruptionBudget
metadata:
name: orders-api
namespace: orders
spec:
maxUnavailable: 1
selector:
matchLabels:
app: orders-apiComo as peças encaixam:
- Afinidade preferencial por spot. O scheduler favorece os nós spot e o Karpenter tenta primeiro essa preferência, mas o serviço continua a correr quando o spot se esgota.
- Distribuição por zonas com
DoNotSchedule. Seis réplicas ficam duas por zona, por isso uma recuperação numa zona leva no máximo um terço do serviço. A distribuição por hostname evita, sempre que possível, que as réplicas partilhem um nó. - Distribuição por tipos de capacidade. O Karpenter também aceita
karpenter.sh/capacity-typecomo chave de topologia. CommaxSkew: 1, cerca de metade das réplicas corre em on-demand, o que aguenta uma recuperação em massa mas devolve metade do desconto. Reserva-o para serviços em que perder todas as réplicas spot de uma vez quebraria o SLO. - O sleep do preStop. O endpoint de um pod que está a terminar é marcado como não pronto de imediato, mas os proxies e os load balancers demoram alguns segundos a deixar de lhe enviar tráfego. O sleep mantém o processo a servir durante essa janela; o SIGTERM chega depois. A ação
sleepcorre no kubelet, por isso a imagem não precisa de shell (ligada por omissão desde o Kubernetes 1.30 e estável desde o 1.34). - O grace period cobre o hook preStop e o encerramento que se segue. Exemplo prático: um sleep de 10 segundos mais até 20 segundos para terminar os pedidos em curso cabem em 45 segundos. Isso cabe num aviso de dois minutos mesmo que o drain comece tarde, mas não num de 30 segundos. A readiness probe funciona ao contrário: um pod novo só recebe tráfego quando responde.
- O PodDisruptionBudget não consegue impedir uma recuperação; o nó desaparece no prazo, aconteça o que acontecer. Rege as interrupções voluntárias, que são frequentes num pool spot: consolidação, expiração e drift.
Os pods com estado recebem o contrário, um seletor obrigatório e uma toleration, no template de pod de cada StatefulSet:
nodeSelector:
karpenter.sh/capacity-type: on-demand
workload-class: stateful
tolerations:
- key: workload-class
operator: Equal
value: stateful
effect: NoSchedulePara a combinação de instâncias que esta empresa usava, os preços spot ficaram em média 68% abaixo de on-demand. Poupança: 7400 dólares por mês.
A planear mover cargas para spot? Pede uma avaliação gratuita de custos de Kubernetes: classificamos as tuas cargas segundo estes critérios, revemos os teus NodePools e as specs dos teus pods e entregamos-te uma lista do que pode passar para spot e do que precisa antes disso.
Gestão de interrupções e o que vigiar depois
A gestão de interrupções do Karpenter fica desligada até os avisos do teu fornecedor lhe chegarem. No fornecedor onde o Karpenter nasceu, isso significa uma fila de mensagens alimentada pelas regras de eventos da cloud, cujo nome se indica na configuração do Karpenter; vê na versão do Karpenter do teu fornecedor como os recebe. Perante um aviso, o Karpenter aplica um taint ao nó, faz o drain através da Eviction API e lança um substituto em paralelo. Sem isto, o Karpenter só sabe da recuperação quando a instância já desapareceu: os pods morrem sem drain e os substitutos arrancam tarde.
Depois da mudança, vigia a rotação de nós por tipo de capacidade. As métricas do Karpenter têm uma label capacity_type:
sum by (nodepool, capacity_type, reason) (increase(karpenter_nodeclaims_disrupted_total[1d]))
sum by (nodepool, capacity_type) (increase(karpenter_pods_disruption_initiated_total[1d]))
sum by (nodepool, capacity_type, zone) (increase(karpenter_nodeclaims_terminated_total[1d]))A primeira mostra porque saíram os nós (interrupção, consolidação, expiração, drift; os nomes dos motivos de interrupção variam conforme o fornecedor). A segunda conta os pods movidos, e a terceira que zonas perdem mais. Um pool spot que perde muitos nós por dia numa zona precisa de requisitos de instância mais amplos.
Na linha de comandos, a idade dos nós e os despejos recentes:
kubectl get nodes -L karpenter.sh/capacity-type,topology.kubernetes.io/zone --sort-by=.metadata.creationTimestamp
kubectl get events -A --field-selector reason=EvictedOs eventos são guardados durante uma hora por omissão, por isso mantém o histórico nas métricas, ao lado da latência e das taxas de erro de cada serviço na tua instalação de Prometheus. O objetivo: a rotação mexe enquanto os gráficos do SLO ficam planos.
Fase 3: pôr a não produção com horário (semana 3)
O desenvolvimento descia para zero nós fora do horário das 08:00 às 20:00 CET, de segunda a sexta. O staging mantinha um mínimo de 2 nós fora do horário laboral, com capacidade total durante as execuções de CI/CD. Fez-se com o escalamento de nós do Karpenter e CronJobs que fazem cordon e drain.
Fazer só o drain não chega: o Karpenter lança nós novos para os pods despejados. Por isso, um job que implementa isto põe primeiro a zero o limite de CPU do pool, o que bloqueia lançamentos mas nunca remove nós. Depois faz o drain dos nós e apaga as suas NodeClaims, e o Karpenter termina as instâncias. O número de réplicas fica intacto; os pods esperam em Pending até o limite ser levantado.
apiVersion: v1
kind: ServiceAccount
metadata:
name: night-shift
namespace: ops
---
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRole
metadata:
name: night-shift
rules:
- apiGroups: ["karpenter.sh"]
resources: ["nodepools"]
verbs: ["get", "patch"]
- apiGroups: ["karpenter.sh"]
resources: ["nodeclaims"]
verbs: ["list", "delete"]
- apiGroups: [""]
resources: ["nodes"]
verbs: ["get", "list", "patch"]
- apiGroups: [""]
resources: ["pods"]
verbs: ["get", "list"]
- apiGroups: [""]
resources: ["pods/eviction"]
verbs: ["create"]
- apiGroups: ["apps"]
resources: ["daemonsets"]
verbs: ["get"]
---
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRoleBinding
metadata:
name: night-shift
subjects:
- kind: ServiceAccount
name: night-shift
namespace: ops
roleRef:
apiGroup: rbac.authorization.k8s.io
kind: ClusterRole
name: night-shift
---
apiVersion: batch/v1
kind: CronJob
metadata:
name: dev-nodes-off
namespace: ops
spec:
schedule: "0 20 * * 1-5"
timeZone: Europe/Paris
concurrencyPolicy: Forbid
jobTemplate:
spec:
backoffLimit: 1
activeDeadlineSeconds: 1800
template:
spec:
serviceAccountName: night-shift
restartPolicy: Never
securityContext:
runAsNonRoot: true
runAsUser: 65534
affinity:
nodeAffinity:
requiredDuringSchedulingIgnoredDuringExecution:
nodeSelectorTerms:
- matchExpressions:
- key: karpenter.sh/nodepool
operator: DoesNotExist
initContainers:
- name: freeze
image: registry.k8s.io/kubectl:v1.37.2
args: ["patch", "nodepool", "dev", "--type=merge", "-p", '{"spec":{"limits":{"cpu":"0"}}}']
- name: drain
image: registry.k8s.io/kubectl:v1.37.2
args: ["drain", "--selector=karpenter.sh/nodepool=dev", "--ignore-daemonsets", "--delete-emptydir-data", "--timeout=15m"]
containers:
- name: remove
image: registry.k8s.io/kubectl:v1.37.2
args: ["delete", "nodeclaims", "--selector=karpenter.sh/nodepool=dev", "--wait=false"]Pormenores que importam:
- Os init containers correm por ordem e param na primeira falha, por isso nada é drenado se o congelamento não funcionou. (A imagem do kubectl não tem shell.)
- O job corre fora do pool que esvazia: a afinidade
DoesNotExistmantém-no em nós que o Karpenter não gere. - O
drainrecusa pods sem controlador a menos que acrescentes--force, que os apaga de vez. Um drain falhado deixa os nós em cordon: tira-lhes o cordon ou apaga as NodeClaims à mão. - De manhã: um segundo CronJob às
0 8 * * 1-5corre só o patch, com o teu limite diurno, por exemplo{"spec":{"limits":{"cpu":"64"}}}. O Karpenter lança então nós para os pods em espera. - O staging usa o mesmo job com o CPU de dois nós em vez de zero: 16 para dois nós de 8 vCPU. O Karpenter traz de volta no máximo dois nós. Um pipeline de CI que precise do ambiente completo sobe o limite durante a execução e repõe-no depois; a consolidação remove os nós a mais quando ficam vazios.
O cômputo de não produção caiu 65%. Poupança: 3800 dólares por mês. O nosso guia de otimização de custos tem um CronJob que escala cargas em vez de nós; os dois combinam bem.
Fase 4: o armazenamento que ninguém usava (semana 4)
Quatro alterações:
- Apagámos os 23 volumes órfãos: 420 dólares por mês.
- Passámos o armazenamento de logs de volumes de armazenamento em bloco para armazenamento de objetos com regras de ciclo de vida: 280 dólares por mês.
- Passámos as bases de dados de desenvolvimento de volumes com IOPS provisionadas para volumes SSD de uso geral: 190 dólares por mês.
- Garantimos que os PVC são limpos quando um namespace é apagado.
Os volumes órfãos são de dois tipos. O primeiro é visível para o Kubernetes: um PersistentVolume na fase Released, cujo claim já não existe mas cujo disco, com uma reclaim policy Retain, continua a ser faturado. Isto lista-os com o ID do disco:
kubectl get pv -o json | jq -r '.items[] | select(.status.phase == "Released") |
[.metadata.name, .spec.capacity.storage, .spec.persistentVolumeReclaimPolicy,
"\(.spec.claimRef.namespace)/\(.spec.claimRef.name)", (.spec.csi.volumeHandle // "-")] | @tsv'Apagar um PersistentVolume em estado Released não apaga o seu disco. Faz um snapshot se alguém puder precisar dos dados e, depois, remove também o disco através do teu fornecedor.
O segundo tipo é invisível para o Kubernetes: discos cujo objeto PersistentVolume foi apagado enquanto o disco continuou a existir. Compara os volume handles que o cluster conhece com a lista de discos não anexados do teu fornecedor, um ID por linha:
kubectl get pv -o jsonpath='{range .items[*]}{.spec.csi.volumeHandle}{"\n"}{end}' | sort -u > in-cluster.txt
sort -u provider-unattached.txt > provider-sorted.txt
comm -13 in-cluster.txt provider-sorted.txtCada ID que aparece é um disco que nenhum PersistentVolume reclama. O formato dos handles varia conforme o driver CSI, por isso confirma que coincide com a exportação do teu fornecedor.
A limpeza ao apagar um namespace precisa de duas definições. Apagar um namespace apaga os seus PVC, mas os discos só são removidos com a reclaim policy Delete, a que vem por omissão na StorageClass, por isso questiona qualquer classe Retain na não produção. Além disso, os StatefulSets mantêm os seus claims por omissão; persistentVolumeClaimRetentionPolicy com whenDeleted: Delete, estável desde o Kubernetes 1.32, remove-os juntamente com o StatefulSet. Poupança: 890 dólares por mês.
Fase 5: o compromisso fica para o fim (semana 5)
Um compromisso de utilização dá desconto a tudo o que corres, desperdício incluído. Comprado antes do right-sizing, fixa requests inflacionados; comprado antes do spot, compromete-te com capacidade on-demand que estás prestes a abandonar. Por isso veio depois de quatro semanas de utilização otimizada, quando a base permanente já era visível: as cargas com estado de produção e a capacidade mínima do staging. Um compromisso de cômputo a um ano cobriu 70% da base on-demand restante. Poupança: 1100 dólares por mês.
Para encontrares essa base nos teus clusters, toma o mínimo de vCPU on-demand ao longo de quatro semanas. Um compromisso paga instâncias inteiras, por isso mede a capacidade dos nós, não a allocatable:
min_over_time(
sum(
kube_node_status_capacity{resource="cpu", unit="core"}
* on (node) group_left ()
kube_node_labels{label_karpenter_sh_capacity_type="on-demand"}
)[28d:1h]
)O kube_node_labels só tem o tipo de capacidade se o kube-state-metrics correr com --metric-labels-allowlist=nodes=[karpenter.sh/capacity-type]. Converte o resultado em gasto por hora com os teus preços on-demand antes de comparar ofertas. Cobrir menos do que o mínimo todo deixa margem para ele descer à medida que mais serviços passam pelo right-sizing ou vão para spot.
O resultado e o que não fizemos
As cinco fases, por poupança mensal:
- Right-sizing: 5200 dólares.
- Capacidade spot para cargas sem estado: 7400 dólares.
- Horários para a não produção: 3800 dólares.
- Limpeza do armazenamento: 890 dólares.
- Compromisso de cômputo a um ano: 1100 dólares.
O total é de 18 390 dólares por mês. A fatura passou de 38 000 para 19 610 dólares, uma redução de 48%. A aplicação serviu o mesmo tráfego com a mesma latência.
O que não fizemos:
- Alterar código da aplicação.
- Reduzir o número de réplicas de qualquer serviço de produção.
- Comprometer a disponibilidade ou a recuperação de desastres.
- Introduzir um novo ponto único de falha.
- Causar qualquer indisponibilidade durante o trabalho.
Para veres para onde vai a tua despesa antes de começares, o nosso guia de imputação de custos de Kubernetes por equipa transforma as mesmas métricas num custo mensal por equipa, para que cada equipa veja quanto custam os seus requests.
Avaliação gratuita de custos de Kubernetes
Se a tua fatura cresce mais depressa do que o teu tráfego, a nossa equipa de consultoria Kubernetes faz esta análise nos teus clusters sem custos. A avaliação cobre:
- Requests face à utilização: por contentor, o request atual, o P95 de 14 dias e o request que definiríamos.
- Preparação para spot: que cargas cumprem os critérios acima, o que bloqueia as restantes e um desenho de NodePools e de agendamento para o teu fornecedor.
- Capacidade ociosa e restos: horas de não produção, volumes Released e não anexados, e o mínimo on-demand que um compromisso poderia cobrir.
Recebes uma lista escrita de alterações ordenadas por poupança e por risco, sem qualquer compromisso.
Pede a tua avaliação gratuita de custos de Kubernetes ou vê como a consultoria SRE e o platform engineering evitam que a poupança volte a escapar.