SRE: Optimización de Costos en la Nube
Apoya este blog
Si te resulta util este contenido, considera apoyar el blog.
Introduccion
A lo largo de esta serie de SRE cubrimos SLIs y SLOs, gestion de incidentes, observabilidad, chaos engineering, capacity planning, GitOps, y gestion de secretos. Construimos una base solida para correr sistemas confiables, pero la confiabilidad es solo la mitad de la historia. Si tu factura de infraestructura sigue creciendo sin control, no importa lo confiable que sea todo porque eventualmente alguien va a hacer preguntas dificiles sobre el costo.
El gasto en la nube tiene la tendencia de crecer de a poco. Levantas un cluster de prueba y te olvidas, alguien pide una instancia grande “por las dudas,” los entornos de desarrollo corren 24/7, y antes de que te des cuenta tu factura mensual se duplico. El movimiento FinOps surgio para traer responsabilidad financiera al gasto en la nube, y los equipos de SRE estan en una posicion unica para impulsar la optimizacion de costos porque ya entienden la infraestructura en profundidad.
En este articulo vamos a cubrir principios de FinOps, right-sizing de workloads, instancias spot, resource quotas, visibilidad de costos con Kubecost y OpenCost, deteccion de recursos ociosos, storage tiering, planificacion de capacidad reservada, alertas de costos vinculadas a SLOs, y estrategias de etiquetado para asignacion de costos. Son todas tecnicas practicas que podes empezar a aplicar hoy.
Vamos al tema.
Principios de FinOps
FinOps (Financial Operations) es una practica cultural que reune a equipos de ingenieria, finanzas y negocio para gestionar los costos en la nube de forma colaborativa. No se trata de cortar costos a cualquier precio. Se trata de tomar decisiones informadas y obtener el maximo valor de cada peso o dolar gastado.
El ciclo de vida de FinOps tiene tres fases:
- Informar: Entender que estas gastando, donde y por que. No podes optimizar lo que no podes ver.
- Optimizar: Tomar accion para reducir el desperdicio. Right-size de instancias, usar nodos spot, limpiar recursos ociosos.
- Operar: Monitorear costos continuamente, establecer presupuestos y construir conciencia de costos en tu cultura de ingenieria.
Para los equipos de SRE, el insight clave es que el costo deberia tratarse como una metrica de primera clase, igual que la latencia, la disponibilidad y la tasa de errores. Ya tenes dashboards para SLIs. Agrega un panel de costos a esos dashboards. Cuando revises el rendimiento de tus SLOs semanalmente, revisa tambien tus metricas de costos.
Algunos principios practicos para adoptar:
- Todos son responsables del costo, no solo finanzas. Los ingenieros que aprovisionan recursos deberian entender el impacto en costos.
- Las decisiones de costos se basan en datos. Usa datos reales de utilizacion, no suposiciones ni “capaz lo necesitemos algun dia.”
- La optimizacion de costos es continua, no un proyecto de una sola vez. Tratala como la confiabilidad, siempre mejorando.
- Optimiza por valor, no solo por ahorro. A veces gastar mas es la decision correcta si mejora la confiabilidad o la productividad del equipo.
Right-sizing de workloads
El right-sizing es la optimizacion de costos con mayor impacto que podes hacer en Kubernetes. La mayoria de los equipos sobre-aprovisionan sus workloads significativamente porque los desarrolladores piden recursos basandose en estimaciones del peor caso en vez de uso real.
El Vertical Pod Autoscaler (VPA) es tu mejor amigo aca. Incluso si no lo habilitas en modo automatico, correrlo en modo recomendacion te da datos de lo que tus pods realmente usan versus lo que piden.
Instala el VPA:
# Instalar componentes del VPA
git clone https://github.com/kubernetes/autoscaler.git
cd autoscaler/vertical-pod-autoscaler
./hack/vpa-up.sh
Crea un VPA en modo recomendacion para tus workloads:
# vpa/tr-web-vpa.yaml
apiVersion: autoscaling.k8s.io/v1
kind: VerticalPodAutoscaler
metadata:
name: tr-web-vpa
namespace: default
spec:
targetRef:
apiVersion: apps/v1
kind: Deployment
name: tr-web
updatePolicy:
updateMode: "Off" # Solo recomendaciones, sin auto-updates
resourcePolicy:
containerPolicies:
- containerName: tr-web
minAllowed:
cpu: 50m
memory: 64Mi
maxAllowed:
cpu: 2000m
memory: 2Gi
controlledResources:
- cpu
- memory
Despues de unos dias corriendo, revisa las recomendaciones:
kubectl describe vpa tr-web-vpa
# La salida va a verse algo asi:
# Recommendation:
# Container Recommendations:
# Container Name: tr-web
# Lower Bound:
# Cpu: 25m
# Memory: 80Mi
# Target:
# Cpu: 100m
# Memory: 180Mi
# Uncapped Target:
# Cpu: 100m
# Memory: 180Mi
# Upper Bound:
# Cpu: 350m
# Memory: 400Mi
Ahora compara eso con lo que realmente pediste:
# Revisar resource requests actuales en todos los pods
kubectl get pods -A -o jsonpath='{range .items[*]}{.metadata.namespace}/{.metadata.name}{"\t"}{range .spec.containers[*]}{.name}{"\t"}Req: {.resources.requests.cpu}/{.resources.requests.memory}{"\t"}Lim: {.resources.limits.cpu}/{.resources.limits.memory}{"\n"}{end}{end}' | column -t
Si tus pods piden 500m de CPU pero solo usan 100m en promedio, estas pagando 5 veces mas computo del que necesitas. Esa diferencia es desperdicio puro.
Una buena regla general para configurar requests y limits:
- Requests: Configuralos al P95 del uso real (de las recomendaciones del VPA o metricas de Prometheus). Esto asegura que el scheduler coloque pods en nodos con capacidad suficiente.
- Limits: Configuralos a 2-3x del request para CPU (para permitir bursting), y 1.5-2x para memoria (para evitar OOM kills mientras prevenis consumo descontrolado).
- Revisa trimestralmente: Los patrones de uso cambian a medida que tu aplicacion evoluciona. Lo que estaba bien dimensionado hace seis meses puede estar mal hoy.
Aca hay un query de Prometheus para encontrar los workloads mas sobre-aprovisionados:
# Ratio de sobre-aprovisionamiento de CPU por deployment
# Valores > 2 significan que el workload pide 2x+ mas CPU de la que usa
sum by (namespace, owner_name) (
kube_pod_container_resource_requests{resource="cpu"}
) /
sum by (namespace, owner_name) (
rate(container_cpu_usage_seconds_total[24h])
)
Instancias spot y preemptible
Las instancias spot (AWS), VMs preemptible (GCP), o VMs spot (Azure) ofrecen descuentos del 60-90% comparado con precios on-demand. La contrapartida es que el proveedor cloud puede reclamarlas con poco aviso (usualmente 2 minutos). Para workloads stateless y tolerantes a fallas en Kubernetes, es un gran negocio.
El truco es correr tus workloads en una mezcla de nodos on-demand y spot. Workloads criticos como tu base de datos van en nodos on-demand. Servidores web stateless y jobs batch van en nodos spot.
Configura un node group spot (ejemplo EKS):
# eks-nodegroup-spot.yaml
apiVersion: eksctl.io/v1alpha5
kind: ClusterConfig
metadata:
name: production-cluster
region: us-east-1
spec:
managedNodeGroups:
- name: on-demand-critical
instanceType: t3.large
desiredCapacity: 2
minSize: 2
maxSize: 4
labels:
node-type: on-demand
workload-type: critical
taints:
- key: workload-type
value: critical
effect: NoSchedule
- name: spot-general
instanceTypes:
- t3.large
- t3.xlarge
- t3a.large
- t3a.xlarge
- m5.large
- m5a.large
spot: true
desiredCapacity: 3
minSize: 1
maxSize: 10
labels:
node-type: spot
workload-type: general
Fijate que el node group spot usa multiples tipos de instancia. Esto es importante porque la disponibilidad de spot varia por tipo de instancia. Usar un conjunto diverso aumenta tus chances de conseguir capacidad.
Ahora programa tus workloads apropiadamente usando node affinity y tolerations:
# deployments/tr-web.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
name: tr-web
namespace: default
spec:
replicas: 3
template:
spec:
affinity:
nodeAffinity:
preferredDuringSchedulingIgnoredDuringExecution:
- weight: 80
preference:
matchExpressions:
- key: node-type
operator: In
values:
- spot
podAntiAffinity:
preferredDuringSchedulingIgnoredDuringExecution:
- weight: 100
podAffinityTerm:
labelSelector:
matchExpressions:
- key: app
operator: In
values:
- tr-web
topologyKey: kubernetes.io/hostname
tolerations:
- key: "node-type"
operator: "Equal"
value: "spot"
effect: "NoSchedule"
containers:
- name: tr-web
image: kainlite/tr:latest
resources:
requests:
cpu: 100m
memory: 180Mi
limits:
cpu: 300m
memory: 360Mi
El preferredDuringSchedulingIgnoredDuringExecution con weight 80 significa que el scheduler va a intentar
colocar pods en nodos spot pero va a caer a on-demand si no hay capacidad spot disponible. Esto es importante
para la resiliencia.
Tambien necesitas un PodDisruptionBudget para manejar la reclamacion de nodos spot de forma elegante:
# pdb/tr-web-pdb.yaml
apiVersion: policy/v1
kind: PodDisruptionBudget
metadata:
name: tr-web-pdb
namespace: default
spec:
minAvailable: 2
selector:
matchLabels:
app: tr-web
Esto asegura que al menos 2 pods esten siempre corriendo, incluso durante la reclamacion de nodos spot. Combinado con multiples replicas distribuidas en diferentes nodos, tu servicio se mantiene disponible mientras ahorras 60-90% en computo.
Resource quotas y limit ranges
Sin guardarrailes, cualquier miembro del equipo puede deployar un workload que pida 64 CPUs y 256GB de memoria. Las resource quotas y limit ranges previenen este tipo de costo descontrolado.
Un ResourceQuota establece limites duros por namespace:
# quotas/dev-namespace-quota.yaml
apiVersion: v1
kind: ResourceQuota
metadata:
name: compute-quota
namespace: dev
spec:
hard:
requests.cpu: "4" # Total de requests de CPU en todos los pods
requests.memory: 8Gi # Total de requests de memoria
limits.cpu: "8" # Total de limits de CPU
limits.memory: 16Gi # Total de limits de memoria
pods: "20" # Maximo numero de pods
services.loadbalancers: "2" # Limitar servicios LB costosos
persistentvolumeclaims: "10"
requests.storage: 100Gi # Total de storage en PVCs
Un LimitRange establece defaults y restricciones por pod. Es especialmente util para atrapar pods desplegados sin resource requests:
# quotas/dev-namespace-limitrange.yaml
apiVersion: v1
kind: LimitRange
metadata:
name: default-limits
namespace: dev
spec:
limits:
- type: Container
default: # Limits por defecto si no se especifican
cpu: 200m
memory: 256Mi
defaultRequest: # Requests por defecto si no se especifican
cpu: 50m
memory: 64Mi
min: # Minimo permitido
cpu: 10m
memory: 16Mi
max: # Maximo permitido por container
cpu: "2"
memory: 4Gi
- type: Pod
max: # Maximo por pod (todos los containers combinados)
cpu: "4"
memory: 8Gi
- type: PersistentVolumeClaim
min:
storage: 1Gi
max:
storage: 50Gi
Ahora si alguien deploya un pod sin resource requests, automaticamente recibe 50m de CPU y 64Mi de memoria como defaults. Y si alguien intenta pedir 32 CPUs, el API server rechaza el request.
Para namespaces de produccion, vas a querer quotas diferentes:
# quotas/production-namespace-quota.yaml
apiVersion: v1
kind: ResourceQuota
metadata:
name: compute-quota
namespace: production
spec:
hard:
requests.cpu: "16"
requests.memory: 32Gi
limits.cpu: "32"
limits.memory: 64Gi
pods: "50"
services.loadbalancers: "5"
persistentvolumeclaims: "20"
requests.storage: 500Gi
scopeSelector:
matchExpressions:
- scopeName: PriorityClass
operator: In
values:
- high
- medium
Kubecost y OpenCost
No podes optimizar lo que no podes medir. Kubecost (y su nucleo open source, OpenCost) te da visibilidad de costos en tu cluster de Kubernetes, desglosado por namespace, deployment, label y equipo.
Instala OpenCost con Helm:
helm repo add opencost https://opencost.github.io/opencost-helm-chart
helm repo update
helm install opencost opencost/opencost \
--namespace opencost \
--create-namespace \
--set opencost.exporter.defaultClusterId="production" \
--set opencost.ui.enabled=true \
--set opencost.prometheus.internal.enabled=false \
--set opencost.prometheus.external.url="http://prometheus-server.monitoring.svc:9090"
Para Kubecost (que incluye mas funcionalidades como recomendaciones e insights de ahorro):
helm repo add kubecost https://kubecost.github.io/cost-analyzer
helm repo update
helm install kubecost kubecost/cost-analyzer \
--namespace kubecost \
--create-namespace \
--set kubecostToken="your-token-here" \
--set prometheus.server.global.external_labels.cluster_id="production" \
--set prometheus.nodeExporter.enabled=false \
--set prometheus.serviceAccounts.nodeExporter.create=false
Una vez instalado, podes consultar los datos de costos via la API:
# Obtener asignacion de costos por namespace de los ultimos 7 dias
curl -s "http://kubecost.kubecost.svc:9090/model/allocation?window=7d&aggregate=namespace" \
| jq '.data[0] | to_entries[] | {
namespace: .key,
totalCost: .value.totalCost,
cpuCost: .value.cpuCost,
memCost: .value.ramCost,
pvCost: .value.pvCost,
cpuEfficiency: .value.cpuEfficiency,
ramEfficiency: .value.ramEfficiency
}'
# Ejemplo de salida:
# {
# "namespace": "default",
# "totalCost": 42.15,
# "cpuCost": 18.30,
# "memCost": 15.85,
# "pvCost": 8.00,
# "cpuEfficiency": 0.35,
# "ramEfficiency": 0.42
# }
Esa eficiencia de CPU de 0.35 significa que solo estas usando el 35% de la CPU por la que estas pagando. Eso es una gran oportunidad de optimizacion.
Crea un dashboard de Grafana para visibilidad de costos:
# grafana/cost-dashboard.json (simplificado)
# Queries utiles de Prometheus para paneles de costos:
# Estimacion de costo mensual por namespace
sum by (namespace) (
container_cpu_allocation * on(node) group_left()
node_cpu_hourly_cost * 730
) +
sum by (namespace) (
container_memory_allocation_bytes / 1024 / 1024 / 1024 * on(node) group_left()
node_ram_hourly_cost * 730
)
# Costo ocioso (recursos pedidos pero no usados)
sum by (namespace) (
(kube_pod_container_resource_requests{resource="cpu"} -
rate(container_cpu_usage_seconds_total[1h]))
* on(node) group_left() node_cpu_hourly_cost * 730
)
# Costo por request (util para tracking de costo-por-SLI)
sum(rate(container_cpu_usage_seconds_total{namespace="default"}[1h])
* on(node) group_left() node_cpu_hourly_cost)
/
sum(rate(http_requests_total{namespace="default"}[1h]))
Deteccion de recursos ociosos
Los recursos ociosos son la fruta al alcance de la mano de la optimizacion de costos. Son cosas por las que estas pagando pero nadie esta usando. En un cluster de Kubernetes tipico, el 20-30% del gasto va a recursos ociosos.
Aca hay un script para encontrar recursos ociosos comunes:
#!/bin/bash
# idle-resource-audit.sh
# Encontrar recursos ociosos y desperdiciados en tu cluster
echo "=== PersistentVolumeClaims sin usar ==="
# PVCs no montados por ningun pod
kubectl get pvc -A -o json | jq -r '
.items[] |
select(.status.phase == "Bound") |
.metadata.namespace + "/" + .metadata.name
' | while read pvc; do
ns=$(echo $pvc | cut -d/ -f1)
name=$(echo $pvc | cut -d/ -f2)
used=$(kubectl get pods -n $ns -o json | jq -r \
--arg pvc "$name" \
'.items[].spec.volumes[]? | select(.persistentVolumeClaim.claimName == $pvc) | .name' \
2>/dev/null)
if [ -z "$used" ]; then
size=$(kubectl get pvc $name -n $ns -o jsonpath='{.spec.resources.requests.storage}')
echo " SIN USAR: $pvc ($size)"
fi
done
echo ""
echo "=== Servicios LoadBalancer ==="
kubectl get svc -A --field-selector spec.type=LoadBalancer \
-o custom-columns='NAMESPACE:.metadata.namespace,NAME:.metadata.name,IP:.status.loadBalancer.ingress[0].ip,AGE:.metadata.creationTimestamp'
echo ""
echo "=== Deployments con 0 replicas ==="
kubectl get deploy -A -o json | jq -r '
.items[] |
select(.spec.replicas == 0) |
.metadata.namespace + "/" + .metadata.name
'
echo ""
echo "=== Pods en CrashLoopBackOff ==="
kubectl get pods -A --field-selector=status.phase!=Running,status.phase!=Succeeded \
-o custom-columns='NAMESPACE:.metadata.namespace,NAME:.metadata.name,STATUS:.status.phase,RESTARTS:.status.containerStatuses[0].restartCount'
echo ""
echo "=== Persistent Volumes sin adjuntar ==="
kubectl get pv -o json | jq -r '
.items[] |
select(.status.phase == "Available" or .status.phase == "Released") |
.metadata.name + " (" + .spec.capacity.storage + ") - " + .status.phase
'
Para un enfoque mas automatizado, configura un CronJob que corra esta auditoria semanalmente y envie los resultados a Slack:
# cronjob/idle-resource-audit.yaml
apiVersion: batch/v1
kind: CronJob
metadata:
name: idle-resource-audit
namespace: monitoring
spec:
schedule: "0 9 * * 1" # Cada lunes a las 9am
jobTemplate:
spec:
template:
spec:
serviceAccountName: resource-auditor
containers:
- name: auditor
image: bitnami/kubectl:latest
command:
- /bin/bash
- -c
- |
UNUSED_PVCS=$(kubectl get pvc -A -o json | jq '[.items[] | select(.status.phase == "Bound")] | length')
TOTAL_PVCS=$(kubectl get pvc -A -o json | jq '.items | length')
LB_COUNT=$(kubectl get svc -A --field-selector spec.type=LoadBalancer -o json | jq '.items | length')
ZERO_REPLICAS=$(kubectl get deploy -A -o json | jq '[.items[] | select(.spec.replicas == 0)] | length')
curl -X POST "$SLACK_WEBHOOK_URL" \
-H 'Content-type: application/json' \
-d "{
\"text\": \"Reporte Semanal de Recursos Ociosos\",
\"blocks\": [{
\"type\": \"section\",
\"text\": {
\"type\": \"mrkdwn\",
\"text\": \"*Auditoria Semanal de Recursos Ociosos*\n- PVCs: $TOTAL_PVCS total\n- LoadBalancers: $LB_COUNT activos\n- Deployments con cero replicas: $ZERO_REPLICAS\"
}
}]
}"
env:
- name: SLACK_WEBHOOK_URL
valueFrom:
secretKeyRef:
name: slack-webhook
key: url
restartPolicy: OnFailure
Storage tiering
Los costos de almacenamiento pueden acumularse sin que te des cuenta, especialmente si todo usa SSD de alto rendimiento por defecto. No todos los datos necesitan almacenamiento rapido. Logs, backups y datos archivados pueden vivir en tiers de almacenamiento mas baratos.
Define multiples StorageClasses para diferentes tiers:
# storage/storageclass-fast.yaml
apiVersion: storage.k8s.io/v1
kind: StorageClass
metadata:
name: fast-ssd
labels:
cost-tier: high
provisioner: ebs.csi.aws.com
parameters:
type: gp3
iops: "5000"
throughput: "250"
encrypted: "true"
reclaimPolicy: Delete
volumeBindingMode: WaitForFirstConsumer
allowVolumeExpansion: true
---
# storage/storageclass-standard.yaml
apiVersion: storage.k8s.io/v1
kind: StorageClass
metadata:
name: standard
labels:
cost-tier: medium
annotations:
storageclass.kubernetes.io/is-default-class: "true"
provisioner: ebs.csi.aws.com
parameters:
type: gp3
iops: "3000"
throughput: "125"
encrypted: "true"
reclaimPolicy: Delete
volumeBindingMode: WaitForFirstConsumer
allowVolumeExpansion: true
---
# storage/storageclass-cold.yaml
apiVersion: storage.k8s.io/v1
kind: StorageClass
metadata:
name: cold-storage
labels:
cost-tier: low
provisioner: ebs.csi.aws.com
parameters:
type: sc1
encrypted: "true"
reclaimPolicy: Retain
volumeBindingMode: WaitForFirstConsumer
allowVolumeExpansion: true
Usa el tier correcto para cada workload:
# Base de datos: SSD rapido para baja latencia
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: postgresql-data
namespace: default
spec:
storageClassName: fast-ssd
accessModes:
- ReadWriteOnce
resources:
requests:
storage: 20Gi
---
# Logs de aplicacion: almacenamiento estandar
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: app-logs
namespace: default
spec:
storageClassName: standard
accessModes:
- ReadWriteOnce
resources:
requests:
storage: 50Gi
---
# Backups y archivos: almacenamiento frio
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: backup-archive
namespace: default
spec:
storageClassName: cold-storage
accessModes:
- ReadWriteOnce
resources:
requests:
storage: 200Gi
Para object storage (S3, GCS), configura lifecycle policies para mover datos a tiers mas baratos automaticamente:
# terraform/s3-lifecycle.tf
resource "aws_s3_bucket_lifecycle_configuration" "logs" {
bucket = aws_s3_bucket.logs.id
rule {
id = "archive-old-logs"
status = "Enabled"
transition {
days = 30
storage_class = "STANDARD_IA" # ~45% mas barato
}
transition {
days = 90
storage_class = "GLACIER" # ~80% mas barato
}
transition {
days = 365
storage_class = "DEEP_ARCHIVE" # ~95% mas barato
}
expiration {
days = 730 # Borrar despues de 2 anios
}
}
}
La diferencia de costos entre tiers es significativa. Para AWS EBS, gp3 cuesta aproximadamente $0.08/GB/mes mientras que sc1 cuesta $0.015/GB/mes. Para S3, Standard es $0.023/GB/mes mientras que Deep Archive es $0.00099/GB/mes. Mover 1TB de datos de archivo de Standard a Deep Archive ahorra unos $264/anio.
Reservado vs on-demand
Si sabes que vas a necesitar una cierta cantidad de computo por los proximos 1-3 anios, las instancias reservadas o savings plans ofrecen descuentos del 30-60% comparados con on-demand. La contrapartida es el compromiso, pagas lo uses o no.
La clave es solo comprometerte con tu baseline, el computo minimo que siempre necesitas. Deja que on-demand y spot manejen los picos.
Aca como analizar tu cobertura de reservaciones:
# Query de Prometheus: utilizacion promedio de CPU en 30 dias
# Esto muestra tus necesidades base de computo
avg_over_time(
sum(
rate(container_cpu_usage_seconds_total[5m])
)[30d:1h]
)
# Compara con tu capacidad reservada
# Si reservado < baseline, estas sub-comprometido (pagando demasiado on-demand)
# Si reservado > baseline, estas sobre-comprometido (pagando por reservaciones sin usar)
Un enfoque practico para la planificacion de reservaciones:
- Medi tu baseline por al menos 3 meses. Fijate en el uso minimo sostenido, no el promedio.
- Reserva el 70-80% del baseline. Esto te da un margen de seguridad para cambios en los workloads.
- Usa savings plans en vez de instancias reservadas cuando sea posible. Los savings plans son mas flexibles porque aplican a cualquier familia de instancias.
- Revisa trimestralmente. Si tu baseline cambio, ajusta tus compromisos en el momento de la renovacion.
- Considera terminos de 1 anio primero. La diferencia de ahorro entre 1 anio y 3 anios muchas veces no justifica el riesgo de quedar atrapado.
Para Kubernetes especificamente, podes usar Karpenter (AWS) o el cluster autoscaler con politicas de instancias mixtas para elegir automaticamente los tipos de instancia mas baratos disponibles:
# karpenter/provisioner.yaml
apiVersion: karpenter.sh/v1alpha5
kind: Provisioner
metadata:
name: default
spec:
requirements:
- key: karpenter.sh/capacity-type
operator: In
values:
- on-demand
- spot
- key: node.kubernetes.io/instance-type
operator: In
values:
- t3.medium
- t3.large
- t3a.medium
- t3a.large
- m5.large
- m5a.large
- m6i.large
- m6a.large
- key: kubernetes.io/arch
operator: In
values:
- amd64
- arm64 # Las instancias ARM son ~20% mas baratas
limits:
resources:
cpu: "64"
memory: 128Gi
providerRef:
name: default
# Consolidacion: Karpenter va a reemplazar nodos subutilizados
# con nodos mas chicos para ahorrar plata
consolidation:
enabled: true
ttlSecondsAfterEmpty: 30
Fijate en la opcion de arquitectura arm64. Las instancias ARM (como AWS Graviton) son tipicamente 20% mas
baratas y ofrecen rendimiento comparable o mejor para la mayoria de los workloads. Si tus imagenes de container
soportan builds multi-arch (lo cual deberian), es una ganancia facil.
Alertas de costos vinculadas a SLOs
Aca es donde SRE y FinOps se cruzan de manera hermosa: usar tu error budget como mecanismo de control de costos. La idea es que si estas gastando mas de lo necesario para mantener tus SLOs, tenes margen para optimizar.
Pensalo asi. Si tu SLO de disponibilidad es 99.9% y estas corriendo a 99.99%, probablemente estes sobre-aprovisionado. Ese “9” extra te esta costando plata y no es requerido por el SLO. Podrias reducir capacidad hasta que la disponibilidad baje a alrededor de 99.95% y todavia tendrias bastante error budget sobrante.
Configura costo-por-request como metrica:
# prometheus/cost-per-request-rule.yaml
apiVersion: monitoring.coreos.com/v1
kind: PrometheusRule
metadata:
name: cost-metrics
namespace: monitoring
spec:
groups:
- name: cost.rules
interval: 5m
rules:
# Costo por request (estimado)
- record: cost:per_request:ratio
expr: |
(
sum(container_cpu_allocation{namespace="default"} *
on(node) group_left() node_cpu_hourly_cost)
+
sum(container_memory_allocation_bytes{namespace="default"} / 1024 / 1024 / 1024 *
on(node) group_left() node_ram_hourly_cost)
)
/
sum(rate(http_requests_total{namespace="default"}[1h]))
# Estimacion de costo mensual
- record: cost:monthly:estimate
expr: |
sum(
container_cpu_allocation * on(node) group_left()
node_cpu_hourly_cost * 730
) +
sum(
container_memory_allocation_bytes / 1024 / 1024 / 1024 *
on(node) group_left() node_ram_hourly_cost * 730
)
# Eficiencia de costos: valor entregado por dolar
- record: cost:efficiency:ratio
expr: |
sum(rate(http_requests_total{status=~"2.."}[1h]))
/
(
sum(container_cpu_allocation{namespace="default"} *
on(node) group_left() node_cpu_hourly_cost)
+
sum(container_memory_allocation_bytes{namespace="default"} / 1024 / 1024 / 1024 *
on(node) group_left() node_ram_hourly_cost)
)
Ahora crea alertas que se disparen cuando los costos excedan umbrales:
# prometheus/cost-alerts.yaml
apiVersion: monitoring.coreos.com/v1
kind: PrometheusRule
metadata:
name: cost-alerts
namespace: monitoring
spec:
groups:
- name: cost.alerts
rules:
# Alerta cuando el costo mensual estimado excede el presupuesto
- alert: MonthlyCostExceedsBudget
expr: cost:monthly:estimate > 500
for: 6h
labels:
severity: warning
team: platform
annotations:
summary: "El costo mensual estimado excede el presupuesto de $500"
description: "El costo mensual estimado actual es ${{ $value | printf \"%.2f\" }}. El presupuesto es $500."
# Alerta cuando el costo por request sube
- alert: CostPerRequestSpike
expr: cost:per_request:ratio > 0.001
for: 1h
labels:
severity: warning
team: platform
annotations:
summary: "El costo por request excede $0.001"
description: "El costo actual por request es ${{ $value | printf \"%.6f\" }}. Esto puede indicar sobre-aprovisionamiento o una caida de trafico."
# Alerta cuando la eficiencia de CPU cae (sobre-aprovisionamiento)
- alert: LowCPUEfficiency
expr: |
sum by (namespace) (rate(container_cpu_usage_seconds_total[24h]))
/
sum by (namespace) (kube_pod_container_resource_requests{resource="cpu"})
< 0.2
for: 24h
labels:
severity: info
team: platform
annotations:
summary: "Namespace {{ $labels.namespace }} utilizacion de CPU por debajo del 20%"
description: "El namespace {{ $labels.namespace }} solo esta usando {{ $value | printf \"%.1f\" }}% de la CPU pedida. Considera right-sizing."
# Alerta cuando el error budget esta sano pero los costos son altos
- alert: OverProvisionedForSLO
expr: |
(1 - slo:error_budget:remaining_ratio) < 0.1
and
cost:monthly:estimate > 400
for: 24h
labels:
severity: info
team: platform
annotations:
summary: "Sobre-aprovisionado: SLO sano pero costos altos"
description: "El error budget consumido es solo {{ $value | printf \"%.1f\" }}% pero el costo mensual es alto. Considera reducir capacidad para ahorrar costos manteniendo el SLO."
La alerta OverProvisionedForSLO es la mas interesante. Se dispara cuando tu error budget casi no se toca
(lo que significa que estas muy por encima de tu objetivo de SLO) Y tus costos son altos. Es una señal de que
podes reducir capacidad de forma segura.
Estrategias de etiquetado
Sin etiquetado adecuado, tus datos de costos son solo un numero grande sin contexto. Necesitas saber que equipo, proyecto y entorno es responsable de cada costo.
En Kubernetes, los labels sirven como etiquetas para asignacion de costos. Define un estandar de etiquetado consistente:
# labels/standard-labels.yaml
# Todo recurso deberia tener estos labels
metadata:
labels:
# Quien es el dueño?
app.kubernetes.io/name: tr-web
app.kubernetes.io/component: frontend
app.kubernetes.io/part-of: tr-blog
app.kubernetes.io/managed-by: argocd
# Asignacion de costos
cost-center: engineering
team: platform
environment: production
project: tr-blog
# Ciclo de vida
lifecycle: permanent # o: temporary, ephemeral, review
expiry: "none" # o: "2026-04-01" para recursos temporales
Aplica estos labels con un motor de politicas como Kyverno:
# kyverno/require-cost-labels.yaml
apiVersion: kyverno.io/v1
kind: ClusterPolicy
metadata:
name: require-cost-labels
annotations:
policies.kyverno.io/title: Require Cost Allocation Labels
policies.kyverno.io/description: >-
Todos los deployments deben tener labels de asignacion de costos
para tracking y chargeback.
spec:
validationFailureAction: Enforce
background: true
rules:
- name: check-cost-labels
match:
any:
- resources:
kinds:
- Deployment
- StatefulSet
- DaemonSet
- Job
- CronJob
validate:
message: >-
Todos los workloads deben tener labels de asignacion de costos:
cost-center, team, environment y project.
pattern:
metadata:
labels:
cost-center: "?*"
team: "?*"
environment: "?*"
project: "?*"
- name: check-pvc-labels
match:
any:
- resources:
kinds:
- PersistentVolumeClaim
validate:
message: "Los PVCs deben tener labels de cost-center y team."
pattern:
metadata:
labels:
cost-center: "?*"
team: "?*"
- name: check-service-labels
match:
any:
- resources:
kinds:
- Service
validate:
message: "Los Services deben tener labels de cost-center y team."
pattern:
metadata:
labels:
cost-center: "?*"
team: "?*"
Con esta politica en su lugar, cualquier deployment sin labels de asignacion de costos es rechazado en el momento de admision. Esto asegura 100% de cobertura de labels, lo que significa que tus reportes de costos son precisos.
Para recursos cloud fuera de Kubernetes (buckets S3, instancias RDS, etc.), usa Terraform para aplicar tags:
# terraform/provider.tf
provider "aws" {
region = "us-east-1"
default_tags {
tags = {
Environment = "production"
Team = "platform"
Project = "tr-blog"
ManagedBy = "terraform"
CostCenter = "engineering"
}
}
}
Una vez que el etiquetado es consistente, podes generar reportes de costos por equipo:
# Consultar Kubecost por costo por label de equipo
curl -s "http://kubecost.kubecost.svc:9090/model/allocation?window=30d&aggregate=label:team" \
| jq '.data[0] | to_entries[] | {
team: .key,
monthlyCost: (.value.totalCost | . * 100 | round / 100),
cpuEfficiency: (.value.cpuEfficiency | . * 100 | round),
ramEfficiency: (.value.ramEfficiency | . * 100 | round)
}'
# Ejemplo de salida:
# { "team": "platform", "monthlyCost": 285.42, "cpuEfficiency": 45, "ramEfficiency": 52 }
# { "team": "backend", "monthlyCost": 156.78, "cpuEfficiency": 62, "ramEfficiency": 58 }
# { "team": "data", "monthlyCost": 412.33, "cpuEfficiency": 78, "ramEfficiency": 71 }
Estos datos hacen que las conversaciones de costos sean productivas. En vez de “necesitamos cortar costos,” podes decir “el equipo de platform tiene 45% de eficiencia de CPU, hagamos right-size de esos workloads para ahorrar un estimado de $128/mes.”
Notas finales
La optimizacion de costos en la nube no es un proyecto de una sola vez. Es una practica continua que requiere visibilidad, responsabilidad y mejora continua. La buena noticia es que como equipo de SRE, ya tenes la mayoria de las habilidades y herramientas que necesitas. Sabes como medir cosas (SLIs), establecer objetivos (SLOs), y automatizar respuestas (alertas y runbooks). Aplica esos mismos patrones al costo.
Empeza con las ganancias rapidas: corré el VPA en modo recomendacion y hace right-size de tus 10 workloads mas sobre-aprovisionados. Instala OpenCost para tener visibilidad de a donde va tu plata. Configura una revision semanal de costos junto con tu revision de SLOs. Despues gradualmente adopta instancias spot, storage tiering, y alertas conscientes del costo.
El punto clave es que la confiabilidad y la eficiencia de costos no estan en conflicto. Con el enfoque correcto, podes reducir el gasto mientras mantenes o incluso mejoras tus SLOs. Cada dolar ahorrado en sobre-aprovisionamiento es un dolar que podes invertir en mejores herramientas, mas funcionalidades de confiabilidad, o tu equipo.
¡Espero que te haya resultado útil y lo hayas disfrutado! ¡Hasta la próxima!
Errata
Si encontrás algún error o tenés alguna sugerencia, por favor mandame un mensaje para que se corrija.
También podés revisar el código fuente y los cambios en las fuentes acá
$ Comentarios
Online: 0Por favor inicie sesión para poder escribir comentarios.