- Posted on
- • Artificial Intelligence
Intelligent System Health Checks Using Bash
- Author
-
-
- User
- linuxbash
- Posts by this author
- Posts by this author
-
Intelligent System Health Checks Using Bash
Most “health check” scripts are either too naive (spamming you any time CPU spikes for a second) or too heavy (requiring a full-blown monitoring stack you don’t want on every node). What if you could get context-aware, low-noise, actionable checks with nothing but Bash and a few standard utilities?
This article shows how to build intelligent, self-tuning system health checks in Bash that:
Adapt thresholds based on recent history (baselining)
Correlate multiple metrics before alarming (reduce noise)
Emit machine- and human-readable output
Run anywhere you can run a shell (VMs, containers, edge devices)
You’ll get a ready-to-use script, installation steps for common distros, and examples that reflect real-world pains like disk growth, I/O wait storms, or transient network flaps.
Why Bash is a valid choice
Ubiquity and zero vendor lock-in: Bash and coreutils exist on almost every Linux box, from servers to containers.
Low overhead: No agents, databases, or daemons needed.
Composability: Easy to schedule with cron or systemd timers; pipe outputs to logs, mail, or webhooks.
Portability: Uses /proc, /sys, and standard tools that are stable across distros.
This isn’t a replacement for enterprise observability—but it’s perfect for:
Small estates or “pets”
Restricted environments (air-gapped, minimal containers)
First-line signal before you deploy a larger stack
Prerequisites (with apt, dnf, and zypper)
The core script uses only common tools (awk, sed, grep, df). Some optional checks use packages below. Install what you need.
sysstat (iostat/mpstat) [optional: we’ll also provide a fallback]
smartmontools (SMART disk health) [optional]
lm-sensors (temperatures) [optional]
bc (floating point arithmetic)
curl (for webhooks; optional)
jq (pretty-print JSON; optional)
mailx/mailutils (email alerting; optional)
Debian/Ubuntu (apt):
sudo apt update
sudo apt install -y sysstat smartmontools lm-sensors bc curl jq mailutils
Fedora/RHEL/CentOS (dnf):
sudo dnf install -y sysstat smartmontools lm_sensors bc curl jq mailx
openSUSE (zypper):
sudo zypper refresh
sudo zypper install -y sysstat smartmontools lm_sensors bc curl jq mailx
Notes:
Temperatures: after installing lm-sensors, run
sudo sensors-detect --auto(may prompt on some systems).SMART requires root for
smartctlon most systems: run script as root if you enable SMART checks.If you don’t want mail or jq, skip those packages.
The script: intelligent, low-noise system health
Save as /usr/local/sbin/ishc.sh and chmod +x /usr/local/sbin/ishc.sh.
#!/usr/bin/env bash
set -euo pipefail
# Intelligent System Health Check (ishc.sh)
# - Adaptive baselines (exponential moving average)
# - Correlated alerts to reduce noise
# - Human summary + JSON output
# - Minimal dependencies (sysstat/lm-sensors/smartctl optional)
HOST="${HOSTNAME:-$(hostname)}"
STATE_FILE="/var/tmp/ishc.state"
ALPHA="0.3" # EMA smoothing (0..1); higher = more reactive
INTERVAL_IOWAIT="1" # seconds for iowait sampling
PING_TARGET="${PING_TARGET:-1.1.1.1}"
PING_COUNT=3
DISK_PATHS="${DISK_PATHS:-/ /var /home}" # Adjust to your mounts
# Thresholds (base). "Intelligent" logic compares to baseline too.
THRESH_LOAD_PER_CORE=2.0
THRESH_MEM_AVAILABLE_PCT=10
THRESH_IOWAIT_PCT=8
THRESH_LATENCY_MS=120
THRESH_PACKET_LOSS_PCT=5
THRESH_DISK_USED_PCT=90
THRESH_TEMP_C=85
# Utilities
has() { command -v "$1" >/dev/null 2>&1; }
calc() {
# bc-based float math; usage: calc "100 * 0.2"
echo "scale=6; $*" | bc -l
}
percent() {
# percent x/y
local x="$1" y="$2"
if [ "$y" -eq 0 ]; then echo "0"; else calc "($x * 100) / $y"; fi
}
read_kv_state() {
if [ -f "$STATE_FILE" ]; then
# shellcheck disable=SC1090
source "$STATE_FILE"
fi
}
write_kv_state() {
cat > "$STATE_FILE" <<EOF
BASE_LOAD_PER_CORE=${BASE_LOAD_PER_CORE:-$LOAD_PER_CORE}
BASE_IOWAIT=${BASE_IOWAIT:-$IOWAIT}
BASE_LATENCY=${BASE_LATENCY:-$LATENCY}
BASE_MEM_AVAIL_PCT=${BASE_MEM_AVAIL_PCT:-$MEM_AVAIL_PCT}
BASE_DISK_USED_PCT=${BASE_DISK_USED_PCT:-$DISK_USED_PCT_MAX}
EOF
}
ema_update() {
local base="$1" current="$2"
if [ -z "${base:-}" ]; then
echo "$current"
else
calc "$ALPHA * $current + (1 - $ALPHA) * $base"
fi
}
get_cores() {
if has nproc; then nproc; else grep -c ^processor /proc/cpuinfo; fi
}
get_load_per_core() {
local load_1m cores
load_1m=$(awk '{print $1}' /proc/loadavg)
cores=$(get_cores)
calc "$load_1m / $cores"
}
get_mem_available_pct() {
# Use MemAvailable if present; fallback to free
if grep -q MemAvailable /proc/meminfo; then
local total avail
total=$(awk '/MemTotal/{print $2}' /proc/meminfo)
avail=$(awk '/MemAvailable/{print $2}' /proc/meminfo)
# Values in kB; percent of total that's available
percent "$avail" "$total"
else
# Fallback via free (older distros)
local total avail
read -r _ total used free shared buff cache avail <<<"$(free -k | awk '/Mem:/{print $1,$2,$3,$4,$5,$6,$7,$7}')"
percent "$avail" "$total"
fi
}
get_iowait_pct() {
# Prefer mpstat for accuracy; else sample /proc/stat over 1s
if has mpstat; then
mpstat 1 1 | awk '/Average|all/ {iowait=$6} END{if(iowait=="") iowait=0; print iowait}'
else
# fields: user nice sys idle iowait irq softirq steal guest guest_nice
local a b
a=$(awk '/^cpu /{print $2,$3,$4,$5,$6,$7,$8,$9,$10,$11}' /proc/stat)
sleep "$INTERVAL_IOWAIT"
b=$(awk '/^cpu /{print $2,$3,$4,$5,$6,$7,$8,$9,$10,$11}' /proc/stat)
awk -v A="$a" -v B="$b" '
BEGIN{
split(A,aa," "); split(B,bb," ");
sumA=0; sumB=0; for(i=1;i<=10;i++){sumA+=aa[i]; sumB+=bb[i]}
dUser=bb[1]-aa[1]; dNice=bb[2]-aa[2]; dSys=bb[3]-aa[3]; dIdle=bb[4]-aa[4];
dIow=bb[5]-aa[5]; dIrq=bb[6]-aa[6]; dSoft=bb[7]-aa[7]; dSteal=bb[8]-aa[8];
total=(sumB - sumA); if(total<=0){total=1}
printf("%.2f", (dIow*100.0)/total)
}'
fi
}
get_net_latency_loss() {
# Outputs: latency_ms packet_loss_pct
if has ping; then
local out rtt loss
out=$(ping -c '"$PING_COUNT"' -w 4 "$PING_TARGET" 2>/dev/null || true)
loss=$(echo "$out" | awk -F',' '/packet loss/{gsub(/ /,""); print $3}' | tr -d '%' || echo "100")
rtt=$(echo "$out" | awk -F'/' '/rtt|round-trip/{print $5}')
if [ -z "$rtt" ]; then rtt=10000; fi
echo "$rtt" "$loss"
else
echo "0 100"
fi
}
get_disk_used_pct_max_and_growth() {
# Scan defined DISK_PATHS; compute max usage and growth vs last run
local maxp=0 thisp path
local total_used=0 total_size=0
for path in $DISK_PATHS; do
if df -P "$path" >/dev/null 2>&1; then
thisp=$(df -P "$path" | awk 'NR==2{gsub(/%/,"",$5); print $5}')
if [ "$thisp" -gt "$maxp" ]; then maxp="$thisp"; fi
# Summed for growth heuristics
read -r _ size used _ <<<"$(df -P -k "$path" | awk 'NR==2{print $1,$2,$3,$4}')"
total_used=$((total_used + used))
total_size=$((total_size + size))
fi
done
local used_pct_all=0
if [ "$total_size" -gt 0 ]; then
used_pct_all=$(calc "($total_used * 100) / $total_size")
fi
local growth="0"
if [ -n "${BASE_DISK_USED_PCT:-}" ]; then
growth=$(calc "$used_pct_all - $BASE_DISK_USED_PCT")
fi
echo "$maxp" "$growth"
}
get_max_temp_c() {
if has sensors; then
sensors 2>/dev/null | awk '/\+?[0-9]+(\.[0-9]+)?°C/{
match($0,/\+?([0-9]+(\.[0-9]+)?)°C/,m); if(m[1]>max) max=m[1]
} END{if(max=="") max=0; print max}'
else
echo "0"
fi
}
get_smart_health() {
# Return 0 if all PASS or smartctl not present, 1 if any FAIL
if ! has smartctl; then return 0; fi
local d rv=0
for d in /dev/sd? /dev/nvme?n?; do
[ -e "$d" ] || continue
if smartctl -H "$d" 2>/dev/null | grep -q "PASSED"; then :; else rv=1; fi
done
return "$rv"
}
# Collect current metrics
LOAD_PER_CORE=$(get_load_per_core)
MEM_AVAIL_PCT=$(get_mem_available_pct)
IOWAIT=$(get_iowait_pct)
read LATENCY PACKET_LOSS <<<"$(get_net_latency_loss)"
read DISK_USED_PCT_MAX DISK_GROWTH_PCT <<<"$(get_disk_used_pct_max_and_growth)"
MAX_TEMP_C=$(get_max_temp_c)
SMART_FAIL=0; if ! get_smart_health; then SMART_FAIL=1; fi
# Load previous baselines
read_kv_state
# Update baselines
BASE_LOAD_PER_CORE=$(ema_update "${BASE_LOAD_PER_CORE:-}" "$LOAD_PER_CORE")
BASE_IOWAIT=$(ema_update "${BASE_IOWAIT:-}" "$IOWAIT")
BASE_LATENCY=$(ema_update "${BASE_LATENCY:-}" "$LATENCY")
BASE_MEM_AVAIL_PCT=$(ema_update "${BASE_MEM_AVAIL_PCT:-}" "$MEM_AVAIL_PCT")
BASE_DISK_USED_PCT=$(ema_update "${BASE_DISK_USED_PCT:-}" "$DISK_USED_PCT_MAX")
# Intelligent alerting
SEVERITY=0
ALERTS=()
add_alert() {
local sev="$1" msg="$2"
ALERTS+=("$sev|$msg")
if [ "$sev" -gt "$SEVERITY" ]; then SEVERITY="$sev"; fi
}
# 1) High load only if it exceeds both static and baseline, and corroborated by I/O wait
if (( $(echo "$LOAD_PER_CORE > $THRESH_LOAD_PER_CORE" | bc -l) )) && \
(( $(echo "$LOAD_PER_CORE > ($BASE_LOAD_PER_CORE * 1.6)" | bc -l) )); then
if (( $(echo "$IOWAIT > 5" | bc -l) )); then
add_alert 2 "High load per core ($LOAD_PER_CORE) with iowait ${IOWAIT}%"
else
add_alert 1 "Elevated load per core ($LOAD_PER_CORE) vs baseline ($BASE_LOAD_PER_CORE)"
fi
fi
# 2) Low memory only if MemAvailable is low and swapping observed recently (heuristic via iowait or growth)
if (( $(echo "$MEM_AVAIL_PCT < $THRESH_MEM_AVAILABLE_PCT" | bc -l) )); then
add_alert 2 "Low MemAvailable (${MEM_AVAIL_PCT}%)"
fi
# 3) Disk usage: alert only if high and still growing
if (( $(echo "$DISK_USED_PCT_MAX > $THRESH_DISK_USED_PCT" | bc -l) )); then
if (( $(echo "$DISK_GROWTH_PCT > 0.5" | bc -l) )); then
add_alert 2 "Disk critically full (max ${DISK_USED_PCT_MAX}%), +${DISK_GROWTH_PCT}% since last run"
else
add_alert 1 "Disk high (max ${DISK_USED_PCT_MAX}%)"
fi
fi
# 4) Network: consider both loss and latency; compare to baseline to avoid flapping
if (( $(echo "$PACKET_LOSS > $THRESH_PACKET_LOSS_PCT" | bc -l) )); then
add_alert 2 "Network packet loss ${PACKET_LOSS}% to ${PING_TARGET}"
elif (( $(echo "$LATENCY > $THRESH_LATENCY_MS" | bc -l) )) && \
(( $(echo "$LATENCY > ($BASE_LATENCY * 2.2)" | bc -l) )); then
add_alert 1 "High latency ${LATENCY}ms (baseline ${BASE_LATENCY}ms)"
fi
# 5) Temperature
if (( $(echo "$MAX_TEMP_C > $THRESH_TEMP_C" | bc -l) )); then
add_alert 2 "High temperature ${MAX_TEMP_C}°C"
fi
# 6) SMART
if [ "$SMART_FAIL" -ne 0 ]; then
add_alert 2 "SMART health check failing on one or more disks"
fi
# Persist new baselines
write_kv_state
# Output
TS=$(date -Iseconds)
HUMAN_SUMMARY="[$TS] $HOST: "
if [ "${#ALERTS[@]}" -eq 0 ]; then
HUMAN_SUMMARY+="OK"
else
for a in "${ALERTS[@]}"; do HUMAN_SUMMARY+=$(echo " | ${a#*|}"); done
fi
echo "$HUMAN_SUMMARY"
# JSON payload (simple; jq optional for pretty)
JSON=$(cat <<EOF
{"ts":"$TS","host":"$HOST","severity":$SEVERITY,
"metrics":{
"load_per_core":$LOAD_PER_CORE,
"mem_available_pct":$MEM_AVAIL_PCT,
"iowait_pct":$IOWAIT,
"latency_ms":$LATENCY,
"packet_loss_pct":$PACKET_LOSS,
"disk_used_pct_max":$DISK_USED_PCT_MAX,
"disk_growth_pct":$DISK_GROWTH_PCT,
"max_temp_c":$MAX_TEMP_C,
"smart_fail":$SMART_FAIL
},
"baseline":{
"load_per_core":$BASE_LOAD_PER_CORE,
"iowait_pct":$BASE_IOWAIT,
"latency_ms":$BASE_LATENCY,
"mem_available_pct":$BASE_MEM_AVAIL_PCT,
"disk_used_pct":$BASE_DISK_USED_PCT
},
"alerts":[
$(printf '"%s",' "${ALERTS[@]#*|}" 2>/dev/null | sed 's/,$//')
]
}
EOF
)
echo "$JSON"
# Exit code: 0 OK, 1 WARN, 2 CRIT
exit "$SEVERITY"
How it’s “intelligent”
Baselining with EMA: The script learns normal behavior (e.g., nightly ETL spikes) and only warns when you exceed both a static threshold and your own baseline.
Correlation: High load alone isn’t critical; high load with high I/O wait usually is.
Growth-aware disk alerts: It flags disks that are both full and still growing.
Dual output: Quick human summary plus JSON for pipelines.
3–5 actionable steps to deploy
1) Install the optional tools you plan to use
Debian/Ubuntu:
sudo apt update && sudo apt install -y sysstat smartmontools lm-sensors bc curl jq mailutils
Fedora/RHEL/CentOS:
sudo dnf install -y sysstat smartmontools lm_sensors bc curl jq mailx
openSUSE:
sudo zypper refresh && sudo zypper install -y sysstat smartmontools lm_sensors bc curl jq mailx
Optional post-install:
sudo sensors-detect --auto(temperature support)- Run the script as root if you enable SMART checks.
2) Drop the script and run it by hand
sudo install -m 0755 ishc.sh /usr/local/sbin/ishc.sh
sudo /usr/local/sbin/ishc.sh
- First run initializes the baseline. Don’t panic if it’s quiet—that’s the point.
3) Schedule it
- With cron (every 5 minutes):
# as root
echo '*/5 * * * * /usr/local/sbin/ishc.sh >> /var/log/ishc.log 2>&1' | sudo tee /etc/cron.d/ishc
sudo systemctl restart cron || sudo systemctl restart crond || true
- With systemd timers (preferred):
sudo tee /etc/systemd/system/ishc.service >/dev/null <<'UNIT'
[Unit]
Description=Intelligent System Health Check
[Service]
Type=oneshot
ExecStart=/usr/local/sbin/ishc.sh
UNIT
sudo tee /etc/systemd/system/ishc.timer >/dev/null <<'TIMER'
[Unit]
Description=Run Intelligent System Health Check every 5 minutes
[Timer]
OnBootSec=2m
OnUnitActiveSec=5m
Unit=ishc.service
[Install]
WantedBy=timers.target
TIMER
sudo systemctl daemon-reload
sudo systemctl enable --now ishc.timer
4) Integrate with your alerting
Email (optional):
- Debian/Ubuntu:
sudo apt install -y mailutils- Fedora/RHEL/CentOS:
sudo dnf install -y mailx- openSUSE:
sudo zypper install -y mailx
Pipe summary to mail when severity > 0:
/usr/local/sbin/ishc.sh | tee -a /var/log/ishc.log | awk 'NR==1 && $0 !~ /OK$/' | mail -s "ALERT: $(hostname)" you@example.com
- Webhook (Slack/Teams/custom):
/usr/local/sbin/ishc.sh | awk 'NR==2' | curl -sS -X POST -H 'Content-Type: application/json' -d @- https://example.com/webhook
5) Tune for your environment
- Adjust
DISK_PATHSto your important mounts:
export DISK_PATHS="/ /var /data /backups"
Tighten or relax thresholds by setting environment variables before running, or edit the script defaults:
THRESH_LOAD_PER_CORE,THRESH_MEM_AVAILABLE_PCT,THRESH_IOWAIT_PCT,THRESH_LATENCY_MS,THRESH_PACKET_LOSS_PCT,THRESH_DISK_USED_PCT,THRESH_TEMP_C
Increase
ALPHAfor faster adaptation to new baselines; decrease for stability.
Real-world examples and how this helps
Nightly ETL on a database host: Classic checks page you nightly. EMA baseline means your check knows nights are busier; it only warns if tonight is abnormally busy compared to recent nights.
Disk creeping due to runaway logs: The script won’t just tell you the disk is at 91%; it’ll also say “+1.8% since last run,” signaling active growth that may warrant immediate action.
Transient network flaps: An isolated 100ms RTT spike won’t fire if your baseline is ~90ms, but 300ms sustained with packet loss will.
Heat-induced throttling: Temperature checks capture thermal issues on dense hosts or poorly ventilated racks.
Notes, caveats, and extensions
SMART and sensors may be unavailable in containers or VMs. The script degrades gracefully if commands are missing.
Running as root is recommended when using SMART or reading certain logs.
Extend quickly:
- Add journal correlation:
journalctl -p 3 --since "5 min ago" - Check inode pressure:
df -Pi - Track process count or zombie processes
- Send JSON to a central endpoint for fleet dashboards
- Add journal correlation:
For extremely minimal environments, trim optional checks to reduce dependencies.
Conclusion and next step (CTA)
You don’t need a heavyweight agent to get meaningful, low-noise health insights. With a Bash script, a few standard tools, and some light intelligence (baselining + correlation), you can catch the right problems at the right time—without drowning in false positives.
Next steps:
Install the prerequisites for your distro.
Drop the script in /usr/local/sbin, run it once to seed the baseline, then enable the systemd timer.
Tailor thresholds, paths, and outputs to your environment.
Wire alerts to your email or webhook—and enjoy signal over noise.
If you’d like a hardened version with packaging and CI, or a variant tuned for containers/Kubernetes, say the word and I’ll share a drop-in bundle.