All writing

~/writing/load-average-seven-hour-heartbeat

Systems debugging
4 min read

The load average with a seven hour heartbeat

Every seven hours the router's load average hit 1.0 for forty five minutes. CPU was 98% idle. Nothing ran, nothing blocked, nothing was stolen. The culprit was a beat frequency between two five second clocks, and one of them was mine.

My router is a VyOS VM, and its load average has a heartbeat. Every seven hours, load climbs to 1.0, holds it for forty five minutes, and drops back to 0.06. Grafana draws it as a row of mesas, drifting backward about seventy minutes a day.

48 hours of one-minute load average on the router: six flat-topped plateaus at 1.0, evenly spaced about seven hours apart, baseline near zero between them.

Load 1.0 is one core of work. I went looking for the work.

There is no work

No systemd timer or cron job runs on a seven hour cadence, and those fire at wall clock times anyway: these drift. API polling in front of the router is a constant 840 requests per hour, mesa or not.

CPU: 98% idle through every window. iowait: zero. D state, the classic load-without-CPU explanation: the kernel’s blocked-task counter reads zero the whole time. atop’s process accounting catches even short-lived processes: nothing above a few CPU-seconds. Steal from the hypervisor peaks at 0.093%, and the host’s backup jobs run at fixed clock times.

One mesa zoomed in: load average parked above 1.0 for 45 minutes while CPU busy percent stays under 5% the whole time.

It also predates everything: same mesas before the last power outage, through reboots and config changes.

Four days of load average from the week before: ten more identical mesas on the same seven hour cadence.

Runnable, blocked, stolen: all zero. The lie is in the measurement.

The kernel takes a survey, not a census

The kernel doesn’t measure load continuously. Every 5.000 seconds (LOAD_FREQ, kernel/sched/loadavg.c) it glances at the run queue and counts heads. Load average is a decaying average of those glances.

Now give some daemon a wake period of 5.001 seconds. Almost always its wake misses the glance and it’s invisible. But the phase slides 1 ms per cycle, and eventually the wake lands on the glance, and keeps landing there, sample after sample. The kernel counts one runnable task at every glance and reports load 1.0. The daemon is doing nothing, at exactly the wrong moment.

the beat arithmetic
sampler period     5.000 s
daemon period      5.001 s
drift              1 ms/cycle
beat period        5 s x (5 s / 1 ms) = 25 000 s = 6 h 56 m
observed           ~6 h 55 m

Mesa width is how long the wake stays within a few ms of the glance. The drift rules out everything cron-shaped in one stroke.

Load average is a survey, not a census

Anything periodic that aliases against the 5 s sampler gets counted every time or never. Load 1.0 can be a busy core or an idle daemon with good timing. The CPU counters know which.

Naming the daemon

I left a tripwire on the router: a transient unit sampling every 20 seconds, logging any runnable or blocked task whenever load passed 0.8. The next mesa arrived on schedule, 19:05 to 19:48. Twenty samples, load around 0.9, and the run queue was empty in every one. Even hunting during the mesa, a process listing finds nobody awake. The contributor exists only at the kernel’s sampling instants, milliseconds wide.

So I traced sched_wakeup for a minute. One family of short-lived processes wakes with a median period of 4.9999 seconds. Fork tracing walked it back: the router’s HTTP API server, execing an op-mode wrapper, forking a burst of a dozen helpers lasting about 15 ms, once per request.

The requests were mine. My monitoring daemon polls the router’s API for interface counters every 5.000 seconds, on a Go ticker. It went live July 5. The mesas start July 6.

The fix is one digit:

cmd/monitor/main.go
// 6s, not 5: a 5.000s tick beats against the kernel's 5s LOAD_FREQ
// run-queue sampling on the router and paints phantom load-1.0
// plateaus every ~7h.
samp.Start(context.Background(), 6*time.Second)
48 hours spanning the fix: three mesas on the left, then the deploy, then 26 hours of flat baseline through four windows where mesas were due.

Deployed at 21:45. Four mesa windows have come due since. None showed. The baseline noise is slightly chunkier now, the 6 s tick meeting the 5 s sampler one glance in six instead of every one: the same physics, no longer resonant.

Nothing was ever wrong. The aliasing also runs in reverse: a real periodic load that wakes between glances is invisible to load average. If you alert on load, you’re alerting on a strobe light. Alert on CPU, latency, queue depth: things measured continuously.

Two five second clocks, a millisecond apart, one of them mine.