the shed // linux // cpu saturation

“The CPU is at 90%” tells you almost nothing. This script splits the number into user work, disk waiting, and hypervisor steal, so you know whether to tune code, fix storage, or call your cloud provider.

Step through the build below:

cpu_saturation_monitor.rb

The problem: top and load average conflate very different situations. A box with load 8 on 4 cores might be CPU-bound, or stuck waiting on a dead NFS mount (iowait), or losing cycles to a noisy neighbour on a VM (steal). Each has a different fix. This script samples /proc/stat twice, converts the jiffy deltas to percentages, adds load-per-core and kernel PSI pressure, and tells you which one you have.

#!/usr/bin/env ruby
# frozen_string_literal: true
# cpu_saturation_monitor.rb - tell "busy" from "starved" using /proc/stat deltas,
# load average per core and (when available) kernel PSI. Stdlib only.
#
#   ruby cpu_saturation_monitor.rb [--interval 2] [--json]
#   ruby cpu_saturation_monitor.rb --stat-a a.txt --stat-b b.txt [--loadavg f] [--psi f]
# Exit: 0 OK, 1 WARN, 2 CRIT
require 'optparse'
require 'json'
require 'etc'
FIELDS = %i[user nice system idle iowait irq softirq steal].freeze
THRESH = { iowait: [10.0, 25.0], steal: [5.0, 15.0], busy: [85.0, 95.0], load_per_core: [1.0, 2.0] }.freeze
# First "cpu " line of /proc/stat -> hash of jiffies per state
def parse_stat(text)
  line = text.lines.find { |l| l.start_with?('cpu ') } or raise 'no aggregate cpu line'
  FIELDS.zip(line.split[1..8].map(&:to_i)).to_h
end
# Percent of elapsed jiffies spent in each state between two samples
def percentages(a, b)
  delta = FIELDS.to_h { |f| [f, b[f] - a[f]] }
  total = delta.values.sum
  raise 'no CPU time elapsed between samples' if total <= 0
  pct = delta.transform_values { |v| (v * 100.0 / total).round(1) }
  pct[:busy] = (100.0 - pct[:idle] - pct[:iowait]).round(1)
  pct
end
def level(metric, value)
  warn_at, crit_at = THRESH.fetch(metric)
  return 2 if value >= crit_at
  value >= warn_at ? 1 : 0
end
def psi_cpu(text)
  return nil unless text
  some = text.lines.find { |l| l.start_with?('some') } or return nil
  some[/avg10=([\d.]+)/, 1].to_f
end
def evaluate(pct, load1, cores, psi)
  checks = {
    busy:          [pct[:busy], level(:busy, pct[:busy])],
    iowait:        [pct[:iowait], level(:iowait, pct[:iowait])],
    steal:         [pct[:steal], level(:steal, pct[:steal])],
    load_per_core: [(load1 / cores).round(2), level(:load_per_core, load1 / cores)]
  }
  checks[:psi_some_avg10] = [psi, psi >= 40 ? 2 : (psi >= 10 ? 1 : 0)] if psi
  checks
end
HINT = {
  iowait: 'CPUs are waiting on disk/NFS - look at iostat, not at the CPU',
  steal: 'hypervisor is taking cycles - noisy neighbour or oversold host',
  busy: 'genuinely CPU-bound - find the hot process (top / pidstat)',
  load_per_core: 'more runnable tasks than cores',
  psi_some_avg10: 'tasks are stalling waiting for CPU time'
}.freeze
if $PROGRAM_NAME == __FILE__
  o = { interval: 2.0, json: false }
  OptionParser.new do |op|
    op.on('--interval N', Float) { |v| o[:interval] = v }
    op.on('--json') { o[:json] = true }
    op.on('--stat-a F') { |v| o[:a] = v }
    op.on('--stat-b F') { |v| o[:b] = v }
    op.on('--loadavg F') { |v| o[:load] = v }
    op.on('--psi F') { |v| o[:psi] = v }
    op.on('--cores N', Integer) { |v| o[:cores] = v }
  end.parse!
  if o[:a]
    a = parse_stat(File.read(o[:a]))
    b = parse_stat(File.read(o[:b]))
  else
    a = parse_stat(File.read('/proc/stat'))
    sleep o[:interval]
    b = parse_stat(File.read('/proc/stat'))
  end
  pct = percentages(a, b)
  load1 = File.read(o[:load] || '/proc/loadavg').split[0].to_f
  cores = o[:cores] || Etc.nprocessors
  psi_path = o[:psi] || '/proc/pressure/cpu'
  psi = psi_cpu(File.exist?(psi_path) ? File.read(psi_path) : nil)
  checks = evaluate(pct, load1, cores, psi)
  worst = checks.values.map(&:last).max
  if o[:json]
    puts JSON.pretty_generate(status: %w[OK WARN CRIT][worst], cores: cores, percent: pct, checks: checks)
  else
    puts format('CPU saturation (%d cores, load1 %.2f)', cores, load1)
    checks.each do |name, (val, lvl)|
      puts format('  %-4s %-15s %6.2f  %s', %w[OK WARN CRIT][lvl], name, val, lvl.zero? ? '' : HINT[name])
    end
    puts "RESULT: #{%w[OK WARN CRIT][worst]}"
  end
  exit worst
end

1. Read the aggregate cpu line twice, N seconds apart. 2. Subtract to get jiffies spent per state, divide by the total: percentages. 3. “Busy” = 100 – idle – iowait, since iowait is idle time with a pending I/O. 4. Compare each metric to WARN/CRIT thresholds, attach a hint, exit 0/1/2. Offline flags (--stat-a/--stat-b) make it testable with fixture files.

CPU saturation (4 cores, load1 6.40)
  OK   busy             51.40  
  CRIT iowait           32.40  CPUs are waiting on disk/NFS - look at iostat, not at the CPU
  CRIT steal            18.40  hypervisor is taking cycles - noisy neighbour or oversold host
  WARN load_per_core     1.60  more runnable tasks than cores
  WARN psi_some_avg10   22.50  tasks are stalling waiting for CPU time
RESULT: CRIT
Get the code

Full script + README on GitHub: ruby-devops-toolkit/cpu-saturation-monitor

Flow diagram: two /proc/stat samples to deltas to verdict

sample, diff, classify
prerequisites

What you need

  • Ruby 3.0+ (tested on 3.3.6), Linux with /proc
  • No gems: only optparse, json, etc
  • Optional: /proc/pressure/cpu (kernel 4.20+ with PSI enabled)
the script

Full code

cpu_saturation_monitor.rbruby
#!/usr/bin/env ruby
# frozen_string_literal: true
# cpu_saturation_monitor.rb - tell "busy" from "starved" using /proc/stat deltas,
# load average per core and (when available) kernel PSI. Stdlib only.
#
#   ruby cpu_saturation_monitor.rb [--interval 2] [--json]
#   ruby cpu_saturation_monitor.rb --stat-a a.txt --stat-b b.txt [--loadavg f] [--psi f]
# Exit: 0 OK, 1 WARN, 2 CRIT
require 'optparse'
require 'json'
require 'etc'
FIELDS = %i[user nice system idle iowait irq softirq steal].freeze
THRESH = { iowait: [10.0, 25.0], steal: [5.0, 15.0], busy: [85.0, 95.0], load_per_core: [1.0, 2.0] }.freeze
# First "cpu " line of /proc/stat -> hash of jiffies per state
def parse_stat(text)
  line = text.lines.find { |l| l.start_with?('cpu ') } or raise 'no aggregate cpu line'
  FIELDS.zip(line.split[1..8].map(&:to_i)).to_h
end
# Percent of elapsed jiffies spent in each state between two samples
def percentages(a, b)
  delta = FIELDS.to_h { |f| [f, b[f] - a[f]] }
  total = delta.values.sum
  raise 'no CPU time elapsed between samples' if total <= 0
  pct = delta.transform_values { |v| (v * 100.0 / total).round(1) }
  pct[:busy] = (100.0 - pct[:idle] - pct[:iowait]).round(1)
  pct
end
def level(metric, value)
  warn_at, crit_at = THRESH.fetch(metric)
  return 2 if value >= crit_at
  value >= warn_at ? 1 : 0
end
def psi_cpu(text)
  return nil unless text
  some = text.lines.find { |l| l.start_with?('some') } or return nil
  some[/avg10=([\d.]+)/, 1].to_f
end
def evaluate(pct, load1, cores, psi)
  checks = {
    busy:          [pct[:busy], level(:busy, pct[:busy])],
    iowait:        [pct[:iowait], level(:iowait, pct[:iowait])],
    steal:         [pct[:steal], level(:steal, pct[:steal])],
    load_per_core: [(load1 / cores).round(2), level(:load_per_core, load1 / cores)]
  }
  checks[:psi_some_avg10] = [psi, psi >= 40 ? 2 : (psi >= 10 ? 1 : 0)] if psi
  checks
end
HINT = {
  iowait: 'CPUs are waiting on disk/NFS - look at iostat, not at the CPU',
  steal: 'hypervisor is taking cycles - noisy neighbour or oversold host',
  busy: 'genuinely CPU-bound - find the hot process (top / pidstat)',
  load_per_core: 'more runnable tasks than cores',
  psi_some_avg10: 'tasks are stalling waiting for CPU time'
}.freeze
if $PROGRAM_NAME == __FILE__
  o = { interval: 2.0, json: false }
  OptionParser.new do |op|
    op.on('--interval N', Float) { |v| o[:interval] = v }
    op.on('--json') { o[:json] = true }
    op.on('--stat-a F') { |v| o[:a] = v }
    op.on('--stat-b F') { |v| o[:b] = v }
    op.on('--loadavg F') { |v| o[:load] = v }
    op.on('--psi F') { |v| o[:psi] = v }
    op.on('--cores N', Integer) { |v| o[:cores] = v }
  end.parse!
  if o[:a]
    a = parse_stat(File.read(o[:a]))
    b = parse_stat(File.read(o[:b]))
  else
    a = parse_stat(File.read('/proc/stat'))
    sleep o[:interval]
    b = parse_stat(File.read('/proc/stat'))
  end
  pct = percentages(a, b)
  load1 = File.read(o[:load] || '/proc/loadavg').split[0].to_f
  cores = o[:cores] || Etc.nprocessors
  psi_path = o[:psi] || '/proc/pressure/cpu'
  psi = psi_cpu(File.exist?(psi_path) ? File.read(psi_path) : nil)
  checks = evaluate(pct, load1, cores, psi)
  worst = checks.values.map(&:last).max
  if o[:json]
    puts JSON.pretty_generate(status: %w[OK WARN CRIT][worst], cores: cores, percent: pct, checks: checks)
  else
    puts format('CPU saturation (%d cores, load1 %.2f)', cores, load1)
    checks.each do |name, (val, lvl)|
      puts format('  %-4s %-15s %6.2f  %s', %w[OK WARN CRIT][lvl], name, val, lvl.zero? ? '' : HINT[name])
    end
    puts "RESULT: #{%w[OK WARN CRIT][worst]}"
  end
  exit worst
end
walkthrough

How it works, step by step

Parsing. The first line of /proc/stat starts with cpu and lists cumulative jiffies for user, nice, system, idle, iowait, irq, softirq, steal. parse_stat zips those into a hash; zipping with a symbol list keeps the mapping explicit.

Deltas. The counters only ever grow, so a single sample is useless; percentages subtracts sample A from B and divides by the total elapsed jiffies. It raises if no time elapsed, which would otherwise divide by zero.

Judging. THRESH is a table of [warn, crit] pairs and level returns 0/1/2. Load is divided by Etc.nprocessors so the same threshold works on a 2-core VM and a 64-core server. PSI some avg10 is the share of time at least one task stalled waiting for CPU.

Output. Each non-OK line carries a hint pointing at the right next tool, and the worst level becomes the exit status.

example output

Running it

cpu_saturation_monitor.rb
CPU saturation (4 cores, load1 6.40)
OK busy 51.40
CRIT iowait 32.40 CPUs are waiting on disk/NFS – look at iostat, not at the CPU
CRIT steal 18.40 hypervisor is taking cycles – noisy neighbour or oversold host
WARN load_per_core 1.60 more runnable tasks than cores
WARN psi_some_avg10 22.50 tasks are stalling waiting for CPU time
RESULT: CRIT
troubleshooting

When things go sideways

  • All zeros on a quiet box: normal. Raise --interval for a steadier sample.
  • No PSI line: the kernel lacks PSI or booted with psi=0; the check is skipped, not failed.
  • Containers: /proc/stat shows the host. Use cgroup files for per-container numbers.
  • Testing note: verified in the Linux sandbox with a live run and with hand-built fixture files exercising CRIT iowait/steal.
extending

Where to take it next

  • Loop mode that keeps a rolling window and alerts on sustained saturation
  • Per-core breakdown from the cpuN lines to spot one pinned core
  • Push metrics into the Prometheus exporter in this toolkit

Tha-Shed

Type a brief, get a finished film. Characters, voices and music included.

Start creating