Kubernetes Cost Optimization

Reduce Kubernetes costs without sacrificing reliability. Covers resource right-sizing, spot instances, cluster autoscaling, namespace quotas, cost allocation, and workload scheduling.

Kubernetes clusters consume cloud resources dynamically, but without proactive tuning, they over-provision, under-utilize, and incur unnecessary costs. Kubernetes Cost Optimization is the disciplined practice of aligning compute, storage, and networking usage with actual workloads—ensuring every pod, node, and service pays for its own value. It matters when clusters run longer than needed, when nodes are oversized for their load, when idle workloads consume memory and CPU without benefit, and when resource requests and limits are misaligned with observed usage.

Right-Sizing Pods

Pods that are too large or too small waste resources. A pod with a memory request of 1Gi but consuming only 250Mi wastes 750Mi of memory that could serve other workloads. The standard tool for identifying such imbalances is kubectl top pods, which requires metrics to be collected by a metrics server.

kubectl top pods --all-namespaces

To collect CPU and memory metrics, the metrics server must be running and configured with a --kubelet-preferred-address-types=InternalIP,ExternalIP,Hostname flag and expose a /metrics endpoint. The metrics server must be reachable at http://metrics-server.kube-system.svc.cluster.local:8080.

The next step is to identify the gap between requested and actual usage. For each pod, compare:

A common failure is assuming kubectl top pods reflects actual resource usage. The metrics server uses kubectl get --raw /apis/metrics.k8s.io/v1beta1/pods and returns a JSON response with a items array of PodMetrics objects. Each entry includes timestamp, window, and metrics, where metrics is an array of Metric objects with name and value.

For instance, a pod may report:

{
  "timestamp": "2023-10-05T10:00:00Z",
  "window": "30s",
  "metrics": [
    {
      "name": "cpu",
      "value": "120m"
    },
    {
      "name": "memory",
      "value": "250Mi"
    }
  ]
}

The value field uses a string with a unit suffix: m for milli, Mi for mebibytes, Gi for gibibytes. A value of 120m means 120 milli-CPU cores, or 0.12 CPU. Misinterpreting m as megabytes is a silent failure—CPU usage appears underutilized when it’s actually 12% of a single core.

To align requests with actual usage, use kubectl patch to update pod specs:

kubectl patch pod my-app-7c9f8d5b6b -n production -p '
{
  "spec": {
    "containers": [
      {
        "name": "app",
        "resources": {
          "requests": {
            "memory": "256Mi"
          },
          "limits": {
            "memory": "512Mi"
          }
        }
      }
    ]
  }
}' --type=merge

The --type=merge flag ensures that only the specified fields are updated, preserving existing values. A common mistake is using --type=strategic without understanding how it merges nested objects. With strategic merge patching, resources.requests.memory updates only the requests object, but resources.requests as a whole replaces the entire map.

Optimizing Node Sizing and Scheduling

Nodes are the physical or virtual machines that host pods. Over-provisioning nodes leads to wasted vCPU and memory costs. The goal is to ensure that each node’s CPU and memory utilization are high and consistent.

To analyze node utilization, use kubectl top nodes, which returns the same JSON format as top pods but for nodes.

kubectl top nodes

This command collects data from the metrics server at http://metrics-server.kube-system.svc.cluster.local:8080/apis/metrics.k8s.io/v1beta1/nodes.

Each node entry includes cpu, memory, and timestamp. The cpu value is in milli-CPU (m), and memory in bytes with a suffix like Mi, Gi. A node with cpu: 1200m and memory: 4.2Gi consumes 1.2 CPU cores and 4.2 gibibytes of memory.

To find underutilized nodes, compare the sum of pod resource requests to the node’s total capacity. A node with 8 vCPUs and 16Gi memory hosting pods with a total request of 4.5 vCPUs and 8Gi memory is underutilized.

Use kubectl describe node <name> to inspect node capacity and conditions. The Allocated Resources block shows:

Allocated resources:
  (Total limits may be higher than available, but not more than capacity)
  Resource           Requests      Limits
  --------           --------      ------
  cpu                4500m (4.5)   7200m (7.2)
  memory             8192Mi (8Gi)  10240Mi (10Gi)
  ephemeral-storage  500Mi         1000Mi

The Capacity section lists the node’s total resources:

Capacity:
  cpu:                8
  memory:             16384Mi (16Gi)
  ephemeral-storage:  100Gi

A node is underutilized if both CPU and memory usage are below 70% of capacity and have low variance across a 15-minute window.

To reduce node count and cost, apply horizontal pod autoscaling (HPA) and cluster autoscaling (CA). The HPA uses the autoscaling/v2 API, defined in a HorizontalPodAutoscaler manifest:

apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
metadata:
  name: web-app-hpa
  namespace: production
spec:
  scaleTargetRef:
    apiVersion: apps/v1
    kind: Deployment
    name: web-app
  minReplicas: 2
  maxReplicas: 10
  metrics:
  - type: Resource
    resource:
      name: cpu
      target:
        type: Utilization
        averageUtilization: 70
  - type: Resource
    resource:
      name: memory
      target:
        type: Utilization
        averageUtilization: 75

The averageUtilization field sets the target across all replicas. If cpu and memory are both below their thresholds, the HPA scales up. The HPA controller polls the metrics server every 30 seconds and updates the deployment’s replicas field.

For cluster autoscaling, configure a ClusterAutoscaler object in the cluster-autoscaler namespace. The controller watches for un-schedulable pods and node pool metrics.

A common failure is not setting scaleDownUnneededTime and scaleDownDelayAfterAdd. Without scaleDownUnneededTime, nodes are scaled down too quickly after becoming idle. Without scaleDownDelayAfterAdd, newly added nodes are scaled down before they stabilize.

The ClusterAutoscaler uses a scaleDown algorithm that considers:

A silent failure occurs when the autoscaler adds a node but the pod fails to schedule due to missing node selectors or taints. The kubectl get events command reveals:

kubectl get events -n kube-system --field-selector=involvedObject.kind=Node


Type     Reason            Age   From                 Message
----     ------            ----  ----                 -------
Normal   NodeScheduled     2m    cluster-autoscaler   Successfully assigned pod-abc to node-123
Warning  FailedScheduling  1m    scheduler            0/10 nodes are available: 10 No resources.

The FailedScheduling event indicates that the scheduler could not place the pod on any node—often due to missing taints or required node labels.

Efficient Use of Resource Requests and Limits

Requests and limits define how Kubernetes schedules and manages pods. Requests determine where a pod is scheduled; limits define how much resource the pod can consume and how it is throttled.

A pod with resources.requests.cpu: 200m is scheduled to a node with at least 200 milli-CPU available. A node with 4 vCPUs (4000m) can host 20 such pods before reaching capacity.

Limits determine the maximum resource usage and how the kubelet applies CPU and memory throttling. For CPU, the cgroup controller uses a cpu.shares value. A pod with limits.cpu: 500m has cpu.shares: 500 in its cgroup.

A common mistake is setting limits.cpu without setting requests.cpu, leading to inconsistent scheduling and performance.

Use kubectl describe pod <name> to inspect the cgroup and memory limits:

kubectl describe pod my-app-7c9f8d5b6b -n production

The Status section includes Containers with Resource Requests and Resource Limits, and the Events section shows:

Events:
  Type     Reason            Age   From               Message
  ----     ------            ----  ----               -------
  Normal   Scheduled         5s    default-scheduler  Successfully assigned my-app-7c9f8d5b6b to node-123
  Normal   Pulled            4s    kubelet            Container image "my-app:v1.2" already present on machine
  Normal   Created           3s    kubelet            Created container app
  Normal   Started           2s    kubelet            Started container app
  Warning  MemoryPressure    1s    kubelet            Pod memory usage exceeds threshold

The MemoryPressure event is critical: when pod memory usage exceeds limits.memory, the kubelet applies memory throttling and may start evicting pods.

To detect memory pressure, use kubectl top pod --containers and monitor container.statuses[0].usage.memory over time. A pod that consistently uses 90% of its limit but spikes to 110% triggers MemoryPressure.

The most subtle failure is when requests.memory is set too low. A pod with requests.memory: 256Mi but consuming 500Mi is scheduled to a node with only 256Mi available, but the pod’s actual memory usage exceeds the request. The node’s Allocated Resources show:

Allocated resources:
  memory: 256Mi (256Mi)  512Mi (512Mi)

Here, 256Mi (256Mi) means the sum of requests.memory is 256Mi, but the limits.memory sum is 512Mi. This discrepancy causes memory contention and page faults.

To fix this, increase requests.memory to match observed usage. Use kubectl patch with a JSON merge patch:

kubectl patch deployment my-app -n production -p '
{
  "spec": {
    "template": {
      "spec": {
        "containers": [
          {
            "name": "app",
            "resources": {
              "requests": {
                "memory": "512Mi"
              },
              "limits": {
                "memory": "1Gi"
              }
            }
          }
        ]
      }
    }
  }
}' --type=merge

The --type=merge ensures that only resources in the container are updated, preserving existing image, ports, and env.

Cost-Driven Pod Lifecycle

Pods that run longer than necessary incur idle costs. A batch job that runs for 30 minutes but is scheduled for 8 hours wastes 7.5 hours of CPU and memory.

To optimize lifecycle, use kubectl apply with a Pod manifest that includes activeDeadlineSeconds and tolerations for node taints.

apiVersion: v1
kind: Pod
metadata:
  name: data-processor
  namespace: batch
spec:
  containers:
  - name: processor
    image: data-processor:v1
    resources:
      requests:
        cpu: 100m
        memory: 256Mi
      limits:
        cpu: 200m
        memory: 512Mi
  activeDeadlineSeconds: 1800
  nodeSelector:
    role: batch
  tolerations:
  - key: "node-role.kubernetes.io/batch"
    operator: "Exists"
    effect: "NoSchedule"
    tolerationSeconds: 3600

The activeDeadlineSeconds field sets the maximum time a pod can run. After 1800 seconds (30 minutes), the pod is terminated, even if it has not finished.

The nodeSelector ensures the pod runs on nodes labeled role: batch. The tolerations allow the pod to tolerate the node-role.kubernetes.io/batch taint, which is applied by the node-controller to all batch nodes.

When the pod is scheduled and runs, the kubectl get pod data-processor -o wide shows:

NAME              READY   STATUS    RESTARTS   AGE   IP           NODE
data-processor    1/1     Running   0          5m    10.244.1.5   batch-node-1

The STATUS field changes to Completed when the pod finishes. After activeDeadlineSeconds, it transitions to Failed with Reason: DeadlineExceeded.

To reduce idle costs, set imagePullPolicy: IfNotPresent and livenessProbe with a failureThreshold of 3.

livenessProbe:
  exec:
    command:
    - /bin/sh
    - -c
    - cat /tmp/healthy
  initialDelaySeconds: 30
  periodSeconds: 10
  failureThreshold: 3

The failureThreshold of 3 means the pod is considered failed after three consecutive probe failures. If the probe fails every 10 seconds, the pod is marked as unhealthy after 30 seconds of failure.

When a pod fails, the kubectl describe pod data-processor shows:

Events:
  Type     Reason            Age   From               Message
  ----     ------            ----  ----               -------
  Normal   Pulled            5s    kubelet            Container image "data-processor:v1" already present on machine
  Normal   Created           4s    kubelet            Created container processor
  Normal   Started           3s    kubelet            Started container processor
  Warning  Unhealthy         25s   kubelet            Liveness probe failed: Get http://10.244.1.5:8080/health: net/http: request canceled (Client.Timeout exceeded while awaiting headers)
  Normal   Killing           15s   kubelet            Container processor failed liveness probe, will be restarted
  Warning  Failed            10s   kubelet            Pod failed to reach activeDeadlineSeconds

The Failed event with Reason: DeadlineExceeded indicates that the pod was killed due to exceeding the deadline.

Summary

Kubernetes Cost Optimization is not a one-off task but an ongoing discipline. It begins with identifying misaligned resource requests and limits, continues with tuning node sizing and autoscaling, and culminates in aligning pod lifecycles with workload patterns. The tools are simple: kubectl, kubectl top, kubectl patch, and kubectl describe. The data flows through standard HTTP endpoints, JSON responses, and well-documented Kubernetes APIs. The patterns are universal: request vs. limit, activeDeadlineSeconds, horizontal and cluster autoscaling, and probe-based health checks. The failures are subtle: miscalculated cpu.shares, misinterpreted memory units, and unhandled tolerations. Mastering these patterns enables teams to maintain clusters that are both efficient and resilient.

This page was rewritten on 16 October 2026. It replaced a templated version that was not specific to its own title. How these pages are written and checked. If anything here is wrong, tell us at [email protected] and we will correct it.