Files
Varaverk/Docker_essentials/docker_watchdog.sh
T

308 lines
12 KiB
Bash

#!/bin/bash
# -----------------------------------------------------------------------------------------------
# --------------------------------- Docker Watchdog --------------------------------------------
# -----------------------------------------------------------------------------------------------
# Self-healing watchdog for Docker containers — monitors memory, CPU and HTTP responsiveness.
# Restarts containers that exceed configured thresholds using a strike system for CPU and
# responsiveness checks to avoid restarting on brief spikes.
#
# Behaviour:
# Memory — immediate restart if hard limit is exceeded
# CPU — strike system, restarts after CPU_FAIL_LIMIT consecutive over-threshold checks
# HTTP — strike system, restarts after RESP_FAIL_LIMIT consecutive failed curl checks
#
# Strike system:
# Strikes persist between runs via WATCHDOG_STATE_FILE (/tmp — resets on reboot)
# Strike cadence depends on cron schedule:
# Every 15min + 2 strikes = 30min sustained abuse before restart
# Every 10min + 2 strikes = 20min sustained abuse before restart
# Every 5min + 2 strikes = 10min sustained abuse before restart
#
# All configuration lives in Master.conf under the Docker Watchdog section.
# Supports --dry-run to show what would be restarted without taking any action.
# -----------------------------------------------------------------------------------------------
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
source "$SCRIPT_DIR/../Master.conf"
source "$SCRIPT_DIR/../common.sh"
parse_args "$@"
# Auto-detect total CPU cores for normalisation
TOTAL_CORES=$(nproc)
# -----------------------------------------------------------------------------------------------
# ━━━ $ICON_GEAR Setup ━━━
# -----------------------------------------------------------------------------------------------
echo ""
echo "━━━ $ICON_GEAR Setup ━━━"
# ROOT CHECK
if [[ "$EUID" -ne 0 ]]; then
error "Must be run as root"
exit 1
fi
success "Running as root"
info "$ICON_WATCHDOG Watchdog initialising — $TOTAL_CORES cores detected"
# Ensure state file exists
touch "$WATCHDOG_STATE_FILE" 2>/dev/null || {
error "Cannot create state file: $WATCHDOG_STATE_FILE"
exit 1
}
# -----------------------------------------------------------------------------------------------
# ━━━ $ICON_SUMMARY Status ━━━
# -----------------------------------------------------------------------------------------------
if [[ "$SHOW_STATUS" == true ]]; then
echo ""
echo "━━━━━ $ICON_SUMMARY STATUS ━━━━━"
echo "$ICON_WATCHDOG Containers monitored: ${!WATCHDOG_CONTAINERS[*]}"
echo "$ICON_MEM Soft mem threshold: ${SOFT_MEM_THRESHOLD}% of per-container limit"
echo "$ICON_ZFS CPU soft threshold: ${SOFT_CPU_THRESHOLD}%"
echo "$ICON_ZFS CPU hard threshold: ${HARD_CPU_THRESHOLD}%"
echo "$ICON_RETRY CPU fail limit: ${CPU_FAIL_LIMIT} strikes"
echo "$ICON_PING Resp fail limit: ${RESP_FAIL_LIMIT} strikes"
echo "$ICON_TIME Curl timeout: ${CURL_TIMEOUT}s"
echo "$ICON_NOTIFY Notifications: unRAID=${NOTIFY_UNRAID:-false} Discord=$([[ -n "${DISCORD_WEBHOOK:-}" ]] && echo enabled || echo disabled)"
echo "$ICON_GEAR Dry Run: $DRY_RUN"
echo "━━━━━━━━━━━━━━━━━━━━━━━"
exit 0
fi
[[ "$DRY_RUN" == true ]] && warn "DRY RUN — no containers will be restarted"
# -----------------------------------------------------------------------------------------------
# STATE HELPERS
# Reads and writes per-container strike counts to the state file.
# State file format: container:metric:count
# -----------------------------------------------------------------------------------------------
# Returns current strike count for a container/metric pair.
# Usage: get_strikes "Emby" "CPU"
get_strikes() {
local container="$1" metric="$2"
grep -E "^${container}:${metric}:" "$WATCHDOG_STATE_FILE" 2>/dev/null | cut -d':' -f3
}
# Sets strike count for a container/metric pair.
# Usage: set_strikes "Emby" "CPU" 2
set_strikes() {
local container="$1" metric="$2" count="$3"
grep -vE "^${container}:${metric}:" "$WATCHDOG_STATE_FILE" 2>/dev/null > "${WATCHDOG_STATE_FILE}.tmp"
echo "${container}:${metric}:${count}" >> "${WATCHDOG_STATE_FILE}.tmp"
mv "${WATCHDOG_STATE_FILE}.tmp" "$WATCHDOG_STATE_FILE"
}
# -----------------------------------------------------------------------------------------------
# HELPERS
# -----------------------------------------------------------------------------------------------
# Fetches memory and CPU stats for a container in a single docker stats call.
# Returns: MEM_USAGE|CPU_PERCENT
parse_stats() {
local container="$1"
docker stats --no-stream --format "{{.MemUsage}}|{{.CPUPerc}}" "$container"
}
# Converts a memory value and unit to MB.
# Supports KiB, MiB, GiB — returns UNKNOWN for unrecognised units.
convert_to_mb() {
local value="$1" unit="$2"
case "$unit" in
KiB) awk "BEGIN {print $value / 1024}" ;;
MiB) echo "$value" ;;
GiB) awk "BEGIN {print $value * 1024}" ;;
*) echo "UNKNOWN" ;;
esac
}
# Restarts a container locally and sends a notification.
# In dry run mode reports what would happen without acting.
restart_container() {
local container="$1" reason="$2"
if [[ "$DRY_RUN" == true ]]; then
warn "DRY RUN — would restart $container ($reason)"
return
fi
info "Restarting $container ($reason)..."
if docker restart "$container" >/dev/null 2>&1; then
echo "$ICON_STARTED $container restarted"
notify "$container restarted — $reason" "Docker Watchdog" "warning"
log "Restarted $container — reason: $reason"
else
error "Failed to restart $container"
notify "Failed to restart $container$reason" "Docker Watchdog" "alert"
fi
}
# -----------------------------------------------------------------------------------------------
# MEMORY CHECK
# Compares current container memory usage against its configured hard limit.
# Restarts immediately if at or above 100% of limit.
# Warns if at or above SOFT_MEM_THRESHOLD % of limit.
# Usage: check_memory "Emby" 16384
# -----------------------------------------------------------------------------------------------
check_memory() {
local container="$1" limit_mb="$2"
local stats mem_raw mem_val mem_unit mem_mb usage_pct
stats=$(parse_stats "$container")
mem_raw=$(echo "$stats" | awk -F'|' '{print $1}' | awk '{print $1}')
mem_val=$(echo "$mem_raw" | sed -E 's/([0-9.]+).*/\1/')
mem_unit=$(echo "$mem_raw" | sed -E 's/[0-9.]+([a-zA-Z]+).*/\1/')
mem_mb=$(convert_to_mb "$mem_val" "$mem_unit")
local mem_int
mem_int=$(printf "%.0f" "$mem_mb")
usage_pct=$(( (mem_int * 100) / limit_mb ))
if (( usage_pct >= 100 )); then
error "$ICON_MEM $container memory ${mem_int}MB (${usage_pct}%) — exceeded ${limit_mb}MB hard limit"
restart_container "$container" "memory hard limit"
set_strikes "$container" "CPU" 0
set_strikes "$container" "RESP" 0
elif (( usage_pct >= SOFT_MEM_THRESHOLD )); then
warn "$ICON_MEM $container memory ${mem_int}MB (${usage_pct}% of ${limit_mb}MB limit)"
else
success "$ICON_MEM $container memory ${mem_int}MB (${usage_pct}% of ${limit_mb}MB limit)"
fi
}
# -----------------------------------------------------------------------------------------------
# CPU CHECK
# Normalises CPU usage against total core count and applies the strike system.
# Warns at SOFT_CPU_THRESHOLD, strikes at HARD_CPU_THRESHOLD.
# Restarts after CPU_FAIL_LIMIT consecutive strikes — resets strikes on restart or recovery.
# Usage: check_cpu "Emby"
# -----------------------------------------------------------------------------------------------
check_cpu() {
local container="$1"
local stats cpu_raw cpu_norm cpu_int violations
stats=$(parse_stats "$container")
cpu_raw=$(echo "$stats" | awk -F'|' '{print $2}' | tr -d '%')
cpu_norm=$(awk "BEGIN {print $cpu_raw / $TOTAL_CORES}")
cpu_int=$(printf "%.0f" "$cpu_norm")
violations=$(get_strikes "$container" "CPU")
[[ -z "$violations" ]] && violations=0
if (( cpu_int >= HARD_CPU_THRESHOLD )); then
((violations++))
error "$ICON_ZFS $container CPU ${cpu_int}% — hard threshold hit ($violations/$CPU_FAIL_LIMIT strikes)"
set_strikes "$container" "CPU" "$violations"
elif (( cpu_int >= SOFT_CPU_THRESHOLD )); then
((violations++))
warn "$ICON_ZFS $container CPU ${cpu_int}% — soft threshold hit ($violations/$CPU_FAIL_LIMIT strikes)"
set_strikes "$container" "CPU" "$violations"
else
if (( violations > 0 )); then
info "$ICON_ZFS $container CPU ${cpu_int}% — recovered, resetting strikes"
else
success "$ICON_ZFS $container CPU ${cpu_int}%"
fi
set_strikes "$container" "CPU" 0
violations=0
fi
if (( violations >= CPU_FAIL_LIMIT )); then
error "$ICON_ZFS $container hit CPU limit for $CPU_FAIL_LIMIT consecutive checks"
restart_container "$container" "sustained CPU abuse"
set_strikes "$container" "CPU" 0
fi
}
# -----------------------------------------------------------------------------------------------
# RESPONSIVENESS CHECK
# Sends an HTTP request to the container's configured URL.
# Skips containers with no URL defined in WATCHDOG_CONTAINER_URLS.
# Applies the same strike system as CPU — restarts after RESP_FAIL_LIMIT consecutive failures.
# Usage: check_responsiveness "Emby"
# -----------------------------------------------------------------------------------------------
check_responsiveness() {
local container="$1"
local url="${WATCHDOG_CONTAINER_URLS[$container]:-}"
[[ -z "$url" ]] && return
local fails
fails=$(get_strikes "$container" "RESP")
[[ -z "$fails" ]] && fails=0
if ! curl -s --max-time "$CURL_TIMEOUT" "$url" >/dev/null 2>&1; then
((fails++))
warn "$ICON_PING $container unresponsive at $url ($fails/$RESP_FAIL_LIMIT strikes)"
set_strikes "$container" "RESP" "$fails"
else
if (( fails > 0 )); then
info "$ICON_PING $container responsive again — resetting strikes"
else
success "$ICON_PING $container responsive at $url"
fi
set_strikes "$container" "RESP" 0
fails=0
fi
if (( fails >= RESP_FAIL_LIMIT )); then
error "$ICON_PING $container unresponsive for $RESP_FAIL_LIMIT consecutive checks"
restart_container "$container" "HTTP unresponsive"
set_strikes "$container" "RESP" 0
fi
}
# -----------------------------------------------------------------------------------------------
# ━━━ $ICON_WATCHDOG Watchdog Check ━━━
# -----------------------------------------------------------------------------------------------
echo ""
echo "━━━ $ICON_WATCHDOG Watchdog Check — $(date '+%Y-%m-%d %H:%M:%S') ━━━"
echo ""
SKIPPED=()
RESTARTED=0
START=$(date +%s)
for container in "${!WATCHDOG_CONTAINERS[@]}"; do
echo "━━━ $ICON_CONTAINERS $container ━━━"
# Verify container exists
if ! docker inspect "$container" &>/dev/null; then
warn "$container not found on this host — skipping"
SKIPPED+=("$container")
echo ""
continue
fi
# Verify container is running
if ! docker ps --filter "name=^/${container}$" --format "{{.Names}}" | grep -qw "$container"; then
warn "$ICON_NOT_RUNNING $container is not running — skipping"
SKIPPED+=("$container")
echo ""
continue
fi
check_memory "$container" "${WATCHDOG_CONTAINERS[$container]}"
check_cpu "$container"
check_responsiveness "$container"
echo ""
done
END=$(date +%s)
# -----------------------------------------------------------------------------------------------
# ━━━ $ICON_SUMMARY Summary ━━━
# -----------------------------------------------------------------------------------------------
echo "━━━━━ $ICON_SUMMARY WATCHDOG SUMMARY ━━━━━"
echo "$ICON_TIME $(date '+%Y-%m-%d %H:%M:%S')"
echo "$ICON_TIME Duration: $(format_duration $((END - START)))"
echo "$ICON_WATCHDOG Monitored: ${#WATCHDOG_CONTAINERS[@]} containers"
echo "$ICON_NOT_RUNNING Skipped: ${#SKIPPED[@]} containers"
if [[ "$DRY_RUN" == true ]]; then
echo "$ICON_WARN Dry Run: no restarts executed"
fi
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"