SRE: Optimización de Costos en la Nube

2026-03-13 | Gabriel Garrido | 21 min de lectura
Share:

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:


  1. Informar: Entender que estas gastando, donde y por que. No podes optimizar lo que no podes ver.
  2. Optimizar: Tomar accion para reducir el desperdicio. Right-size de instancias, usar nodos spot, limpiar recursos ociosos.
  3. 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:


  1. Medi tu baseline por al menos 3 meses. Fijate en el uso minimo sostenido, no el promedio.
  2. Reserva el 70-80% del baseline. Esto te da un margen de seguridad para cambios en los workloads.
  3. Usa savings plans en vez de instancias reservadas cuando sea posible. Los savings plans son mas flexibles porque aplican a cualquier familia de instancias.
  4. Revisa trimestralmente. Si tu baseline cambio, ajusta tus compromisos en el momento de la renovacion.
  5. 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: 0

Por favor inicie sesión para poder escribir comentarios.

2026-03-13 | Gabriel Garrido