Menu

Infrastructure & FinOps

Kubernetes Cost Optimization: a Practical Guide for 2025

A practical Kubernetes cost optimization guide: namespace cost allocation, right-sizing, VPA, HPA, spot nodes, Karpenter, quotas, cleanup and FinOps routines.

Fabrizio Galiano14 min read
KubernetesFinOpsDevOps

Kubernetes is not expensive because Kubernetes is inherently inefficient. It becomes expensive when nobody measures what has been requested, what is actually used, which nodes are half empty, which namespaces have no owner and which resources keep running long after the service that created them has disappeared.

The problem is not only technical. It is operational. If teams can create namespaces, deployments, PVCs, jobs and temporary environments without limits, labels or periodic review, the cluster slowly becomes a collection of invisible decisions. None of them looks dramatic alone. Together, they become cloud spend, complexity and risk.

This guide is practical by design. The goal is not to "spend less" in the abstract, but to build a repeatable method: measure, attribute, correct, automate and keep control without reducing reliability or developer experience.

Start here: do not optimize before you measure

The first question is not "which autoscaler should we install?". The first question is: how much does each namespace, team, environment and workload cost? Without that view, optimization becomes guesswork. You may spend time tuning a minor component while the real waste remains elsewhere.

A minimal baseline should include:

  • namespaces and workloads labelled with team, environment, service and owner;
  • real CPU and memory metrics, ideally from Prometheus or an equivalent provider metric source;
  • cost allocation tools such as OpenCost or Kubecost;
  • a recurring report that separates production, staging, development, batch and temporary workloads;
  • a person or team accountable for every namespace.

A simple policy is often more useful than a large FinOps programme:

metadata:
  labels:
    owner: "platform"
    team: "payments"
    environment: "production"
    service: "checkout"

If you do not know who owns a resource, you do not know who should approve its cost, who can reduce it and who is accountable if it is removed by mistake.

1. Requests: where most waste begins

In Kubernetes, requests are not just advice. They are the amount of CPU and memory the scheduler uses to decide where pods can run. If a container uses 80 millicores on average but requests 500 millicores, the cluster reserves capacity that may never be used.

This creates a very common pattern: nodes look full to the scheduler, but actual usage is low. The cluster scales out, new nodes are created, the bill grows, yet consumed CPU remains modest.

Start with a simple snapshot:

kubectl top pods -A --sort-by=cpu
kubectl top pods -A --sort-by=memory

Then compare real usage with declared requests:

kubectl get pods -A \
  -o custom-columns='NAMESPACE:.metadata.namespace,POD:.metadata.name,CPU_REQ:.spec.containers[*].resources.requests.cpu,MEM_REQ:.spec.containers[*].resources.requests.memory'

The useful number is not a one-minute peak. Look at p50, p90 and p95 over several days or weeks. For stable web workloads, a reasonable request often comes from p90 or p95 plus a margin. For batch jobs or highly variable workloads, be more conservative.

Practical rule: if a workload consistently uses less than 20-30% of its declared request, you are probably paying for reserved capacity that produces no value. If it is frequently close to its request or limit, the opposite problem may exist: throttling or OOM risk.

2. Use VPA as an advisor, not necessarily as autopilot

The Vertical Pod Autoscaler is useful because it analyses historical usage and recommends CPU and memory values. That does not mean it should automatically modify every workload. In many production environments, recommendation mode is the safer first step.

apiVersion: autoscaling.k8s.io/v1
kind: VerticalPodAutoscaler
metadata:
  name: checkout-vpa
spec:
  targetRef:
    apiVersion: apps/v1
    kind: Deployment
    name: checkout
  updatePolicy:
    updateMode: "Off"

After a few days:

kubectl describe vpa checkout-vpa

Use the recommendations as input for a pull request, not as absolute truth. A good operating model is: VPA suggests, the team reviews, GitOps applies. That keeps changes auditable and reduces the risk of automatic modifications that are hard to explain later.

Also pay attention to the relationship with HPA: the Horizontal Pod Autoscaler scales based on utilization relative to requests. If requests are wrong, HPA will make poor decisions too. Fix requests first, then tune horizontal autoscaling.

3. Limits: protection, not magic

limits prevent a container from consuming resources beyond a threshold. They matter, but they need judgment. A CPU limit that is too low can introduce throttling and hurt latency. A memory limit that is too low can cause OOMKills.

A pragmatic strategy is:

  • always define CPU and memory requests;
  • use memory limits that match the application's behaviour;
  • avoid overly aggressive CPU limits on latency-sensitive services;
  • enforce namespace defaults and maximums through LimitRange;
  • monitor CPU throttling and OOMKills after every sizing change.
apiVersion: v1
kind: LimitRange
metadata:
  name: default-limits
  namespace: staging
spec:
  limits:
  - type: Container
    defaultRequest:
      cpu: "100m"
      memory: "128Mi"
    default:
      cpu: "500m"
      memory: "512Mi"
    max:
      cpu: "2"
      memory: "2Gi"

Do not use the same defaults everywhere. A staging namespace, a batch namespace and a production namespace running critical services have different profiles.

4. Namespace quotas: prevent cost incidents before they happen

ResourceQuota is a safety belt. It does not optimize automatically, but it prevents a mistake or experiment from turning into uncontrolled allocated capacity.

apiVersion: v1
kind: ResourceQuota
metadata:
  name: team-alpha-quota
  namespace: team-alpha
spec:
  hard:
    requests.cpu: "8"
    requests.memory: 16Gi
    limits.cpu: "16"
    limits.memory: 32Gi
    count/pods: "40"
    count/persistentvolumeclaims: "10"

Quotas work best when paired with a lightweight process: if a team needs more capacity, it opens a short request explaining why. This is not about slowing people down. It is about making an economic and technical decision visible.

5. Spot nodes: real savings, but only for suitable workloads

Spot or preemptible nodes can reduce cost significantly, but they are not a universal solution. They can be terminated with short notice and require applications that can tolerate interruption.

Good candidates:

  • CI/CD runners;
  • idempotent batch jobs;
  • repeatable data pipelines;
  • stateless services with multiple replicas;
  • development and staging environments.

Poor candidates:

  • databases and stateful workloads not designed for disruption;
  • single-replica services;
  • critical components without retries, timeouts and graceful shutdown;
  • long-running jobs without checkpointing.

Availability does not come from the fact that the node is cheaper. It comes from replicas, zone distribution, topologySpreadConstraints, readiness probes, clean shutdown and application-level retries.

apiVersion: scheduling.k8s.io/v1
kind: PriorityClass
metadata:
  name: spot-friendly
value: 100
globalDefault: false
description: "Workloads that can tolerate spot interruptions"

Labels, taints and tolerations help separate resilient workloads from critical workloads. Do not mix everything into the same pool and hope the autoscaler always makes the right choice.

6. Node autoscaling: from Cluster Autoscaler to Karpenter

Cluster Autoscaler is still valid, but it mostly operates around node groups. Newer tools such as Karpenter on EKS can create nodes more dynamically based on pending pods, choose more suitable instance types and consolidate underutilized capacity.

The point is not to install Karpenter and expect automatic savings. The point is to give it enough freedom to optimize:

  • do not restrict instance families too aggressively;
  • separate on-demand and spot pools where appropriate;
  • set CPU and memory limits to avoid uncontrolled growth;
  • use disruption budgets to protect critical services and time windows;
  • enable consolidation only after you understand its effect on workloads.

An autoscaler does not fix bad manifests. If requests and scheduling constraints are unrealistic, even the best autoscaler will produce expensive or fragmented nodes.

7. Reduce non-production environments

Development, test and staging are often the easiest areas to optimize. Many environments do not need to run 24/7. If they are only used during business hours, you can scale them down at night and during weekends.

apiVersion: batch/v1
kind: CronJob
metadata:
  name: scale-down-staging
  namespace: platform
spec:
  schedule: "0 20 * * 1-5"
  jobTemplate:
    spec:
      template:
        spec:
          serviceAccountName: staging-scaler
          restartPolicy: OnFailure
          containers:
          - name: kubectl
            image: bitnami/kubectl
            command:
            - kubectl
            - scale
            - deployment
            - --all
            - --replicas=0
            - -n
            - staging

Before doing this, define exceptions: demo environments, nightly tests, teams in other time zones and scheduled jobs. Scale-to-zero without governance becomes another incident.

8. Clean up PVCs, jobs and orphaned resources

Some Kubernetes waste is not CPU. It is storage, images, completed jobs, forgotten namespaces and resources that are no longer referenced.

To find PVCs not mounted by current pods:

kubectl get pvc --all-namespaces -o json \
  | jq -r '.items[] | .metadata.namespace + "/" + .metadata.name' \
  | sort -u > /tmp/all-pvcs.txt

kubectl get pods --all-namespaces -o json \
  | jq -r '.items[] | .metadata.namespace as $ns | .spec.volumes[]?
    | select(.persistentVolumeClaim) | $ns + "/" + .persistentVolumeClaim.claimName' \
  | sort -u > /tmp/used-pvcs.txt

comm -23 /tmp/all-pvcs.txt /tmp/used-pvcs.txt

Do not automatically delete the result. A PVC may be used by a scheduled job or a restore procedure. But the list is a good starting point for review with the owner.

For completed jobs, use ttlSecondsAfterFinished where appropriate:

apiVersion: batch/v1
kind: Job
metadata:
  name: report-export
spec:
  ttlSecondsAfterFinished: 86400
  template:
    spec:
      restartPolicy: Never
      containers:
      - name: export
        image: example/report-export:1.0.0

9. Watch what does not scale like pods: control plane, logging and egress

When people discuss Kubernetes costs, they usually think about worker nodes. But costs often grow elsewhere too:

  • overly verbose logs sent to expensive logging platforms;
  • high-cardinality metrics;
  • egress across zones, regions or providers;
  • load balancers created for temporary services;
  • volumes using oversized storage classes;
  • snapshots and backups without retention.

A cluster can have well-sized nodes and still be too expensive because every pod emits unnecessary logs, every environment creates dedicated load balancers and every volume keeps using premium storage after the need has changed.

10. A simple monthly routine

The difference between optimization and control is repetition. A monthly routine can be enough:

  • top 10 namespaces by cost;
  • top 10 workloads where requests are much higher than real usage;
  • unmounted PVCs or storage growth above threshold;
  • namespaces without an owner or mandatory labels;
  • nodes with low average utilization;
  • non-production workloads running outside business hours;
  • review of VPA recommendations;
  • recent incidents related to OOM, throttling or autoscaling.

Turn each point into small tickets. You do not need a large optimization project once a year. You need to prevent disorder from accumulating.

Common mistakes to avoid

  • Reducing requests without looking at peaks. You reduce cost today and create instability tomorrow.
  • Putting everything on spot. Spot is useful, but only for workloads designed for interruption.
  • Confusing limits with safety. An aggressive CPU limit can degrade the service.
  • Optimizing without owners. If nobody owns a resource, nobody can really decide.
  • Ignoring storage and logging. Not all cost is in worker nodes.
  • Installing tools without process. OpenCost, VPA or Karpenter help only if somebody uses the data to change the system.

Conclusion

Optimizing Kubernetes does not mean making the cluster as small as possible. It means using capacity in proportion to the value produced. Some workloads deserve margin, redundancy and on-demand nodes. Others can live on spot, scale to zero or receive tighter limits.

Maturity is the ability to distinguish between them. Measure first, assign ownership, correct requests, use VPA as guidance, apply quotas, separate resilient workloads, clean up orphaned resources and turn cost into an operational signal. When cost becomes visible, it stops being a surprise and becomes an engineering decision.

Related solution

Cloud & infrastructure

Unpredictable cloud costs or infrastructure that grew without a plan? We design, migrate and run cloud, bare-metal and hybrid environments.

Related articles