# Cost tracking

> What pausing a workload frees, and how much of that comes off your bill.

Source: https://docs.napwake.dev/next/concepts/cost-tracking/

CPU

1 vCPU3 vCPU

4 vCPU paused, 1 vCPU spare. **3 vCPU short: saved**

Memory

12 GiB

12 GiB paused, 14 GiB spare. **None short: nothing saved**

**Fits in spare capacity: freed**Wouldn't fit: freed, and saved

The [worked example](https://docs.napwake.dev/next/concepts/cost-tracking/#worked-example)'s node pool. Pausing freed all of it. Only what the pool couldn't take back, 3 vCPU, came off the bill.

Napwake tracks what each workload costs, frees and saves, in its ManagedWorkload’s `status.cost`. Tracking is always on. The figures cover the current calendar month in UTC. Each month starts from zero, and last month’s figures aren’t kept.

## Freed and saved

Freed becomes saved only once nodes go. The two numbers answer different questions:

|       | Field            | What it is                                                                                                       |
| ----- | ---------------- | ---------------------------------------------------------------------------------------------------------------- |
| Freed | `freedThisMonth` | What the paused replicas requested, at their nodes’ list price, while paused. What pausing released on its nodes |
| Saved | `savedThisMonth` | The part of that which provably came off your bill                                                               |

Napwake works at the workload layer, and your bill is for nodes. Pausing removes pods, which frees what they requested. That’s money only once a cluster autoscaler removes nodes, such as Cluster Autoscaler, Karpenter or a managed equivalent. With none, or with scale-down blocked, a pause saves nothing, and `savedThisMonth` says so.

### How saved is measured

Saved is what the pauses hold off the bill right now. Every 5 minutes, the operator’s leader measures it in each node pool, asking how much of what’s paused there its nodes could hold if it came back:

|           | In CPU and in memory                                                                                                                          |
| --------- | --------------------------------------------------------------------------------------------------------------------------------------------- |
| Paused    | What the replicas Napwake holds paused in the pool requested                                                                                  |
| Spare     | What the pool’s schedulable, Ready nodes have left, allocatable less what their bound pods request, finished pods aside                       |
| Shortfall | `max(0, paused − spare)`. Bringing the paused replicas back would need that much more capacity, so it costs at least that                     |
| Credited  | The shortfall, shared by the pool’s paused workloads in proportion to what each holds paused there, as `savedCPUHours` and `savedMemoryHours` |
| Priced    | `savedThisMonth` is those hours at the workload’s rates, as for freed, `costTracking.rates` included                                          |
| Recorded  | A pause records its pools, and its pods in each, in `status.pause.resources.nodePools`                                                        |

Saved is a **lower bound**. CPU and memory are compared apart, and spare is summed across the pool’s nodes, as if pods could be split between them. Real scheduling only ever needs more. So saved never credits capacity that’s still there, and can undercount but never overcount.

Nodes removed for any reason count, if the paused replicas would need them back. That’s by an autoscaler after a pause, by consolidation, or by hand. If other workloads scale down, the paused replicas fit in what they leave, so the credit falls.

#### Worked example

|                                   | In the example                                                                                     |
| --------------------------------- | -------------------------------------------------------------------------------------------------- |
| Pool                              | Three `m6i.xlarge` nodes in us-east-1, 4 vCPU and 16 GiB each. Its pods request 11 vCPU and 34 GiB |
| Paused                            | `api`, 4 replicas of 500m and 1 GiB, and `worker`, 2 replicas of 1 vCPU and 4 GiB                  |
| Paused in all                     | 4 vCPU and 12 GiB                                                                                  |
| Spare                             | 1 vCPU and 14 GiB                                                                                  |
| Shortfall                         | 3 vCPU. No memory, which would fit, so memory saves nothing                                        |
| Credited                          | 1.5 vCPU each to `api` and `worker`, which hold 2 vCPU each                                        |
| Credited every 5 minutes          | 0.125 vCPU-hours each                                                                              |
| $/hour                            | At an `m6i.xlarge`’s $0.0317 per vCPU-hour, `api` frees $0.080 (2 vCPU and 4 GiB) and saves $0.048 |
| A node removed                    | Spare falls, and saved rises towards freed                                                         |
| Another team takes the spare vCPU | Saved rises too, as the paused replicas couldn’t come back without a node                          |

Node pools, and where saved falls short

| Case              | What happens                                                                                                                                                                               |
| ----------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| A node’s pool     | The first of its `karpenter.sh/nodepool`, `cloud.google.com/gke-nodepool`, `eks.amazonaws.com/nodegroup` and `kubernetes.azure.com/agentpool` labels. Nodes with none of them are one pool |
| Between pools     | Spare isn’t lent. Pods that ran on GPU nodes can’t come back on general-purpose ones                                                                                                       |
| Instance types    | Not a pool. One pool can run several                                                                                                                                                       |
| No pools recorded | When none of a pause’s pods was on a node as it paused                                                                                                                                     |
| Then              | It’s compared with what every pool has left once its own paused replicas are held                                                                                                          |
| Gaps              | Time the operator wasn’t sampling isn’t credited, such as across a restart or a leader change                                                                                              |
| Failed samples    | An interval whose sample failed isn’t credited                                                                                                                                             |
| Each sample       | Credits at most the 5 minutes before it                                                                                                                                                    |
| Unwritten credit  | Held in memory until the cost is next brought up to date, at most 5 minutes later                                                                                                          |
| Restarts          | A restart in between loses it. Each such gap is a few minutes of a lower bound                                                                                                             |
| `watchNamespaces` | The operator can’t see other namespaces’ pods’ requests, so it can’t know their spare capacity                                                                                             |
| Then              | `savedThisMonth` and `projectedMonthlySavings` read `unknown`, and `savingsUnknown` says why. Freed is still measured                                                                      |
| The cache         | Nodes without their image lists, and the pods bound to them                                                                                                                                |
| Per pod           | Only its node, phase and requests. About 2.6 KB, so 26 MB for 10,000 pods                                                                                                                  |
| Over time         | Pools’ figures are exported as [metrics](https://docs.napwake.dev/next/reference/metrics/#savings)                                                                                                                 |

## How costs are calculated

Everything is priced on what the pods **request**, not what they use. Requests are what a pod reserves on a node. So they’re its cost in capacity, and what a pause frees. Sidecars injected when the pods were created count too, such as a service mesh proxy. The template doesn’t list them, but pausing frees them.

### Resource hours

The month’s totals are resource-hours, updated at every phase change and at least every 5 minutes. So time counts in the phase it was spent in.

| Field                                 | Counts                                                                                                                                    |
| ------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------- |
| `awakeCPUHours`, `awakeMemoryHours`   | vCPU-hours and GiB-hours the replicas requested while awake, in any phase but `Paused`                                                    |
| `storageHours`                        | GiB-hours of the PersistentVolumeClaims its pods mount, in every phase. A pause doesn’t free storage                                      |
| `pausedCPUHours`, `pausedMemoryHours` | vCPU-hours and GiB-hours the replicas Napwake paused had requested, while paused. What pausing freed                                      |
| `savedCPUHours`, `savedMemoryHours`   | Its share of the vCPU-hours and GiB-hours its pools were [short](https://docs.napwake.dev/next/concepts/cost-tracking/#how-saved-is-measured) of. What pausing saved, at most the paused hours |

How the hours are counted

| Case                                   | How it’s counted                                                                                                                  |
| -------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------- |
| Replicas, while awake                  | Read from the target each time, or taken as last read if they can’t be                                                            |
| At a phase change                      | Time before it still counts in the phase it was spent in                                                                          |
| What a replica requests                | Read from its pods hourly, and when the pod template changes                                                                      |
| While paused                           | Paused hours are priced on what it ran when the pause began (`status.pause.resources`). Saved hours are added as they’re measured |
| Pods unreadable at the pause           | The snapshot keeps what was last read of it awake                                                                                 |
| A pause finished after an interruption | The same. It finds the target at zero already, so its replicas come from what was last read                                       |
| A gap over 2 hours                     | Counts as 2 hours, as while the operator wasn’t running. What the workload did meanwhile isn’t known                              |
| `tracked`                              | How much of the month the totals cover                                                                                            |
| Less than the month so far             | If the workload was created during the month, or after a gap over 2 hours                                                         |
| Precision                              | A billionth of an hour, as Kubernetes quantities, so small workloads’ short pauses still add up. `83333333n` is 0.083 vCPU-hours  |

### Prices

Resource-hours become dollars at a rate per vCPU-hour, per GiB-hour of memory, and per GiB-month of storage. Each comes from the first of these that sets it:

1. 1Your own ratesIn `spec.costTracking.rates`
2. 2Its nodes' list priceIn `status.cost.listRates`. CPU and memory only
3. 3The defaultsFor nodes with no list price, such as on-premises, custom, or kind nodes

| List price               |                                                                                                                                                     |
| ------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------- |
| Instance type and region | From each pod’s node’s labels, `node.kubernetes.io/instance-type` and `topology.kubernetes.io/region`                                               |
| Which price              | On-demand, or spot for a [spot node](https://docs.napwake.dev/next/concepts/cost-tracking/#spot-nodes), averaged over the workload’s pods                                                                |
| `status.cost.listPrice`  | Which it was. `OnDemand`, `Spot`, or `SpotAndOnDemand` for pods on both                                                                             |
| When                     | Read again hourly while the workload is awake, and kept while it’s paused                                                                           |
| A pause                  | What it frees and saves is priced at the nodes it last ran on                                                                                       |
| Left out                 | Savings plans, reserved instances, committed-use discounts, negotiated rates, and AWS spot’s hour-by-hour price. Set `costTracking.rates` for those |

| Resource | Default rate                                  | Source                                     |
| -------- | --------------------------------------------- | ------------------------------------------ |
| CPU      | $0.031/vCPU-hour                              | AWS on-demand (m6i.large, us-east-1, 2026) |
| Memory   | $0.004/GiB-hour                               | AWS on-demand (m6i.large, us-east-1, 2026) |
| Storage  | $0.08/GiB-month (priced at 730 hours a month) | AWS EBS gp3, us-east-1                     |

Rates apply to the whole month’s hours each time dollars are worked out. So changing `costTracking.rates` reprices the month so far, not only what follows.

How a node’s price becomes rates, and where the prices come from

| What                         | How                                                                                                                     |
| ---------------------------- | ----------------------------------------------------------------------------------------------------------------------- |
| Splitting a price            | A provider prices an instance whole. Napwake splits it between its vCPUs and memory                                     |
| In the defaults’ proportion  | A vCPU costs as much as 7.75 GiB                                                                                        |
| For example                  | An `m6i.large` (2 vCPU, 8 GiB, $0.096 an hour) comes out at $0.0317 per vCPU-hour and $0.0041 per GiB-hour              |
| Instance families            | A memory-optimized instance’s memory, and a compute-optimized one’s CPU, cost what they cost there                      |
| A pod                        | Pays for the share of the node it requests                                                                              |
| The price table              | The on-demand Linux list price of every AWS, Google Cloud and Azure instance type, in every region it’s sold            |
| Spot                         | Its spot price wherever it’s sold as spot                                                                               |
| Source                       | The providers’ public price lists, through [instances.vantage.sh](https://instances.vantage.sh), refreshed each release |
| Offline                      | Nothing is looked up at runtime, so it works offline and air-gapped. Napwake reads Nodes’ labels to do this             |
| Azure constrained vCPU sizes | `Standard_E4-2s_v5` is billed and priced as its base size, `Standard_E4s_v5`, spread over the vCPUs it has              |
| GPU nodes                    | Priced the same way, so a pod on one pays its share of the GPU too                                                      |

#### Spot nodes

A node labelled as spot or preemptible is priced at its type’s spot price in its region.

| Label                                                                           | On                      |
| ------------------------------------------------------------------------------- | ----------------------- |
| `karpenter.sh/capacity-type: spot`                                              | Karpenter, on any cloud |
| `eks.amazonaws.com/capacityType: SPOT`                                          | EKS managed node groups |
| `cloud.google.com/gke-spot: "true"`, `cloud.google.com/gke-preemptible: "true"` | GKE                     |
| `kubernetes.azure.com/scalesetpriority: spot`                                   | AKS                     |

| Spot price                               |                                                                                                          |
| ---------------------------------------- | -------------------------------------------------------------------------------------------------------- |
| Google Cloud, Azure                      | One published price per region                                                                           |
| AWS                                      | Moves with demand. Napwake uses the average across the region’s zones when its price table was generated |
| So for AWS                               | Close, but not what any one hour cost                                                                    |
| A type its provider doesn’t sell as spot | On-demand. `kubectl napwake scan` says which workloads that leaves costing less than shown               |

### Custom rates

Set your own rates to match what you pay:

managedworkload.yaml

```yaml
spec:
  costTracking:
    rates:
      cpuPerHour: "0.045"      # GKE Autopilot pricing
      memoryPerHour: "0.005"    # GKE Autopilot pricing
      storagePerMonth: "0.10"   # Premium SSD
```

## In dry-run

Dry-run measures what pausing would free, never what it would save. A workload in [dry-run](https://docs.napwake.dev/next/guides/dry-run/) is never paused, so all its time is awake time. Saved depends on whether the nodes would have gone, which they can’t while it runs. What pausing would have freed is kept apart in `status.dryRun`:

| Field                               |                                       |
| ----------------------------------- | ------------------------------------- |
| `freedCPUHours`, `freedMemoryHours` | The hours pausing would have freed    |
| `estimatedFreed`                    | Those hours, priced at the same rates |

## Viewing costs across workloads

`kubectl get managedworkloads -A` shows what pausing each workload has `Freed` this month, what of that it `Saved`, and, in dry-run, what it `Would Free`. For more:

```bash
kubectl get managedworkloads -A -o custom-columns=\
NAMESPACE:.metadata.namespace,NAME:.metadata.name,PHASE:.status.phase,\
COST:.status.cost.costThisMonth,FREED:.status.cost.freedThisMonth,\
SAVED:.status.cost.savedThisMonth,PROJECTED:.status.cost.projectedMonthlyCost
```

| Command                                                                                        | Shows                                                                            |
| ---------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------- |
| [`kubectl napwake status`](https://docs.napwake.dev/next/getting-started/kubectl-plugin/#see-what-napwake-is-doing)    | Sums what pausing freed, and what of it was saved, across the cluster            |
| [`kubectl napwake nodes`](https://docs.napwake.dev/next/getting-started/kubectl-plugin/#see-why-nodes-dont-scale-down) | Shows what keeps nodes from going                                                |
| [Metrics](https://docs.napwake.dev/next/reference/metrics/#savings)                                                    | Each node pool’s paused and spare capacity, and what its shortfall saves an hour |
| Per-workload cost                                                                              | Not exported as Prometheus metrics                                               |

## Status fields

The hours are the record. The dollar figures are worked out from them, rounded to the cent for display.

status.cost

```yaml
status:
  cost:
    tracked: 412h30m0s
    lastAccumulatedAt: "2026-10-18T14:30:00Z"
    awakeCPUHours: "160500m"
    awakeMemoryHours: "321"
    storageHours: "4125"
    pausedCPUHours: "664500m"
    pausedMemoryHours: "1329"
    savedCPUHours: "398700m"
    savedMemoryHours: "0"
    costThisMonth: "$6.84"
    freedThisMonth: "$26.47"
    savedThisMonth: "$12.62"
    costWithoutNapwakeThisMonth: "$33.31"
    projectedMonthlyCost: "$12.35"
    projectedMonthlyFreed: "$47.74"
    projectedMonthlySavings: "$22.76"
    running:
      replicas: 2
      cpuMillis: 1000
      memoryBytes: 2147483648
      storageBytes: 10737418240
    pricedAt: "2026-10-18T14:00:00Z"
    pricedTemplateHash: 3f9c2a71d0e4b815
    listRates:
      cpuPerHour: 31660u      # $0.031660 per vCPU-hour
      memoryPerHour: 4085u    # $0.004085 per GiB-hour
    listPrice: OnDemand
```

| Field                                                                                                                           | What it is                                                                                                                    |
| ------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------- |
| `tracked`                                                                                                                       | How much of the month the totals cover                                                                                        |
| `lastAccumulatedAt`                                                                                                             | When the totals were last brought up to date                                                                                  |
| `awakeCPUHours`, `awakeMemoryHours`, `storageHours`, `pausedCPUHours`, `pausedMemoryHours`, `savedCPUHours`, `savedMemoryHours` | The [resource-hours](https://docs.napwake.dev/next/concepts/cost-tracking/#resource-hours)                                                                                         |
| `costThisMonth`                                                                                                                 | The awake and storage hours, priced                                                                                           |
| `freedThisMonth`                                                                                                                | The paused hours, priced. What pausing freed, money only once nodes go. The `Freed` column of `kubectl get managedworkloads`  |
| `savedThisMonth`                                                                                                                | The saved hours, priced. At least what pausing took off the bill. The `Saved` column. `unknown` while `savingsUnknown` is set |
| `savingsUnknown`                                                                                                                | Why saved can’t be measured, such as an operator limited by `watchNamespaces`. Unset when it’s measured                       |
| `costWithoutNapwakeThisMonth`                                                                                                   | What it would have cost this month had it never paused. `costThisMonth` plus `freedThisMonth`, to within a cent               |
| `projectedMonthlyCost`, `projectedMonthlyFreed`, `projectedMonthlySavings`                                                      | Cost, freed and saved carried to the whole month at the same rate. The amount ÷ `tracked` × the month’s hours                 |
| `pending`                                                                                                                       | Shown until a day is tracked, including each month’s first day, as a workload’s pattern is daily                              |
| `running`                                                                                                                       | What it ran when the totals were last brought up to date while it was awake                                                   |
| `pricedAt`, `pricedTemplateHash`                                                                                                | When what a replica requests, and `listRates`, were last read, and the pod template’s hash then                               |
| `listRates`                                                                                                                     | The list rates of the nodes its pods last ran on, spot nodes at spot                                                          |
| No `listRates`                                                                                                                  | Its nodes aren’t in Napwake’s price table, so the defaults apply                                                              |
| `listPrice`                                                                                                                     | Which list prices `listRates` are. `OnDemand`, `Spot`, or `SpotAndOnDemand` for pods on both. Unset with `listRates`          |
| `resourceReduction`                                                                                                             | What the current pause freed on its nodes. Only while paused                                                                  |

status.cost.resourceReduction

```yaml
resourceReduction:
  cpuMillis: 3000            # 3 vCPUs freed
  memoryBytes: 6442450944    # 6 GiB freed
  replicas: 3                # 3 pod replicas removed
```

`savedCPUHours` and `savedMemoryHours` measure how much of that came off the bill.

Next

* [Cluster Autoscaler →Settings that let freed capacity go.](https://docs.napwake.dev/next/guides/cluster-autoscaler/)
* [Node pools for real savings →Giving non-prod workloads nodes that pausing empties.](https://docs.napwake.dev/next/guides/node-pools/)
