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.
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,serviceandowner; - 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.
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.
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