Volver al blog
KubernetesCost OptimizationSREFinOpsPrometheus

Optimización de costes en Kubernetes: 9 prácticas SRE por orden de riesgo

Reduce el gasto en Kubernetes sin romper tus SLO: PromQL para requests ociosos, right-sizing, quotas, consolidación, HPA y escalado fuera de horario.

P
Davi Nunes
March 20, 2026Actualizado November 12, 202611 min de lectura

Para reducir el gasto en Kubernetes sin poner en riesgo tus SLO, mide primero dos huecos: la CPU y la memoria solicitadas que los pods no usan, y la capacidad de los nodos que nadie solicitó. Después ciérralos por orden de riesgo: entornos de no producción y restos, requests, barreras de protección, empaquetado de nodos, autoescalado y, al final, capacidad spot, con una revisión semanal para conservar el ahorro. Los autoescaladores de nodos y los objetivos del HPA trabajan a partir de los requests, así que van después del right-sizing. Cada práctica incluye su consulta o su manifiesto.

1. Mide los dos huecos antes de cambiar nada

Riesgo: ninguno. Pagas por nodos, el scheduler los llena según los requests y los contenedores usan lo que usan. Eso deja dos huecos:

  • El hueco de requests: CPU y memoria reservadas pero no usadas. El right-sizing lo cierra (práctica 4).
  • El hueco de empaquetado: capacidad de los nodos que nadie reservó. La consolidación lo cierra (práctica 6).

Registra ambos por namespace como recording rules de 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 descarta los pods terminados que siguen reportando requests, y los filtros de container evitan que los totales de pod y los contenedores pause se cuenten dos veces.

Cómo leer los resultados:

  • Ratio ocioso de CPU: una parte es margen para los picos. Nuestra regla práctica: una media semanal por encima de 0,6 pone a un namespace el siguiente en la cola del right-sizing.
  • Ratio ocioso de memoria: espera que sea más bajo. Con 0,5, un namespace reserva el doble de su working set.
  • Hueco de empaquetado alto y hueco de requests bajo: un problema de nodos. Lo contrario significa nodos llenos de reservas sin usar, y eso no lo arregla ningún autoescalador.

Mantén estas reglas junto al resto de tu configuración de Prometheus y revisa la retención: Prometheus guarda 15 días por defecto, y la práctica 4 mira 14 días atrás.

2. Apaga la no producción fuera del horario de oficina

Riesgo: bajo; aquí no corre nada que vea un usuario. Ejemplo práctico: un namespace de staging que funciona de 07:00 a 20:00 los días laborables está activo 65 de las 168 horas de la semana, así que escalarlo a cero el resto del tiempo elimina sus pods durante el 61% de la semana. El ahorro solo es real cuando el autoescalador de nodos retira los nodos que quedan vacíos (práctica 6).

Un CronJob ejecuta kubectl scale con una ServiceAccount que no puede hacer nada más:

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

Una copia con schedule: "0 7 * * 1-5" y --replicas=1 despierta el namespace los días laborables, así que se queda a cero todo el fin de semana. La imagen no tiene shell, de ahí args. Sin timeZone, el schedule sigue la zona horaria del kube-controller-manager.

Un Deployment con HPA despierta con 1 réplica y su HPA toma el control. Si necesitas cantidades distintas por Deployment, usa en su lugar el escalador cron de KEDA. En ambos casos, un controlador GitOps que aplique un campo replicas fijo deshará el escalado, así que quita ese campo donde otra cosa gestione las réplicas.

3. Encuentra el almacenamiento y otros restos

Riesgo: encontrarlos no cuesta nada; borrarlos es permanente. Los volúmenes se facturan tanto si un pod los usa como si no:

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
  • Los volúmenes Released vienen de la política de reclaim Retain. Borrar el PersistentVolume no borra el disco que hay detrás: elimínalo también a través de tu proveedor, después de un snapshot final si los datos pueden importar. Los volúmenes aprovisionados dinámicamente heredan la política de su StorageClass, que por defecto es Delete, así que una clase con Retain fue una decisión de alguien. Pregúntale.
  • Los claims sin montar suelen quedar de StatefulSets borrados, que por defecto conservan sus claims. El claim de un CronJob solo está montado mientras se ejecuta el job, así que compruébalo antes de borrar.
  • Los volúmenes sobredimensionados no se pueden reducir: Kubernetes no permite reducir un claim por debajo de su tamaño actual. Corrige la plantilla del claim y mueve los datos a un claim más pequeño la próxima vez que toques el volumen.

Cuenta también kube_service_spec_type{type="LoadBalancer"} por namespace: cada uno es un balanceador de carga facturado.

4. Ajusta los requests con 14 días de uso

Riesgo: bajo, si cambias un servicio cada vez. Suele ser el mayor ahorro. Empieza por los namespaces con el ratio ocioso más alto y compara el request de cada contenedor con lo que usó su réplica más cargada durante dos 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 subconsultas de dos semanas son caras, así que ejecútalas bajo demanda, un namespace cada vez. Agrupan por nombre de contenedor; si dos Deployments de un namespace comparten uno, filtra con pod=~"checkout-api-.*".

Para una segunda opinión, ejecuta el Vertical Pod Autoscaler en modo solo recomendación. Fija updateMode de forma explícita, porque el valor por defecto en las versiones actuales es 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 es lo que fijaría el VPA: con los valores por defecto del recommender, el percentil 90 del uso más un margen del 15%. Por debajo de lowerBound es probable que el rendimiento se resienta, y por encima de upperBound probablemente es desperdicio. En modo Off el VPA no cambia nada, así que puede convivir con un HPA; en los demás modos no debe actuar sobre la misma métrica que uno.

Después, los límites:

  • Memoria: límite igual al request. Los pods que usan más memoria de la que solicitaron son los primeros en ser desalojados cuando el nodo sufre presión de memoria. Con el límite igual al request, un contenedor que se queda corto de memoria sufre un OOM kill él solo, en lugar de llevar al nodo entero a una situación de presión.
  • CPU: un límite cuesta latencia. Por defecto, el kernel aplica los límites de CPU por periodos de 100 ms, así que un servicio multihilo puede gastar su cuota al principio del periodo y después esperar, incluso en un nodo ocioso. Nuestro criterio por defecto es no poner límites de CPU en los servicios sensibles a la latencia. Donde los mantengas, vigila el 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]))

Ejemplo práctico: checkout-api tiene 6 réplicas que solicitan 1 CPU y 2Gi cada una. En 14 días, su réplica más cargada muestra un P95 de 0,21 núcleos y un pico de working set de 610Mi. Con nuestro margen del 25%:

  • Request de CPU: 0,21 × 1,25 = 0,26 núcleos, redondeado hacia arriba a 300m.
  • Request y límite de memoria: 610Mi × 1,25 = 763Mi, redondeado hacia arriba a 768Mi.
  • Las seis réplicas solicitan ahora 1,8 núcleos en lugar de 6, y 4,5Gi en lugar de 12Gi.

Despliégalo como cualquier release y vigílalo durante una semana: los SLI de latencia y de errores, los reinicios y kube_pod_container_status_last_terminated_reason{reason="OOMKilled"}. Un servicio que ya está consumiendo su presupuesto de errores espera.

¿No sabes en qué punto está tu clúster? Solicita una evaluación gratuita de costes de Kubernetes: ejecutamos estas consultas contra tu Prometheus y te entregamos una lista por namespace de los requests que puedes recortar, con el margen detrás de cada cifra.

5. Pon barreras para que el desperdicio no vuelva

Riesgo: bajo; solo se aplican a los pods nuevos. Un contenedor sin requests se programa como si no costara nada, y un HPA no puede calcular la utilización de CPU sin un request de CPU. Un LimitRange rellena valores por defecto y una ResourceQuota pone un tope al 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"
  • Los valores por defecto son un suelo para los contenedores olvidados, no un método de dimensionamiento. Mantenlos pequeños.
  • Un LimitRange no compara sus valores por defecto con lo que fija un contenedor: un request de memoria de 512Mi sin límite recibe el límite por defecto de 256Mi, y el pod se rechaza. Fija los dos.
  • En cuanto una quota cubre CPU o memoria, se rechazan los pods sin requests ni valores por defecto, así que el LimitRange va primero.
  • Deja limits.cpu fuera de la quota salvo que todos los pods deban llevar un límite de CPU, porque una quota sobre un límite lo hace obligatorio.
  • Dimensiona las quotas a partir de los requests ya ajustados más el crecimiento previsto.

6. Empaqueta los nodos sin tumbar servicios

Riesgo: medio; la consolidación desaloja pods de producción. Los autoescaladores de nodos deciden por requests, no por uso. Cluster Autoscaler considera retirar un nodo cuando sus pods solicitan menos de --scale-down-utilization-threshold de su CPU y memoria asignables (0,5 por defecto) durante --scale-down-unneeded-time (10 minutos por defecto). Karpenter simula el scheduling con los mismos requests.

Con Karpenter, la consolidación se configura 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 retira o sustituye cualquier nodo cuyos pods quepan en otros nodos o en un único nodo más barato. consolidateAfter espera a que no se haya añadido ni retirado ningún pod de un nodo durante ese tiempo, lo que evita la rotación constante. El segundo budget bloquea la consolidación de nodos no vacíos de 08:00 a 18:00 UTC los días laborables, y limits pone un tope a lo que el pool puede lanzar.

La consolidación desaloja a través de la Eviction API, así que los PodDisruptionBudgets deciden si puede tumbar un servicio. Dale uno a cada Deployment con dos o más réplicas:

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

Lo que bloquea el empaquetado en lugar de protegerte:

  • Un PDB con maxUnavailable: 0, o con minAvailable igual al número de réplicas, nunca permite un desalojo. Karpenter informa de Unconsolidatable en el nodo.
  • karpenter.sh/do-not-disrupt: "true" deja el nodo de un pod fuera de la consolidación.
  • Con los valores por defecto de Cluster Autoscaler, un pod con emptyDir retiene su nodo salvo que lleve la anotación cluster-autoscaler.kubernetes.io/safe-to-evict: "true".

El tamaño de los nodos también importa. Ejemplo práctico: unos DaemonSets que solicitan 400m de CPU por nodo ocupan el 20% de un nodo de 2 núcleos y el 2,5% de uno de 16, pero los nodos grandes dejan huecos más grandes. Nuestra regla práctica: el pod más grande ocupa como mucho una cuarta parte de un nodo, y el autoescalador elige entre varios tamaños.

7. Autoescala las réplicas sin oscilaciones

Riesgo: medio; los errores se notan en la latencia. Un HPA mide la utilización de CPU respecto al request. Ejemplo práctico: un pod solicita 1 CPU y tiene picos de 250m. Con un objetivo del 70%, el HPA ve un 25% y nunca escala, así que minReplicas carga con todo el tráfico y con todo el desperdicio. Ajustado a 300m, esos mismos 250m se leen como un 83%, y el HPA sigue la demanda 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

El escalado hacia arriba es inmediato por defecto. Las oscilaciones vienen del escalado hacia abajo: el HPA se queda con la recomendación más alta dentro de su ventana de estabilización, 300 segundos por defecto. Aquí, 600 segundos absorben una bajada corta, y la política retira como mucho el 20% de las réplicas por minuto. Mantén minReplicas en 2 o más, porque con una sola réplica cada desalojo es una caída del servicio. Escala los consumidores de colas por la profundidad de la cola, no por CPU.

8. Capacidad spot: al final, y solo para cargas que la toleran

Riesgo: el más alto de los cambios de cómputo. La capacidad spot es capacidad sobrante que se vende con descuento y que el proveedor puede recuperar con muy poco aviso. Va después del right-sizing y del empaquetado porque abarata todo lo que ejecutas, desperdicio incluido. Nuestra guía para ejecutar cargas de Kubernetes en capacidad spot de forma segura explica qué cargas encajan ahí, cómo gestionar las interrupciones y cómo separar los NodePools por tipo de capacidad.

9. Trata el coste como un SLI junto al presupuesto de errores

Riesgo: ninguno; esto es lo que conserva el ahorro. Los requests vuelven a crecer con cada servicio nuevo y cada manifiesto copiado. Pon una alerta, pero que nunca avise a la guardia:

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"

La segunda condición descarta los namespaces demasiado pequeños para importar. Envía severity: ticket a una cola de trabajo; nuestra guía sobre alertas de Kubernetes que la gente no aprende a ignorar explica el enrutamiento.

Revisa los ratios ociosos en la misma reunión semanal que los presupuestos de errores, con una regla: un servicio que ha agotado su presupuesto de errores no recibe recortes de coste hasta que se recupere, y uno con presupuesto disponible y un ratio ocioso por encima de 0,6 es el siguiente para el right-sizing.

Los descuentos por compromiso de uso van al final, dimensionados al suelo y no a la media:

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])

Ese es el total del clúster. Para saber quién lo gasta, nuestra guía de asignación de costes de Kubernetes por equipo convierte las mismas métricas en un coste mensual por equipo.

Evaluación gratuita de costes de Kubernetes

Si quieres estas cifras de tu clúster antes de cambiar nada, nuestro equipo de consultoría de Kubernetes las mide sin coste. La evaluación cubre:

  • Los dos huecos: el hueco de requests y el de empaquetado por namespace, con tus propios datos de Prometheus.
  • Candidatos a right-sizing: por contenedor, el request actual, el P95 y el pico de 14 días, y el request que fijaríamos.
  • Lo que bloquea el ahorro: PDBs y ajustes de pods que impiden la consolidación, y namespaces y volúmenes que funcionan cuando nadie los usa.

Recibes una lista escrita de cambios ordenados por ahorro y por riesgo, sin ningún compromiso.

Solicita tu evaluación gratuita de costes de Kubernetes, o descubre cómo nuestra consultoría SRE vincula el coste con el presupuesto de errores.