#!/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} # The fan speed is only pushed to the BMC when it moves by at least this much. # # Without it the setpoint is a continuous function of instantaneous temperature: # the CPU curve is ~1% of fan per degree, idle CPU noise is +/-2c, so the fan # gets a new speed every single pass and never settles. Measured on iz-pve0, # stock iDRAC held one speed for 5 minutes straight through the same jitter # while this script made 7 changes in 16 minutes. FAN_SPEED_DEADBAND=${FAN_SPEED_DEADBAND:-5} # Re-push the held speed this often anyway, in case the BMC forgets it. Not # measured - the MD1200 EMM does forget, iDRAC is believed not to, and one # ipmitool call every 5 minutes is cheaper than finding out the hard way. FAN_REASSERT_INTERVAL=${FAN_REASSERT_INTERVAL:-300} # fan_speed_changed_enough # True when the request has moved far enough to be worth acting on. A request # for full speed is never held back. fan_speed_changed_enough() { local wanted=$1 applied=$2 delta [ "$wanted" -ge "$HIGH_FAN_SPEED" ] && return 0 delta=$((wanted - applied)) [ "$delta" -lt 0 ] && delta=$((-delta)) [ "$delta" -ge "$FAN_SPEED_DEADBAND" ] } # next_fan_speed -> the speed to hold from here. # Kept separate from the re-assert timer on purpose: a re-assert must re-push # the value already being held, never silently adopt the current request, or # the deadband leaks a few percent every FAN_REASSERT_INTERVAL. next_fan_speed() { local wanted=$1 applied=$2 if [ -z "$applied" ] || fan_speed_changed_enough "$wanted" "$applied"; then echo "$wanted" else echo "$applied" fi } 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 # 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 - 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 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 -> 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 }