the shed // ruby for devops

“90% memory used” means nothing on its own — the kernel happily eats spare RAM for page cache and hands it right back under pressure. This script combines MemAvailable, swap, kernel Pressure Stall Information (PSI), and an OOM-killer scan into one WARN/CRIT report that actually predicts trouble instead of just describing a number.

Step through the build below: the problem, the full script, how it works, and real output from both a healthy box and a simulated CRIT scenario.

mem_pressure_monitor.rb

free -h gives you a number, and that number is frequently a lie about how much trouble you’re in. A healthy box can sit at 90% memory “used” indefinitely — the kernel deliberately spends spare RAM on page cache and reclaims it instantly when something else needs it. A monitoring check built only on “percent used” pages you constantly for a perfectly fine box, and stays silent on a box that’s one allocation away from an OOM kill.

What actually predicts trouble is MemAvailable (which already accounts for reclaimable cache), whether the box is actively swapping, whether the kernel scheduler is stalling tasks waiting on memory (PSI — Pressure Stall Information), and whether the OOM killer has already fired. This script pulls all four signals into one report so it can drop into cron or an alerting pipeline instead of a human squinting at free.

#!/usr/bin/env ruby# frozen_string_literal: true## mem_pressure_monitor.rb -- Memory & swap pressure monitor for Linux.## `free -h` tells you a number. It does not tell you whether that number is# actually a problem right now. A box can sit at 90% memory "used" forever# because the kernel is using the rest for page cache -- totally fine. The# thing that actually predicts an OOM kill or a latency cliff is: is memory# genuinely SCARCE (MemAvailable, which already accounts for reclaimable# cache), is the box actively swapping, is the kernel scheduler stalling# tasks waiting on memory (PSI), and has the OOM killer already fired.# This script pulls all four signals into one WARN/CRIT report so it can# drop into cron or an alerting pipeline instead of a human staring at `free`.## No gems required -- everything here is Ruby stdlib.## Usage:#   ruby mem_pressure_monitor.rb [options]## Examples:#   ruby mem_pressure_monitor.rb#   ruby mem_pressure_monitor.rb --json#   ruby mem_pressure_monitor.rb --mem-warn 20 --mem-crit 8 --swap-crit 80## Exit codes (cron/CI friendly):#   0 - OK#   1 - WARN (memory or swap pressure building)#   2 - CRIT (memory critically low, heavy swapping, PSI saturated, or a#       recent OOM kill was found)require 'optparse'require 'json'require 'open3'options = {  mem_warn_pct: 15,   # WARN if MemAvailable < 15% of MemTotal  mem_crit_pct: 5,    # CRIT if MemAvailable < 5%  swap_warn_pct: 50,  # WARN if swap used > 50% of SwapTotal  swap_crit_pct: 90,  # CRIT if swap used > 90%  psi_warn: 10.0,     # WARN if PSI "some avg60" > 10%  psi_crit: 30.0,     # CRIT if PSI "some avg60" > 30%  oom_lookback_min: 60,  json: false,  meminfo_path: '/proc/meminfo',  psi_path: '/proc/pressure/memory'}OptionParser.new do |opts|  opts.banner = 'Usage: mem_pressure_monitor.rb [options]'  opts.on('--mem-warn PCT', Float, 'MemAvailable %% WARN threshold (default 15)') { |v| options[:mem_warn_pct] = v }  opts.on('--mem-crit PCT', Float, 'MemAvailable %% CRIT threshold (default 5)') { |v| options[:mem_crit_pct] = v }  opts.on('--swap-warn PCT', Float, 'Swap-used %% WARN threshold (default 50)') { |v| options[:swap_warn_pct] = v }  opts.on('--swap-crit PCT', Float, 'Swap-used %% CRIT threshold (default 90)') { |v| options[:swap_crit_pct] = v }  opts.on('--psi-warn PCT', Float, 'PSI "some avg60" %% WARN threshold (default 10)') { |v| options[:psi_warn] = v }  opts.on('--psi-crit PCT', Float, 'PSI "some avg60" %% CRIT threshold (default 30)') { |v| options[:psi_crit] = v }  opts.on('--oom-lookback MIN', Integer, 'Minutes of journal/dmesg history to scan for OOM kills (default 60)') { |v| options[:oom_lookback_min] = v }  opts.on('--meminfo-path PATH', String, 'Path to meminfo file (default /proc/meminfo; useful in containers with /host/proc mounted, and for testing)') { |v| options[:meminfo_path] = v }  opts.on('--psi-path PATH', String, 'Path to PSI memory file (default /proc/pressure/memory)') { |v| options[:psi_path] = v }  opts.on('--json', 'Emit machine-readable JSON instead of text') { options[:json] = true }  opts.on('-h', '--help', 'Show this help') { puts opts; exit 0 }end.parse!SEVERITY_RANK = { ok: 0, warn: 1, crit: 2, unknown: 0 }.freezedef worse(a, b)  SEVERITY_RANK[a] >= SEVERITY_RANK[b] ? a : bend# ---------------------------------------------------------------------------# /proc/meminfo# ---------------------------------------------------------------------------def read_meminfo(path = '/proc/meminfo')  fields = {}  File.foreach(path) do |line|    # Lines look like: "MemTotal:        4009408 kB"    if line =~ /^(\w+):\s+(\d+)(?:\s+(\w+))?/      fields[Regexp.last_match(1)] = Regexp.last_match(2).to_i    end  end  fieldsenddef analyze_memory(meminfo, warn_pct, crit_pct)  total = meminfo['MemTotal'].to_f  available = meminfo['MemAvailable'].to_f  return { status: :unknown, error: 'MemTotal missing from /proc/meminfo' } if total.zero?  available_pct = (available / total * 100).round(1)  status =    if available_pct <= crit_pct      :crit    elsif available_pct <= warn_pct      :warn    else      :ok    end  {    status: status,    total_mb: (total / 1024).round,    available_mb: (available / 1024).round,    available_pct: available_pct,    used_pct: (100 - available_pct).round(1)  }enddef analyze_swap(meminfo, warn_pct, crit_pct)  total = meminfo['SwapTotal'].to_f  free = meminfo['SwapFree'].to_f  return { status: :ok, total_mb: 0, used_mb: 0, used_pct: 0.0, note: 'no swap configured' } if total.zero?  used = total - free  used_pct = (used / total * 100).round(1)  status =    if used_pct >= crit_pct      :crit    elsif used_pct >= warn_pct      :warn    else      :ok    end  { status: status, total_mb: (total / 1024).round, used_mb: (used / 1024).round, used_pct: used_pct }end# ---------------------------------------------------------------------------# /proc/pressure/memory (PSI -- Pressure Stall Information, Linux 4.20+)# ---------------------------------------------------------------------------def read_psi(path = '/proc/pressure/memory')  return nil unless File.readable?(path)  lines = File.read(path)  parsed = {}  lines.each_line do |line|    kind = line[/^(some|full)/, 1]    next unless kind    values = {}    line.scan(/(\w+)=([\d.]+)/) { |k, v| values[k] = v.to_f }    parsed[kind] = values  end  parsedrescue Errno::ENOENT, Errno::EACCES  nilenddef analyze_psi(psi, warn_pct, crit_pct)  return { status: :unknown, note: 'PSI not available on this kernel/cgroup (needs Linux 4.20+, CONFIG_PSI=y)' } if psi.nil? || psi.empty?  some_avg60 = psi.dig('some', 'avg60') || 0.0  status =    if some_avg60 >= crit_pct      :crit    elsif some_avg60 >= warn_pct      :warn    else      :ok    end  { status: status, some_avg10: psi.dig('some', 'avg10'), some_avg60: some_avg60, some_avg300: psi.dig('some', 'avg300') }end# ---------------------------------------------------------------------------# OOM killer detection -- try journalctl first, fall back to dmesg. Both can# legitimately be unavailable (no systemd, no CAP_SYSLOG) -- that's reported# as :unknown, not treated as a hard failure.# ---------------------------------------------------------------------------def scan_for_oom(lookback_min)  patterns = [/Out of memory/i, /oom[-_ ]kill/i, /Killed process/i]  out, status = try_journalctl(lookback_min)  source = 'journalctl'  if status.nil? || !status.success?    out, status = try_dmesg    source = 'dmesg'  end  return { status: :unknown, note: 'neither journalctl nor dmesg were readable in this environment (needs root/CAP_SYSLOG)' } if status.nil? || !status.success?  hits = out.lines.select { |l| patterns.any? { |p| l =~ p } }  { status: hits.empty? ? :ok : :crit, source: source, oom_events_found: hits.size, sample: hits.first(3).map(&:strip) }enddef try_journalctl(lookback_min)  # capture3 so journalctl's permission-hint noise on stderr doesn't leak  # onto our stdout report -- we only care whether it actually worked.  out, _err, status = Open3.capture3('journalctl', '-k', "--since=-#{lookback_min}min", '--no-pager', '-q')  [out, status]rescue Errno::ENOENT, Errno::EACCES  [nil, nil]enddef try_dmesg  out, _err, status = Open3.capture3('dmesg')  [out, status]rescue Errno::ENOENT, Errno::EACCES  [nil, nil]end# ---------------------------------------------------------------------------# Run# ---------------------------------------------------------------------------meminfo = read_meminfo(options[:meminfo_path])mem = analyze_memory(meminfo, options[:mem_warn_pct], options[:mem_crit_pct])swap = analyze_swap(meminfo, options[:swap_warn_pct], options[:swap_crit_pct])psi = analyze_psi(read_psi(options[:psi_path]), options[:psi_warn], options[:psi_crit])oom = scan_for_oom(options[:oom_lookback_min])overall = [mem[:status], swap[:status], psi[:status], oom[:status]].reduce(:ok) { |acc, s| worse(acc, s || :ok) }exit_code = { ok: 0, unknown: 0, warn: 1, crit: 2 }[overall]if options[:json]  puts JSON.pretty_generate(memory: mem, swap: swap, psi: psi, oom: oom, overall: overall, exit_code: exit_code)else  fmt = ->(s) { s.to_s.upcase.rjust(5) }  puts "mem-pressure-monitor: #{Time.now}"  puts  puts "[#{fmt.call(mem[:status])}] memory available: #{mem[:available_mb]} MB / #{mem[:total_mb]} MB (#{mem[:available_pct]}% free, #{mem[:used_pct]}% used)"  if swap[:total_mb].zero?    puts "[#{fmt.call(swap[:status])}] swap: not configured"  else    puts "[#{fmt.call(swap[:status])}] swap used: #{swap[:used_mb]} MB / #{swap[:total_mb]} MB (#{swap[:used_pct]}%)"  end  if psi[:status] == :unknown    puts "[UNKWN] PSI: #{psi[:note]}"  else    puts "[#{fmt.call(psi[:status])}] PSI memory pressure: some avg10=#{psi[:some_avg10]} avg60=#{psi[:some_avg60]} avg300=#{psi[:some_avg300]}"  end  if oom[:status] == :unknown    puts "[UNKWN] OOM scan: #{oom[:note]}"  elsif oom[:status] == :crit    puts "[ CRIT] OOM scan (#{oom[:source]}): #{oom[:oom_events_found]} event(s) in last #{options[:oom_lookback_min]}m"    oom[:sample].each { |l| puts "        #{l}" }  else    puts "[   OK] OOM scan (#{oom[:source]}): no OOM-kill events in last #{options[:oom_lookback_min]}m"  end  puts  puts "Overall: #{overall.to_s.upcase}"endexit exit_code

MemAvailable, not MemFree. /proc/meminfo is a plain Key: value kB text file. The script deliberately reads MemAvailable for the pressure calculation, not MemFree — MemAvailable already accounts for cache the kernel would reclaim under pressure, so it doesn’t cry wolf on a box that’s just using RAM the way RAM is supposed to be used.

PSI reacts faster than a used-memory percentage. /proc/pressure/memory reports the percent of the last 60 seconds any task spent stalled waiting on memory. That catches thrashing before it shows up as “low available memory” in the simpler check.

Everything degrades gracefully instead of crashing. No swap configured? Reported OK with a note, no divide-by-zero. PSI unavailable (older kernel, restrictive container)? Reported unknown, not a failure. Can’t read journalctl or dmesg (no root, no CAP_SYSLOG)? Same thing — unknown, and Open3.capture3 is used specifically so journalctl’s permission-hint noise on stderr doesn’t leak into the report.

Overrideable paths make it both container-friendly and testable. --meminfo-path and --psi-path exist so a monitoring container with the host’s /proc bind-mounted at /host/proc can point at the real data — and as a side effect, that’s exactly what made it possible to deterministically exercise the WARN/CRIT code paths in a sandbox with no swap and a perfectly healthy PSI reading.

mem-pressure-monitor: 2026-08-08 12:03:56 -0500[   OK] memory available: 3511 MB / 3915 MB (89.7% free, 10.3% used)[   OK] swap: not configured[   OK] PSI memory pressure: some avg10=0.0 avg60=0.0 avg300=0.0[UNKWN] OOM scan: neither journalctl nor dmesg were readable in this environment (needs root/CAP_SYSLOG)Overall: OK$ ruby mem_pressure_monitor.rb --meminfo-path meminfo_crit.txt --psi-path psi_crit.txtmem-pressure-monitor: 2026-08-08 12:03:56 -0500[ CRIT] memory available: 88 MB / 3906 MB (2.3% free, 97.7% used)[ CRIT] swap used: 1904 MB / 1953 MB (97.5%)[ CRIT] PSI memory pressure: some avg10=45.2 avg60=38.5 avg300=12.1[ CRIT] OOM scan (journalctl): 2 event(s) in last 60m        Aug 08 11:58:02 web01 kernel: Out of memory: Killed process 4821 (ruby) total-vm:2048000kB        Aug 08 11:58:02 web01 kernel: oom-kill:constraint=CONSTRAINT_NONE,nodemask=(null),cpuset=/,mems_allowed=0,global_oom,task_memcg=/user.sliceOverall: CRIT
Get the code

Full script + README on GitHub: ruby-devops-toolkit/mem-pressure-monitor

Prerequisites
  • Ruby >= 2.7 (tested on 3.0.2)
  • No gemsoptparse, json, and open3 are all Ruby standard library
  • Linux with /proc/meminfo (universal)
  • PSI (/proc/pressure/memory) needs Linux 4.20+ with CONFIG_PSI=y — degrades gracefully if missing
  • journalctl or dmesg readable for OOM-kill scanning (root or CAP_SYSLOG) — also degrades gracefully
usage

Running it

ruby mem_pressure_monitor.rb [options]

Key options
  • --mem-warn PCT / --mem-crit PCT — MemAvailable %% thresholds (default 15 / 5)
  • --swap-warn PCT / --swap-crit PCT — swap-used %% thresholds (default 50 / 90)
  • --psi-warn PCT / --psi-crit PCT — PSI “some avg60” %% thresholds (default 10 / 30)
  • --meminfo-path PATH / --psi-path PATH — override for containers (e.g. /host/proc) and testing
  • --json — emit machine-readable JSON instead of text
examples.shbash
# Plain snapshot
ruby mem_pressure_monitor.rb
# Tighter thresholds, JSON for alerting
ruby mem_pressure_monitor.rb --mem-warn 25 --mem-crit 10 --json
# Monitoring container with the host's /proc bind-mounted
ruby mem_pressure_monitor.rb --meminfo-path /host/proc/meminfo --psi-path /host/proc/pressure/memory
mem-pressure-monitor architecture diagram

Four independent signals feed a worst-of severity roll-up
how it works

Full walkthrough

1. /proc/meminfo parsing

A regex parses every Key: value kB line into a hash. MemAvailable (not MemFree) drives the pressure calculation.

2. Swap analysis

SwapTotal - SwapFree gives bytes used. A box with no swap configured reports OK with a note instead of dividing by zero.

3. PSI parsing

/proc/pressure/memory has some/full lines with avg10/avg60/avg300/total fields. The script uses some avg60 as the primary signal.

4. OOM-kill scanning

Tries journalctl -k --since=-<N>min first, falls back to dmesg, and reports unknown (not a failure) if neither is readable. Output is scanned for Out of memory, oom-kill, and Killed process patterns.

5. Severity roll-up

Each of the four checks produces its own status; the overall result is the worst of the four, and the process exit code follows it (0 OK, 1 WARN, 2 CRIT).

example output

Healthy box, then a simulated CRIT scenario

mem_pressure_monitor.rb
mem-pressure-monitor: 2026-08-08 12:03:56 -0500
[ OK] memory available: 3511 MB / 3915 MB (89.7% free, 10.3% used)
[ OK] swap: not configured
[ OK] PSI memory pressure: some avg10=0.0 avg60=0.0 avg300=0.0
[UNKWN] OOM scan: neither journalctl nor dmesg were readable in this environment (needs root/CAP_SYSLOG)
Overall: OK
$ ruby mem_pressure_monitor.rb –meminfo-path meminfo_crit.txt –psi-path psi_crit.txt
mem-pressure-monitor: 2026-08-08 12:03:56 -0500
[ CRIT] memory available: 88 MB / 3906 MB (2.3% free, 97.7% used)
[ CRIT] swap used: 1904 MB / 1953 MB (97.5%)
[ CRIT] PSI memory pressure: some avg10=45.2 avg60=38.5 avg300=12.1
[ CRIT] OOM scan (journalctl): 2 event(s) in last 60m
Aug 08 11:58:02 web01 kernel: Out of memory: Killed process 4821 (ruby) total-vm:2048000kB
Aug 08 11:58:02 web01 kernel: oom-kill:constraint=CONSTRAINT_NONE,nodemask=(null),cpuset=/,mems_allowed=0,global_oom,task_memcg=/user.slice
Overall: CRIT
troubleshooting

Troubleshooting

Common issues
  • OOM scan reports “neither journalctl nor dmesg were readable” — expected in most containers and for non-root users. Run with sudo, add CAP_SYSLOG, or join the systemd-journal group.
  • PSI reports “not available on this kernel/cgroup” — older kernels (<4.20) or CONFIG_PSI=n builds don’t expose it; some container runtimes hide it too. The other three checks still work fine.
  • “swap: not configured” even though the host has swap — check you’re not pointed at a container’s /proc; swap accounting is host-level, not namespace-level.
  • Thresholds trip right after a big batch job finishes — often real: a job that allocated a lot of memory and released it can leave MemAvailable briefly low until the kernel reclaims. If it’s routine, schedule around it rather than loosening --mem-crit.
extending it

Where to take this next

Ideas
  • Add a --watch N mode that re-checks every N seconds and only alerts on a state change
  • Feed the JSON output into a de-duplicated Slack/webhook alerter
  • Track top memory-consuming processes via /proc/*/status alongside the OOM scan
  • Add cgroup v2 memory.pressure / memory.max awareness for containerized workloads