LUNAROPS · OPERATIONAL UPLINK 100% UPTIME 1,247d POSTS 893 JEFF.MOON@LUNAROPS.DEV UTC --:--:--

OpenCost: Kubernetes Cost Attribution

kubernetesfinopsopencostcostprometheusgrafanacloud

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:

  1. Enable Spot Instance Data Feed in EC2 console → Spot Requests → Spot Instance Data Feed → Enable
  2. 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:

  1. Deploy OpenCost with Helm, point it at your Prometheus
  2. Configure cloud provider credentials for live pricing
  3. Add the ServiceMonitor so Prometheus scrapes cost metrics
  4. Import the Grafana dashboards (IDs 22208 and 22252)
  5. Establish a label standard (team, cost-center, environment) and enforce it with Gatekeeper
  6. Run the monthly chargeback report to show teams their actual spend
  7. 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