Fan speed now takes the loudest request from CPU/GPU and from each drive interpolated against its own reported temperature limit, so bay position and drive technology stop mattering. Reported limits are clamped by class because WD Reds report the SCT critical limit (85) rather than an operating maximum. Adds a rolling temperature history and four alarms (absolute, rising trend, SMART health, disk count), rate limited per key with a one hour cooldown, delivered by mail through the host relay. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
221 lines
7.9 KiB
Bash
221 lines
7.9 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
|
|
|
|
# Internal drives only. The DAS shelf enumerates behind a SAS expander
|
|
# (*-sas-exp*) and has its own controller - it must not drive server fans.
|
|
DISK_GLOB=${DISK_GLOB:-/dev/disk/by-path/pci-*-scsi-*}
|
|
|
|
# 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
|
|
[ -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/ { split($0, a, /[ \/]+/); for (i in a) if (a[i] ~ /^[0-9]+$/) v = a[i]; print v; 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
|
|
}
|