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

Idle detection

When Napwake pauses a workload, and what wakes it again.

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

The clockidleAfter: 1h

Asleep Waking up Awake09:14 last activity10:14 pauseAt
The status example. The last activity was at 09:14. With the default idleAfter of an hour, it pauses at 10:14.

Napwake keeps an activity clock for every ManagedWorkload. It’s the last time anything showed the workload was in use. Any sign of activity resets it. Once the clock is older than idlePolicy.idleAfter, Napwake pauses the workload. It’s scaled to zero, keeping its replica count and a resource snapshot.

  • There’s no learning period. A new workload can pause after its first idleAfter without activity.
  • With idlePolicy.pauseEarly, a confident forecast can pause it sooner (Pausing early).
managedworkload.yaml
spec:
idlePolicy:
idleAfter: 1h # default
activity:
cpuThreshold: 10 # percent of CPU requests, 0.1 to 100; default

Write a threshold that isn’t a whole number as a string, such as cpuThreshold: "0.5". Kubernetes APIs write decimals that way. It suits a workload that idles at a fraction of a percent.

Every source is checked. The most recent wins, and none has priority.

Source Counts when Setup
CPU Above activity.cpuThreshold% of what the reporting pods request, from 0.1 to 100 metrics-server
Deploy The pod template changes, such as an image, an environment variable, any edit None
napwake.dev/last-activity It holds a time newer than the last activity Your tooling
napwake.dev/active-until It holds a time in the future. A hold, not an activity time Your tooling
napwake.dev/last-request It holds a time newer than the last activity The doorman, when it holds a request for the paused workload
Prometheus A query in activity.prometheus returns above zero --prometheus-url (Prometheus activity)
  • A change to the replica count isn’t a deploy, so Napwake’s own pause and resume never look like activity.
  • Memory isn’t a source. It stays allocated whether the workload is used or not.
How CPU is measured
  • Only containers in the target’s pod template count, with native sidecars (init containers with restartPolicy: Always).
  • Sidecars injected at pod creation, such as Istio or Linkerd proxies, don’t. Their work isn’t use, and the template doesn’t request it. Cost counts them, as they cost the same and pausing frees them too.
  • Usage is measured over the pods metrics-server reports, against those pods’ requests. One pod serving while others are Pending, crash-looping, or not yet reporting after a scale-up reads at its own utilization.
  • Requests come from a running pod, not the template. So a LimitRange default or a Vertical Pod Autoscaler’s recommendation counts.
  • That pod is a running one of the target’s current revision. A failed pod, such as an evicted one, isn’t read. It keeps its own revision’s requests until it’s cleaned up.
  • Requests are read again every 5 minutes, and when the target’s spec changes. While a replica’s revision is unclear, as in a rollout, they’re read on each check.
  • With no pod running, the newest starting one is read, such as a Pending pod in a Recreate rollout. With none at all, the template’s requests are used. A container with only a CPU limit requests its limit, as in Kubernetes.

Set either on the ManagedWorkload or its target, as an RFC 3339 time.

Terminal window
# Record activity now, e.g. from a developer portal when someone opens an environment
kubectl annotate deployment my-api -n staging --overwrite \
napwake.dev/last-activity="$(date -u +%Y-%m-%dT%H:%M:%SZ)"
# Keep the workload awake until a time, e.g. during a demo
kubectl annotate deployment my-api -n staging --overwrite \
napwake.dev/active-until="2026-10-03T18:00:00Z"

kubectl napwake wake wakes it through napwake.dev/wake-requested instead, and waits until it’s Running.

  • A last-activity time in the future counts as now. For a hold, use active-until, which says when it ends.
  • Annotations only keep a workload awake or wake it. They never pause it.
  • A value that isn’t RFC 3339, such as epoch seconds or a space for the T, is ignored everywhere. Each gets one InvalidActivityAnnotation warning event on the ManagedWorkload, naming the annotation and where it is.

Each check runs these steps in order. The first that holds decides.

Step If Then
1 CPU or a configured query can’t be read Stay awake
2 active-until is in the future Stay awake
3 The last activity was under idleAfter ago, or longer after a pause made in use Stay awake, unless the forecast pauses it early (below)
4 The forecast is confident (DailyActive or later, dailyPhase: Active) and predicts above cpuThreshold in this hour or the next Stay awake, with IdleVetoed
5 An awake workload depends on this one, or dependsOn forms a cycle Stay awake, with HeldByDependents or DependencyCycle
6 None of these Idle, then paused, with an IdleDetected event. Or PausedEarly, naming the hours forecast quiet

With pauseEarly, step 3 goes on to step 4 as if the clock had run out, when:

  • the last activity was at least 20 minutes ago,
  • the forecast is confident that every hour until idleAfter runs out is quiet, and at least this hour and the next,
  • and a request would wake the workload through the doorman once it’s paused.

While step 4 holds a pause off, IdleVetoed is True with reason ForecastExpectsDemand, its message naming the hour. An IdleVetoed event marks each veto’s start. After an hour far quieter than forecast, as on a holiday, the forecast doubts what it expects. For the next hour it holds nothing back (Anomaly detection). It never blocks a pause until it’s confident, so a workload with no history is left to the clock.

Case What changes
At zero replicas, not by Napwake Not checked. It’s already off, and left that way
No idlePolicy None of this runs. It pauses only when someone asks
A pause request (kubectl napwake pause) The clock runs out at once
Skipped for a request Steps 1 and 3, and the hour after a GitOps tool undid its last pause
Still holding a request up Steps 2 and 5. Step 4 too, unless the request has napwake.dev/pause-overrides-forecast: "true", as kubectl napwake pause --yes sets
A request held back It’s answered with the reason
dryRun: true Idle, with the IdleDetected or PausedEarly event prefixed [dry-run], but nothing changes. Activity sets it back to Running (Dry run)

Napwake can’t see requests to an awake workload. One in steady light use, under its CPU threshold, can look idle and be paused while people use it. The next request wakes it, with a cold start.

A pause is wrong when the workload’s own use undoes it soon after it begins. Soon means within idleAfter or 15 minutes, whichever is shorter. Any of these undoes it:

One that comes while the workload is still pausing calls the pause off. That pause was wrong too.

After a wrong pause, the idle clock waits longer before the next. Each wrong pause in a row doubles the wait, up to idleAfter plus an hour:

Wrong pauses in a row Wait with idleAfter: 5m Wait with idleAfter: 1h
0 5 minutes 1 hour
1 10 minutes 2 hours
2 20 minutes 2 hours
3 40 minutes 2 hours
4 or more 1 hour 5 minutes 2 hours

A pause that lasts the window ends the wait. So do these, which no pause can last through:

  • dry-run
  • protecting the workload
  • removing its idlePolicy
  • ignoring it
  • its target going

A would-be pause in dry-run never waits longer. The count is kept in status.idleWait, so a restart keeps it. The wait only ever delays a pause.

These don’t make a pause wrong, since Napwake didn’t misjudge the workload’s use:

  • a pause someone asked for
  • the forecast waking it ahead of demand
  • a Prometheus activity query
  • a wake for an awake dependent that uses it
Why the window, and what it does in the simulator

Steady light use wakes a wrong pause within seconds, or a minute or two. A pause that lasted a quarter of an hour paid for its cold start. And a short idleAfter is what you said idle means.

In the simulator, orders/api on the test cluster has idleAfter: 5m and steady requests all working day. It goes from 79 cold starts a working day to 10. It’s awake about 3.5 hours a week more, on weekday evenings.

A PausedWhileInUse warning event says how soon the pause was undone. It also says how long the clock waits now, and what to change. The PausedWhileInUse condition says the same until a pause holds, or the wait ends otherwise. kubectl napwake status lists it under Needs attention.

a request came 40 seconds after it paused, so it was in use: it now waits 10 minutes without activity
before pausing, not 5 minutes, until a pause holds. It used up to 0.6% of its CPU requests while in use,
and up to 0.2% idle: try napwake.dev/cpu-threshold: "0.4"

The suggested number comes from the CPU Napwake measures through each stretch the clock counts as idle:

  • A wrong pause shows what the workload uses in use. That’s the most in the stretch before it.
  • A pause that holds shows what it uses idle. That’s the most in the 15 minutes before it, or idleAfter if shorter. Napwake keeps the most over the last week.

Napwake suggests a threshold when the CPU in use is at least twice the idle CPU, and 0.2 points more. It’s halfway between, to a tenth. A workload you opted in with the label is told the annotation, and one you wrote the field.

Otherwise CPU can’t tell its use from its idle. Then Napwake suggests a Prometheus query that counts its requests. Until a pause has held, it hasn’t measured idle use, and says so rather than guess.

status:
idleWait:
wrongPauses: 1
lastWrongPause:
pausedAt: "2026-10-26T09:02:11Z"
after: 40s
by: request # request, click, wakeRequest or annotation
from: 10.4.1.7
inUseCPU: "0.6"
idleCPU: "0.2"
idleCPUAt: "2026-10-25T17:06:00Z"

napwake_wrong_pauses_total counts wrong pauses, by workload. Dry-run never pauses, so its would-be pauses aren’t judged.

Napwake never pauses while a source can’t be read. Without that data, a busy workload looks idle. Nor does that time count as idle.

  • A check that can’t read a source isn’t watching. A source out for longer than the allowed gap restarts the clock (lastActivitySource: unobserved).
  • Once the source is back, an Unobserved event says so. A pause then needs a full idleAfter in which every source saw nothing.
  • A shorter blind spot leaves the last activity alone. One is the minute before a woken workload’s pods have metrics.
  • A Prometheus query may come back saying “no requests in the last 5 minutes”. That doesn’t make the hour before look idle.

For CPU, the MetricsAvailable condition says why, and for queries, PrometheusAvailable.

The reasons, and what to do

A target at zero isn’t an error, as no usage is real data. Nor are new pods, with no metrics until they run and for a scrape or two of metrics-server’s after.

Reason Status Means To do
NoReadyPods Unknown After a wake or rollout, none of the target’s pods is Ready yet. No warning
AwaitingPodMetrics Unknown Then, for up to 3 minutes, Ready pods without metrics yet. No warning
NoPodMetrics False Ready pods with no metrics for over 3 minutes. One warning event says so Check metrics-server is installed and reports them (kubectl top pods)
NoCPURequests False The running pods, or with none the pod template, request no CPU for its containers. Utilization can’t be measured Set CPU requests, or a default in the namespace’s LimitRange
MetricsUnavailable False The Metrics API failed, or didn’t answer in 2 seconds. The message has the error

After a MetricsUnavailable, the API isn’t read again for a minute. So every workload’s check fails at once, instead of waiting out a timeout.

For queries, PrometheusAvailable reports EndpointNotConfigured or QueryFailed. A query that answers but has never matched a series, as with a missing metric or label, can never keep the workload awake. PrometheusQueryEmpty=True and a warning event say so (When a query can’t be evaluated).

The clock lives in status.activity, so it survives operator restarts. An outage can delay a pause, but never cause one.

status:
activity:
lastActivityTime: "2026-10-02T09:14:00Z"
lastActivitySource: cpu # created, woke, request, cpu, rollout, annotation, prometheus, unobserved, or scaled-up
pauseAt: "2026-10-02T10:14:00Z"
lastEvaluatedTime: "2026-10-02T09:41:00Z"

If the operator stops watching for a while, it can’t know what happened. So an awake workload’s clock restarts when it watches again (lastActivitySource: unobserved). An Unobserved event says how long the gap was:

The gap The Unobserved event says
The operator didn’t check it Napwake wasn't watching for 9 minutes, so its idle clock starts again
A source was out Napwake couldn't read its activity for 9 minutes, so its idle clock starts again
A restart, or a change of leader the operator restarted, or another took over, so its idle clock starts again; it wasn't watching for up to 4 minutes
  • The gap allowed is half of idleAfter, at least 2 minutes (two missed checks), at most 7.
  • A restart or a change of leader always restarts an awake workload’s clock, however recently it was written.
  • The event comes with the first check that can read every source.
  • A gap or a restart isn’t activity, so a paused workload stays paused. In dry-run, a would-be pause goes on, as the real pause would (Dry run).
  • A restart can delay a pause by up to idleAfter, but never brings one forward.
How the clock is saved, and why a restart restarts it
  • An awake workload is checked about once a minute.
  • The clock is written to status with cost, at most every 5 minutes. It’s also written at once on a change of phase or condition. And when new activity is ahead of what’s written by over a tenth of idleAfter, at most 5 minutes.
  • Activity between writes is held in memory, so it counts while the operator runs.
  • After a restart or a change of leader, the operator has only what was written. That can be up to 5 minutes behind its last check. It can also be missing activity seen since, up to a tenth of idleAfter.
  • So the clock restarts. Its event says up to, since the last check may have come later than what’s written.
While it’s paused
A request reaches one of its Services Wakes. The request is held while it starts, then answered (Wake on request)
A last-activity, last-request or active-until annotation, on the ManagedWorkload or its target, changes to an RFC 3339 time Wakes, whatever time it states, so a clock behind the operator’s can’t lose a wake
kubectl napwake wake, or a tool setting napwake.dev/wake-requested on the ManagedWorkload, such as a portal’s “start environment” button Wakes, whatever the wake setting, with a WakeRequested event. The tool reads the answer in the WakeRequest condition
An annotation set before the pause, even to a later time Stays asleep. The pause records the annotations as they stood, and only a change counts
A Prometheus activity query shows activity. Queries run about once a minute, as while awake Wakes. One active as a requested pause began must go quiet first, so it doesn’t undo the request
A query can’t be evaluated Never wakes it
autoResume: true, and a confident forecast (dailyPhase: Active) predicts above cpuThreshold this hour, or the next from 15 minutes before Wakes, ready before people arrive. Not in the hour after one far quieter than forecast
Something else scales it up, such as kubectl scale Wakes
Dry-run is turned on Wakes. Dry-run never leaves a workload paused

Whatever would wake it, coming while it’s still pausing, calls the pause off instead.

Each wake restarts the clock, so the next pause is a full idleAfter away. After a pause made in use, it’s longer. With pauseEarly and a quiet forecast, the pause can come 20 minutes after the wake. lastActivitySource says what woke it:

lastActivitySource Woken by
request A held request
prometheus An activity query
scaled-up Something else scaling it up
woke Anything else