the shed // ruby // linux // /proc

A box can look idle in every CPU graph and still be quietly rotting: zombies pile up behind a parent that never calls wait(), and D-state processes sit frozen on a dead NFS mount. This script finds both and tells you which parent to fix.

Step through the build below:

proc_state_audit.rb

Zombie (state Z) processes hold a PID-table slot and nothing else, but a leaking parent can exhaust pid_max and stop the box forking. Processes in uninterruptible sleep (state D) are worse: kill -9 does nothing, load average climbs, and the usual cause is a hung NFS server or failing disk. ps shows them, but nobody reads ps at 3am. We want a cron-friendly check that groups zombies by their parent (the thing you need to restart) and lists what each D-state process is blocked on.

// proc_state_audit.rb

#!/usr/bin/env ruby
# frozen_string_literal: true
# proc_state_audit.rb - find zombie and stuck (D-state) processes from /proc.
# Zombies pile up when a parent never wait()s; D-state processes are blocked in
# the kernel (usually dead NFS / failing disk). Neither shows up in CPU graphs.
# Usage: ruby proc_state_audit.rb [--root DIR] [--json] [--zombie-warn N] [--dstate-secs N]
# Exit: 0 OK, 1 WARN, 2 CRIT.  Stdlib only.
require 'optparse'
require 'json'

Proc_ = Struct.new(:pid, :ppid, :name, :state, :start_ticks, :wchan, keyword_init: true)

opts = { root: '/proc', json: false, zombie_warn: 1, zombie_crit: 20, d_crit: 3 }
OptionParser.new do |o|
  o.on('--root DIR', 'proc root (fixtures)') { |v| opts[:root] = v }
  o.on('--json') { opts[:json] = true }
  o.on('--zombie-warn N', Integer) { |v| opts[:zombie_warn] = v }
  o.on('--zombie-crit N', Integer) { |v| opts[:zombie_crit] = v }
  o.on('--d-crit N', Integer, 'D-state count that is CRIT') { |v| opts[:d_crit] = v }
end.parse!

# /proc/PID/stat is "pid (comm) state ppid ...". comm may contain spaces and
# parentheses, so split on the LAST ')' rather than on whitespace.
def parse_stat(line)
  open_i  = line.index('(')
  close_i = line.rindex(')')
  return nil unless open_i && close_i
  rest = line[(close_i + 2)..].split
  { pid: line[0...open_i].to_i, name: line[(open_i + 1)...close_i],
    state: rest[0], ppid: rest[1].to_i, start: rest[19].to_i }
end

def scan(root)
  Dir.children(root).grep(/\A\d+\z/).filter_map do |pid|
    st = parse_stat(File.read(File.join(root, pid, 'stat')))
    next unless st
    wchan = File.read(File.join(root, pid, 'wchan')).strip rescue '?'
    Proc_.new(pid: st[:pid], ppid: st[:ppid], name: st[:name], state: st[:state],
              start_ticks: st[:start], wchan: wchan)
  rescue Errno::ENOENT, Errno::EACCES, Errno::ESRCH
    nil # process exited while we were reading - normal on a live box
  end
end

procs   = scan(opts[:root])
by_pid  = procs.to_h { |p| [p.pid, p] }
zombies = procs.select { |p| p.state == 'Z' }
dstate  = procs.select { |p| p.state == 'D' }

# Group zombies by parent: the parent is the process that needs fixing.
parents = zombies.group_by(&:ppid).map do |ppid, kids|
  { ppid: ppid, parent: by_pid[ppid]&.name || '?', zombies: kids.size, pids: kids.map(&:pid).first(10) }
end.sort_by { |h| -h[:zombies] }

status = if zombies.size >= opts[:zombie_crit] || dstate.size >= opts[:d_crit] then 'CRIT'
         elsif zombies.size >= opts[:zombie_warn] || !dstate.empty? then 'WARN'
         else 'OK' end

report = { status: status, total: procs.size, zombie_count: zombies.size, dstate_count: dstate.size,
           zombie_parents: parents,
           dstate: dstate.map { |p| { pid: p.pid, name: p.name, wchan: p.wchan } } }

if opts[:json]
  puts JSON.pretty_generate(report)
else
  puts "proc-state-audit: #{status}  (#{procs.size} processes, #{zombies.size} zombie, #{dstate.size} D-state)"
  parents.each { |h| puts format('  ZOMBIES  parent=%-6d %-14s count=%-3d e.g. pids %s', h[:ppid], h[:parent], h[:zombies], h[:pids].join(',')) }
  dstate.each  { |p| puts format('  D-STATE  pid=%-6d %-14s blocked in: %s', p.pid, p.name, p.wchan) }
end
exit({ 'OK' => 0, 'WARN' => 1, 'CRIT' => 2 }[status])

1. Parse /proc/PID/stat safely. The stat file is pid (comm) state ppid .... The command name can contain spaces and parentheses, so the parser splits on the last ) instead of on whitespace. A test covers a process literally named we ird) (name.

2. Scan tolerantly. Every numeric directory under the proc root is read. Processes exit between readdir and read, so ENOENT/EACCES/ESRCH are swallowed rather than crashing the scan.

3. Group zombies by parent. A zombie cannot be killed; its parent must reap it or die. group_by(&:ppid) turns 300 zombies into one line naming the guilty parent.

4. Grade and exit. Any zombie is WARN (threshold configurable), 20+ zombies or 3+ D-state processes is CRIT, and any D-state process is at least WARN. Exit codes 0/1/2 make it drop into cron or monitoring.

5. Fixture mode. --root DIR points the scanner at a fake proc tree, which is how the test-suite and the sample output below were produced.

// captured in the sandbox

proc-state-audit: WARN  (7 processes, 3 zombie, 2 D-state)
  ZOMBIES  parent=884    legacy-worker  count=3   e.g. pids 901,903,902
  D-STATE  pid=1200   rsync          blocked in: nfs_wait_bit_killable
  D-STATE  pid=1201   backup-agent   blocked in: nfs_wait_bit_killable
exit=1
{
  "status": "WARN",
  "total": 7,
  "zombie_count": 3,
  "dstate_count": 2,
  "zombie_parents": [
    {
      "ppid": 884,
      "parent": "legacy-worker",
      "zombies": 3,
      "pids": [
        901,
        903,
        902
Finished in 0.319938s, 12.5024 runs/s, 28.1305 assertions/s.

4 runs, 9 assertions, 0 failures, 0 errors, 0 skips
Get the code

Full script + README on GitHub: ruby-devops-toolkit/proc-state-audit

Data flow diagram for proc_state_audit.rb

How the pieces fit together
the problem

Why this matters

Zombie (state Z) processes hold a PID-table slot and nothing else, but a leaking parent can exhaust pid_max and stop the box forking. Processes in uninterruptible sleep (state D) are worse: kill -9 does nothing, load average climbs, and the usual cause is a hung NFS server or failing disk. ps shows them, but nobody reads ps at 3am. We want a cron-friendly check that groups zombies by their parent (the thing you need to restart) and lists what each D-state process is blocked on.

Prerequisites – Linux (any kernel with /proc)
  • Ruby 2.7+ (tested on 3.3.6); stdlib only, no gems
  • Linux with /proc mounted (no root needed for state and ppid; wchan may read as 0 for other users’ processes)
  • Optional: cron or a Nagios-style runner to act on exit codes 0/1/2
the script

Complete code

proc_state_audit.rbruby
#!/usr/bin/env ruby
# frozen_string_literal: true
# proc_state_audit.rb - find zombie and stuck (D-state) processes from /proc.
# Zombies pile up when a parent never wait()s; D-state processes are blocked in
# the kernel (usually dead NFS / failing disk). Neither shows up in CPU graphs.
# Usage: ruby proc_state_audit.rb [--root DIR] [--json] [--zombie-warn N] [--dstate-secs N]
# Exit: 0 OK, 1 WARN, 2 CRIT.  Stdlib only.
require 'optparse'
require 'json'

Proc_ = Struct.new(:pid, :ppid, :name, :state, :start_ticks, :wchan, keyword_init: true)

opts = { root: '/proc', json: false, zombie_warn: 1, zombie_crit: 20, d_crit: 3 }
OptionParser.new do |o|
  o.on('--root DIR', 'proc root (fixtures)') { |v| opts[:root] = v }
  o.on('--json') { opts[:json] = true }
  o.on('--zombie-warn N', Integer) { |v| opts[:zombie_warn] = v }
  o.on('--zombie-crit N', Integer) { |v| opts[:zombie_crit] = v }
  o.on('--d-crit N', Integer, 'D-state count that is CRIT') { |v| opts[:d_crit] = v }
end.parse!

# /proc/PID/stat is "pid (comm) state ppid ...". comm may contain spaces and
# parentheses, so split on the LAST ')' rather than on whitespace.
def parse_stat(line)
  open_i  = line.index('(')
  close_i = line.rindex(')')
  return nil unless open_i && close_i
  rest = line[(close_i + 2)..].split
  { pid: line[0...open_i].to_i, name: line[(open_i + 1)...close_i],
    state: rest[0], ppid: rest[1].to_i, start: rest[19].to_i }
end

def scan(root)
  Dir.children(root).grep(/\A\d+\z/).filter_map do |pid|
    st = parse_stat(File.read(File.join(root, pid, 'stat')))
    next unless st
    wchan = File.read(File.join(root, pid, 'wchan')).strip rescue '?'
    Proc_.new(pid: st[:pid], ppid: st[:ppid], name: st[:name], state: st[:state],
              start_ticks: st[:start], wchan: wchan)
  rescue Errno::ENOENT, Errno::EACCES, Errno::ESRCH
    nil # process exited while we were reading - normal on a live box
  end
end

procs   = scan(opts[:root])
by_pid  = procs.to_h { |p| [p.pid, p] }
zombies = procs.select { |p| p.state == 'Z' }
dstate  = procs.select { |p| p.state == 'D' }

# Group zombies by parent: the parent is the process that needs fixing.
parents = zombies.group_by(&:ppid).map do |ppid, kids|
  { ppid: ppid, parent: by_pid[ppid]&.name || '?', zombies: kids.size, pids: kids.map(&:pid).first(10) }
end.sort_by { |h| -h[:zombies] }

status = if zombies.size >= opts[:zombie_crit] || dstate.size >= opts[:d_crit] then 'CRIT'
         elsif zombies.size >= opts[:zombie_warn] || !dstate.empty? then 'WARN'
         else 'OK' end

report = { status: status, total: procs.size, zombie_count: zombies.size, dstate_count: dstate.size,
           zombie_parents: parents,
           dstate: dstate.map { |p| { pid: p.pid, name: p.name, wchan: p.wchan } } }

if opts[:json]
  puts JSON.pretty_generate(report)
else
  puts "proc-state-audit: #{status}  (#{procs.size} processes, #{zombies.size} zombie, #{dstate.size} D-state)"
  parents.each { |h| puts format('  ZOMBIES  parent=%-6d %-14s count=%-3d e.g. pids %s', h[:ppid], h[:parent], h[:zombies], h[:pids].join(',')) }
  dstate.each  { |p| puts format('  D-STATE  pid=%-6d %-14s blocked in: %s', p.pid, p.name, p.wchan) }
end
exit({ 'OK' => 0, 'WARN' => 1, 'CRIT' => 2 }[status])
walkthrough

How it works, step by step

1. Parse /proc/PID/stat safely

The stat file is pid (comm) state ppid .... The command name can contain spaces and parentheses, so the parser splits on the last ) instead of on whitespace. A test covers a process literally named we ird) (name.

2. Scan tolerantly

Every numeric directory under the proc root is read. Processes exit between readdir and read, so ENOENT/EACCES/ESRCH are swallowed rather than crashing the scan.

3. Group zombies by parent

A zombie cannot be killed; its parent must reap it or die. group_by(&:ppid) turns 300 zombies into one line naming the guilty parent.

4. Grade and exit

Any zombie is WARN (threshold configurable), 20+ zombies or 3+ D-state processes is CRIT, and any D-state process is at least WARN. Exit codes 0/1/2 make it drop into cron or monitoring.

5. Fixture mode

--root DIR points the scanner at a fake proc tree, which is how the test-suite and the sample output below were produced.

example output

Run it

Tested live in a Linux sandbox: 4 minitest cases against fixture proc trees (clean host, grouped zombies, a process name with parentheses, CRIT on stuck IO) plus a run against the sandbox’s real /proc.

proc_state_audit.rb
proc-state-audit: WARN (7 processes, 3 zombie, 2 D-state)
ZOMBIES parent=884 legacy-worker count=3 e.g. pids 901,903,902
D-STATE pid=1200 rsync blocked in: nfs_wait_bit_killable
D-STATE pid=1201 backup-agent blocked in: nfs_wait_bit_killable
exit=1
{
"status": "WARN",
"total": 7,
"zombie_count": 3,
"dstate_count": 2,
"zombie_parents": [
{
"ppid": 884,
"parent": "legacy-worker",
"zombies": 3,
"pids": [
901,
903,
902
Finished in 0.319938s, 12.5024 runs/s, 28.1305 assertions/s.
4 runs, 9 assertions, 0 failures, 0 errors, 0 skips
troubleshooting

When it doesn't work

Troubleshooting
  • wchan shows 0 or ? – reading /proc/PID/wchan for other users’ processes needs root or kernel.kptr_restrict=0. The audit still works; you just lose the blocked-in symbol.
  • Short-lived D-state blips – a process in D for a few milliseconds during disk IO is normal. Run it twice, a few seconds apart, before paging anyone, or raise --d-crit.
  • Inside containers – you only see your PID namespace. Run it on the host (or with --root /proc from a privileged container) to see everything.
next steps

Extending it

Ideas
  • Sample twice and only report D-state PIDs present in both runs
  • Send the JSON to a webhook or Prometheus textfile collector
  • Read /proc/PID/stack (root) for the full kernel call chain
  • Add process start time (stat field 22) to show how long a zombie has been around

Tha-Shed

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

Start creating