Skip to content
Docs
Release candidateThese docs are for Napwake 0.2.0-rc.9, a release candidate, and may change before it's released. Read the docs for the latest release.

Concepts

Cost tracking

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

View .mdAsk Claude (opens claude.ai)Ask ChatGPT (opens chatgpt.com)

CPU

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

Memory

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

The 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 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.

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.

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

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.

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

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, 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

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

Set your own rates to match what you pay:

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

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

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:

Terminal window
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

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

status.cost
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
status.cost.resourceReduction
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.