Concepts
Cost tracking
What pausing a workload frees, and how much of that comes off your bill.
CPU
4 vCPU paused, 1 vCPU spare. 3 vCPU short: saved
Memory
12 GiB paused, 14 GiB spare. None short: nothing saved
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
Section titled “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
Section titled “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
Section titled “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 |
How costs are calculated
Section titled “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
Section titled “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 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
Section titled “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:
- 1Your own ratesIn
spec.costTracking.rates - 2Its nodes' list priceIn
status.cost.listRates. CPU and memory only - 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, 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, 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
Section titled “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
Section titled “Custom rates”Set your own rates to match what you pay:
spec: costTracking: rates: cpuPerHour: "0.045" # GKE Autopilot pricing memoryPerHour: "0.005" # GKE Autopilot pricing storagePerMonth: "0.10" # Premium SSDIn dry-run
Section titled “In dry-run”Dry-run measures what pausing would free, never what it would save. A workload in 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
Section titled “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:
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 |
Sums what pausing freed, and what of it was saved, across the cluster |
kubectl napwake nodes |
Shows what keeps nodes from going |
| Metrics | 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
Section titled “Status fields”The hours are the record. The dollar figures are worked out from them, rounded to the cent for display.
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 |
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 |
resourceReduction: cpuMillis: 3000 # 3 vCPUs freed memoryBytes: 6442450944 # 6 GiB freed replicas: 3 # 3 pod replicas removedsavedCPUHours and savedMemoryHours measure how much of that came off the bill.