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:
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:
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: 64MiUna 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:
# 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 esDelete, así que una clase conRetainfue 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:
# 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:
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 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:
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:
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.cpufuera 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:
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 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:
apiVersion: policy/v1
kind: PodDisruptionBudget
metadata:
name: checkout-api
namespace: checkout
spec:
maxUnavailable: 1
selector:
matchLabels:
app: checkout-apiLo que bloquea el empaquetado en lugar de protegerte:
- Un PDB con
maxUnavailable: 0, o conminAvailableigual al número de réplicas, nunca permite un desalojo. Karpenter informa deUnconsolidatableen 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
emptyDirretiene su nodo salvo que lleve la anotacióncluster-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.
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: 60El 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:
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:
# 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.