Your Kubernetes cluster bill arrives as a single number. The platform team knows the total. Nobody knows which team or workload generated which portion of it. When you ask “why did our bill go up 40% this month?” nobody has a good answer.
OpenCost solves this. It’s a CNCF project that runs inside your cluster, watches resource allocation and usage, pulls live pricing from your cloud provider’s API, and continuously computes the cost of every pod, deployment, namespace, and label. The result is a per-namespace cost breakdown that lets you answer “the data-pipeline namespace now costs $3,200/month because someone deployed a Spark job with a 128Gi memory request.”
This guide walks through deployment, cloud provider configuration, the Prometheus metrics and API, and how to build a chargeback workflow with labels.
How the Cost Model Works
OpenCost’s core formula is simple:
Workload Cost = max(requested resources, actual usage) × hourly provider price
The max() matters. If a container requests 4 CPUs but only uses 1, you’re charged for 4 — because those 4 CPUs are reserved on the node and unavailable to other workloads. This matches how cloud billing actually works and correctly penalizes over-provisioning.
Costs decompose into components:
| Component |
Source |
| CPU |
container_cpu_allocation × node_cpu_hourly_cost |
| Memory |
container_memory_allocation_bytes × node_ram_hourly_cost |
| GPU |
container_gpu_allocation × node_gpu_hourly_cost |
| Persistent volumes |
PV capacity × storage class cost |
| Load balancers |
Per-LB hourly cost × namespace |
| Network egress |
Bytes transferred × egress rate |
Node prices come from your cloud provider’s pricing API, updated continuously. For on-premises clusters you supply your own rates.
Idle cost is the gap between what nodes cost and what workloads are charged:
Idle Cost = Cluster Node Cost − Sum(All Workload Costs)
A cluster that’s 60% idle has 40% efficiency. That idle cost gets attributed to the __idle__ allocation by default, or you can distribute it proportionally across namespaces.
Architecture
┌──────────────────────────────────────────────────────┐
│ Kubernetes Cluster │
│ │
│ ┌────────────┐ ┌───────────┐ ┌─────────────┐ │
│ │ cAdvisor │ │ kube-stat │ │ node-exp. │ │
│ │ (per node)│ │ -metrics │ │ (per node) │ │
│ └─────┬──────┘ └─────┬─────┘ └──────┬──────┘ │
│ └────────────┬────┘ │ │
│ ▼ │ │
│ ┌────────────┐ │ │
│ │ Prometheus │◄───────────────┘ │
│ └──────┬─────┘ │
│ │ scrapes /metrics │
│ ▼ │
│ ┌────────────────────────────────────┐ │
│ │ OpenCost (port 9003) │ │
│ │ Cost model · Allocation engine │ │
│ │ Cloud pricing API client │ │
│ └──────┬─────────────────────────────┘ │
│ │ │
│ ┌───────────┴──────────────┐ │
│ │ │ │
│ ┌────▼─────┐ ┌───────▼──────┐ │
│ │ Web UI │ │ /metrics │ │
│ │ :9090 │ │ :9003 │ │
│ └──────────┘ └───────┬──────┘ │
│ │ │
└────────────────────────────────────┼─────────────────┘
│ scrapes
┌───────▼──────┐
│ Prometheus │
│ + Grafana │
└──────────────┘
│
┌──────────▼──────────┐
│ Cloud Provider API │
│ AWS / GCP / Azure │
└─────────────────────┘
OpenCost runs as a single Deployment. It reads Kubernetes resource allocation from Prometheus (which scrapes cAdvisor and kube-state-metrics), fetches node pricing from your cloud provider’s API, and continuously computes allocation costs. It exposes the results via a REST API on port 9003 and Prometheus metrics on the same port at /metrics.
Prerequisites
You need Prometheus running in your cluster. The standard setup uses the kube-prometheus-stack Helm chart, which installs Prometheus, Alertmanager, and Grafana with pre-configured scrape configs for cAdvisor and kube-state-metrics.
1
2
3
4
5
6
7
8
|
helm repo add prometheus-community https://prometheus-community.github.io/helm-charts
helm repo update
helm install kube-prometheus-stack prometheus-community/kube-prometheus-stack \
--namespace monitoring \
--create-namespace \
--set prometheus.prometheusSpec.retention=30d \
--set grafana.enabled=true
|
Note the Prometheus service name and namespace — you’ll need them for OpenCost configuration.
Installation
1
2
|
helm repo add opencost-charts https://opencost.github.io/opencost-helm-chart
helm repo update
|
Create values.yaml:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
|
opencost:
exporter:
# Point to your Prometheus instance
defaultClusterId: "my-homelab"
cloudProviderApiKey: "" # GCP only — leave empty for AWS/Azure/on-prem
prometheus:
# Adjust to match your Prometheus deployment
internal:
enabled: true
namespaceName: monitoring
port: 9090
serviceName: kube-prometheus-stack-prometheus
# Or use an external Prometheus:
# external:
# enabled: true
# url: "http://prometheus.monitoring.svc.cluster.local:9090"
ui:
enabled: true
# On-prem / custom pricing (overrides cloud API)
# customPricing:
# enabled: true
# configPath: /var/configs/
# provider: custom
# description: "On-prem cluster"
# CPU: "0.031611" # Per CPU-hour
# RAM: "0.004237" # Per GB-hour
# storage: "0.00005479" # Per GB-hour
# zoneNetworkEgress: "0.01"
# regionNetworkEgress: "0.01"
# internetNetworkEgress: "0.143"
serviceAccount:
create: true
annotations: {}
# AWS IRSA:
# eks.amazonaws.com/role-arn: arn:aws:iam::ACCOUNT_ID:role/opencost-role
# GKE Workload Identity:
# iam.gke.io/workload-identity-pool: PROJECT.svc.id.goog
persistentVolume:
enabled: true
size: 5Gi
storageClass: "" # Use default storage class
|
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
|
helm install opencost opencost-charts/opencost \
--namespace opencost \
--create-namespace \
-f values.yaml
# Verify it's running
kubectl get pods -n opencost
kubectl logs -n opencost -l app.kubernetes.io/name=opencost -f
# Access the UI
kubectl port-forward -n opencost service/opencost 9090:9090
# http://localhost:9090
# Access the API
kubectl port-forward -n opencost service/opencost 9003:9003
|
Cloud Provider Configuration
AWS
OpenCost auto-detects EC2 on-demand pricing via the public pricing API. For spot pricing and actual billed amounts (with savings plans and reserved instance discounts applied), you need additional configuration.
On-demand pricing works immediately with no extra config — OpenCost reads the node’s topology.kubernetes.io/region label and queries the AWS pricing endpoint.
Spot pricing requires an S3 Spot Instance Data Feed:
- Enable Spot Instance Data Feed in EC2 console → Spot Requests → Spot Instance Data Feed → Enable
- Note the S3 bucket name and prefix
Cost and Usage Report (for actual billed amounts including discounts):
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
|
# Create IAM policy for OpenCost
cat > opencost-policy.json <<'EOF'
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Action": [
"s3:GetObject",
"s3:ListBucket"
],
"Resource": [
"arn:aws:s3:::your-cur-bucket",
"arn:aws:s3:::your-cur-bucket/*"
]
},
{
"Effect": "Allow",
"Action": [
"athena:StartQueryExecution",
"athena:GetQueryExecution",
"athena:GetQueryResults"
],
"Resource": "*"
},
{
"Effect": "Allow",
"Action": [
"glue:GetDatabase",
"glue:GetTable",
"glue:GetPartitions"
],
"Resource": "*"
}
]
}
EOF
aws iam create-policy \
--policy-name OpenCostPolicy \
--policy-document file://opencost-policy.json
|
With IRSA (IAM Roles for Service Accounts), annotate the service account:
1
2
3
|
serviceAccount:
annotations:
eks.amazonaws.com/role-arn: arn:aws:iam::123456789012:role/opencost-role
|
Store the S3/Athena config in a Kubernetes secret:
1
2
3
4
5
6
7
8
|
kubectl create secret generic cloud-costs \
--namespace opencost \
--from-literal=ATHENA_BUCKET_NAME=s3://your-athena-results/ \
--from-literal=ATHENA_REGION=us-east-1 \
--from-literal=ATHENA_DATABASE=your_cur_database \
--from-literal=ATHENA_TABLE=your_cur_table \
--from-literal=SPOT_FEED_BUCKET=your-spot-feed-bucket \
--from-literal=SPOT_PREFIX=your-prefix
|
GCP
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
|
# Create service account
gcloud iam service-accounts create opencost-sa \
--display-name "OpenCost Service Account"
# Grant required roles
gcloud projects add-iam-policy-binding YOUR_PROJECT \
--member="serviceAccount:opencost-sa@YOUR_PROJECT.iam.gserviceaccount.com" \
--role="roles/compute.viewer"
gcloud projects add-iam-policy-binding YOUR_PROJECT \
--member="serviceAccount:opencost-sa@YOUR_PROJECT.iam.gserviceaccount.com" \
--role="roles/bigquery.user"
gcloud projects add-iam-policy-binding YOUR_PROJECT \
--member="serviceAccount:opencost-sa@YOUR_PROJECT.iam.gserviceaccount.com" \
--role="roles/bigquery.jobUser"
# Generate key
gcloud iam service-accounts keys create opencost-key.json \
--iam-account opencost-sa@YOUR_PROJECT.iam.gserviceaccount.com
# Create Kubernetes secret
kubectl create secret generic gcp-service-key \
--namespace opencost \
--from-file=./opencost-key.json
|
On GKE, use Workload Identity instead of key files — annotate the service account and bind it to the GCP service account:
1
2
3
|
serviceAccount:
annotations:
iam.gke.io/workload-identity-pool: "YOUR_PROJECT.svc.id.goog"
|
For Helm values, set your GCP API key:
1
2
3
|
opencost:
exporter:
cloudProviderApiKey: "YOUR_GCP_API_KEY"
|
Azure
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
|
# Create custom role
az role definition create --role-definition '{
"Name": "OpenCostRole",
"IsCustom": true,
"Actions": [
"Microsoft.Commerce/RateCard/read",
"Microsoft.Commerce/UsageAggregates/read"
],
"AssignableScopes": ["/subscriptions/YOUR_SUBSCRIPTION_ID"]
}'
# Create service principal
az ad sp create-for-rbac \
--name opencost-sp \
--role "OpenCostRole" \
--scopes /subscriptions/YOUR_SUBSCRIPTION_ID
# Note the output: appId, password, tenantId
|
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
|
cat > azure-config.json <<EOF
{
"subscriptionId": "YOUR_SUBSCRIPTION_ID",
"serviceKey": {
"appId": "YOUR_APP_ID",
"displayName": "opencost-sp",
"password": "YOUR_PASSWORD",
"tenant": "YOUR_TENANT_ID"
}
}
EOF
kubectl create secret generic azure-service-key \
--namespace opencost \
--from-file=service-key.json=./azure-config.json
|
On-premises / custom pricing
For bare-metal or private cloud, override the default pricing (which uses GCP us-central1 rates) with your actual hardware costs:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
|
opencost:
customPricing:
enabled: true
provider: custom
description: "On-prem cluster - amortized hardware cost"
# Per CPU-hour (amortize server cost over 3-year lifespan)
# Example: $8000 server, 32 CPUs, 26,280 hours over 3 years
# $8000 / (32 × 26,280) = $0.00953/CPU-hour
CPU: "0.0095"
# Per GB RAM-hour
RAM: "0.0012"
# Per GB storage-hour (NFS/Ceph)
storage: "0.000054"
# Network egress (internal only for on-prem)
zoneNetworkEgress: "0.0"
regionNetworkEgress: "0.0"
internetNetworkEgress: "0.08"
|
You can also mount a custom pricing JSON file for more granular control:
1
2
3
4
5
6
7
8
9
10
11
12
|
opencost:
customPricing:
enabled: true
configPath: /var/configs/
# Mount a ConfigMap with custom-pricing.json
extraVolumes:
- name: custom-pricing
configMap:
name: opencost-custom-pricing
extraVolumeMounts:
- name: custom-pricing
mountPath: /var/configs/
|
The Allocation API
The API on port 9003 is the most useful interface for automation and CI/CD integration.
Basic queries
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
|
BASE="http://localhost:9003"
# Total costs by namespace for the past 7 days
curl -sG "$BASE/allocation" \
-d window=7d \
-d aggregate=namespace \
-d includeIdle=true | jq '.data[] | to_entries[] | {ns: .key, cost: .value.totalCost}'
# Daily cost breakdown by namespace for the last month
curl -sG "$BASE/allocation" \
-d window=30d \
-d step=1d \
-d aggregate=namespace | jq .
# Cost by deployment in a specific namespace
curl -sG "$BASE/allocation" \
-d window=7d \
-d aggregate=deployment \
-d filterNamespaces=production | jq .
# Cost by pod (most granular)
curl -sG "$BASE/allocation" \
-d window=24h \
-d aggregate=pod \
-d filterNamespaces=data-pipeline | jq '.data[0] | to_entries | sort_by(.value.totalCost) | reverse | .[0:10]'
|
Aggregating by label
Label-based aggregation is the foundation of chargeback. Tag your workloads:
1
2
3
4
5
6
7
|
# deployment.yaml
metadata:
labels:
team: platform
cost-center: eng-infra
environment: production
app: api-gateway
|
Then query by those labels:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
|
# Cost by team label
curl -sG "$BASE/allocation" \
-d window=30d \
-d aggregate=label:team | jq '.data[0]'
# Cost by cost-center, filtered to production
curl -sG "$BASE/allocation" \
-d window=30d \
-d aggregate=label:cost-center \
-d filterLabels=environment:production | jq .
# Multi-dimension: namespace + team
curl -sG "$BASE/allocation" \
-d window=7d \
-d aggregate=namespace,label:team | jq .
|
Understanding the response
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
|
{
"data": [
{
"production": {
"name": "production",
"properties": {
"cluster": "my-homelab",
"namespace": "production"
},
"window": {
"start": "2026-03-22T00:00:00Z",
"end": "2026-03-29T00:00:00Z"
},
"cpuCost": 12.34,
"cpuCostAdjustment": 0,
"gpuCost": 0,
"networkCost": 0.52,
"loadBalancerCost": 3.60,
"pvCost": 1.20,
"ramCost": 4.56,
"ramCostAdjustment": 0,
"sharedCost": 0,
"externalCost": 0,
"totalCost": 22.22,
"totalEfficiency": 0.43,
"cpuEfficiency": 0.38,
"ramEfficiency": 0.51
}
}
],
"message": "Data requested from 2026-03-22T00:00:00Z to 2026-03-29T00:00:00Z"
}
|
totalEfficiency: 0.43 means the production namespace uses 43% of what it requests — significant over-provisioning.
Assets API (node-level costs)
1
2
3
4
5
6
7
8
9
|
# Total infrastructure cost by node
curl -sG "$BASE/assets" \
-d window=7d \
-d aggregate=node | jq '.data[0] | to_entries[] | {node: .key, cost: .value.totalCost}'
# Cloud costs (if configured)
curl -sG "$BASE/cloudCost" \
-d window=30d \
-d aggregate=service | jq .
|
Prometheus Metrics
OpenCost exposes Prometheus metrics at :9003/metrics. Add it to your Prometheus scrape config:
1
2
3
4
5
6
7
8
9
|
# prometheus-additional-scrape-configs.yaml
- job_name: opencost
honor_labels: true
scrape_interval: 1m
scrape_timeout: 30s
metrics_path: /metrics
static_configs:
- targets:
- opencost.opencost.svc.cluster.local:9003
|
If you’re using kube-prometheus-stack, add this via the additionalScrapeConfigs value or create a ServiceMonitor:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
|
apiVersion: monitoring.coreos.com/v1
kind: ServiceMonitor
metadata:
name: opencost
namespace: opencost
labels:
release: kube-prometheus-stack # Must match your Prometheus selector
spec:
selector:
matchLabels:
app.kubernetes.io/name: opencost
endpoints:
- port: http
path: /metrics
interval: 1m
scrapeTimeout: 30s
namespaceSelector:
matchNames:
- opencost
|
Key metrics
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
|
# ── Hourly costs ──────────────────────────────────────────────────
# Node cost per hour (all nodes)
sum(node_total_hourly_cost)
# CPU cost per hour by namespace
sum(
container_cpu_allocation * on(node) group_left() node_cpu_hourly_cost
) by (namespace)
# RAM cost per hour by namespace
sum(
container_memory_allocation_bytes / 1024 / 1024 / 1024
* on(node) group_left() node_ram_hourly_cost
) by (namespace)
# ── Monthly projections ───────────────────────────────────────────
# Projected monthly cluster cost
sum(node_total_hourly_cost) * 730
# Projected monthly cost by namespace
sum(
container_cpu_allocation * on(node) group_left() node_cpu_hourly_cost
+ container_memory_allocation_bytes / 1024 / 1024 / 1024
* on(node) group_left() node_ram_hourly_cost
) by (namespace) * 730
# ── Efficiency ────────────────────────────────────────────────────
# CPU efficiency by namespace (usage / request)
sum(rate(container_cpu_usage_seconds_total[5m])) by (namespace)
/ sum(container_cpu_allocation) by (namespace)
# Load balancer costs by namespace
sum(kubecost_load_balancer_cost) by (namespace)
# Storage costs by namespace
sum(kubecost_pv_info * on(persistentvolume) group_left()
kube_persistentvolume_capacity_bytes / 1024 / 1024 / 1024
* kubecost_node_core_hours_total) by (namespace)
|
Alerting rules
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
|
# opencost-alerts.yaml
apiVersion: monitoring.coreos.com/v1
kind: PrometheusRule
metadata:
name: opencost-alerts
namespace: opencost
labels:
release: kube-prometheus-stack
spec:
groups:
- name: opencost
rules:
# Alert when hourly spend spikes >25% vs 7-day average
- alert: KubernetesHourlyCostSpike
expr: |
sum(node_total_hourly_cost)
> 1.25 * avg_over_time(sum(node_total_hourly_cost)[7d:1h])
for: 15m
labels:
severity: warning
annotations:
summary: "Kubernetes hourly cost spike detected"
description: "Cluster hourly cost {{ $value | humanize }} is >25% above 7-day average"
# Alert on very low CPU efficiency (over-provisioning)
- alert: NamespaceLowCPUEfficiency
expr: |
sum(rate(container_cpu_usage_seconds_total[30m])) by (namespace)
/ sum(container_cpu_allocation) by (namespace)
< 0.15
for: 1h
labels:
severity: info
annotations:
summary: "Low CPU efficiency in {{ $labels.namespace }}"
description: "Namespace {{ $labels.namespace }} CPU efficiency is {{ $value | humanizePercentage }}"
# Alert when a namespace exceeds monthly budget projection
- alert: NamespaceMonthlyBudgetExceeded
expr: |
(sum(
container_cpu_allocation * on(node) group_left() node_cpu_hourly_cost
+ container_memory_allocation_bytes / 1024 / 1024 / 1024
* on(node) group_left() node_ram_hourly_cost
) by (namespace) * 730)
> on(namespace) group_left()
kube_namespace_labels{label_monthly_budget!=""}
* on(namespace) group_left(label_monthly_budget)
(kube_namespace_labels | label_replace(
kube_namespace_labels, "threshold", "$1", "label_monthly_budget", "(.+)"
))
labels:
severity: warning
annotations:
summary: "{{ $labels.namespace }} projected to exceed monthly budget"
|
Grafana Dashboards
Import the official OpenCost dashboards from grafana.com:
- OpenCost Overview (ID: 22208) — cluster-wide cost summary, top namespaces, efficiency
- OpenCost Namespace (ID: 22252) — drill-down into a single namespace
Or provision them automatically:
1
2
3
4
5
6
7
8
9
10
11
|
# grafana-dashboards-configmap.yaml
apiVersion: v1
kind: ConfigMap
metadata:
name: opencost-dashboards
namespace: monitoring
labels:
grafana_dashboard: "1" # Grafana sidecar auto-imports labeled ConfigMaps
data:
opencost-overview.json: |
{ "id": null, "uid": "opencost-overview", ... }
|
Key panels to build yourself:
1
2
3
4
5
6
7
8
9
10
11
|
# "Top 10 namespaces by projected monthly cost" (Bar chart)
topk(10,
sum(
container_cpu_allocation * on(node) group_left() node_cpu_hourly_cost
+ container_memory_allocation_bytes / 1024 / 1024 / 1024
* on(node) group_left() node_ram_hourly_cost
) by (namespace) * 730
)
# "Cost vs efficiency scatter" — high cost + low efficiency = right-size candidates
# X axis: efficiency, Y axis: monthly cost, colored by namespace
|
kubectl-cost Plugin
For quick cost lookups from the CLI:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
|
# Install via krew
kubectl krew install cost
# Namespace costs this month
kubectl cost namespace --window month
# Deployment costs in the production namespace
kubectl cost deployment --namespace production --window 7d
# Pod-level costs sorted by total
kubectl cost pod --namespace data-pipeline --window 24h --show-all-resources
# Label-based costs
kubectl cost label --label team --window 30d
|
Example output:
+-----------+----------+--------+--------+---------+
| NAMESPACE | CPU | MEMORY | PV | TOTAL |
+-----------+----------+--------+--------+---------+
| production| $18.23 | $6.41 | $2.10 | $26.74 |
| staging | $4.11 | $1.98 | $0.50 | $6.59 |
| data-pipe | $31.05 | $12.33 | $8.00 | $51.38 |
| __idle__ | $22.14 | $9.87 | — | $32.01 |
+-----------+----------+--------+--------+---------+
| TOTAL | $75.53 | $30.59 | $10.60 | $116.72 |
+-----------+----------+--------+--------+---------+
Chargeback and Showback Workflows
Label-based cost centers
Establish a labeling standard across your organization. Every workload gets these labels:
1
2
3
4
5
|
labels:
team: platform # Engineering team
cost-center: cc-1042 # Finance cost center code
environment: production # dev / staging / production
project: api-v2 # Project or initiative
|
Enforce this with an admission webhook or OPA Gatekeeper policy:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
|
# Gatekeeper constraint requiring cost labels
apiVersion: constraints.gatekeeper.sh/v1beta1
kind: K8sRequiredLabels
metadata:
name: require-cost-labels
spec:
match:
kinds:
- apiGroups: ["apps"]
kinds: ["Deployment", "StatefulSet", "DaemonSet"]
parameters:
labels:
- key: team
- key: cost-center
- key: environment
|
Monthly chargeback report script
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
|
#!/usr/bin/env bash
# monthly-cost-report.sh — generate per-team cost report
OPENCOST_URL="http://localhost:9003"
MONTH_START=$(date -d "$(date +%Y-%m-01)" +%Y-%m-%dT00:00:00Z)
MONTH_END=$(date +%Y-%m-%dT%H:%M:%SZ)
WINDOW="${MONTH_START},${MONTH_END}"
echo "=== Kubernetes Cost Report: $(date +%B\ %Y) ==="
echo ""
echo "--- Cost by Team ---"
curl -sG "$OPENCOST_URL/allocation" \
-d "window=${WINDOW}" \
-d aggregate=label:team \
--data-urlencode "filterLabels=environment:production" \
| jq -r '
.data[0] | to_entries[]
| [.key, (.value.cpuCost | tostring), (.value.ramCost | tostring),
(.value.pvCost | tostring), (.value.totalCost | tostring)]
| @tsv
' | column -t -s $'\t' -N "TEAM,CPU_COST,RAM_COST,PV_COST,TOTAL"
echo ""
echo "--- Cost by Cost Center ---"
curl -sG "$OPENCOST_URL/allocation" \
-d "window=${WINDOW}" \
-d aggregate=label:cost-center \
| jq -r '.data[0] | to_entries[] | [.key, (.value.totalCost | round | tostring)] | @tsv' \
| sort -t $'\t' -k2 -rn \
| column -t -s $'\t' -N "COST-CENTER,MONTHLY_COST_USD"
echo ""
echo "--- Efficiency by Namespace ---"
curl -sG "$OPENCOST_URL/allocation" \
-d "window=${WINDOW}" \
-d aggregate=namespace \
| jq -r '
.data[0] | to_entries[]
| [.key,
(.value.totalCost | round | tostring),
((.value.totalEfficiency * 100) | round | tostring) + "%"]
| @tsv
' | sort -t $'\t' -k2 -rn \
| column -t -s $'\t' -N "NAMESPACE,TOTAL_COST,EFFICIENCY"
|
CI/CD cost gate
Add a cost check to your deployment pipeline to surface resource request changes before they reach production:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
|
#!/usr/bin/env bash
# cost-gate.sh — fail CI if deployment cost exceeds threshold
NAMESPACE="${1:-production}"
THRESHOLD_USD="${2:-100}" # Monthly cost threshold per deployment
# Get current cost for the deployment being updated
DEPLOYMENT_COST=$(curl -sG "http://opencost.opencost.svc.cluster.local:9003/allocation" \
-d window=7d \
-d aggregate=deployment \
-d filterNamespaces="${NAMESPACE}" \
| jq --arg dep "$DEPLOYMENT_NAME" \
'.data[0][$dep].totalCost * 4.33') # Weekly → monthly
if (( $(echo "$DEPLOYMENT_COST > $THRESHOLD_USD" | bc -l) )); then
echo "❌ Cost gate failed: ${DEPLOYMENT_NAME} projected monthly cost"
echo " \$${DEPLOYMENT_COST} exceeds threshold \$${THRESHOLD_USD}"
exit 1
fi
echo "✅ Cost gate passed: ${DEPLOYMENT_NAME} projected \$${DEPLOYMENT_COST}/month"
|
OpenCost vs Kubecost
OpenCost started inside Kubecost. In 2022, Kubecost donated it to the CNCF as a vendor-neutral specification and implementation. The relationship today:
| Feature |
OpenCost (free) |
Kubecost (commercial) |
| Allocation by namespace/label |
Yes |
Yes |
| On-demand pricing |
Yes |
Yes |
| Spot/preemptible pricing |
Manual config |
Automatic |
| Reserved instance discounts |
No |
Yes |
| Savings plan attribution |
No |
Yes |
| Multi-cluster aggregation |
No |
Yes |
| RBAC for cost data |
Basic |
Advanced |
| Budget alerts |
Via Prometheus |
Built-in |
| Anomaly detection |
Via Prometheus |
Built-in |
| Network cost breakdown |
Basic |
Detailed |
| Support |
Community |
Commercial SLA |
For single-cluster setups on on-demand instances — the majority of homelabs and small-to-medium production clusters — OpenCost covers everything you need. Kubecost’s advantages become meaningful at scale (100+ nodes, multi-cluster) or when RI/SP discount attribution matters for accurate chargeback.
Practical Workflow: Finding What to Rightsize
A common first exercise after deploying OpenCost: find the workloads with the highest cost-to-efficiency ratio.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
|
# Find namespaces with >$50/month projected cost AND <30% efficiency
curl -sG "http://localhost:9003/allocation" \
-d window=7d \
-d aggregate=namespace \
| jq -r '
.data[0] | to_entries[]
| select(
(.value.totalCost * 4.33) > 50
and .value.totalEfficiency < 0.30
)
| [.key,
((.value.totalCost * 4.33) | round | tostring),
((.value.totalEfficiency * 100) | round | tostring) + "%",
(.value.cpuEfficiency * 100 | round | tostring) + "%",
(.value.ramEfficiency * 100 | round | tostring) + "%"]
| @tsv
' | column -t -s $'\t' \
-N "NAMESPACE,MONTHLY_COST,TOTAL_EFF,CPU_EFF,RAM_EFF"
|
Example output:
NAMESPACE MONTHLY_COST TOTAL_EFF CPU_EFF RAM_EFF
data-pipeline $214 18% 12% 28%
ml-training $156 22% 31% 8%
legacy-app $67 25% 20% 34%
data-pipeline costs $214/month but only uses 18% of what it requests. Drill deeper:
1
|
kubectl cost deployment --namespace data-pipeline --window 7d --show-all-resources
|
You’ll likely find a Spark driver pod sitting idle between jobs with enormous resource requests that nobody cleaned up. Rightsizing that one deployment could save $170/month.
Summary
OpenCost answers the question every platform team eventually faces: “where is our Kubernetes spend going?” The installation is lightweight, the data model is straightforward, and the API makes it easy to build automation on top.
The path from zero to cost visibility:
- Deploy OpenCost with Helm, point it at your Prometheus
- Configure cloud provider credentials for live pricing
- Add the ServiceMonitor so Prometheus scrapes cost metrics
- Import the Grafana dashboards (IDs 22208 and 22252)
- Establish a label standard (
team, cost-center, environment) and enforce it with Gatekeeper
- Run the monthly chargeback report to show teams their actual spend
- Use the efficiency metrics to identify rightsizing candidates
The idle cost number is usually the most surprising thing people see on first install. A cluster at 40% efficiency is silently paying for 60% of its compute to do nothing.
Comments