the shed // ruby x devops

Your pager says the disk is filling. df says which one. This stdlib-only Ruby script says what grew overnight — the question that actually finds the culprit — and exits 0/1/2 so cron can page you before it’s at 95%.

Step through the build below:

disk_usage_report.rb

The filesystem almost full page arrives at 3am. df tells you which mount is in trouble — and nothing else. du -sh /* takes ten minutes on a big tree, tells you what is big right now, and still doesn’t answer the question you actually have: what grew since yesterday? A 2 GB log directory is normal; a log directory that gained 2 GB overnight is your culprit.

This tutorial builds a single stdlib-only Ruby script that answers all three questions in one pass: the df-level filesystem view, the top-N directories and files eating the space, and — via a JSON snapshot persisted between runs — the growth delta for every directory. It exits 0/1/2 against configurable thresholds, so the same script is both a human report and a cron alert.

disk_usage_report.rb — Ruby, stdlib only
#!/usr/bin/env ruby
# frozen_string_literal: true
#
# disk_usage_report.rb — disk usage reporting with growth tracking.
#
# Answers the three questions every on-call engineer asks when a
# "filesystem almost full" alert fires at 3am:
#   1. Which filesystems are actually in trouble?  (df-level view)
#   2. What inside them is eating the space?       (top-N dirs/files)
#   3. What GREW since the last time we looked?    (snapshot deltas)
#
# Stdlib only — no gems. Text and --json output, cron-friendly exit codes:
#   0 = everything under thresholds, 1 = WARN crossed, 2 = CRIT crossed.
#
# Usage:
#   ruby disk_usage_report.rb /var /home --top 10 --state /var/tmp/du_state.json
#   ruby disk_usage_report.rb /var --warn-pct 80 --crit-pct 92 --json

require 'find'
require 'json'
require 'optparse'
require 'time'

options = {
  top: 10,            # how many largest dirs/files to report
  warn_pct: 80,       # filesystem %use that triggers WARN
  crit_pct: 90,       # filesystem %use that triggers CRIT
  min_mb: 1,          # ignore files smaller than this in the top-N file list
  state: nil,         # JSON snapshot path for growth tracking
  json: false
}

OptionParser.new do |o|
  o.banner = 'Usage: disk_usage_report.rb PATH [PATH...] [options]'
  o.on('--top N', Integer, 'Top N dirs and files to show (default 10)') { |v| options[:top] = v }
  o.on('--warn-pct N', Integer, 'WARN when filesystem use%% >= N (default 80)') { |v| options[:warn_pct] = v }
  o.on('--crit-pct N', Integer, 'CRIT when filesystem use%% >= N (default 90)') { |v| options[:crit_pct] = v }
  o.on('--min-mb N', Integer, 'Ignore files under N MB in file list (default 1)') { |v| options[:min_mb] = v }
  o.on('--state FILE', 'Snapshot file for growth deltas between runs') { |v| options[:state] = v }
  o.on('--json', 'Emit JSON instead of text') { options[:json] = true }
end.parse!

paths = ARGV.empty? ? ['.'] : ARGV
paths.each do |p|
  abort "disk_usage_report: no such directory: #{p}" unless File.directory?(p)
end

# ---------------------------------------------------------------------------
# 1. Filesystem-level view. `df -Pk` is POSIX and stable enough to parse:
#    Filesystem 1024-blocks Used Available Capacity Mounted-on
# ---------------------------------------------------------------------------
def filesystems(paths)
  out = `df -Pk #{paths.map { |p| "'#{p}'" }.join(' ')} 2>/dev/null`
  return [] unless $?.success?

  out.lines.drop(1).map do |line|
    cols = line.split
    next if cols.size < 6
    {
      'filesystem' => cols[0],
      'size_kb'    => cols[1].to_i,
      'used_kb'    => cols[2].to_i,
      'avail_kb'   => cols[3].to_i,
      'use_pct'    => cols[4].delete('%').to_i,
      'mount'      => cols[5..].join(' ')
    }
  end.compact.uniq { |fs| fs['mount'] }
end

# ---------------------------------------------------------------------------
# 2. Walk each path once. We accumulate:
#    - total bytes per immediate child directory (the "who is eating it" view)
#    - the largest individual files
#    Find.prune keeps us out of other mounted filesystems' /proc-style traps.
# ---------------------------------------------------------------------------
def scan(root, min_bytes)
  dir_bytes  = Hash.new(0)
  big_files  = []           # [[bytes, path], ...] kept small via periodic trim
  root_dev   = File.stat(root).dev
  errors     = 0

  Find.find(root) do |path|
    begin
      st = File.lstat(path)
      # Do not cross filesystem boundaries — a bind-mounted /var/lib/docker
      # would otherwise get double-counted against the wrong mount.
      if st.directory? && st.dev != root_dev
        Find.prune
        next
      end
      next unless st.file?

      # Attribute the file to the top-level child of root it lives under,
      # e.g. /var/log/syslog counts toward "/var/log".
      rel = path.sub(%r{\A#{Regexp.escape(root)}/?}, '')
      child = rel.include?('/') ? File.join(root, rel.split('/').first) : root
      dir_bytes[child] += st.size

      if st.size >= min_bytes
        big_files << [st.size, path]
        # trim occasionally so memory stays flat on huge trees
        big_files = big_files.max_by(200) { |b, _| b } if big_files.size > 4000
      end
    rescue Errno::EACCES, Errno::ENOENT, Errno::ELOOP
      errors += 1 # unreadable/racing files are counted, not fatal
    end
  end

  { dirs: dir_bytes, files: big_files, errors: errors }
end

# ---------------------------------------------------------------------------
# 3. Growth tracking. The state file is just {"dir" => bytes} from last run;
#    the delta between runs is usually more interesting than the absolute
#    number — a 2 GB log dir is fine, a log dir that grew 2 GB overnight isn't.
# ---------------------------------------------------------------------------
def load_state(path)
  return {} unless path && File.exist?(path)
  JSON.parse(File.read(path))
rescue JSON::ParserError
  {}
end

def human(bytes)
  units = %w[B KB MB GB TB]
  u = 0
  b = bytes.to_f
  while b >= 1024 && u < units.size - 1
    b /= 1024
    u += 1
  end
  format(b >= 10 || u.zero? ? '%.0f %s' : '%.1f %s', b, units[u])
end

min_bytes = options[:min_mb] * 1024 * 1024
fs_view   = filesystems(paths)
prev      = load_state(options[:state])

all_dirs  = {}
all_files = []
errors    = 0
paths.each do |root|
  r = scan(root, min_bytes)
  all_dirs.merge!(r[:dirs]) { |_k, a, b| a + b }
  all_files.concat(r[:files])
  errors += r[:errors]
end

top_dirs  = all_dirs.sort_by { |_d, b| -b }.first(options[:top])
top_files = all_files.max_by(options[:top]) { |b, _| b }

growth = top_dirs.map do |dir, bytes|
  delta = prev.key?(dir) ? bytes - prev[dir] : nil
  [dir, bytes, delta]
end

# Persist the new snapshot for next run (whole dir map, not just top-N,
# so a directory that newly enters the top-N still has a real delta).
if options[:state]
  File.write(options[:state], JSON.pretty_generate(all_dirs))
end

# ---------------------------------------------------------------------------
# 4. Verdict + output
# ---------------------------------------------------------------------------
worst = fs_view.map { |f| f['use_pct'] }.max || 0
status = if worst >= options[:crit_pct] then 'CRIT'
         elsif worst >= options[:warn_pct] then 'WARN'
         else 'OK'
         end

if options[:json]
  puts JSON.pretty_generate(
    'generated_at' => Time.now.iso8601,
    'status'       => status,
    'filesystems'  => fs_view,
    'top_dirs'     => growth.map { |d, b, delta| { 'dir' => d, 'bytes' => b, 'delta_bytes' => delta } },
    'top_files'    => top_files.map { |b, p| { 'file' => p, 'bytes' => b } },
    'scan_errors'  => errors
  )
else
  puts "disk usage report — #{Time.now.strftime('%Y-%m-%d %H:%M')}  [#{status}]"
  puts
  puts 'FILESYSTEMS'
  fs_view.each do |f|
    flag = f['use_pct'] >= options[:crit_pct] ? ' <-- CRIT' : (f['use_pct'] >= options[:warn_pct] ? ' <-- WARN' : '')
    puts format('  %-24s %8s used / %8s  (%3d%%)  %s%s',
                f['mount'], human(f['used_kb'] * 1024), human(f['size_kb'] * 1024), f['use_pct'], f['filesystem'], flag)
  end
  puts
  puts "TOP #{options[:top]} DIRECTORIES"
  growth.each do |dir, bytes, delta|
    d = delta.nil? ? '   (new)' : format('%+9s', human(delta.abs) .prepend(delta.negative? ? '-' : '+'))
    puts format('  %10s  %s  %s', human(bytes), d, dir)
  end
  puts
  puts "TOP #{options[:top]} FILES (>= #{options[:min_mb]} MB)"
  top_files.each { |b, p| puts format('  %10s  %s', human(b), p) }
  puts
  puts "unreadable entries skipped: #{errors}" if errors.positive?
end

exit(status == 'CRIT' ? 2 : status == 'WARN' ? 1 : 0)

One walk, not many. A single Find.find pass visits every entry; each file’s bytes are attributed to the top-level child of the scan root it lives under (so /var/log/nginx/access.log counts toward /var/log — the granularity a human wants first).

lstat, and prune on device change. File.lstat never follows symlinks, and any directory whose st.dev differs from the root’s device is pruned — a bind-mounted /var/lib/docker can’t get double-counted against the wrong mount. Permission errors are counted and reported, never fatal.

The snapshot is the feature. The whole dir => bytes map is written to the --state JSON after every run. Next run, each top directory shows a signed delta. The report below was produced by growing a fixture log dir 32 MB between two runs — it surfaces instantly.

Exit codes are the contract. 0 under thresholds, 1 at --warn-pct, 2 at --crit-pct — cron, CI, and Nagios-style checks consume it with zero wrapping.

$ ruby disk_usage_report.rb /tmp/dutest –top 5 –state /tmp/du_state.json # second run, after growth
disk usage report — 2026-08-24 14:50  [OK]

FILESYSTEMS
  /                          5.9 GB used /   9.5 GB  ( 62%)  /dev/sda1

TOP 5 DIRECTORIES
      102 MB     +32 MB  /tmp/dutest/logs
       95 MB       +0 B  /tmp/dutest/db
       30 MB       +0 B  /tmp/dutest/uploads
       12 MB       +0 B  /tmp/dutest/cache
         6 B       +0 B  /tmp/dutest

TOP 5 FILES (>= 1 MB)
       95 MB  /tmp/dutest/db/data.sqlite
       80 MB  /tmp/dutest/logs/app.log
       30 MB  /tmp/dutest/uploads/video.mp4
       22 MB  /tmp/dutest/logs/nginx-access.log
       12 MB  /tmp/dutest/cache/bundle.tar

Get the code

Full script + README on GitHub: ruby-devops-toolkit/disk-usage-report

0
gems required
1
filesystem walk
0/1/2
exit codes
setup

Prerequisites

you need
  • Ruby >= 2.7 — only stdlib is used: find, json, optparse, time. Nothing to bundle install on a box you’re firefighting.
  • Linux or macOS — anything with a POSIX df; the -Pk flags pin the one output format that’s stable everywhere.
  • Read access to the trees you scan. Run under sudo for full coverage; unreadable entries are skipped and counted, never fatal.
diagram

How a run flows

disk_usage_report.rb architecture: walk, aggregate, snapshot, report

one Find.find walk feeds the aggregate; the JSON snapshot turns run N and run N+1 into growth deltas
reference

The full script

disk_usage_report.rbruby
#!/usr/bin/env ruby
# frozen_string_literal: true
#
# disk_usage_report.rb — disk usage reporting with growth tracking.
#
# Answers the three questions every on-call engineer asks when a
# "filesystem almost full" alert fires at 3am:
#   1. Which filesystems are actually in trouble?  (df-level view)
#   2. What inside them is eating the space?       (top-N dirs/files)
#   3. What GREW since the last time we looked?    (snapshot deltas)
#
# Stdlib only — no gems. Text and --json output, cron-friendly exit codes:
#   0 = everything under thresholds, 1 = WARN crossed, 2 = CRIT crossed.
#
# Usage:
#   ruby disk_usage_report.rb /var /home --top 10 --state /var/tmp/du_state.json
#   ruby disk_usage_report.rb /var --warn-pct 80 --crit-pct 92 --json

require 'find'
require 'json'
require 'optparse'
require 'time'

options = {
  top: 10,            # how many largest dirs/files to report
  warn_pct: 80,       # filesystem %use that triggers WARN
  crit_pct: 90,       # filesystem %use that triggers CRIT
  min_mb: 1,          # ignore files smaller than this in the top-N file list
  state: nil,         # JSON snapshot path for growth tracking
  json: false
}

OptionParser.new do |o|
  o.banner = 'Usage: disk_usage_report.rb PATH [PATH...] [options]'
  o.on('--top N', Integer, 'Top N dirs and files to show (default 10)') { |v| options[:top] = v }
  o.on('--warn-pct N', Integer, 'WARN when filesystem use%% >= N (default 80)') { |v| options[:warn_pct] = v }
  o.on('--crit-pct N', Integer, 'CRIT when filesystem use%% >= N (default 90)') { |v| options[:crit_pct] = v }
  o.on('--min-mb N', Integer, 'Ignore files under N MB in file list (default 1)') { |v| options[:min_mb] = v }
  o.on('--state FILE', 'Snapshot file for growth deltas between runs') { |v| options[:state] = v }
  o.on('--json', 'Emit JSON instead of text') { options[:json] = true }
end.parse!

paths = ARGV.empty? ? ['.'] : ARGV
paths.each do |p|
  abort "disk_usage_report: no such directory: #{p}" unless File.directory?(p)
end

# ---------------------------------------------------------------------------
# 1. Filesystem-level view. `df -Pk` is POSIX and stable enough to parse:
#    Filesystem 1024-blocks Used Available Capacity Mounted-on
# ---------------------------------------------------------------------------
def filesystems(paths)
  out = `df -Pk #{paths.map { |p| "'#{p}'" }.join(' ')} 2>/dev/null`
  return [] unless $?.success?

  out.lines.drop(1).map do |line|
    cols = line.split
    next if cols.size < 6
    {
      'filesystem' => cols[0],
      'size_kb'    => cols[1].to_i,
      'used_kb'    => cols[2].to_i,
      'avail_kb'   => cols[3].to_i,
      'use_pct'    => cols[4].delete('%').to_i,
      'mount'      => cols[5..].join(' ')
    }
  end.compact.uniq { |fs| fs['mount'] }
end

# ---------------------------------------------------------------------------
# 2. Walk each path once. We accumulate:
#    - total bytes per immediate child directory (the "who is eating it" view)
#    - the largest individual files
#    Find.prune keeps us out of other mounted filesystems' /proc-style traps.
# ---------------------------------------------------------------------------
def scan(root, min_bytes)
  dir_bytes  = Hash.new(0)
  big_files  = []           # [[bytes, path], ...] kept small via periodic trim
  root_dev   = File.stat(root).dev
  errors     = 0

  Find.find(root) do |path|
    begin
      st = File.lstat(path)
      # Do not cross filesystem boundaries — a bind-mounted /var/lib/docker
      # would otherwise get double-counted against the wrong mount.
      if st.directory? && st.dev != root_dev
        Find.prune
        next
      end
      next unless st.file?

      # Attribute the file to the top-level child of root it lives under,
      # e.g. /var/log/syslog counts toward "/var/log".
      rel = path.sub(%r{\A#{Regexp.escape(root)}/?}, '')
      child = rel.include?('/') ? File.join(root, rel.split('/').first) : root
      dir_bytes[child] += st.size

      if st.size >= min_bytes
        big_files << [st.size, path]
        # trim occasionally so memory stays flat on huge trees
        big_files = big_files.max_by(200) { |b, _| b } if big_files.size > 4000
      end
    rescue Errno::EACCES, Errno::ENOENT, Errno::ELOOP
      errors += 1 # unreadable/racing files are counted, not fatal
    end
  end

  { dirs: dir_bytes, files: big_files, errors: errors }
end

# ---------------------------------------------------------------------------
# 3. Growth tracking. The state file is just {"dir" => bytes} from last run;
#    the delta between runs is usually more interesting than the absolute
#    number — a 2 GB log dir is fine, a log dir that grew 2 GB overnight isn't.
# ---------------------------------------------------------------------------
def load_state(path)
  return {} unless path && File.exist?(path)
  JSON.parse(File.read(path))
rescue JSON::ParserError
  {}
end

def human(bytes)
  units = %w[B KB MB GB TB]
  u = 0
  b = bytes.to_f
  while b >= 1024 && u < units.size - 1
    b /= 1024
    u += 1
  end
  format(b >= 10 || u.zero? ? '%.0f %s' : '%.1f %s', b, units[u])
end

min_bytes = options[:min_mb] * 1024 * 1024
fs_view   = filesystems(paths)
prev      = load_state(options[:state])

all_dirs  = {}
all_files = []
errors    = 0
paths.each do |root|
  r = scan(root, min_bytes)
  all_dirs.merge!(r[:dirs]) { |_k, a, b| a + b }
  all_files.concat(r[:files])
  errors += r[:errors]
end

top_dirs  = all_dirs.sort_by { |_d, b| -b }.first(options[:top])
top_files = all_files.max_by(options[:top]) { |b, _| b }

growth = top_dirs.map do |dir, bytes|
  delta = prev.key?(dir) ? bytes - prev[dir] : nil
  [dir, bytes, delta]
end

# Persist the new snapshot for next run (whole dir map, not just top-N,
# so a directory that newly enters the top-N still has a real delta).
if options[:state]
  File.write(options[:state], JSON.pretty_generate(all_dirs))
end

# ---------------------------------------------------------------------------
# 4. Verdict + output
# ---------------------------------------------------------------------------
worst = fs_view.map { |f| f['use_pct'] }.max || 0
status = if worst >= options[:crit_pct] then 'CRIT'
         elsif worst >= options[:warn_pct] then 'WARN'
         else 'OK'
         end

if options[:json]
  puts JSON.pretty_generate(
    'generated_at' => Time.now.iso8601,
    'status'       => status,
    'filesystems'  => fs_view,
    'top_dirs'     => growth.map { |d, b, delta| { 'dir' => d, 'bytes' => b, 'delta_bytes' => delta } },
    'top_files'    => top_files.map { |b, p| { 'file' => p, 'bytes' => b } },
    'scan_errors'  => errors
  )
else
  puts "disk usage report — #{Time.now.strftime('%Y-%m-%d %H:%M')}  [#{status}]"
  puts
  puts 'FILESYSTEMS'
  fs_view.each do |f|
    flag = f['use_pct'] >= options[:crit_pct] ? ' <-- CRIT' : (f['use_pct'] >= options[:warn_pct] ? ' <-- WARN' : '')
    puts format('  %-24s %8s used / %8s  (%3d%%)  %s%s',
                f['mount'], human(f['used_kb'] * 1024), human(f['size_kb'] * 1024), f['use_pct'], f['filesystem'], flag)
  end
  puts
  puts "TOP #{options[:top]} DIRECTORIES"
  growth.each do |dir, bytes, delta|
    d = delta.nil? ? '   (new)' : format('%+9s', human(delta.abs) .prepend(delta.negative? ? '-' : '+'))
    puts format('  %10s  %s  %s', human(bytes), d, dir)
  end
  puts
  puts "TOP #{options[:top]} FILES (>= #{options[:min_mb]} MB)"
  top_files.each { |b, p| puts format('  %10s  %s', human(b), p) }
  puts
  puts "unreadable entries skipped: #{errors}" if errors.positive?
end

exit(status == 'CRIT' ? 2 : status == 'WARN' ? 1 : 0)
walkthrough

Step by step

1. The filesystem view: parsing df -Pk

POSIX specifies df -P output down to the column: filesystem, 1024-blocks, used, available, capacity, mount point. That’s why the script shells out instead of poking at statvfs — Ruby’s stdlib has no portable statvfs binding, and parsing a specified format is honest and robust. Rows are de-duplicated by mount point so scanning /var and /var/log doesn’t print the same filesystem twice.

2. The walk: Find.find + lstat + prune

Find.find streams every path under the root. Three decisions make it production-safe: lstat instead of stat (symlinks are counted as links, never followed into loops); Find.prune whenever a directory’s device number differs from the root’s (mounts don’t leak into each other’s totals); and a rescue for EACCES/ENOENT/ELOOP that increments a counter instead of crashing mid-scan on a racing tmpfile.

3. Keeping memory flat on huge trees

The naive version keeps every file it sees, then sorts — on a 5-million-file tree that’s gigabytes of arrays. Here the candidate list is trimmed back to the 200 largest with max_by(200) whenever it crosses 4000 entries. Result: bounded memory, same top-N answer.

4. Growth deltas from the JSON snapshot

After reporting, the entire directory map — not just the top N — is persisted to the --state file. That detail matters: a directory that newly claws its way into the top N still has a previous value to diff against, so its first appearance already shows a real delta instead of (new).

verify

Alert mode

cron — alerting on thresholds
$ ruby disk_usage_report.rb /var –warn-pct 50 –crit-pct 95 >/dev/null; echo $?
1
$ ruby disk_usage_report.rb /var –warn-pct 40 –crit-pct 50 >/dev/null; echo $?
2
# 2 = CRIT -> page; 1 = WARN -> ticket; 0 -> sleep
debug

Troubleshooting

when it surprises you
  • Numbers differ from du -shdu reports allocated blocks, this script reports file sizes. Sparse files read higher here; hard links (counted once per path) can push either way. Both answers are correct for what they measure.
  • unreadable entries skipped: N — you’re not root. Re-run with sudo if those trees need counting.
  • Slow on NFS — every lstat is a network round trip; run the scan on the file server itself.
  • All deltas show (new) — the state file moved or was deleted; deltas need two runs against the same --state path.
extend

Where to take it

ideas
  • Emit the --json output into Prometheus’s textfile collector — instant per-directory growth dashboards.
  • Track per-file deltas too, for a “fastest-growing files” view that catches one runaway core-dump file.
  • Add --exclude GLOB for cache trees you never care about.
  • Alert on growth rate instead of fullness: “/var full in ~3 days at current rate” is a better page than “/var at 80%”.