Hi Virtualmin team (long time no see) hope you are all enjoying summer
,
I would like to suggest a feature for Virtualmin/Webmin: a lightweight built-in high CPU watchdog that can send an email alert only after sustained high CPU usage, including a useful diagnostic report.
Background
On a Virtualmin server with multiple domains and separate PHP-FPM pools, I recently had a situation where the server CPU/load spiked heavily. The issue was caused by bot/scanner traffic hitting one virtual host, which then passed many requests into WordPress/PHP-FPM.
The existing monitoring options are useful, but I could not find a simple built-in feature that sends a detailed “what is happening right now” report when CPU is high for more than a few minutes.
Requested feature
A Virtualmin/Webmin module or option that can:
-
Monitor total CPU usage every minute.
-
Trigger only after CPU has been above a configurable threshold for a configurable duration, for example:
-
CPU above 95%
-
for 5 consecutive checks
-
-
Send an email alert to one or more recipients.
-
Apply a cooldown period to avoid repeated alerts, for example one alert every 30 minutes.
-
Include Virtualmin-aware diagnostics in the alert email.
Suggested alert report contents
The email report could include:
-
Current uptime/load average.
-
Top CPU-consuming processes.
-
Active PHP-FPM pools and worker counts.
-
Mapping between PHP-FPM pool names and Virtualmin domains.
-
Relevant PHP-FPM pool settings:
-
pm.max_children -
pm.start_servers -
pm.min_spare_servers -
pm.max_spare_servers -
memory_limit -
max_execution_time -
max_input_vars -
request_slowlog_timeout -
slowlog -
request_terminate_timeout
-
-
Recent PHP-FPM slowlog entries if configured.
-
Recent Virtualmin access/error log summaries for the affected domains:
-
Top requests
-
Top IP addresses
-
Top user agents
-
Top HTTP status/request combinations
-
Scanner-like requests such as
.env,.git/config,xmlrpc.php,wp-login.php, suspicious/api/paths, etc.
-
-
MariaDB/MySQL processlist if local credentials are available.
-
Recent
journalctlentries related to:-
PHP-FPM
-
Nginx/Apache
-
MariaDB/MySQL
-
OOM kills
-
segfaults
-
service failures
-
-
Optional check for Nginx default server configuration, for example whether unknown HTTPS hostnames/IP-host requests are falling into the first SSL virtual host.
Why this would help
When a shared Virtualmin server suddenly reaches high CPU, it is often not enough to know that CPU is high. The administrator needs to know immediately:
-
Which domain or PHP-FPM pool is responsible.
-
Whether the cause is bots, WordPress cron, admin-ajax, XML-RPC, a plugin, database queries, backups, AWStats, or something else.
-
Which log files show the cause.
-
Whether the issue is isolated to one virtual server or system-wide.
Having this information in the first alert email would reduce troubleshooting time significantly.
Example of a custom script we created
Below is a simplified and anonymized version of the shell script we created. It runs every minute from cron and sends a diagnostic email only after sustained high CPU usage.
#!/usr/bin/env bash
set -u
ALERT_FROM="server-watchdog@example.com”
ALERT_RECIPIENTS=(
"admin-alerts@example.com”
"backup-admin@example.net”
)
HOSTNAME_FQDN="$(hostname -f 2>/dev/null || hostname)”
STATE_DIR="/var/lib/server-watchdog”
STATE_FILE="$STATE_DIR/cpu.state”
LAST_ALERT_FILE="$STATE_DIR/last-alert”
LAST_REPORT_FILE="$STATE_DIR/last-report.txt”
LOCK_FILE="/run/server-cpu-watchdog.lock”
CPU_THRESHOLD=95
REQUIRED_HITS=5
ALERT_COOLDOWN_SECONDS=1800
mkdir -p “$STATE_DIR”
exec 9>”$LOCK_FILE”
flock -n 9 || exit 0
read_cpu() {
awk '/^cpu / {
idle=$5
total=0
for (i=2; i<=NF; i++) total += $i
print total, idle
}' /proc/stat
}
read -r total1 idle1 < <(read_cpu)
sleep 2
read -r total2 idle2 < <(read_cpu)
total_delta=$((total2 - total1))
idle_delta=$((idle2 - idle1))
if [ "$total_delta" -le 0 ]; then
exit 0
fi
cpu_usage=$(( (100 * (total_delta - idle_delta)) / total_delta ))
old_hits=0
[ -f "$STATE_FILE" ] && old_hits="$(cat "$STATE_FILE" 2>/dev/null || echo 0)”
if [ "$cpu_usage" -ge "$CPU_THRESHOLD" ]; then
hits=$((old_hits + 1))
else
hits=0
fi
echo "$hits" > “$STATE_FILE”
if [ "$hits" -lt "$REQUIRED_HITS" ]; then
exit 0
fi
now="$(date +%s)”
last_alert=0
[ -f "$LAST_ALERT_FILE" ] && last_alert="$(cat "$LAST_ALERT_FILE" 2>/dev/null || echo 0)”
if [ $((now - last_alert)) -lt "$ALERT_COOLDOWN_SECONDS" ]; then
exit 0
fi
echo "$now" > “$LAST_ALERT_FILE”
REPORT="$(mktemp /tmp/server-cpu-report.XXXXXX)”
trap 'rm -f "$REPORT"’ EXIT
{
echo "CPU Watchdog Alert”
echo "Host: $HOSTNAME_FQDN”
echo "Date: $(date -Is)”
echo "CPU measured: ${cpu_usage}%”
echo "Threshold: ${CPU_THRESHOLD}%”
echo "Required hits: ${REQUIRED_HITS}”
echo
echo "===== UPTIME =====“
uptime
echo
echo "===== TOP PROCESSES =====“
ps -eo pid,ppid,user,%cpu,%mem,etime,cmd --sort=-%cpu | head -70
echo
echo "===== PHP-FPM POOL COUNTS =====“
ps -eo cmd | grep "php-fpm: pool" | grep -v grep | awk '{print $3}' | sort | uniq -c | sort -nr
echo
echo "===== ACTIVE PHP-FPM PROCESSES =====“
ps -eo pid,ppid,user,%cpu,%mem,etime,cmd --sort=-%cpu | grep "php-fpm: pool" | grep -v grep | head -100
echo
echo "===== PHP-FPM POOL CONFIG MAPPING =====“
pools="$(ps -eo cmd | grep "php-fpm: pool" | grep -v grep | awk '{print $3}' | sort | uniq -c | sort -nr | awk '{print $2}' | head -12)”
for pool in $pools; do
echo "--- Pool: $pool —"
conf="$(find /etc/php -type f -path "*/fpm/pool.d/${pool}.conf" 2>/dev/null | head -1)”
if [ -n "$conf" ]; then
echo "Config: $conf”
grep -E "^(user|group|listen|pm\.max_children|pm\.start_servers|pm\.min_spare_servers|pm\.max_spare_servers|php_value\[upload_tmp_dir\]|php_value\[session.save_path\]|php_value\[error_log\]|php_value\[memory_limit\]|php_value\[max_execution_time\]|php_admin_value\[max_input_time\]|php_admin_value\[max_input_vars\]|slowlog|request_slowlog_timeout|request_terminate_timeout|pm.max_requests)" "$conf" 2>/dev/null
else
echo "Config: not found”
fi
echo
done
echo "===== MARIADB / MYSQL PROCESS =====“
ps -eo pid,user,%cpu,%mem,etime,cmd --sort=-%cpu | grep -E "mariadbd|mysqld" | grep -v grep || true
echo
echo "===== TOP VIRTUALMIN LOG ACTIVITY FOR ACTIVE PHP DOMAINS =====“
for pool in $pools; do
conf="$(find /etc/php -type f -path "*/fpm/pool.d/${pool}.conf" 2>/dev/null | head -1)”
[ -z "$conf" ] && continue
domain_name="$(grep -E "php_value\[upload_tmp_dir\]|php_value\[session.save_path\]|php_value\[error_log\]" "$conf" 2>/dev/null | sed -n 's#.*= /home/[^/]*/domains/\([^/]*\)/.*#\1#p' | head -1)”
[ -z "$domain_name" ] && continue
access_log="/var/log/virtualmin/${domain_name}_access_log”
error_log="/var/log/virtualmin/${domain_name}_error_log”
slow_log="$(grep -E "^slowlog[[:space:]]*=" "$conf" 2>/dev/null | awk -F= '{gsub(/^[ \t]+|[ \t]+$/, "", $2); print $2}' | head -1)”
echo "——————————————————————————————"
echo "Pool: $pool”
echo "Domain from pool config: $domain_name”
echo "Access log: $access_log”
echo "Error log: $error_log”
[ -n "$slow_log" ] && echo "Slowlog: $slow_log”
echo "——————————————————————————————"
echo
if [ -f "$access_log" ]; then
echo "Top requests:”
LC_ALL=C tail -50000 "$access_log" 2>/dev/null \
| grep -aE "GET|POST” \
| awk -F\" '{print $2}’ \
| awk '{print $1, $2}’ \
| sort | uniq -c | sort -nr | head -50
echo
echo "Top user agents:”
LC_ALL=C tail -50000 "$access_log" 2>/dev/null \
| awk -F\" '{print $6}’ \
| sort | uniq -c | sort -nr | head -40
echo
echo "Top IPs:”
LC_ALL=C tail -50000 "$access_log" 2>/dev/null \
| awk '{print $1}’ \
| sort | uniq -c | sort -nr | head -40
echo
echo "Top status + request:”
LC_ALL=C tail -50000 "$access_log" 2>/dev/null \
| awk -F\" '{split($3,s," "); print s[1], $2}’ \
| sort | uniq -c | sort -nr | head -50
echo
echo "Scanner-like recent lines:”
LC_ALL=C tail -50000 "$access_log" 2>/dev/null \
| grep -aE "/api/|/swagger|/storybook|/teamcity|/supabase|/system|/stub_status|wp-admin/install.php|\.env|\.git/config|xmlrpc.php|wp-login.php” \
| tail -80
echo
fi
if [ -f "$error_log" ]; then
echo "Recent error log:”
tail -100 "$error_log" 2>/dev/null
echo
fi
if [ -n "${slow_log:-}" ] && [ -f "$slow_log" ]; then
echo "Recent PHP-FPM slowlog:”
tail -120 "$slow_log" 2>/dev/null
echo
fi
done
echo "===== NGINX DEFAULT SERVER CHECK =====“
nginx -T 2>/dev/null | grep -nE "default_server|ssl_reject_handshake|server_name _" | head -50 || true
echo
echo "===== RECENT JOURNAL SIGNALS =====“
journalctl --since "15 minutes ago" --no-pager 2>/dev/null \
| grep -Ei "oom|killed process|php-fpm|mariadb|mysql|nginx|segfault|out of memory|failed|error” \
| tail -150 || true
echo
} > “$REPORT”
cp "$REPORT" “$LAST_REPORT_FILE”
SUBJECT="[CPU ALERT] ${HOSTNAME_FQDN} CPU ${cpu_usage}% for ${REQUIRED_HITS} checks”
if command -v mail >/dev/null 2>&1; then
mail -r "$ALERT_FROM" -s "$SUBJECT" "${ALERT_RECIPIENTS[@]}" < “$REPORT”
elif command -v mailx >/dev/null 2>&1; then
mailx -r "$ALERT_FROM" -s "$SUBJECT" "${ALERT_RECIPIENTS[@]}" < “$REPORT”
elif command -v sendmail >/dev/null 2>&1; then
{
echo "From: $ALERT_FROM”
echo "To: $(IFS=, ; echo "${ALERT_RECIPIENTS[*]}”)”
echo "Subject: $SUBJECT”
echo
cat “$REPORT”
} | sendmail -t
fi
Example cron:
* * * * * root /usr/local/sbin/server-cpu-watchdog.sh >/dev/null 2>&1
Possible implementation idea
This could be added as an advanced option in Webmin/Virtualmin monitoring:
-
CPU threshold
-
Number of consecutive checks
-
Email recipients
-
Cooldown period
-
Include PHP-FPM pool diagnostics
-
Include Virtualmin domain log summaries
-
Include MySQL processlist
-
Include recent service/journal errors
This would be very useful for shared hosting servers where one virtual server, WordPress site, PHP-FPM pool, plugin, bot attack, or scheduled task can suddenly affect the whole machine.
Thanks for considering this feature.