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.
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.
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.
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.
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.
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.
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.
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.
We use cookies for analytics (Google Analytics) and advertising (Google AdSense) to improve your experience and support free content. Privacy Policy