Para reduzir a despesa em Kubernetes sem pôr os teus SLO em risco, mede primeiro duas lacunas: a CPU e a memória pedidas que os pods não usam, e a capacidade dos nós que ninguém pediu. Depois, fecha-as por ordem de risco: não produção e restos, requests, salvaguardas, empacotamento de nós, escalamento automático e, por último, capacidade spot, com uma revisão semanal para manter as poupanças. Os autoscalers de nós e os alvos do HPA trabalham a partir dos requests, por isso vêm depois do right-sizing. Cada prática traz a sua consulta ou o seu manifesto.
1. Mede as duas lacunas antes de mudar alguma coisa
Risco: nenhum. Pagas por nós, o scheduler enche-os de acordo com os requests e os contentores usam o que usam. Isso deixa duas lacunas:
- A lacuna de requests: CPU e memória reservadas mas não usadas. O right-sizing fecha-a (prática 4).
- A lacuna de empacotamento: capacidade dos nós que ninguém reservou. A consolidação fecha-a (prática 6).
Regista as duas por namespace como recording rules do Prometheus:
groups:
- name: k8s-cost-efficiency
interval: 1m
rules:
- record: namespace:cpu_requested_cores:sum
expr: |
sum by (namespace) (
kube_pod_container_resource_requests{resource="cpu", unit="core"}
* on (namespace, pod) group_left ()
max by (namespace, pod) (kube_pod_status_phase{phase="Running"})
)
- record: namespace:cpu_used_cores:sum_rate5m
expr: |
sum by (namespace) (
rate(container_cpu_usage_seconds_total{container!="", container!="POD"}[5m])
)
- record: namespace:memory_requested_bytes:sum
expr: |
sum by (namespace) (
kube_pod_container_resource_requests{resource="memory", unit="byte"}
* on (namespace, pod) group_left ()
max by (namespace, pod) (kube_pod_status_phase{phase="Running"})
)
- record: namespace:memory_working_set_bytes:sum
expr: |
sum by (namespace) (
container_memory_working_set_bytes{container!="", container!="POD"}
)
# Request gap
- record: namespace:cpu_request_idle:ratio
expr: |
clamp_min(1 - namespace:cpu_used_cores:sum_rate5m / namespace:cpu_requested_cores:sum, 0)
- record: namespace:memory_request_idle:ratio
expr: |
clamp_min(1 - namespace:memory_working_set_bytes:sum / namespace:memory_requested_bytes:sum, 0)
# Packing gap
- record: cluster:cpu_unrequested:ratio
expr: |
1 - sum(namespace:cpu_requested_cores:sum)
/ sum(kube_node_status_allocatable{resource="cpu", unit="core"})Multiplicar por kube_pod_status_phase exclui os pods terminados que continuam a reportar requests, e os filtros de container impedem que os totais do pod e os contentores pause sejam contados duas vezes.
Como ler os resultados:
- Rácio ocioso de CPU: parte dele é margem para os picos. A nossa regra prática: uma média semanal acima de 0,6 põe um namespace na linha da frente para o right-sizing.
- Rácio ocioso de memória: conta com um valor mais baixo. A 0,5, um namespace reserva o dobro do seu working set.
- Lacuna de empacotamento alta e lacuna de requests baixa: um problema de nós. O inverso significa nós cheios de reservas por usar, que nenhum autoscaler resolve.
Mantém estas regras ao lado do resto da tua configuração de Prometheus e verifica a retenção: o Prometheus guarda 15 dias por omissão, e a prática 4 olha 14 dias para trás.
2. Desliga a não produção fora do horário de expediente
Risco: baixo; aqui não corre nada que um utilizador veja. Exemplo prático: um namespace de staging que corre das 07:00 às 20:00 nos dias úteis está ativo 65 das 168 horas da semana, por isso escalá-lo para zero no resto do tempo retira os seus pods durante 61% da semana. A poupança só é real quando o autoscaler de nós remove os nós que ficam vazios (prática 6).
Um CronJob corre kubectl scale com uma ServiceAccount que não pode fazer mais nada:
apiVersion: v1
kind: ServiceAccount
metadata:
name: office-hours
namespace: staging
---
apiVersion: rbac.authorization.k8s.io/v1
kind: Role
metadata:
name: office-hours
namespace: staging
rules:
- apiGroups: ["apps"]
resources: ["deployments", "statefulsets"]
verbs: ["get", "list"]
- apiGroups: ["apps"]
resources: ["deployments/scale", "statefulsets/scale"]
verbs: ["get", "patch", "update"]
---
apiVersion: rbac.authorization.k8s.io/v1
kind: RoleBinding
metadata:
name: office-hours
namespace: staging
subjects:
- kind: ServiceAccount
name: office-hours
namespace: staging
roleRef:
apiGroup: rbac.authorization.k8s.io
kind: Role
name: office-hours
---
apiVersion: batch/v1
kind: CronJob
metadata:
name: scale-down-evening
namespace: staging
spec:
schedule: "0 20 * * 1-5"
timeZone: Europe/Lisbon
jobTemplate:
spec:
template:
spec:
serviceAccountName: office-hours
restartPolicy: Never
securityContext:
runAsNonRoot: true
runAsUser: 65534
containers:
- name: kubectl
image: registry.k8s.io/kubectl:v1.37.2
args: ["scale", "deployment,statefulset", "--all", "--replicas=0", "--namespace=staging"]
resources:
requests:
cpu: 50m
memory: 64Mi
limits:
memory: 64MiUma cópia com schedule: "0 7 * * 1-5" e --replicas=1 acorda o namespace nos dias úteis, por isso fica a zero todo o fim de semana. A imagem não tem shell, daí args. Sem timeZone, o schedule segue o fuso horário do kube-controller-manager.
Um Deployment com HPA acorda com 1 réplica e o HPA assume a partir daí. Para números diferentes por Deployment, usa antes o scaler cron do KEDA. Em qualquer dos casos, um controller GitOps que aplique um campo replicas fixo desfaz o escalamento, por isso retira o campo onde outra coisa gere as réplicas.
3. Encontra armazenamento e outros restos
Risco: encontrá-los é grátis; apagá-los é permanente. Os volumes são faturados quer um pod os use, quer não:
# Released volumes: the claim is gone, the disk is still billed (GiB)
kube_persistentvolume_capacity_bytes / 2^30
* on (persistentvolume) group_left ()
(kube_persistentvolume_status_phase{phase="Released"} == 1)
# Claims that no pod mounts
kube_persistentvolumeclaim_info
unless on (namespace, persistentvolumeclaim)
kube_pod_spec_volumes_persistentvolumeclaims_info
# Mounted volumes that are less than 20% full
kubelet_volume_stats_used_bytes / kubelet_volume_stats_capacity_bytes < 0.2- Os volumes Released vêm da política de reclaim
Retain. Apagar o PersistentVolume não apaga o disco por trás dele: remove-o também através do teu fornecedor, depois de um snapshot final se os dados puderem ser importantes. Os volumes aprovisionados dinamicamente herdam a política da sua StorageClass, que por omissão éDelete, por isso uma classe comRetainfoi uma escolha de alguém. Pergunta-lhe. - Os claims não montados ficam muitas vezes de StatefulSets apagados, que por omissão mantêm os seus claims. O claim de um CronJob só está montado enquanto o job corre, por isso confirma antes de apagar.
- Os volumes sobredimensionados não encolhem: o Kubernetes não permite reduzir um claim abaixo do seu tamanho atual. Corrige o template do claim e move os dados para um claim mais pequeno da próxima vez que mexeres no volume.
Conta também kube_service_spec_type{type="LoadBalancer"} por namespace: cada um é um balanceador de carga faturado.
4. Faz o right-sizing dos requests com 14 dias de utilização
Risco: baixo, se mudares um serviço de cada vez. Costuma ser a maior poupança. Começa pelos namespaces com o rácio ocioso mais alto e compara o request de cada contentor com o que a sua réplica mais carregada usou ao longo de duas semanas:
# CPU: 95th percentile of the busiest replica, per container, over 14 days
quantile_over_time(0.95,
max by (namespace, container) (
rate(container_cpu_usage_seconds_total{namespace="checkout", container!="", container!="POD"}[5m])
)[14d:5m]
)
# Memory: peak working set of the busiest replica over 14 days
max_over_time(
max by (namespace, container) (
max_over_time(container_memory_working_set_bytes{namespace="checkout", container!="", container!="POD"}[5m])
)[14d:5m]
)Estas subqueries de duas semanas são pesadas, por isso corre-as a pedido, um namespace de cada vez. Agrupam por nome de contentor; se dois Deployments num namespace partilharem um, filtra com pod=~"checkout-api-.*".
Para uma segunda opinião, corre o Vertical Pod Autoscaler em modo só de recomendação. Define updateMode explicitamente, porque o valor por omissão nas versões atuais é Recreate, que desaloja pods:
apiVersion: autoscaling.k8s.io/v1
kind: VerticalPodAutoscaler
metadata:
name: checkout-api
namespace: checkout
spec:
targetRef:
apiVersion: apps/v1
kind: Deployment
name: checkout-api
updatePolicy:
updateMode: "Off"
resourcePolicy:
containerPolicies:
- containerName: "*"
controlledResources: ["cpu", "memory"]kubectl -n checkout get vpa checkout-api -o jsonpath='{.status.recommendation.containerRecommendations}'target é o que o VPA definiria: com os valores por omissão do recommender, o percentil 90 da utilização mais uma margem de 15%. Abaixo de lowerBound, o desempenho tende a sofrer, e acima de upperBound é provavelmente desperdício. No modo Off o VPA não muda nada, por isso pode ficar ao lado de um HPA; nos outros modos não deve atuar sobre a mesma métrica que um HPA.
Depois, os limites:
- Memória: limite igual ao request. Os pods que usam mais memória do que pediram são os primeiros a ser desalojados quando o nó está sob pressão de memória. Com o limite igual ao request, um contentor que ultrapassa a sua memória leva um OOM kill sozinho, em vez de pôr o nó inteiro sob pressão.
- CPU: um limite custa latência. Por omissão, o kernel aplica os limites de CPU por períodos de 100 ms, por isso um serviço multithread pode gastar a sua quota no início do período e depois ficar à espera, mesmo num nó ocioso. O nosso padrão é não pôr limites de CPU em serviços sensíveis à latência. Onde os mantiveres, vigia o throttling:
sum by (namespace, pod, container) (rate(container_cpu_cfs_throttled_periods_total{container!=""}[5m]))
/
sum by (namespace, pod, container) (rate(container_cpu_cfs_periods_total{container!=""}[5m]))Exemplo prático: o checkout-api corre 6 réplicas que pedem 1 CPU e 2Gi cada uma. Em 14 dias, a sua réplica mais carregada mostra um P95 de 0,21 núcleos e um pico de working set de 610Mi. Com a nossa margem de 25%:
- Request de CPU: 0,21 × 1,25 = 0,26 núcleos, arredondado para cima para 300m.
- Request e limite de memória: 610Mi × 1,25 = 763Mi, arredondado para cima para 768Mi.
- As seis réplicas passam a pedir 1,8 núcleos em vez de 6, e 4,5Gi em vez de 12Gi.
Faz o deploy como qualquer release e acompanha-o durante uma semana: os SLI de latência e de erros, os reinícios e kube_pod_container_status_last_terminated_reason{reason="OOMKilled"}. Um serviço que já esteja a gastar o seu orçamento de erros fica para depois.
Não sabes em que ponto está o teu cluster? Pede uma avaliação gratuita de custos de Kubernetes: corremos estas consultas no teu Prometheus e entregamos-te uma lista, por namespace, dos requests a cortar, com a margem por trás de cada número.
5. Põe salvaguardas para que o desperdício não volte
Risco: baixo; só se aplicam a pods novos. Um contentor sem requests é agendado como se não custasse nada, e um HPA não consegue calcular a utilização de CPU sem um request de CPU. Um LimitRange preenche valores por omissão e uma ResourceQuota põe um teto ao namespace:
apiVersion: v1
kind: LimitRange
metadata:
name: container-defaults
namespace: checkout
spec:
limits:
- type: Container
defaultRequest:
cpu: 100m
memory: 256Mi
default:
memory: 256Mi
---
apiVersion: v1
kind: ResourceQuota
metadata:
name: compute-and-storage
namespace: checkout
spec:
hard:
requests.cpu: "8"
requests.memory: 16Gi
limits.memory: 16Gi
requests.storage: 500Gi
persistentvolumeclaims: "20"
services.loadbalancers: "1"- Os valores por omissão são um mínimo para contentores esquecidos, não um método de dimensionamento. Mantém-nos pequenos.
- Um LimitRange não compara os seus valores por omissão com o que um contentor define: um request de memória de 512Mi sem limite recebe o limite por omissão de 256Mi, e o pod é recusado. Define os dois.
- Assim que uma quota cobre CPU ou memória, os pods sem requests e sem valor por omissão são recusados, por isso o LimitRange entra primeiro.
- Deixa
limits.cpufora da quota, a não ser que todos os pods devam ter um limite de CPU, porque uma quota sobre um limite torna-o obrigatório. - Dimensiona as quotas a partir dos requests já ajustados, mais o crescimento esperado.
6. Empacota os nós sem deitar serviços abaixo
Risco: médio; a consolidação desaloja pods de produção. Os autoscalers de nós decidem por requests, não por utilização. O Cluster Autoscaler considera remover um nó quando os seus pods pedem menos de --scale-down-utilization-threshold da CPU e memória alocáveis (0,5 por omissão) durante --scale-down-unneeded-time (10 minutos por omissão). O Karpenter simula o agendamento com os mesmos requests.
Com o Karpenter, a consolidação é configurada por NodePool:
apiVersion: karpenter.sh/v1
kind: NodePool
metadata:
name: general
spec:
template:
spec:
nodeClassRef: # your provider's NodeClass
group: PROVIDER_API_GROUP
kind: PROVIDER_NODECLASS
name: default
requirements:
- key: kubernetes.io/os
operator: In
values: ["linux"]
expireAfter: 720h
disruption:
consolidationPolicy: WhenEmptyOrUnderutilized
consolidateAfter: 5m
budgets:
- nodes: "10%"
- nodes: "0"
reasons: ["Underutilized"]
schedule: "0 8 * * 1-5"
duration: 10h
limits:
cpu: "200"
memory: 800GiWhenEmptyOrUnderutilized remove ou substitui qualquer nó cujos pods caibam noutros nós ou num único nó mais barato. consolidateAfter espera até que nenhum pod tenha sido acrescentado a um nó ou removido dele durante esse tempo, o que evita a rotação constante. O segundo budget bloqueia a consolidação de nós não vazios das 08:00 às 18:00 UTC nos dias úteis, e limits põe um teto ao que o pool pode lançar.
A consolidação desaloja através da Eviction API, por isso são os PodDisruptionBudgets que decidem se ela pode deitar um serviço abaixo. Dá um a cada Deployment com duas ou mais réplicas:
apiVersion: policy/v1
kind: PodDisruptionBudget
metadata:
name: checkout-api
namespace: checkout
spec:
maxUnavailable: 1
selector:
matchLabels:
app: checkout-apiO que bloqueia o empacotamento em vez de te proteger:
- Um PDB com
maxUnavailable: 0, ou comminAvailableigual ao número de réplicas, nunca permite um desalojamento. O Karpenter reportaUnconsolidatableno nó. karpenter.sh/do-not-disrupt: "true"deixa o nó de um pod fora da consolidação.- Com os valores por omissão do Cluster Autoscaler, um pod com
emptyDirprende o seu nó, a não ser que tenha a anotaçãocluster-autoscaler.kubernetes.io/safe-to-evict: "true".
O tamanho dos nós também conta. Exemplo prático: DaemonSets que pedem 400m de CPU por nó ocupam 20% de um nó de 2 núcleos e 2,5% de um de 16, mas os nós maiores deixam buracos maiores. A nossa regra prática: o maior pod ocupa no máximo um quarto de um nó, e o autoscaler escolhe entre vários tamanhos.
7. Escala as réplicas automaticamente sem oscilações
Risco: médio; os erros aparecem como latência. Um HPA mede a utilização de CPU em relação ao request. Exemplo prático: um pod pede 1 CPU e tem picos de 250m. Com um alvo de 70%, o HPA vê 25% e nunca escala, por isso o minReplicas aguenta o tráfego todo e o desperdício todo. Depois do right-sizing para 300m, os mesmos 250m passam a ler-se como 83%, e o HPA acompanha a procura real.
apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
metadata:
name: checkout-api
namespace: checkout
spec:
scaleTargetRef:
apiVersion: apps/v1
kind: Deployment
name: checkout-api
minReplicas: 3
maxReplicas: 20
metrics:
- type: Resource
resource:
name: cpu
target:
type: Utilization
averageUtilization: 70
behavior:
scaleDown:
stabilizationWindowSeconds: 600
policies:
- type: Percent
value: 20
periodSeconds: 60O scale-up é imediato por omissão. As oscilações vêm do scale-down: o HPA fica com a recomendação mais alta dentro da sua janela de estabilização, 300 segundos por omissão. Aqui, 600 segundos absorvem uma descida curta, e a política remove no máximo 20% das réplicas por minuto. Mantém minReplicas em 2 ou mais, porque com uma só réplica cada desalojamento é uma indisponibilidade. Escala os consumidores de filas pela profundidade da fila, não pela CPU.
8. Capacidade spot: no fim, e só para cargas que a toleram
Risco: o mais alto das alterações de computação. A capacidade spot é capacidade sobrante vendida com desconto, que o fornecedor pode recuperar com muito pouco aviso. Vem depois do right-sizing e do empacotamento porque torna mais barato tudo o que corres, desperdício incluído. O nosso guia para correr cargas de Kubernetes em capacidade spot com segurança explica que cargas pertencem lá, como lidar com as interrupções e como separar os NodePools por tipo de capacidade.
9. Trata o custo como um SLI ao lado do orçamento de erros
Risco: nenhum; é isto que mantém as poupanças. Os requests voltam a subir com cada serviço novo e cada manifesto copiado. Cria um alerta para isso, mas que nunca chame o piquete:
groups:
- name: k8s-cost-review
interval: 30m
rules:
- alert: NamespaceCPURequestsMostlyIdle
expr: |
avg_over_time(namespace:cpu_request_idle:ratio[7d]) > 0.6
and on (namespace)
namespace:cpu_requested_cores:sum > 2
for: 2h
labels:
severity: ticket
annotations:
summary: "{{ $labels.namespace }} left {{ $value | humanizePercentage }} of its requested CPU unused over 7 days"A segunda condição ignora os namespaces demasiado pequenos para contar. Encaminha severity: ticket para uma fila de trabalho; o nosso guia sobre alertas de Kubernetes que as pessoas não aprendem a ignorar explica o encaminhamento.
Revê os rácios ociosos na mesma reunião semanal dos orçamentos de erros, com uma regra: um serviço que esgotou o seu orçamento de erros não leva cortes de custos até recuperar, e um com orçamento disponível e um rácio ocioso acima de 0,6 é o próximo a passar pelo right-sizing.
Os descontos por compromisso de utilização vêm por último, dimensionados pelo mínimo e não pela média:
# Lowest allocatable CPU the cluster ran with over the last 30 days
min_over_time(sum(kube_node_status_allocatable{resource="cpu", unit="core"})[30d:1h])Esse é o total do cluster. Para saber quem o gasta, o nosso guia de imputação de custos de Kubernetes por equipa transforma as mesmas métricas num custo mensal por equipa.
Avaliação gratuita de custos de Kubernetes
Se quiseres estes números para o teu cluster antes de mudar alguma coisa, a nossa equipa de consultoria de Kubernetes mede-os sem custos. A avaliação cobre:
- As duas lacunas: a lacuna de requests e a de empacotamento por namespace, a partir dos teus próprios dados do Prometheus.
- Candidatos a right-sizing: por contentor, o request atual, o P95 e o pico de 14 dias, e o request que definiríamos.
- O que bloqueia as poupanças: PDBs e definições de pods que impedem a consolidação, e namespaces e volumes que correm quando ninguém os usa.
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 nossa consultoria SRE liga o custo ao orçamento de erros.