KubernetesFinOpsDevOps

Kubernetes Cost Optimization: a Practical Guide for 2025

April 25, 2025 · 14 min read · By Fabrizio Galiano

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:

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:

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:

Poor candidates:

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:

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:

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:

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

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.


FG
Fabrizio Galiano
Founder & SRE — Xseven SRLS

Want to experiment with local AI responsibly?

We help teams and companies design local, private and governable AI environments, balancing technical freedom, security, policy and operational control.

Start the conversation