Voltar ao blog
KubernetesCost OptimizationSREFinOpsPrometheus

Otimização de custos em Kubernetes: 9 práticas SRE por ordem de risco

Reduz a despesa em Kubernetes sem quebrar os teus SLO: PromQL para requests ociosos, right-sizing, quotas, consolidação, HPA e escalamento fora de horas.

P
Davi Nunes
March 20, 2026Atualizado November 12, 202611 min de leitura

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:

yaml
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:

yaml
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: 64Mi

Uma 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:

promql
# 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 com Retain foi 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:

promql
# 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:

yaml
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"]
bash
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:
promql
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:

yaml
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.cpu fora 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:

yaml
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: 800Gi

WhenEmptyOrUnderutilized 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:

yaml
apiVersion: policy/v1
kind: PodDisruptionBudget
metadata:
  name: checkout-api
  namespace: checkout
spec:
  maxUnavailable: 1
  selector:
    matchLabels:
      app: checkout-api

O que bloqueia o empacotamento em vez de te proteger:

  • Um PDB com maxUnavailable: 0, ou com minAvailable igual ao número de réplicas, nunca permite um desalojamento. O Karpenter reporta Unconsolidatable no 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 emptyDir prende o seu nó, a não ser que tenha a anotação cluster-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.

yaml
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: 60

O 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:

yaml
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:

promql
# 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.