Files
fan_speed/monitor.sh
Zeb Hering 64e6e03dba Exclude an external shelf by explicit config, not by path shape
Testing on a second host showed the internal/external split cannot be
inferred: iz-pve1's MD1200 sits behind its own HBA while iz-pve0's
internal drives sit behind a SAS expander, so the previous
"pci-*-scsi-*" glob matched every drive on one host and none on the
other. DISK_EXCLUDE_PATTERN now names the shelf's HBA per host.

Also fixes parse_disk_limit, which used awk's for-in over split fields
and so returned 0 or 70 from "0/70" depending on iteration order.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-28 18:43:20 -07:00

235 lines
8.5 KiB
Bash

#!/bin/bash
#
# Disk temperature monitoring, trend tracking and email alarms.
#
# Sourced by fan_speed.sh alongside functions.sh. functions.sh is vendored from
# upstream (tigerblue77/Dell_iDRAC_fan_controller_Docker); everything in here is
# local, so keep the two files separate to keep that boundary readable.
# ---------------------------------------------------------------- configuration
DISK_GLOB=${DISK_GLOB:-/dev/disk/by-path/*}
# Drives to leave alone, matched against the by-path link. Set this to the PCI
# address of an external shelf's HBA on any host that has one.
#
# There is no reliable way to infer this. An external enclosure looks exactly
# like an internal backplane from /dev/disk/by-path: iz-pve1's MD1200 sits
# behind its own HBA at pci-0000:04:00.0, while iz-pve0's *internal* drives sit
# behind a SAS expander at pci-0000:02:00.0. Guessing from "-sas-exp" picks up
# the wrong set on one host or the other, so it has to be stated per host.
DISK_EXCLUDE_PATTERN=${DISK_EXCLUDE_PATTERN:-}
# Each drive's fan ramp is derived from its own maximum operating temperature:
# ramp starts at limit - DISK_RAMP_LOW_OFFSET
# full speed at limit - DISK_RAMP_HIGH_OFFSET
# Larger low offset = react earlier = louder. See README before changing.
DISK_RAMP_LOW_OFFSET=${DISK_RAMP_LOW_OFFSET:-18}
DISK_RAMP_HIGH_OFFSET=${DISK_RAMP_HIGH_OFFSET:-8}
# A drive this close to its own limit raises an alarm - the fans are already
# flat out for it and it is still climbing.
DISK_ALARM_OFFSET=${DISK_ALARM_OFFSET:-5}
# Drives report their limit but do not agree on what it means: Samsung and
# Kioxia report a true operating maximum (70), Toshiba and Seagate report 60,
# WD Reds report 85 - the SCT critical limit, not an operating maximum.
# Clamp by class so one optimistic drive cannot quietly disable cooling.
HDD_LIMIT_CAP=${HDD_LIMIT_CAP:-60}
SSD_LIMIT_CAP=${SSD_LIMIT_CAP:-70}
ALERT_EMAIL=${ALERT_EMAIL:-Servers@ntfy1.izebra.xyz}
ALERT_COOLDOWN=${ALERT_COOLDOWN:-3600}
STATE_DIR=${STATE_DIR:-/root/fan_speed/state}
LOG_FILE=${LOG_FILE:-/root/fan_speed/log/fan_speed.log}
TREND_FILE=${TREND_FILE:-/root/fan_speed/log/temps.csv}
TREND_SAMPLES=${TREND_SAMPLES:-90} # 90 x 10s CHECK_INTERVAL = 15 minutes
TREND_RISE_ALARM=${TREND_RISE_ALARM:-8} # degrees of climb across that window
SMART_CHECK_INTERVAL=${SMART_CHECK_INTERVAL:-3600}
# ------------------------------------------------------------------ disk temps
log_line() {
echo "$(date +'%Y-%m-%d %H:%M:%S') || $*" >> "$LOG_FILE"
}
# Block devices behind DISK_GLOB, deduplicated. Resolved fresh every call:
# kernel names are not stable, a shelf rescan renamed sdaa-sdai to sds-sdaa.
disk_devices() {
local link dev
for link in $DISK_GLOB; do
case "$link" in *-part*) continue ;; esac
if [ -n "$DISK_EXCLUDE_PATTERN" ]; then
case "$link" in *"$DISK_EXCLUDE_PATTERN"*) continue ;; esac
fi
[ -e "$link" ] || continue
dev=$(readlink -f "$link")
[ -b "$dev" ] && echo "$dev"
done | sort -u
}
# smartctl -A output on stdin -> temperature in celsius, empty if it did not say.
# Drives disagree on the attribute name: 194 Temperature_Celsius (most),
# 190 Airflow_Temperature_Cel (Seagate), 194 Temperature_Internal (Intel),
# and SCSI/SAS drives report a "Current Drive Temperature" line instead.
# Temperature_Difference_from_100 is a delta, not a reading - never match it.
parse_disk_temperature() {
awk '
/Current Drive Temperature/ { print $4; exit }
/^ *[0-9]+ +[A-Za-z_]*Temperature[A-Za-z_]*/ && !/Difference/ { print $10; exit }'
}
# smartctl -x output on stdin -> the drive's own maximum operating temperature.
parse_disk_limit() {
awk '
/Min\/Max Temperature Limit/ {
if (match($0, /-?[0-9]+\/[0-9]+/)) { split(substr($0, RSTART, RLENGTH), a, "/"); print a[2] }
exit
}
/Drive Trip Temperature/ { print $4; exit }'
}
# -n standby: skip a sleeping drive rather than spinning it up to measure it.
disk_temperature() {
smartctl -n standby -A "$1" 2>/dev/null | parse_disk_temperature
}
# The drive's own limit, clamped by class (see HDD_LIMIT_CAP above).
disk_limit() {
local dev=$1 limit rotational cap
limit=$(smartctl -x "$dev" 2>/dev/null | parse_disk_limit)
rotational=$(cat "/sys/block/$(basename "$dev")/queue/rotational" 2>/dev/null)
if [ "$rotational" = 1 ]; then cap=$HDD_LIMIT_CAP; else cap=$SSD_LIMIT_CAP; fi
if [ -z "$limit" ] || ! [ "$limit" -le "$cap" ] 2>/dev/null; then limit=$cap; fi
echo "$limit"
}
# Limits do not change, and smartctl -x is far heavier than -A. Read them once.
declare -A DISK_LIMIT
cache_disk_limits() {
local dev
DISK_LIMIT=()
for dev in $(disk_devices); do
DISK_LIMIT[$dev]=$(disk_limit "$dev")
done
DISK_COUNT_EXPECTED=${#DISK_LIMIT[@]}
}
# Reads every drive. Sets:
# HOTTEST_DISK_TEMPERATURE / _DEVICE / _LIMIT, DISK_FAN_SPEED, DISKS_READ
# Each drive is interpolated against its OWN limit and the highest resulting fan
# speed wins - so a 50c SSD rated to 70 asks for less than a 45c disk rated to 60.
# That makes the curve independent of drive technology and of bay position.
retrieve_disk_temperatures() {
local dev temp limit speed
HOTTEST_DISK_TEMPERATURE=0
HOTTEST_DISK_DEVICE="-"
HOTTEST_DISK_LIMIT=0
DISK_FAN_SPEED=$LOW_FAN_SPEED
DISKS_READ=0
for dev in $(disk_devices); do
temp=$(disk_temperature "$dev")
[ -n "$temp" ] || continue
DISKS_READ=$((DISKS_READ + 1))
limit=${DISK_LIMIT[$dev]}
[ -n "$limit" ] || limit=$(disk_limit "$dev")
speed=$(calculate_interpolated_fan_speed "$temp" \
$((limit - DISK_RAMP_LOW_OFFSET)) $((limit - DISK_RAMP_HIGH_OFFSET)) \
"$LOW_FAN_SPEED" "$HIGH_FAN_SPEED")
[ "$speed" -gt "$DISK_FAN_SPEED" ] && DISK_FAN_SPEED=$speed
if [ "$temp" -gt "$HOTTEST_DISK_TEMPERATURE" ]; then
HOTTEST_DISK_TEMPERATURE=$temp
HOTTEST_DISK_DEVICE=$dev
HOTTEST_DISK_LIMIT=$limit
fi
done
}
# --------------------------------------------------------------------- alarms
notify() {
local subject=$1 body=$2
if [ -n "$DRY_RUN" ]; then
echo "MAIL[$ALERT_EMAIL] $subject"
else
printf '%s\n' "$body" | mail -s "$subject" "$ALERT_EMAIL"
fi
log_line "ALERT $subject"
}
# raise_alert <key> <subject> <body>
# Rate limited per key. Without this a 10s loop sends 360 mails an hour and the
# alarm becomes the outage.
raise_alert() {
local key=$1 subject=$2 body=$3
local stamp="$STATE_DIR/$key.alert" now last
mkdir -p "$STATE_DIR"
now=$(date +%s)
if [ -f "$stamp" ]; then
last=$(cat "$stamp")
[ $((now - last)) -lt "$ALERT_COOLDOWN" ] && return 0
fi
echo "$now" > "$stamp"
notify "$subject" "$body"
}
# clear_alert <key> <subject> - one recovery notice, only if the alarm was up.
clear_alert() {
local key=$1 subject=$2
local stamp="$STATE_DIR/$key.alert"
[ -f "$stamp" ] || return 0
rm -f "$stamp"
notify "$subject" "Recovered at $(date '+%Y-%m-%d %H:%M:%S')."
}
# ---------------------------------------------------------------------- trend
# record_sample <cpu> <gpu> <disk> <fan>
record_sample() {
mkdir -p "$(dirname "$TREND_FILE")"
echo "$(date +%s),$1,$2,$3,$4" >> "$TREND_FILE"
if [ "$(wc -l < "$TREND_FILE")" -gt $((TREND_SAMPLES * 2)) ]; then
tail -n "$TREND_SAMPLES" "$TREND_FILE" > "$TREND_FILE.tmp" && mv "$TREND_FILE.tmp" "$TREND_FILE"
fi
}
# rise_over_window <column> -> degrees climbed from the oldest sample in the
# window to the newest. 0 until there is a full window of history, so a restart
# cannot alarm on a partial series.
# Columns: 2 cpu, 3 gpu, 4 disk, 5 fan.
rise_over_window() {
local column=$1 oldest newest lines
lines=$(wc -l < "$TREND_FILE" 2>/dev/null || echo 0)
if [ "$lines" -lt "$TREND_SAMPLES" ]; then echo 0; return; fi
oldest=$(tail -n "$TREND_SAMPLES" "$TREND_FILE" | head -1 | cut -d, -f"$column")
newest=$(tail -n 1 "$TREND_FILE" | cut -d, -f"$column")
[ -n "$oldest" ] && [ -n "$newest" ] || { echo 0; return; }
echo $((newest - oldest))
}
# --------------------------------------------------------------- SMART health
# Expensive across two dozen drives - the caller runs this hourly, not per loop.
check_disk_health() {
local dev status failed=""
for dev in $(disk_devices); do
status=$(smartctl -H "$dev" 2>/dev/null | grep -iE "overall-health|SMART Health Status")
[ -n "$status" ] || continue
case "$status" in
*PASSED*|*OK*) ;;
*) failed+="$dev: $status"$'\n' ;;
esac
done
if [ -n "$failed" ]; then
raise_alert smart "SMART failure on $(hostname)" "$failed"
else
clear_alert smart "SMART recovered on $(hostname)"
fi
}