FinOps for Kubernetes

Optimize Kubernetes costs with resource right-sizing, cluster autoscaling, workload placement, and cost visibility. Covers resource requests vs limits, spot instances for K8s, namespace cost allocation, and the patterns that prevent Kubernetes from becoming a money pit.

FinOps for Kubernetes is the practice of aligning cloud costs with Kubernetes workloads, enabling visibility, accountability, and control over infrastructure spending. It matters when teams deploy applications on Kubernetes across multiple clusters, namespaces, and environments—especially at scale—where cost drift, overspending, and lack of ownership are inevitable. FinOps for Kubernetes turns cost from a reactive, monthly report into a proactive, engineering-driven discipline.

Cost Visibility Across Clusters and Workloads

To begin, you need a clear, real-time view of costs tied to Kubernetes resources. The foundation is aggregating per-pod, per-namespace, and per-workload costs from cloud billing data and Kubernetes metadata.

Use kubectl to expose cost data through custom metrics. Define a Cost custom resource in the cluster:

apiVersion: cost.finops.example/v1
kind: Cost
metadata:
  name: web-app-production
  namespace: prod
spec:
  cluster: us-central1-a
  workload: web-app
  cost: 1847.32
  billingPeriod: "2024-04"
  resources:
    - name: web-app-deployment
      type: Deployment
      cost: 1234.56
      pods:
        - name: web-app-7c9b5d8f4c
          cost: 412.34
          cpu: 123.45
          memory: 298.90
          storage: 111.09
          network: 38.96

This CRD is populated by a controller that reconciles cloud billing data (from AWS Cost Explorer, Azure Cost Management, or GCP Billing API) with Kubernetes object metadata via a cost-sync job.

The job fetches daily billing data via the cloud’s REST API:

curl \
  -H "Authorization: Bearer $CLOUD_API_TOKEN" \
  -H "Accept: application/json" \
  "https://api.cloud-provider.com/v1/billing?startDate=2024-04-01&endDate=2024-04-30"

Response format:

{
  "billingPeriod": "2024-04",
  "totalCost": 123456.78,
  "lineItems": [
    {
      "resourceId": "pod-abc123",
      "serviceName": "EKS",
      "cost": 123.45,
      "tags": {
        "kubernetes.io/cluster": "prod-cluster",
        "app": "web-app",
        "environment": "production"
      },
      "usage": {
        "cpuHours": 234.5,
        "memoryGbHours": 1234.5,
        "networkGb": 123.45
      }
    }
  ]
}

The controller maps each resourceId to a Kubernetes object using kustomize-generated labels and reconciles the Cost CRs nightly. This yields a cost-per-pod view across all clusters.

A common failure: missing kubernetes.io/cluster labels on pods. Without this label, the reconciliation fails silently—costs are assigned to the wrong cluster or not at all. Use kubectl get pods -A -o wide to verify label coverage.

Cost Allocation by Namespace and Team

Each team owns a namespace. To assign cost responsibility, aggregate costs per namespace and correlate with team ownership.

Create a CostAllocation custom resource that captures team ownership:

apiVersion: cost.finops.example/v1
kind: CostAllocation
metadata:
  name: prod-team
  namespace: infra
spec:
  team: "prod"
  namespace: "prod"
  cost: 4567.89
  budget: 5000.00
  assignedTo: "[email protected]"
  costSources:
    - name: "web-app"
      cost: 1234.56
    - name: "auth-service"
      cost: 2345.67
    - name: "analytics-pipeline"
      cost: 987.66

Allocate costs using a simple algorithm: assign pod costs proportionally to the sum of CPU and memory usage per namespace.

Compute namespace cost with a cost-allocator script:

#!/bin/bash


# Fetch all pods and their resource usage from cloud billing
curl -s "https://api.cloud-provider.com/v1/billing?startDate=2024-04-01&endDate=2024-04-30" \
  | jq -r '
    .lineItems
    | map(select(.tags.namespace == "prod"))
    | reduce .[] as $item (0; . + $item.cost)
  ' > /tmp/prod-costs.json

# Sum total cost across all namespaces
total_cost=$(jq -r '.lineItems | map(.cost) | add' /tmp/prod-costs.json)

# For each namespace, compute cost share
for ns in $(kubectl get namespaces -o jsonpath='{.items[*].metadata.name}'); do
  namespace_cost=$(curl -s "https://api.cloud-provider.com/v1/billing?startDate=2024-04-01&endDate=2024-04-30" \
    | jq -r --arg ns "$ns" '
      .lineItems
      | map(select(.tags.namespace == $ns))
      | map(.cost)
      | add
    ')

  share=$(echo "$namespace_cost / $total_cost" | bc -l)

  echo "Namespace: $ns, Cost: $namespace_cost, Share: $share"
done

This script runs as a CronJob in the cluster:

apiVersion: batch/v1
kind: CronJob
metadata:
  name: cost-allocation
  namespace: infra
spec:
  schedule: "0 2 * * *"
  jobTemplate:
    spec:
      template:
        spec:
          containers:
            - name: allocator
              image: alpine:latest
              command: ["/bin/sh", "-c", "apk add --no-cache jq bc && /scripts/cost-allocator.sh"]
              volumeMounts:
                - name: scripts
                  mountPath: /scripts
          volumes:
            - name: scripts
              configMap:
                name: cost-allocator-scripts
          restartPolicy: OnFailure

The script fails silently if jq or bc are missing. Use kubectl logs cost-allocation-1234567890 to catch missing dependencies.

Real-Time Cost Anomaly Detection

Costs change daily. A spike in a service’s usage should trigger a cost anomaly alert.

Use Prometheus to collect resource usage metrics and Alertmanager to detect anomalies.

Define a rule in alerts.yml:

groups:
  - name: k8s-cost-anomalies
    interval: 1m
    rules:
      - alert: HighMemoryCost
        expr: |
          sum by (namespace, pod) (
            rate(container_memory_usage_bytes{job="kubernetes-pods"}[1h])
          ) * 0.000000001 * 60 * 1.5
        for: 15m
        labels:
          severity: warning
        annotations:
          summary: "Memory cost spike in {{ $labels.namespace }}/{{ $labels.pod }}"
          description: |
            Memory cost in pod {{ $labels.pod }} has exceeded expected cost by 50% over the last 15 minutes.
            Expected cost: {{ $value }}
            Current cost: {{ $value }}

This rule detects when a pod’s memory usage exceeds 150% of its baseline cost over the past hour.

A failure mode: Prometheus scrapes container_memory_usage_bytes, but the metric is missing for a subset of pods due to kubelet misconfiguration. Check /var/log/kubelet.log for Failed to get metrics from cAdvisor errors.

To validate, run:

kubectl exec -it -n monitoring prometheus-0 -- \
  promql 'rate(container_memory_usage_bytes{pod=~"web-app.*"}[1h])'

The output should show consistent, non-zero values per pod. If a pod has 0 memory usage, check that cAdvisor is running and --containerized is set in kubelet.

Capacity Planning with Cost Forecasting

Forecast future costs based on historical usage and projected growth.

Use Grafana to visualize cost trends and create a forecast dashboard.

Add a cost-forecast job that runs weekly:

#!/bin/bash
# cost-forecast.sh

# Fetch historical cost data
curl -s "https://api.cloud-provider.com/v1/billing?startDate=2023-01-01&endDate=2024-04-30" \
  | jq -r '
    .lineItems
    | group_by(.tags.namespace)
    | map({
        namespace: .[0].tags.namespace,
        costs: map(.cost),
        timestamps: map(.startTime)
      })
    | .[] | .timestamps | map(. | split("T")[0]) | join(",")
  ' > /tmp/forecast-data.csv

# Run forecast using R script
Rscript /scripts/forecast.R /tmp/forecast-data.csv /tmp/forecast-output.json

The R script uses forecast package to predict next 3 months:

# forecast.R
library(forecast)

args <- commandArgs(trailingOnly = TRUE)
input_file <- args[1]
output_file <- args[2]

data <- read.csv(input_file, header = TRUE, sep = ",")
names(data)[1] <- "date"

# Convert to time series
ts_data <- ts(data$cost, start = c(2023, 1), frequency = 12)

# Fit ARIMA model
fit <- auto.arima(ts_data)

# Forecast
fc <- forecast(fit, h = 3)

# Output JSON
json <- list(
  forecast = as.list(fc$mean),
  lower = as.list(fc$lower),
  upper = as.list(fc$upper),
  confidence = fc$confidence
)

write(json, output_file, pretty = TRUE)

The output is consumed by a Grafana dashboard with a cost-forecast panel that shows predicted costs per namespace and alerts when forecasted cost exceeds budget.

A common mistake: using monthly frequency in ts() but with daily data. This leads to under-smoothed forecasts. Use ts(data$cost, start = c(2023, 1), frequency = 365) for daily data.

Cost Optimization via Autoscaling and Resource Requests

Costs are not just about usage—they are shaped by how workloads are configured.

Set requests and limits in Deployment manifests:

apiVersion: apps/v1
kind: Deployment
metadata:
  name: web-app
  namespace: prod
spec:
  replicas: 3
  selector:
    matchLabels:
      app: web-app
  template:
    metadata:
      labels:
        app: web-app
    spec:
      containers:
        - name: web-app
          image: registry.company.com/web-app:v1.2.3
          resources:
            requests:
              cpu: "500m"
              memory: "1Gi"
            limits:
              cpu: "1"
              memory: "2Gi"
          ports:
            - containerPort: 8080

The requests field determines the cost of scheduling the pod; limits govern memory and CPU allocation.

Use kubectl top pods and kubectl top nodes to monitor actual usage:

kubectl top pods -n prod --containers

Output:

NAME                        CPU(cores)   MEMORY(bytes)
web-app-7c9b5d8f4c-0        380m           987Mi
web-app-7c9b5d8f4c-1        240m           856Mi
web-app-7c9b5d8f4c-2        160m           712Mi

If CPU(cores) is consistently below requests, you are over-provisioning. If MEMORY(bytes) exceeds limits, pods are throttled.

Use HorizontalPodAutoscaler to scale based on cost:

apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
metadata:
  name: web-app-hpa
  namespace: prod
spec:
  scaleTargetRef:
    apiVersion: apps/v1
    kind: Deployment
    name: web-app
  minReplicas: 2
  maxReplicas: 10
  metrics:
    - type: Resource
      resource:
        name: cpu
        target:
          type: AverageValue
          averageValue: "400m"
    - type: Resource
      resource:
        name: memory
        target:
          type: AverageValue
          averageValue: "1.2Gi"

This scales the deployment when average CPU is above 400m or average memory above 1.2Gi.

A silent failure: requests are set but limits are not. The cluster schedules pods based on requests, but memory pressure causes memory cost to exceed requests cost. Use kubectl describe pod web-app-7c9b5d8f4c-0 to verify.

Budgeting and Alerts

Set up a monthly budget with a BudgetMonitor controller.

apiVersion: cost.finops.example/v1
kind: Budget
metadata:
  name: prod-2024-04
  namespace: infra
spec:
  period: "2024-04"
  total: 10000.00
  alerts:
    - threshold: 0.8
      sendTo: "[email protected]"
      condition: "cost > 0.8 * total"
    - threshold: 1.0
      sendTo: "[email protected]"
      condition: "cost > 1.0 * total"

The controller checks the Cost CRs every 15 minutes:

#!/bin/bash
# budget-monitor.sh

while true; do
  cost=$(kubectl get cost prod-2024-04 -n infra -o jsonpath='{.spec.cost}')
  total=$(kubectl get budget prod-2024-04 -n infra -o jsonpath='{.spec.total}')

  ratio=$(echo "$cost / $total" | bc -l)

  if (( $(echo "$ratio >= 0.8" | bc -l) )); then
    echo "Budget alert: $ratio of target ($total)"
    # Send email via SMTP
    sendmail -t << EOF
To: [email protected]
Subject: Budget Alert: $ratio of target

Cost: $cost
Target: $total
Ratio: $ratio
EOF
  fi

  sleep 900
done

Run as a Deployment with readinessProbe and livenessProbe:

livenessProbe:
  exec:
    command:
      - /bin/sh
      - -c
      - "pgrep budget-monitor.sh || exit 1"
  initialDelaySeconds: 30
  periodSeconds: 60

Failure: budget-monitor.sh exits with 1, but kubectl shows no logs. Check kubectl logs deployment/budget-monitor—the script is not in PATH. Add /scripts to PATH in the container.

Summary

FinOps for Kubernetes is not a set of tools—it is a discipline of cost-aware engineering. It begins with visibility, deepens with allocation, and matures into forecasting and optimization. Each step reveals silent failures: missing labels, misconfigured metrics, unmonitored services. The practice demands consistent data flow, well-defined APIs, and a culture where cost is not a finance report but a developer’s daily concern.

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.