Emergency server help: get in touch

Process Hog Report: Which Account Is Using the CPU, RAM and Disk

A read-only Bash script that samples /proc twice and shows which accounts and processes are using the CPU, memory and disk, with each account's domain on cPanel and DirectAdmin, the I/O wait, stuck processes, and a cron mode that only reports when the load is high.

Version
1.0.0
Last updated
October 11, 2026
Language
Bash
Tested on
AlmaLinux 9.8 with cPanel & WHM 138 (lab test with a CPU burner and a disk writer under two accounts, 11 Oct 2026); AlmaLinux 9.8 with DirectAdmin 1.712; Debian 12 (bash 5.2, gawk 5.2); mawk 1.3.4
Testing level
Lab-tested
License
MIT
Pricing
Free

The load is high and the server is slow. top shows a wall of lsphp or php-fpm processes, but the question is which customer is causing it. This script answers that in a few seconds. It samples every process twice, adds up CPU, memory and disk per Linux user (on cPanel and DirectAdmin, each hosting account is a user), shows each account’s main domain, and lists the processes behind it.

Read-only: it only reads /proc, /etc/trueuserdomains (cPanel) and DirectAdmin’s user.conf files. It changes nothing and installs nothing.

What the process hog report shows

  • Header: load average against the number of cores (red when the load is at or above the core count), CPU busy %, I/O wait %, memory used and available, and swap.
  • Accounts by CPU: CPU % (as a share of one core), memory, number of processes, MB read and written during the sample, and the account’s main domain.
  • Accounts by disk I/O: shown when any account read or wrote 1 MB or more.
  • Top processes by CPU, by memory (RSS) and by disk I/O, with their full command lines.
  • Processes in state D (waiting for disk), and the number of zombies.
  • Hints: a note when I/O wait is 20% or more, or when the load is high while the CPUs are mostly idle.

Usage

bash process-hog-report.sh                    # 5-second sample, top 10
bash process-hog-report.sh --interval=15 --top=20
bash process-hog-report.sh --user=site1       # one account only
bash process-hog-report.sh --if-load=4        # print only if the 1-min load is >= 4

Run it as root; as another user it still works, but disk figures and other users’ command lines are hidden.

Sample output

From our cPanel lab, with a CPU burner running as site1 and a disk writer as site2 (shortened):

Process hog report — lab1.srvscripts.com — 2026-10-11 06:27:06 CDT — 6s sample
Load 0.16 0.18 0.17 on 4 cores | CPU busy 28.1% | I/O wait 23.9%
Memory 944 MB used of 3845 MB (2901 MB available) | Swap used 91 MB of 128 MB

Accounts / users by CPU (6s average, % of one core)
USER                CPU%    RSS MB  PROCS   READ MB  WRITE MB  DOMAIN
site1              101.8       5.4      2       0.0       0.0  site1.lab1.srvscripts.com
root                 1.2     891.1    141       0.0       0.2
mongod               1.0      87.4      1       0.0       0.2

Accounts / users by disk I/O (6s)
USER               READ MB  WRITE MB  DOMAIN
site2                  0.0     650.0  site2.lab1.srvscripts.com

Top processes by CPU
    PID USER           CPU%   RSS MB S  COMMAND
 663961 site1         101.8      3.4 R  sh -c while :; do :; done
  47208 mongod          1.0     87.4 S  /usr/local/jetapps/usr/bin/mongod --quiet -f ...

Top processes by disk I/O (read + write during the sample)
    PID USER                MB  COMMAND
 663962 site2            650.0  sh -c cd /home/site2; while :; do dd if=/dev/zero ...

Hint: I/O wait is 23.9%: the disk is the bottleneck. Look at the disk I/O list, backups and D-state processes.

Tested on a real server

ServerWhat we checkedResult
cPanel 138 on AlmaLinux 9.8 (4 cores)CPU burner as site1, disk writer as site2Both accounts found with their domains; 101.8% CPU and 650 MB written; I/O-wait hint shown
DirectAdmin 1.712 on AlmaLinux 9.8CPU burner as the DirectAdmin user101.2% CPU, domain read from user.conf
Debian 12 (FreePBX)Normal load, gawk 5.2Asterisk shown as the top user; runtime 5.8 s for a 5 s sample with 130 processes
Ubuntu containermawk 1.3.4 instead of gawkSame results as gawk

On our cPanel lab the whole run took 8.9 seconds for an 8-second sample with about 300 processes.

Options

OptionMeaning
--interval=SECSeconds between the two samples (1-300, default 5). Longer gives steadier averages
--top=NLines in each list (default 10)
--user=NAMEOnly one Linux user or hosting account
--if-load=NPrint nothing and exit 0 unless the 1-minute load is N or more
--no-colorPlain output on a terminal (colour is off automatically when output is not a terminal)
-V, -hVersion, help

Running it from cron

With --if-load, the script stays silent while the server is fine, and cron mails you a report only when it is busy. On a 4-core server:

# /etc/cron.d/process-hog-report
MAILTO=you@example.com
*/5 * * * * root bash /root/process-hog-report.sh --if-load=6 --interval=10 --top=8

To keep a history instead of mail, append to a log: ... >> /var/log/process-hog.log 2>&1, and rotate it with logrotate. For full minute-by-minute history of every process, set up atop logging as well.

How the numbers are measured

  • CPU % is the user plus system CPU time each process used between the two samples, as a share of one core. A busy multi-core server can show more than 100% per account.
  • Memory is RSS. Shared libraries are counted in every process that maps them, so per-account totals can be higher than the real memory use.
  • Disk is read_bytes and write_bytes from /proc/PID/io: what actually reached the storage layer.
  • Short-lived processes: a process that starts and ends inside the sample window does not appear. Linux adds its disk I/O to the parent that collected it. In our first test, a 600 MB dd that finished during the sample showed up under PID 1. Use a longer interval or atop logging to catch short jobs.
  • Domains come from /etc/trueuserdomains on cPanel and /usr/local/directadmin/data/users/USER/user.conf on DirectAdmin.

Notes

  • On CloudLinux, LVE limits may already be capping an account. Compare with lveinfo and see LVE limits explained.
  • If the top user is mysql, the cause is a query, not MySQL itself: check the slow query summary.
  • If an account’s PHP processes are at the top, look at its access log for the same minutes: the web log top IPs script shows who is hitting it.
  • For the interactive view, see htop explained.

The script

process-hog-report.shDownload
#!/usr/bin/env bash
# Process Hog Report: Which Account Is Using the CPU, RAM and Disk (v1.0.0) - from srvScripts.com
# Source, docs and updates: https://srvscripts.com/scripts/process-hog-report/
# Copyright (c) 2026 srvScripts.com. MIT licence: if you copy, share or adapt this script, keep this notice and credit srvScripts.com.
# process-hog-report.sh — which hosting accounts and processes are using CPU, memory and disk right now
# https://srvscripts.com/scripts/process-hog-report/   License: MIT
#
# Read-only. Samples /proc twice (default 5 seconds apart) and reports per account (Linux user)
# and per process: CPU, memory, disk reads and writes, plus load, I/O wait and stuck (D state)
# processes. On cPanel and DirectAdmin it shows each account's main domain.
#   bash process-hog-report.sh                  # 5-second sample, top 10
#   bash process-hog-report.sh --interval=15 --top=20
#   bash process-hog-report.sh --user=site1     # one account only
#   bash process-hog-report.sh --if-load=4      # cron: print only when the 1-min load is >= 4
set -uo pipefail
export LC_ALL=C

VERSION="1.0.0"
INTERVAL=5
TOP=10
ONLY_USER=""
IF_LOAD=""
COLOR=auto

usage() {
  cat <<'EOF'
Usage: process-hog-report.sh [options]

Options:
  --interval=SEC   seconds between the two samples (default 5, 1-300)
  --top=N          lines in each process list (default 10)
  --user=NAME      only this Linux user / hosting account
  --if-load=N      print nothing and exit 0 unless the 1-minute load is >= N
                   (for cron: you only get mail when the server is busy)
  --no-color       plain output even on a terminal
  -V, --version    print the version
  -h, --help       show this help

CPU % is a share of ONE core (a busy 4-core server can show up to 400 %).
Memory is RSS; shared pages are counted once per process, so per-user totals
can be higher than the real use. Disk figures need root. A process that ends
during the sample is not listed; Linux adds its disk I/O to the parent that
collected it (often PID 1, crond or the web server).
EOF
}

for arg in "$@"; do
  case "$arg" in
    --interval=*) INTERVAL="${arg#*=}" ;;
    --top=*)      TOP="${arg#*=}" ;;
    --user=*)     ONLY_USER="${arg#*=}" ;;
    --if-load=*)  IF_LOAD="${arg#*=}" ;;
    --no-color)   COLOR=never ;;
    -V|--version) echo "process-hog-report $VERSION"; exit 0 ;;
    -h|--help)    usage; exit 0 ;;
    *) echo "Unknown option: $arg (see --help)" >&2; exit 2 ;;
  esac
done

case "$INTERVAL" in ''|*[!0-9]*) echo "--interval must be a whole number of seconds" >&2; exit 2 ;; esac
case "$TOP" in ''|*[!0-9]*) echo "--top must be a number" >&2; exit 2 ;; esac
[ "$INTERVAL" -ge 1 ] && [ "$INTERVAL" -le 300 ] || { echo "--interval must be 1-300" >&2; exit 2; }
if [ -n "$IF_LOAD" ]; then
  case "$IF_LOAD" in *[!0-9.]*|'') echo "--if-load must be a number" >&2; exit 2 ;; esac
fi
[ -r /proc/loadavg ] || { echo "This script needs Linux /proc." >&2; exit 1; }

read -r L1 L5 L15 _ < /proc/loadavg
if [ -n "$IF_LOAD" ] && awk -v l="$L1" -v t="$IF_LOAD" 'BEGIN { exit !(l < t) }'; then
  exit 0
fi

if [ "$COLOR" = auto ] && [ -t 1 ]; then
  B=$'\e[1m'; R=$'\e[31m'; Y=$'\e[33m'; G=$'\e[32m'; N=$'\e[0m'
else
  B=""; R=""; Y=""; G=""; N=""
fi
[ "$(id -u)" -eq 0 ] || echo "${Y}Note: not running as root; other users' disk I/O and some command lines are hidden.${N}" >&2

CORES=$(getconf _NPROCESSORS_ONLN 2>/dev/null || nproc 2>/dev/null || echo 1)
HZ=$(getconf CLK_TCK 2>/dev/null || echo 100)
TMP=$(mktemp -d "${TMPDIR:-/tmp}/phr.XXXXXX") || exit 1
trap 'rm -rf "$TMP"' EXIT

# One line per process: pid uid state cputicks read_bytes write_bytes rss_kb comm
snapshot() {
  local p line rest uid rss rb wb k v _a _b
  for p in /proc/[0-9]*; do
    p=${p#/proc/}
    read -r line 2>/dev/null < "/proc/$p/stat" || continue
    rest=${line##*) }
    comm=${line#*(}; comm=${comm%)*}; comm=${comm// /_}
    set -- $rest
    # rest: 1 state ... 12 utime 13 stime
    local state=$1 ut=${12} st=${13}
    uid=""; rss=0
    while read -r k v _a; do
      case "$k" in
        Uid:) uid=$v ;;
        VmRSS:) rss=$v; break ;;
      esac
    done 2>/dev/null < "/proc/$p/status"
    [ -n "$uid" ] || continue
    rb=0; wb=0
    if [ -r "/proc/$p/io" ]; then
      while read -r k v; do
        case "$k" in
          read_bytes:) rb=$v ;;
          write_bytes:) wb=$v ;;
        esac
      done 2>/dev/null < "/proc/$p/io"
    fi
    echo "$p $uid $state $((ut + st)) $rb $wb $rss $comm"
  done
}

cpu_line() { local a; read -r a < /proc/stat; echo "$a"; }

C0=$(cpu_line); snapshot > "$TMP/a"
sleep "$INTERVAL"
C1=$(cpu_line); snapshot > "$TMP/b"

# Command lines for the final sample (truncated, NUL -> space)
for p in $(awk '{print $1}' "$TMP/b"); do
  if [ -r "/proc/$p/cmdline" ]; then
    c=$(tr '\0' ' ' < "/proc/$p/cmdline" 2>/dev/null | cut -c1-90)
    [ -n "$c" ] && printf '%s\t%s\n' "$p" "$c"
  fi
done > "$TMP/cmd"

getent passwd 2>/dev/null | awk -F: '{print $3, $1}' > "$TMP/users"

# Account -> main domain (cPanel, DirectAdmin)
: > "$TMP/domains"
if [ -r /etc/trueuserdomains ]; then
  awk -F': *' 'NF==2 {print $2, $1}' /etc/trueuserdomains >> "$TMP/domains"
fi
if [ -d /usr/local/directadmin/data/users ]; then
  for f in /usr/local/directadmin/data/users/*/user.conf; do
    [ -r "$f" ] || continue
    u=${f%/user.conf}; u=${u##*/}
    d=$(awk -F= '$1=="domain" {print $2; exit}' "$f")
    [ -n "$d" ] && echo "$u $d"
  done >> "$TMP/domains"
fi

# I/O wait and total CPU over the interval
read -r IOW BUSY < <(awk -v a="$C0" -v b="$C1" 'BEGIN {
  n = split(a, x, " "); split(b, y, " ")
  for (i = 2; i <= n; i++) { d[i] = y[i] - x[i]; tot += d[i] }
  if (tot <= 0) { print 0, 0; exit }
  printf "%.1f %.1f\n", d[6] * 100 / tot, (tot - d[5] - d[6]) * 100 / tot }')

read -r MT MA ST SF < <(awk '/^MemTotal:/{t=$2} /^MemAvailable:/{a=$2} /^SwapTotal:/{st=$2} /^SwapFree:/{sf=$2} END {print t, a, st, sf}' /proc/meminfo)

HOST=$(hostname 2>/dev/null || cat /proc/sys/kernel/hostname)
echo "${B}Process hog report${N} — $HOST — $(date '+%Y-%m-%d %H:%M:%S %Z') — ${INTERVAL}s sample"
LCOL=$G; awk -v l="$L1" -v c="$CORES" 'BEGIN { exit !(l >= c) }' && LCOL=$R
echo "Load ${LCOL}$L1${N} $L5 $L15 on $CORES cores | CPU busy ${BUSY}% | I/O wait ${IOW}%"
awk -v t="$MT" -v a="$MA" -v st="$ST" -v sf="$SF" 'BEGIN {
  printf "Memory %.0f MB used of %.0f MB (%.0f MB available) | Swap used %.0f MB of %.0f MB\n",
         (t - a) / 1024, t / 1024, a / 1024, (st - sf) / 1024, st / 1024 }'
echo

awk -v hz="$HZ" -v iv="$INTERVAL" -v top="$TOP" -v only="$ONLY_USER" -v B="$B" -v N="$N" -v R="$R" -v Y="$Y" \
    -v tmpdir="$TMP" -v self="$$" '
FILENAME == tmpdir "/users"   { uname[$1] = $2; next }
FILENAME == tmpdir "/domains" { dom[$1] = $2; next }
FILENAME == tmpdir "/cmd"     { i = index($0, "\t"); cmd[substr($0, 1, i - 1)] = substr($0, i + 1); next }
FILENAME == tmpdir "/a"       { t0[$1] = $4; r0[$1] = $5; w0[$1] = $6; next }
FILENAME == tmpdir "/b" {
  pid = $1; if (pid == self) next
  u = ($2 in uname) ? uname[$2] : $2
  if (only != "" && u != only) next
  dt = (pid in t0) ? $4 - t0[pid] : $4      # new process: all of its CPU so far
  if (dt < 0) dt = 0
  rd = (pid in r0) ? $5 - r0[pid] : 0; if (rd < 0) rd = 0
  wr = (pid in w0) ? $6 - w0[pid] : 0; if (wr < 0) wr = 0
  cpu = dt * 100 / (hz * iv)
  P[pid] = 1; PU[pid] = u; PC[pid] = cpu; PR[pid] = $7; PS[pid] = $3; PN[pid] = $8
  PIO[pid] = rd + wr
  UC[u] += cpu; UR[u] += $7; UN[u]++; URD[u] += rd; UWR[u] += wr
  if ($3 == "D") { D[pid] = 1; nd++ }
  if ($3 == "Z") nz++
  next
}
function name(pid) { return (pid in cmd) ? cmd[pid] : "[" PN[pid] "]" }
function hdr(t) { print B t N }
END {
  # Accounts by CPU
  hdr("Accounts / users by CPU (" iv "s average, % of one core)")
  printf "%-16s %7s %9s %6s %9s %9s  %s\n", "USER", "CPU%", "RSS MB", "PROCS", "READ MB", "WRITE MB", "DOMAIN"
  n = 0; for (u in UC) n++
  for (k = 1; k <= n && k <= top; k++) {
    best = ""; for (u in UC) if (!(u in done) && (best == "" || UC[u] > UC[best])) best = u
    done[best] = 1
    printf "%-16s %7.1f %9.1f %6d %9.1f %9.1f  %s\n", substr(best, 1, 16), UC[best], UR[best] / 1024, UN[best], URD[best] / 1048576, UWR[best] / 1048576, (best in dom) ? dom[best] : ""
  }
  print ""

  # Accounts by disk I/O (only when someone did at least 1 MB)
  m = 0; for (u in UC) if (URD[u] + UWR[u] >= 1048576) m++
  if (m > 0) {
    hdr("Accounts / users by disk I/O (" iv "s)")
    printf "%-16s %9s %9s  %s\n", "USER", "READ MB", "WRITE MB", "DOMAIN"
    for (k = 1; k <= m && k <= top; k++) {
      best = ""; for (u in UC) if (!(u in dio) && URD[u] + UWR[u] >= 1048576 && (best == "" || URD[u] + UWR[u] > URD[best] + UWR[best])) best = u
      if (best == "") break
      dio[best] = 1
      printf "%-16s %9.1f %9.1f  %s\n", substr(best, 1, 16), URD[best] / 1048576, UWR[best] / 1048576, (best in dom) ? dom[best] : ""
    }
    print ""
  }

  hdr("Top processes by CPU")
  printf "%7s %-12s %6s %8s %1s  %s\n", "PID", "USER", "CPU%", "RSS MB", "S", "COMMAND"
  for (k = 1; k <= top; k++) {
    best = ""; for (p in P) if (!(p in dc) && (best == "" || PC[p] > PC[best])) best = p
    if (best == "" || PC[best] < 0.1) break
    dc[best] = 1
    printf "%7s %-12s %6.1f %8.1f %1s  %s\n", best, substr(PU[best], 1, 12), PC[best], PR[best] / 1024, PS[best], name(best)
  }
  print ""

  hdr("Top processes by memory (RSS)")
  printf "%7s %-12s %8s %6s  %s\n", "PID", "USER", "RSS MB", "CPU%", "COMMAND"
  for (k = 1; k <= top; k++) {
    best = ""; for (p in P) if (!(p in dm) && (best == "" || PR[p] > PR[best])) best = p
    if (best == "") break
    dm[best] = 1
    printf "%7s %-12s %8.1f %6.1f  %s\n", best, substr(PU[best], 1, 12), PR[best] / 1024, PC[best], name(best)
  }
  print ""

  hdr("Top processes by disk I/O (read + write during the sample)")
  shown = 0
  for (k = 1; k <= top; k++) {
    best = ""; for (p in P) if (!(p in di) && (best == "" || PIO[p] > PIO[best])) best = p
    if (best == "" || PIO[best] < 1048576) break
    if (!shown) { printf "%7s %-12s %9s  %s\n", "PID", "USER", "MB", "COMMAND"; shown = 1 }
    di[best] = 1
    printf "%7s %-12s %9.1f  %s\n", best, substr(PU[best], 1, 12), PIO[best] / 1048576, name(best)
  }
  if (!shown) print "No process read or wrote 1 MB or more."
  print ""

  if (nd > 0) {
    hdr(R "Processes waiting for disk (state D): " nd N)
    for (p in D) printf "%7s %-12s  %s\n", p, substr(PU[p], 1, 12), name(p)
    print ""
  }
  if (nz > 0) print Y "Zombie processes: " nz " (harmless unless the number keeps growing)" N
}' "$TMP/users" "$TMP/domains" "$TMP/cmd" "$TMP/a" "$TMP/b"

# Short hints
if awk -v i="$IOW" 'BEGIN { exit !(i >= 20) }'; then
  echo "${Y}Hint:${N} I/O wait is ${IOW}%: the disk is the bottleneck. Look at the disk I/O list, backups and D-state processes."
fi
if awk -v l="$L1" -v c="$CORES" -v b="$BUSY" 'BEGIN { exit !(l >= c && b < 50) }'; then
  echo "${Y}Hint:${N} load is high but the CPUs are not busy: processes are waiting, usually for disk or NFS."
fi
exit 0
Version 1.0.0 · SHA-256 b4f43010608bf1a94c5a16bcc478dd060d4d8bf220998bc47e0e6a963b383994
Download and verify on Linux or macOS
curl -fsSL -o process-hog-report.sh https://scr.srvscripts.com/process-hog-report/process-hog-report.sh && curl -fsSL https://scr.srvscripts.com/process-hog-report/process-hog-report.sh.sha256 | sha256sum -c
Download and verify in Windows PowerShell
Invoke-WebRequest -Uri 'https://scr.srvscripts.com/process-hog-report/process-hog-report.sh' -OutFile 'process-hog-report.sh'; if ((Get-FileHash 'process-hog-report.sh' -Algorithm SHA256).Hash -eq 'B4F43010608BF1A94C5A16BCC478DD060D4D8BF220998BC47E0E6A963B383994') { 'OK: the file is intact' } else { 'MISMATCH: do not run this file' }
Copy the whole line. In Windows PowerShell, curl and sha256sum are not the Linux tools, so use the PowerShell line there.
Also on GitHub: github.com/srvscripts/scripts

Frequently asked questions

How do I find which cPanel account is using the most CPU?

Run the process hog report as root. It adds up the CPU used by every process of each Linux user over a short sample and lists the accounts with their main domains, highest first.

Does the script change anything on the server?

No. It only reads /proc and the panels’ domain lists, and it writes temporary files that it deletes when it exits.

Why does an account show more than 100% CPU?

CPU % is measured against one core. An account running several busy PHP processes on a multi-core server can use 200% or more.

Can I get an email only when the server is overloaded?

Yes. Run it from cron with --if-load=N and MAILTO set. It prints nothing while the 1-minute load is below N, so cron sends mail only when the server is busy.

Does it work on DirectAdmin and plain Linux?

Yes. It works on any Linux with /proc and bash. On DirectAdmin it reads each user’s main domain from user.conf; on servers without a panel the domain column is empty.

Share this script

Changelog

  • 1.0.0 — Initial release

Free website test

Is your website set up right?

Check SSL, security headers, redirects, robots.txt, sitemap, llms.txt and security.txt in one test. It takes about 30 seconds.