1245 lines
55 KiB
Bash
1245 lines
55 KiB
Bash
#!/bin/bash
|
|
# -----------------------------------------------------------------------------------------------
|
|
# --------------------------------- Docker Watchdog --------------------------------------------
|
|
# -----------------------------------------------------------------------------------------------
|
|
# Two-tier self-healing container monitoring system — runs continuously as a background process.
|
|
# Started by array_start.sh at array start — runs until array stops or SIGTERM received.
|
|
#
|
|
# Tier 1 — Strict monitoring (configured containers only)
|
|
# Memory hard limits — immediate restart if exceeded
|
|
# CPU thresholds — strike system, restart after CPU_FAIL_LIMIT strikes
|
|
# HTTP responsiveness — strike system, restart after RESP_FAIL_LIMIT strikes
|
|
# Required containers — must always be running, strike system with skip list
|
|
#
|
|
# Tier 2 — Global health scan (all running containers)
|
|
# Unhealthy status — Docker HEALTHCHECK unhealthy → restart
|
|
# OOM killed — kernel killed container → restart + notify
|
|
# Crash loop detection — RestartCount climbing → notify, critical above limit
|
|
# Dead containers — remove and restart
|
|
# Unexpected exits — non-zero exit code → restart
|
|
#
|
|
# Cross-cutting intelligence:
|
|
# Startup grace period — skip restarts while system is still booting
|
|
# Dependency ordering — restart database before app
|
|
# Restart loop protect — stop restarting after X restarts in X hours → skip list
|
|
# Skip list auto-clear — clears when container recovers
|
|
# Notification batching — one clean summary per cycle, not one ping per event
|
|
# Quiet when healthy — only logs when something needs attention
|
|
# Parity awareness — skips restarts during parity check
|
|
#
|
|
# Continuous loop:
|
|
# Checks run every DOCKER_WATCHDOG_INTERVAL seconds (default 900 = 15min)
|
|
# Clean shutdown on SIGTERM/SIGINT — sent by array stop
|
|
# Variables scoped per-cycle — no state accumulation between cycles
|
|
#
|
|
# State files:
|
|
# WATCHDOG_STATE_FILE — strike counts (/tmp — resets on reboot)
|
|
# SYS_WATCHDOG_FAILED_FILE — persistent skip list (/boot — survives reboots)
|
|
# WATCHDOG_CONTAINER_RESTART_LOG — restart history for loop detection
|
|
#
|
|
# All configuration in Master.conf under Docker Watchdog section.
|
|
# Supports --dry-run and --status.
|
|
# -----------------------------------------------------------------------------------------------
|
|
|
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|
|
|
source "$SCRIPT_DIR/../Master.conf"
|
|
source "$SCRIPT_DIR/../common.sh"
|
|
|
|
parse_args "$@"
|
|
|
|
# -----------------------------------------------------------------------------------------------
|
|
# ━━━ $ICON_GEAR Setup — runs once at start ━━━
|
|
# -----------------------------------------------------------------------------------------------
|
|
echo ""
|
|
echo "━━━ $ICON_GEAR Setup ━━━"
|
|
|
|
if [[ "$EUID" -ne 0 ]]; then
|
|
error "Must be run as root"
|
|
exit 1
|
|
fi
|
|
|
|
success "Running as root"
|
|
|
|
acquire_lock
|
|
|
|
# Select correct per-host watchdog lists — done once at startup
|
|
detect_hosts
|
|
|
|
if [[ "$LOCAL_SERVER_NAME" == "$HOST1" ]]; then
|
|
WATCHDOG_REQUIRED_CONTAINERS=("${HOST1_WATCHDOG_REQUIRED_CONTAINERS[@]}")
|
|
declare -A WATCHDOG_CONTAINER_URLS
|
|
for key in "${!HOST1_WATCHDOG_CONTAINER_URLS[@]}"; do
|
|
WATCHDOG_CONTAINER_URLS["$key"]="${HOST1_WATCHDOG_CONTAINER_URLS[$key]}"
|
|
done
|
|
else
|
|
WATCHDOG_REQUIRED_CONTAINERS=("${HOST2_WATCHDOG_REQUIRED_CONTAINERS[@]}")
|
|
declare -A WATCHDOG_CONTAINER_URLS
|
|
for key in "${!HOST2_WATCHDOG_CONTAINER_URLS[@]}"; do
|
|
WATCHDOG_CONTAINER_URLS["$key"]="${HOST2_WATCHDOG_CONTAINER_URLS[$key]}"
|
|
done
|
|
fi
|
|
|
|
info "Watchdog running as: $LOCAL_SERVER_NAME"
|
|
info "Check interval: ${DOCKER_WATCHDOG_INTERVAL}s"
|
|
|
|
if ! command -v docker >/dev/null 2>&1; then
|
|
error "Docker not found"
|
|
exit 1
|
|
fi
|
|
|
|
success "Docker found"
|
|
[[ "$DRY_RUN" == true ]] && warn "DRY RUN — no containers will be restarted"
|
|
|
|
# Ensure state files exist
|
|
touch "$WATCHDOG_STATE_FILE" "$WATCHDOG_CONTAINER_RESTART_LOG" \
|
|
"$SYS_WATCHDOG_FAILED_FILE" 2>/dev/null
|
|
|
|
# -----------------------------------------------------------------------------------------------
|
|
# ━━━ $ICON_SUMMARY Status ━━━
|
|
# -----------------------------------------------------------------------------------------------
|
|
if [[ "$SHOW_STATUS" == true ]]; then
|
|
echo ""
|
|
echo "━━━━━ $ICON_SUMMARY STATUS ━━━━━"
|
|
echo "$ICON_CONTAINERS Watched: ${!WATCHDOG_CONTAINERS[@]}"
|
|
echo "$ICON_CONTAINERS Required: ${WATCHDOG_REQUIRED_CONTAINERS[*]}"
|
|
echo "$ICON_WATCHDOG Scan all: $WATCHDOG_SCAN_ALL"
|
|
echo "$ICON_WATCHDOG Interval: ${DOCKER_WATCHDOG_INTERVAL}s"
|
|
echo "$ICON_WATCHDOG Startup grace: ${WATCHDOG_STARTUP_GRACE}s"
|
|
echo "$ICON_WATCHDOG Restart limit: $WATCHDOG_CONTAINER_RESTART_LIMIT in ${WATCHDOG_CONTAINER_RESTART_WINDOW}h"
|
|
echo "$ICON_WATCHDOG Batch notify: $WATCHDOG_BATCH_NOTIFY"
|
|
echo "$ICON_WATCHDOG Ignore list: ${WATCHDOG_SCAN_IGNORE[*]:-none}"
|
|
echo "$ICON_GEAR Dry Run: $DRY_RUN"
|
|
UPTIME_S=$(awk '{print int($1)}' /proc/uptime)
|
|
echo "$ICON_TIME System uptime: $(format_duration $UPTIME_S)"
|
|
[[ "$UPTIME_S" -lt "$WATCHDOG_STARTUP_GRACE" ]] && \
|
|
warn "Within startup grace period — restarts suppressed"
|
|
echo "━━━━━━━━━━━━━━━━━━━━━━━"
|
|
exit 0
|
|
fi
|
|
|
|
# -----------------------------------------------------------------------------------------------
|
|
# HELPERS — defined once, used every cycle
|
|
# -----------------------------------------------------------------------------------------------
|
|
|
|
get_strikes() {
|
|
grep "^${1}:" "${2}" 2>/dev/null | cut -d: -f2 || echo "0"
|
|
}
|
|
|
|
set_strikes() {
|
|
local container="$1" count="$2" file="$3"
|
|
if grep -q "^${container}:" "$file" 2>/dev/null; then
|
|
sed -i "s/^${container}:.*/${container}:${count}/" "$file"
|
|
else
|
|
echo "${container}:${count}" >> "$file"
|
|
fi
|
|
}
|
|
|
|
is_skipped() {
|
|
grep -q "^${1}$" "$SYS_WATCHDOG_FAILED_FILE" 2>/dev/null
|
|
}
|
|
|
|
add_to_skip_list() {
|
|
local container="$1" reason="$2"
|
|
if ! is_skipped "$container"; then
|
|
echo "$container" >> "$SYS_WATCHDOG_FAILED_FILE"
|
|
error "$container added to skip list — $reason"
|
|
queue_notify "$container added to skip list on $(hostname) — $reason — manual intervention needed" "critical"
|
|
fi
|
|
}
|
|
|
|
remove_from_skip_list() {
|
|
sed -i "/^${1}$/d" "$SYS_WATCHDOG_FAILED_FILE" 2>/dev/null
|
|
success "$1 removed from skip list — recovered"
|
|
}
|
|
|
|
log_restart() {
|
|
local container="$1"
|
|
local now
|
|
now=$(date '+%Y-%m-%d %H:%M:%S')
|
|
local cutoff
|
|
cutoff=$(date -d "${WATCHDOG_CONTAINER_RESTART_WINDOW} hours ago" '+%Y-%m-%d %H:%M:%S')
|
|
echo "${container}|${now}" >> "$WATCHDOG_CONTAINER_RESTART_LOG"
|
|
local tmp="${WATCHDOG_CONTAINER_RESTART_LOG}.tmp"
|
|
awk -F'|' -v cutoff="$cutoff" '$2 >= cutoff' \
|
|
"$WATCHDOG_CONTAINER_RESTART_LOG" > "$tmp" && \
|
|
mv "$tmp" "$WATCHDOG_CONTAINER_RESTART_LOG"
|
|
}
|
|
|
|
get_restart_count() {
|
|
local container="$1"
|
|
local cutoff
|
|
cutoff=$(date -d "${WATCHDOG_CONTAINER_RESTART_WINDOW} hours ago" '+%Y-%m-%d %H:%M:%S')
|
|
awk -F'|' -v c="$container" -v cutoff="$cutoff" \
|
|
'$1==c && $2>=cutoff' "$WATCHDOG_CONTAINER_RESTART_LOG" 2>/dev/null | wc -l
|
|
}
|
|
|
|
dependencies_satisfied() {
|
|
local container="$1"
|
|
local deps="${WATCHDOG_DEPENDENCIES[$container]:-}"
|
|
[[ -z "$deps" ]] && return 0
|
|
for dep in $deps; do
|
|
local status
|
|
status=$(docker inspect -f '{{.State.Running}}' "$dep" 2>/dev/null)
|
|
if [[ "$status" != "true" ]]; then
|
|
warn "$container — dependency $dep is not running — skipping restart this cycle"
|
|
return 1
|
|
fi
|
|
done
|
|
return 0
|
|
}
|
|
|
|
safe_restart() {
|
|
local container="$1" reason="$2"
|
|
local restart_count
|
|
restart_count=$(get_restart_count "$container")
|
|
if [[ "$restart_count" -ge "$WATCHDOG_CONTAINER_RESTART_LIMIT" ]]; then
|
|
add_to_skip_list "$container" \
|
|
"restarted $restart_count times in ${WATCHDOG_CONTAINER_RESTART_WINDOW}h"
|
|
return 2
|
|
fi
|
|
dependencies_satisfied "$container" || return 1
|
|
local uptime_s
|
|
uptime_s=$(awk '{print int($1)}' /proc/uptime)
|
|
if [[ "$uptime_s" -lt "$WATCHDOG_STARTUP_GRACE" ]]; then
|
|
warn "$container — within startup grace period — skipping restart"
|
|
return 1
|
|
fi
|
|
if [[ "$DRY_RUN" == true ]]; then
|
|
warn "DRY RUN — would restart $container ($reason)"
|
|
return 0
|
|
fi
|
|
info "Restarting $container ($reason) [restart $((restart_count + 1))/$WATCHDOG_CONTAINER_RESTART_LIMIT in window]..."
|
|
if docker restart "$container" >/dev/null 2>&1; then
|
|
success "$ICON_STARTED $container restarted"
|
|
log_restart "$container"
|
|
return 0
|
|
else
|
|
error "Failed to restart $container"
|
|
return 1
|
|
fi
|
|
}
|
|
|
|
queue_notify() {
|
|
local message="$1" severity="${2:-warning}"
|
|
NOTIFY_EVENTS+=("${severity}|${message}")
|
|
log "Queued: $message"
|
|
}
|
|
|
|
flush_notify() {
|
|
[[ ${#NOTIFY_EVENTS[@]} -eq 0 ]] && return
|
|
if [[ "$WATCHDOG_BATCH_NOTIFY" == "true" ]]; then
|
|
local highest_severity="normal"
|
|
local messages=()
|
|
for event in "${NOTIFY_EVENTS[@]}"; do
|
|
local sev="${event%%|*}" msg="${event#*|}"
|
|
messages+=("$msg")
|
|
[[ "$sev" == "critical" ]] && highest_severity="warning"
|
|
[[ "$sev" == "warning" && "$highest_severity" == "normal" ]] && highest_severity="warning"
|
|
done
|
|
local summary
|
|
summary=$(printf '%s. ' "${messages[@]}")
|
|
notify "Docker Watchdog on $(hostname) — ${#NOTIFY_EVENTS[@]} event(s): $summary" \
|
|
"Docker Watchdog" "$highest_severity"
|
|
else
|
|
for event in "${NOTIFY_EVENTS[@]}"; do
|
|
local sev="${event%%|*}" msg="${event#*|}"
|
|
[[ "$sev" == "critical" ]] && sev="warning"
|
|
notify "$msg" "Docker Watchdog" "$sev"
|
|
done
|
|
fi
|
|
NOTIFY_EVENTS=()
|
|
}
|
|
|
|
is_parity_running() {
|
|
grep -q "progress" /var/local/emhttp/parity-date.txt 2>/dev/null
|
|
}
|
|
|
|
# -----------------------------------------------------------------------------------------------
|
|
# CLEAN SHUTDOWN — trap SIGTERM/SIGINT from array stop
|
|
# -----------------------------------------------------------------------------------------------
|
|
WATCHDOG_RUNNING=true
|
|
|
|
cleanup() {
|
|
echo ""
|
|
info "Docker watchdog received shutdown signal — stopping cleanly"
|
|
WATCHDOG_RUNNING=false
|
|
exit 0
|
|
}
|
|
|
|
trap cleanup SIGTERM SIGINT
|
|
|
|
# -----------------------------------------------------------------------------------------------
|
|
# ━━━ CONTINUOUS MONITORING LOOP ━━━
|
|
# -----------------------------------------------------------------------------------------------
|
|
info "Docker watchdog started — checking every ${DOCKER_WATCHDOG_INTERVAL}s"
|
|
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
|
|
|
|
CYCLE=0
|
|
|
|
while [[ "$WATCHDOG_RUNNING" == true ]]; do
|
|
((CYCLE++))
|
|
CYCLE_START=$(date +%s)
|
|
|
|
# Re-source Master.conf each cycle — picks up any config changes without restart
|
|
source "$SCRIPT_DIR/../Master.conf"
|
|
|
|
# Rebuild per-host lists after re-source in case they changed
|
|
if [[ "$LOCAL_SERVER_NAME" == "$HOST1" ]]; then
|
|
WATCHDOG_REQUIRED_CONTAINERS=("${HOST1_WATCHDOG_REQUIRED_CONTAINERS[@]}")
|
|
for key in "${!HOST1_WATCHDOG_CONTAINER_URLS[@]}"; do
|
|
WATCHDOG_CONTAINER_URLS["$key"]="${HOST1_WATCHDOG_CONTAINER_URLS[$key]}"
|
|
done
|
|
else
|
|
WATCHDOG_REQUIRED_CONTAINERS=("${HOST2_WATCHDOG_REQUIRED_CONTAINERS[@]}")
|
|
for key in "${!HOST2_WATCHDOG_CONTAINER_URLS[@]}"; do
|
|
WATCHDOG_CONTAINER_URLS["$key"]="${HOST2_WATCHDOG_CONTAINER_URLS[$key]}"
|
|
done
|
|
fi
|
|
|
|
# Per-cycle variables — cleared each iteration, no accumulation
|
|
NOTIFY_EVENTS=()
|
|
T1_RESTARTS=0
|
|
T1_WARNINGS=0
|
|
T2_RESTARTS=0
|
|
T2_WARNINGS=0
|
|
|
|
# Rebuild ignore map each cycle in case config was updated
|
|
declare -A IGNORE_MAP
|
|
for c in "${WATCHDOG_SCAN_IGNORE[@]}"; do
|
|
[[ -n "$c" ]] && IGNORE_MAP["$c"]=1
|
|
done
|
|
|
|
# Startup grace check
|
|
UPTIME_SECONDS=$(awk '{print int($1)}' /proc/uptime)
|
|
IN_GRACE_PERIOD=false
|
|
if [[ "$UPTIME_SECONDS" -lt "$WATCHDOG_STARTUP_GRACE" ]]; then
|
|
IN_GRACE_PERIOD=true
|
|
fi
|
|
|
|
# Parity check — skip restarts during parity to avoid I/O interference
|
|
if is_parity_running; then
|
|
log "Parity check in progress — skipping restart actions this cycle"
|
|
sleep "$DOCKER_WATCHDOG_INTERVAL"
|
|
continue
|
|
fi
|
|
|
|
# ── TIER 1 — Strict Monitoring ──────────────────────────────────────────────────────────
|
|
|
|
# Required containers
|
|
if [[ ${#WATCHDOG_REQUIRED_CONTAINERS[@]} -gt 0 ]]; then
|
|
for container in "${WATCHDOG_REQUIRED_CONTAINERS[@]}"; do
|
|
[[ -z "$container" ]] && continue
|
|
STATUS=$(docker inspect -f '{{.State.Running}}' "$container" 2>/dev/null)
|
|
|
|
if is_skipped "$container"; then
|
|
if [[ "$STATUS" == "true" ]]; then
|
|
remove_from_skip_list "$container"
|
|
set_strikes "$container" 0 "$WATCHDOG_STATE_FILE"
|
|
sed -i "/^${container}|/d" "$WATCHDOG_CONTAINER_RESTART_LOG" 2>/dev/null
|
|
queue_notify "$container recovered on $(hostname) — removed from skip list" "normal"
|
|
else
|
|
warn "$container — on skip list, manual intervention needed"
|
|
fi
|
|
continue
|
|
fi
|
|
|
|
if [[ "$STATUS" == "true" ]]; then
|
|
set_strikes "$container" 0 "$WATCHDOG_STATE_FILE"
|
|
else
|
|
STRIKES=$(get_strikes "$container" "$WATCHDOG_STATE_FILE")
|
|
STRIKES=$(( STRIKES + 1 ))
|
|
set_strikes "$container" "$STRIKES" "$WATCHDOG_STATE_FILE"
|
|
warn "$container — not running (strike $STRIKES/$SYS_WATCHDOG_STRIKE_LIMIT)"
|
|
((T1_WARNINGS++))
|
|
|
|
if [[ "$STRIKES" -ge "$SYS_WATCHDOG_STRIKE_LIMIT" ]]; then
|
|
result=0
|
|
safe_restart "$container" "required container down" || result=$?
|
|
case $result in
|
|
0) set_strikes "$container" 0 "$WATCHDOG_STATE_FILE"
|
|
((T1_RESTARTS++))
|
|
queue_notify "$container was down and restarted on $(hostname)" "warning" ;;
|
|
2) : ;;
|
|
*) queue_notify "$container failed to restart on $(hostname)" "warning" ;;
|
|
esac
|
|
fi
|
|
fi
|
|
done
|
|
fi
|
|
|
|
# Memory and CPU monitoring
|
|
if [[ ${#WATCHDOG_CONTAINERS[@]} -gt 0 ]]; then
|
|
STATS=$(docker stats --no-stream \
|
|
--format "{{.Name}}|{{.MemUsage}}|{{.CPUPerc}}" 2>/dev/null)
|
|
TOTAL_CORES=$(nproc 2>/dev/null || echo 1)
|
|
|
|
for container in "${!WATCHDOG_CONTAINERS[@]}"; do
|
|
MEM_LIMIT_MB="${WATCHDOG_CONTAINERS[$container]}"
|
|
CONTAINER_STATS=$(echo "$STATS" | grep "^${container}|" | head -1)
|
|
[[ -z "$CONTAINER_STATS" ]] && continue
|
|
|
|
MEM_USAGE=$(echo "$CONTAINER_STATS" | cut -d'|' -f2 | awk '{print $1}')
|
|
MEM_UNIT=$(echo "$MEM_USAGE" | grep -oE '[A-Za-z]+')
|
|
MEM_VALUE=$(echo "$MEM_USAGE" | grep -oE '[0-9.]+')
|
|
|
|
case "$MEM_UNIT" in
|
|
GiB|GB) MEM_MB=$(awk "BEGIN {printf \"%.0f\", $MEM_VALUE * 1024}") ;;
|
|
MiB|MB) MEM_MB=$(awk "BEGIN {printf \"%.0f\", $MEM_VALUE}") ;;
|
|
KiB|KB) MEM_MB=$(awk "BEGIN {printf \"%.0f\", $MEM_VALUE / 1024}") ;;
|
|
*) MEM_MB=0 ;;
|
|
esac
|
|
|
|
CPU_RAW=$(echo "$CONTAINER_STATS" | cut -d'|' -f3 | tr -d '%')
|
|
CPU_NORM=$(awk "BEGIN {printf \"%.1f\", $CPU_RAW / $TOTAL_CORES}")
|
|
CPU_INT=$(printf "%.0f" "$CPU_NORM")
|
|
|
|
if [[ "$MEM_MB" -ge "$MEM_LIMIT_MB" ]]; then
|
|
error "$container — memory exceeded hard limit ${MEM_LIMIT_MB}MB"
|
|
safe_restart "$container" "memory hard limit exceeded"
|
|
((T1_RESTARTS++))
|
|
queue_notify "$container exceeded memory limit on $(hostname) — restarted" "warning"
|
|
fi
|
|
|
|
if [[ "$CPU_INT" -ge "$HARD_CPU_THRESHOLD" ]]; then
|
|
CPU_STRIKES=$(get_strikes "${container}_cpu" "$WATCHDOG_STATE_FILE")
|
|
CPU_STRIKES=$(( CPU_STRIKES + 1 ))
|
|
set_strikes "${container}_cpu" "$CPU_STRIKES" "$WATCHDOG_STATE_FILE"
|
|
if [[ "$CPU_STRIKES" -ge "$CPU_FAIL_LIMIT" ]]; then
|
|
safe_restart "$container" "CPU threshold exceeded"
|
|
set_strikes "${container}_cpu" 0 "$WATCHDOG_STATE_FILE"
|
|
((T1_RESTARTS++))
|
|
queue_notify "$container CPU ${CPU_NORM}% on $(hostname) — restarted" "warning"
|
|
fi
|
|
else
|
|
set_strikes "${container}_cpu" 0 "$WATCHDOG_STATE_FILE"
|
|
fi
|
|
done
|
|
fi
|
|
|
|
# HTTP responsiveness
|
|
if [[ ${#WATCHDOG_CONTAINER_URLS[@]} -gt 0 ]]; then
|
|
for container in "${!WATCHDOG_CONTAINER_URLS[@]}"; do
|
|
URL="${WATCHDOG_CONTAINER_URLS[$container]}"
|
|
if curl -sf --max-time "$CURL_TIMEOUT" "$URL" >/dev/null 2>&1; then
|
|
set_strikes "${container}_http" 0 "$WATCHDOG_STATE_FILE"
|
|
else
|
|
HTTP_STRIKES=$(get_strikes "${container}_http" "$WATCHDOG_STATE_FILE")
|
|
HTTP_STRIKES=$(( HTTP_STRIKES + 1 ))
|
|
set_strikes "${container}_http" "$HTTP_STRIKES" "$WATCHDOG_STATE_FILE"
|
|
warn "$container — not responding at $URL (strike $HTTP_STRIKES/$RESP_FAIL_LIMIT)"
|
|
((T1_WARNINGS++))
|
|
if [[ "$HTTP_STRIKES" -ge "$RESP_FAIL_LIMIT" ]]; then
|
|
safe_restart "$container" "HTTP unresponsive"
|
|
set_strikes "${container}_http" 0 "$WATCHDOG_STATE_FILE"
|
|
((T1_RESTARTS++))
|
|
queue_notify "$container unresponsive at $URL on $(hostname) — restarted" "warning"
|
|
fi
|
|
fi
|
|
done
|
|
fi
|
|
|
|
# ── TIER 2 — Global Health Scan ─────────────────────────────────────────────────────────
|
|
if [[ "$WATCHDOG_SCAN_ALL" == "true" ]]; then
|
|
ALL_CONTAINERS=$(docker ps --format "{{.Names}}" 2>/dev/null)
|
|
|
|
# Unhealthy containers
|
|
if [[ "$WATCHDOG_RESTART_UNHEALTHY" == "true" ]]; then
|
|
UNHEALTHY=$(docker ps --filter health=unhealthy --format "{{.Names}}" 2>/dev/null)
|
|
while IFS= read -r container; do
|
|
[[ -z "$container" ]] && continue
|
|
[[ -n "${IGNORE_MAP[$container]:-}" ]] && continue
|
|
is_skipped "$container" && continue
|
|
error "$container — unhealthy"
|
|
((T2_WARNINGS++))
|
|
result=0
|
|
safe_restart "$container" "unhealthy health status" || result=$?
|
|
[[ $result -eq 0 ]] && ((T2_RESTARTS++)) && \
|
|
queue_notify "$container unhealthy on $(hostname) — restarted" "warning"
|
|
done <<< "$UNHEALTHY"
|
|
fi
|
|
|
|
# OOM killed
|
|
if [[ "$WATCHDOG_NOTIFY_OOM" == "true" ]]; then
|
|
while IFS= read -r container; do
|
|
[[ -z "$container" ]] && continue
|
|
[[ -n "${IGNORE_MAP[$container]:-}" ]] && continue
|
|
is_skipped "$container" && continue
|
|
OOM=$(docker inspect -f '{{.State.OOMKilled}}' "$container" 2>/dev/null)
|
|
if [[ "$OOM" == "true" ]]; then
|
|
error "$container — OOM killed"
|
|
((T2_WARNINGS++))
|
|
result=0
|
|
safe_restart "$container" "OOM killed" || result=$?
|
|
[[ $result -eq 0 ]] && ((T2_RESTARTS++)) && \
|
|
queue_notify "$container OOM killed on $(hostname) — restarted" "warning"
|
|
fi
|
|
done <<< "$ALL_CONTAINERS"
|
|
fi
|
|
|
|
# Crash loop detection
|
|
if [[ "$WATCHDOG_NOTIFY_CRASHLOOP" == "true" ]]; then
|
|
while IFS= read -r container; do
|
|
[[ -z "$container" ]] && continue
|
|
[[ -n "${IGNORE_MAP[$container]:-}" ]] && continue
|
|
is_skipped "$container" && continue
|
|
RESTART_COUNT=$(docker inspect -f '{{.RestartCount}}' "$container" 2>/dev/null || echo 0)
|
|
PREV_COUNT=$(grep "^${container}_docker:" "$WATCHDOG_STATE_FILE" 2>/dev/null | cut -d: -f2 || echo 0)
|
|
set_strikes "${container}_docker" "$RESTART_COUNT" "$WATCHDOG_STATE_FILE"
|
|
if [[ "$RESTART_COUNT" -gt "$PREV_COUNT" && "$RESTART_COUNT" -gt 0 ]]; then
|
|
((T2_WARNINGS++))
|
|
if [[ "$RESTART_COUNT" -ge "$WATCHDOG_CRASH_LIMIT" ]]; then
|
|
error "$container — crash loop CRITICAL: $RESTART_COUNT restarts"
|
|
queue_notify "$container crash loop CRITICAL on $(hostname) — manual intervention needed" "critical"
|
|
else
|
|
warn "$container — restarted since last check (total: $RESTART_COUNT)"
|
|
queue_notify "$container restarted on $(hostname) — count: $RESTART_COUNT" "warning"
|
|
fi
|
|
fi
|
|
done <<< "$ALL_CONTAINERS"
|
|
fi
|
|
|
|
# Dead containers
|
|
if [[ "$WATCHDOG_RESTART_DEAD" == "true" ]]; then
|
|
DEAD=$(docker ps -a --filter status=dead --format "{{.Names}}" 2>/dev/null)
|
|
while IFS= read -r container; do
|
|
[[ -z "$container" ]] && continue
|
|
[[ -n "${IGNORE_MAP[$container]:-}" ]] && continue
|
|
is_skipped "$container" && continue
|
|
error "$container — dead"
|
|
((T2_WARNINGS++))
|
|
RESTART_COUNT=$(get_restart_count "$container")
|
|
if [[ "$RESTART_COUNT" -ge "$WATCHDOG_CONTAINER_RESTART_LIMIT" ]]; then
|
|
add_to_skip_list "$container" "dead — restarted $RESTART_COUNT times"
|
|
elif [[ "$DRY_RUN" == false ]]; then
|
|
docker rm "$container" >/dev/null 2>&1
|
|
if docker start "$container" >/dev/null 2>&1; then
|
|
success "$container removed from dead state and restarted"
|
|
log_restart "$container"
|
|
((T2_RESTARTS++))
|
|
queue_notify "$container was dead on $(hostname) — restarted" "warning"
|
|
fi
|
|
fi
|
|
done <<< "$DEAD"
|
|
fi
|
|
|
|
# Unexpected exits
|
|
if [[ "$WATCHDOG_RESTART_CRASHED" == "true" ]]; then
|
|
CRASHED=$(docker ps -a \
|
|
--filter status=exited \
|
|
--format "{{.Names}}|{{.Status}}" 2>/dev/null | \
|
|
grep -v "Exited (0)")
|
|
while IFS='|' read -r container status; do
|
|
[[ -z "$container" ]] && continue
|
|
[[ -n "${IGNORE_MAP[$container]:-}" ]] && continue
|
|
is_skipped "$container" && continue
|
|
SKIP=false
|
|
for req in "${WATCHDOG_REQUIRED_CONTAINERS[@]}"; do
|
|
[[ "$container" == "$req" ]] && SKIP=true && break
|
|
done
|
|
[[ "$SKIP" == true ]] && continue
|
|
error "$container — $status (unexpected exit)"
|
|
((T2_WARNINGS++))
|
|
result=0
|
|
safe_restart "$container" "unexpected exit" || result=$?
|
|
[[ $result -eq 0 ]] && ((T2_RESTARTS++)) && \
|
|
queue_notify "$container crashed on $(hostname) ($status) — restarted" "warning"
|
|
done <<< "$CRASHED"
|
|
fi
|
|
fi
|
|
|
|
# Send notifications if any events this cycle
|
|
flush_notify
|
|
|
|
# Only log summary if something happened — quiet when all healthy
|
|
TOTAL_RESTARTS=$(( T1_RESTARTS + T2_RESTARTS ))
|
|
TOTAL_WARNINGS=$(( T1_WARNINGS + T2_WARNINGS ))
|
|
|
|
if [[ "$TOTAL_RESTARTS" -gt 0 || "$TOTAL_WARNINGS" -gt 0 ]]; then
|
|
CYCLE_END=$(date +%s)
|
|
echo ""
|
|
echo "━━━ $ICON_WATCHDOG Cycle $CYCLE — $(date '+%Y-%m-%d %H:%M:%S') ━━━"
|
|
echo "$ICON_WATCHDOG T1: $T1_RESTARTS restarts / $T1_WARNINGS warnings"
|
|
echo "$ICON_WATCHDOG T2: $T2_RESTARTS restarts / $T2_WARNINGS warnings"
|
|
echo "$ICON_TIME Duration: $(format_duration $(( CYCLE_END - CYCLE_START )))"
|
|
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
|
|
else
|
|
log "Cycle $CYCLE — all healthy ($(date '+%H:%M:%S'))"
|
|
fi
|
|
|
|
# Sleep until next cycle — interruptible by SIGTERM
|
|
sleep "$DOCKER_WATCHDOG_INTERVAL" &
|
|
wait $!
|
|
|
|
done
|
|
#
|
|
# Tier 1 — Strict monitoring (configured containers only)
|
|
# Memory hard limits — immediate restart if exceeded
|
|
# CPU thresholds — strike system, restart after CPU_FAIL_LIMIT strikes
|
|
# HTTP responsiveness — strike system, restart after RESP_FAIL_LIMIT strikes
|
|
# Required containers — must always be running, strike system with skip list
|
|
#
|
|
# Tier 2 — Global health scan (all running containers)
|
|
# Unhealthy status — Docker HEALTHCHECK unhealthy → restart
|
|
# OOM killed — kernel killed container → restart + notify
|
|
# Crash loop detection — RestartCount climbing → notify, critical above limit
|
|
# Dead containers — remove and restart
|
|
# Unexpected exits — non-zero exit code → restart
|
|
#
|
|
# Cross-cutting intelligence (applies to both tiers):
|
|
# Startup grace period — skip restarts while system is still booting
|
|
# Dependency ordering — restart database before app, not the other way around
|
|
# Restart loop protect — stop restarting after X restarts in X hours → skip list
|
|
# Skip list auto-clear — clears when container recovers, fresh start
|
|
# Notification batching — one clean summary per run, not one ping per event
|
|
#
|
|
# State files:
|
|
# WATCHDOG_STATE_FILE — strike counts (/tmp — resets on reboot)
|
|
# SYS_WATCHDOG_FAILED_FILE — persistent skip list (/boot — survives reboots)
|
|
# WATCHDOG_CONTAINER_RESTART_LOG — restart history for loop detection (/boot)
|
|
#
|
|
# All configuration in Master.conf under Docker Watchdog section.
|
|
# Supports --dry-run and --status.
|
|
# -----------------------------------------------------------------------------------------------
|
|
|
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|
|
|
source "$SCRIPT_DIR/../Master.conf"
|
|
source "$SCRIPT_DIR/../common.sh"
|
|
|
|
parse_args "$@"
|
|
|
|
# -----------------------------------------------------------------------------------------------
|
|
# ━━━ $ICON_GEAR Setup ━━━
|
|
# -----------------------------------------------------------------------------------------------
|
|
echo ""
|
|
echo "━━━ $ICON_GEAR Setup ━━━"
|
|
|
|
if [[ "$EUID" -ne 0 ]]; then
|
|
error "Must be run as root"
|
|
exit 1
|
|
fi
|
|
|
|
success "Running as root"
|
|
|
|
acquire_lock
|
|
|
|
# Select correct per-host watchdog lists
|
|
detect_hosts
|
|
|
|
if [[ "$LOCAL_SERVER_NAME" == "$HOST1" ]]; then
|
|
WATCHDOG_REQUIRED_CONTAINERS=("${HOST1_WATCHDOG_REQUIRED_CONTAINERS[@]}")
|
|
declare -A WATCHDOG_CONTAINER_URLS
|
|
for key in "${!HOST1_WATCHDOG_CONTAINER_URLS[@]}"; do
|
|
WATCHDOG_CONTAINER_URLS["$key"]="${HOST1_WATCHDOG_CONTAINER_URLS[$key]}"
|
|
done
|
|
else
|
|
WATCHDOG_REQUIRED_CONTAINERS=("${HOST2_WATCHDOG_REQUIRED_CONTAINERS[@]}")
|
|
declare -A WATCHDOG_CONTAINER_URLS
|
|
for key in "${!HOST2_WATCHDOG_CONTAINER_URLS[@]}"; do
|
|
WATCHDOG_CONTAINER_URLS["$key"]="${HOST2_WATCHDOG_CONTAINER_URLS[$key]}"
|
|
done
|
|
fi
|
|
|
|
info "Watchdog running as: $LOCAL_SERVER_NAME"
|
|
if ! command -v docker >/dev/null 2>&1; then
|
|
error "Docker not found"
|
|
exit 1
|
|
fi
|
|
|
|
success "Docker found"
|
|
[[ "$DRY_RUN" == true ]] && warn "DRY RUN — no containers will be restarted"
|
|
|
|
# State files
|
|
touch "$WATCHDOG_STATE_FILE" "$WATCHDOG_CONTAINER_RESTART_LOG" \
|
|
"$SYS_WATCHDOG_FAILED_FILE" 2>/dev/null
|
|
|
|
# Build ignore map for Tier 2
|
|
declare -A IGNORE_MAP
|
|
for c in "${WATCHDOG_SCAN_IGNORE[@]}"; do
|
|
[[ -n "$c" ]] && IGNORE_MAP["$c"]=1
|
|
done
|
|
|
|
# Batch notification collector
|
|
NOTIFY_EVENTS=()
|
|
|
|
# -----------------------------------------------------------------------------------------------
|
|
# ━━━ $ICON_SUMMARY Status ━━━
|
|
# -----------------------------------------------------------------------------------------------
|
|
if [[ "$SHOW_STATUS" == true ]]; then
|
|
echo ""
|
|
echo "━━━━━ $ICON_SUMMARY STATUS ━━━━━"
|
|
echo "$ICON_CONTAINERS Watched: ${!WATCHDOG_CONTAINERS[@]}"
|
|
echo "$ICON_CONTAINERS Required: ${WATCHDOG_REQUIRED_CONTAINERS[*]}"
|
|
echo "$ICON_WATCHDOG Scan all: $WATCHDOG_SCAN_ALL"
|
|
echo "$ICON_WATCHDOG Startup grace: ${WATCHDOG_STARTUP_GRACE}s"
|
|
echo "$ICON_WATCHDOG Restart limit: $WATCHDOG_CONTAINER_RESTART_LIMIT in ${WATCHDOG_CONTAINER_RESTART_WINDOW}h"
|
|
echo "$ICON_WATCHDOG Batch notify: $WATCHDOG_BATCH_NOTIFY"
|
|
echo "$ICON_WATCHDOG Ignore list: ${WATCHDOG_SCAN_IGNORE[*]:-none}"
|
|
echo "$ICON_GEAR Dry Run: $DRY_RUN"
|
|
UPTIME_S=$(awk '{print int($1)}' /proc/uptime)
|
|
echo "$ICON_TIME System uptime: $(format_duration $UPTIME_S)"
|
|
[[ "$UPTIME_S" -lt "$WATCHDOG_STARTUP_GRACE" ]] && \
|
|
warn "Within startup grace period — restarts suppressed"
|
|
echo "━━━━━━━━━━━━━━━━━━━━━━━"
|
|
exit 0
|
|
fi
|
|
|
|
# -----------------------------------------------------------------------------------------------
|
|
# HELPERS
|
|
# -----------------------------------------------------------------------------------------------
|
|
|
|
# Strike count management
|
|
get_strikes() {
|
|
grep "^${1}:" "${2}" 2>/dev/null | cut -d: -f2 || echo "0"
|
|
}
|
|
|
|
set_strikes() {
|
|
local container="$1" count="$2" file="$3"
|
|
if grep -q "^${container}:" "$file" 2>/dev/null; then
|
|
sed -i "s/^${container}:.*/${container}:${count}/" "$file"
|
|
else
|
|
echo "${container}:${count}" >> "$file"
|
|
fi
|
|
}
|
|
|
|
# Persistent skip list management
|
|
is_skipped() {
|
|
grep -q "^${1}$" "$SYS_WATCHDOG_FAILED_FILE" 2>/dev/null
|
|
}
|
|
|
|
add_to_skip_list() {
|
|
local container="$1" reason="$2"
|
|
if ! is_skipped "$container"; then
|
|
echo "$container" >> "$SYS_WATCHDOG_FAILED_FILE"
|
|
error "$container added to skip list — $reason"
|
|
queue_notify "$container added to skip list on $(hostname) — $reason — manual intervention needed" "critical"
|
|
fi
|
|
}
|
|
|
|
remove_from_skip_list() {
|
|
sed -i "/^${1}$/d" "$SYS_WATCHDOG_FAILED_FILE" 2>/dev/null
|
|
success "$1 removed from skip list — recovered"
|
|
}
|
|
|
|
# Restart loop tracking — /boot/ bounded file, auto-purges old entries
|
|
log_restart() {
|
|
local container="$1"
|
|
local now
|
|
now=$(date '+%Y-%m-%d %H:%M:%S')
|
|
local cutoff
|
|
cutoff=$(date -d "${WATCHDOG_CONTAINER_RESTART_WINDOW} hours ago" '+%Y-%m-%d %H:%M:%S')
|
|
|
|
# Append this restart
|
|
echo "${container}|${now}" >> "$WATCHDOG_CONTAINER_RESTART_LOG"
|
|
|
|
# Purge entries older than window — keeps file bounded
|
|
local tmp="${WATCHDOG_CONTAINER_RESTART_LOG}.tmp"
|
|
awk -F'|' -v cutoff="$cutoff" '$2 >= cutoff' \
|
|
"$WATCHDOG_CONTAINER_RESTART_LOG" > "$tmp" && \
|
|
mv "$tmp" "$WATCHDOG_CONTAINER_RESTART_LOG"
|
|
}
|
|
|
|
# Count restarts for a container within the current window
|
|
get_restart_count() {
|
|
local container="$1"
|
|
local cutoff
|
|
cutoff=$(date -d "${WATCHDOG_CONTAINER_RESTART_WINDOW} hours ago" '+%Y-%m-%d %H:%M:%S')
|
|
awk -F'|' -v c="$container" -v cutoff="$cutoff" \
|
|
'$1==c && $2>=cutoff' "$WATCHDOG_CONTAINER_RESTART_LOG" 2>/dev/null | wc -l
|
|
}
|
|
|
|
# Dependency check — returns 0 if all dependencies are running
|
|
dependencies_satisfied() {
|
|
local container="$1"
|
|
local deps="${WATCHDOG_DEPENDENCIES[$container]:-}"
|
|
[[ -z "$deps" ]] && return 0
|
|
|
|
for dep in $deps; do
|
|
local status
|
|
status=$(docker inspect -f '{{.State.Running}}' "$dep" 2>/dev/null)
|
|
if [[ "$status" != "true" ]]; then
|
|
warn "$container — dependency $dep is not running — skipping restart this cycle"
|
|
return 1
|
|
fi
|
|
done
|
|
return 0
|
|
}
|
|
|
|
# Restart a container with restart loop protection
|
|
# Returns 0 on success, 1 on failure, 2 if loop limit hit
|
|
safe_restart() {
|
|
local container="$1" reason="$2"
|
|
|
|
# Check restart loop
|
|
local restart_count
|
|
restart_count=$(get_restart_count "$container")
|
|
|
|
if [[ "$restart_count" -ge "$WATCHDOG_CONTAINER_RESTART_LIMIT" ]]; then
|
|
add_to_skip_list "$container" \
|
|
"restarted $restart_count times in ${WATCHDOG_CONTAINER_RESTART_WINDOW}h"
|
|
return 2
|
|
fi
|
|
|
|
# Check dependencies
|
|
dependencies_satisfied "$container" || return 1
|
|
|
|
# Check startup grace
|
|
local uptime_s
|
|
uptime_s=$(awk '{print int($1)}' /proc/uptime)
|
|
if [[ "$uptime_s" -lt "$WATCHDOG_STARTUP_GRACE" ]]; then
|
|
warn "$container — within startup grace period (${uptime_s}s / ${WATCHDOG_STARTUP_GRACE}s) — skipping restart"
|
|
return 1
|
|
fi
|
|
|
|
if [[ "$DRY_RUN" == true ]]; then
|
|
warn "DRY RUN — would restart $container ($reason)"
|
|
return 0
|
|
fi
|
|
|
|
info "Restarting $container ($reason) [restart $((restart_count + 1))/$WATCHDOG_CONTAINER_RESTART_LIMIT in window]..."
|
|
if docker restart "$container" >/dev/null 2>&1; then
|
|
success "$ICON_STARTED $container restarted"
|
|
log_restart "$container"
|
|
return 0
|
|
else
|
|
error "Failed to restart $container"
|
|
return 1
|
|
fi
|
|
}
|
|
|
|
# Queue a notification event for batching
|
|
queue_notify() {
|
|
local message="$1" severity="${2:-warning}"
|
|
NOTIFY_EVENTS+=("${severity}|${message}")
|
|
log "Queued: $message"
|
|
}
|
|
|
|
# Send all queued notifications
|
|
flush_notify() {
|
|
[[ ${#NOTIFY_EVENTS[@]} -eq 0 ]] && return
|
|
|
|
if [[ "$WATCHDOG_BATCH_NOTIFY" == "true" ]]; then
|
|
# Build single batched message
|
|
local critical_count=0 warning_count=0
|
|
local messages=()
|
|
local highest_severity="normal"
|
|
|
|
for event in "${NOTIFY_EVENTS[@]}"; do
|
|
local sev="${event%%|*}"
|
|
local msg="${event#*|}"
|
|
messages+=("$msg")
|
|
[[ "$sev" == "critical" ]] && ((critical_count++)) && highest_severity="warning"
|
|
[[ "$sev" == "warning" ]] && ((warning_count++)) && \
|
|
[[ "$highest_severity" == "normal" ]] && highest_severity="warning"
|
|
done
|
|
|
|
local summary
|
|
summary=$(printf '%s. ' "${messages[@]}")
|
|
notify "Docker Watchdog on $(hostname) — ${#NOTIFY_EVENTS[@]} event(s): $summary" \
|
|
"Docker Watchdog" "$highest_severity"
|
|
else
|
|
# Send individual notifications
|
|
for event in "${NOTIFY_EVENTS[@]}"; do
|
|
local sev="${event%%|*}"
|
|
local msg="${event#*|}"
|
|
[[ "$sev" == "critical" ]] && sev="warning"
|
|
notify "$msg" "Docker Watchdog" "$sev"
|
|
done
|
|
fi
|
|
|
|
NOTIFY_EVENTS=()
|
|
}
|
|
|
|
# -----------------------------------------------------------------------------------------------
|
|
# STARTUP GRACE CHECK
|
|
# -----------------------------------------------------------------------------------------------
|
|
UPTIME_SECONDS=$(awk '{print int($1)}' /proc/uptime)
|
|
IN_GRACE_PERIOD=false
|
|
|
|
if [[ "$UPTIME_SECONDS" -lt "$WATCHDOG_STARTUP_GRACE" ]]; then
|
|
GRACE_REMAINING=$(( WATCHDOG_STARTUP_GRACE - UPTIME_SECONDS ))
|
|
warn "System uptime $(format_duration $UPTIME_SECONDS) — startup grace period active (${GRACE_REMAINING}s remaining)"
|
|
warn "Checks will run but restarts are suppressed during grace period"
|
|
IN_GRACE_PERIOD=true
|
|
else
|
|
info "System uptime $(format_duration $UPTIME_SECONDS) — grace period passed"
|
|
fi
|
|
|
|
# -----------------------------------------------------------------------------------------------
|
|
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
|
# TIER 1 — Strict Monitoring
|
|
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
|
# -----------------------------------------------------------------------------------------------
|
|
|
|
echo ""
|
|
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
|
|
echo " $ICON_WATCHDOG TIER 1 — Strict Monitoring"
|
|
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
|
|
|
|
T1_RESTARTS=0
|
|
T1_WARNINGS=0
|
|
|
|
# ── Required Containers ──────────────────────────────────────────────────────────────────────
|
|
if [[ ${#WATCHDOG_REQUIRED_CONTAINERS[@]} -gt 0 ]]; then
|
|
echo ""
|
|
echo "━━━ $ICON_CONTAINERS Required Containers ━━━"
|
|
|
|
for container in "${WATCHDOG_REQUIRED_CONTAINERS[@]}"; do
|
|
[[ -z "$container" ]] && continue
|
|
|
|
STATUS=$(docker inspect -f '{{.State.Running}}' "$container" 2>/dev/null)
|
|
|
|
# Check skip list
|
|
if is_skipped "$container"; then
|
|
if [[ "$STATUS" == "true" ]]; then
|
|
remove_from_skip_list "$container"
|
|
set_strikes "$container" 0 "$WATCHDOG_STATE_FILE"
|
|
# Clear restart history on recovery
|
|
sed -i "/^${container}|/d" "$WATCHDOG_CONTAINER_RESTART_LOG" 2>/dev/null
|
|
queue_notify "$container recovered on $(hostname) — removed from skip list" "normal"
|
|
else
|
|
warn "$ICON_NOT_RUNNING $container — on skip list, manual intervention needed"
|
|
RESTART_COUNT=$(get_restart_count "$container")
|
|
warn " Restarted $RESTART_COUNT time(s) in last ${WATCHDOG_CONTAINER_RESTART_WINDOW}h"
|
|
fi
|
|
continue
|
|
fi
|
|
|
|
if [[ "$STATUS" == "true" ]]; then
|
|
success "$ICON_RUNNING $container — running"
|
|
set_strikes "$container" 0 "$WATCHDOG_STATE_FILE"
|
|
else
|
|
STRIKES=$(get_strikes "$container" "$WATCHDOG_STATE_FILE")
|
|
STRIKES=$(( STRIKES + 1 ))
|
|
set_strikes "$container" "$STRIKES" "$WATCHDOG_STATE_FILE"
|
|
warn "$ICON_NOT_RUNNING $container — not running (strike $STRIKES/$SYS_WATCHDOG_STRIKE_LIMIT)"
|
|
((T1_WARNINGS++))
|
|
|
|
if [[ "$STRIKES" -ge "$SYS_WATCHDOG_STRIKE_LIMIT" ]]; then
|
|
result=0
|
|
safe_restart "$container" "required container down" || result=$?
|
|
case $result in
|
|
0)
|
|
set_strikes "$container" 0 "$WATCHDOG_STATE_FILE"
|
|
((T1_RESTARTS++))
|
|
queue_notify "$container was down and restarted on $(hostname)" "warning"
|
|
;;
|
|
2)
|
|
: # already added to skip list in safe_restart
|
|
;;
|
|
*)
|
|
queue_notify "$container failed to restart on $(hostname) — check dependencies and logs" "warning"
|
|
;;
|
|
esac
|
|
fi
|
|
fi
|
|
done
|
|
fi
|
|
|
|
# ── Memory and CPU Monitoring ────────────────────────────────────────────────────────────────
|
|
if [[ ${#WATCHDOG_CONTAINERS[@]} -gt 0 ]]; then
|
|
echo ""
|
|
echo "━━━ $ICON_MEM Memory / CPU Monitoring ━━━"
|
|
|
|
STATS=$(docker stats --no-stream \
|
|
--format "{{.Name}}|{{.MemUsage}}|{{.CPUPerc}}" 2>/dev/null)
|
|
TOTAL_CORES=$(nproc 2>/dev/null || echo 1)
|
|
|
|
for container in "${!WATCHDOG_CONTAINERS[@]}"; do
|
|
MEM_LIMIT_MB="${WATCHDOG_CONTAINERS[$container]}"
|
|
CONTAINER_STATS=$(echo "$STATS" | grep "^${container}|" | head -1)
|
|
[[ -z "$CONTAINER_STATS" ]] && continue
|
|
|
|
MEM_USAGE=$(echo "$CONTAINER_STATS" | cut -d'|' -f2 | awk '{print $1}')
|
|
MEM_UNIT=$(echo "$MEM_USAGE" | grep -oE '[A-Za-z]+')
|
|
MEM_VALUE=$(echo "$MEM_USAGE" | grep -oE '[0-9.]+')
|
|
|
|
case "$MEM_UNIT" in
|
|
GiB|GB) MEM_MB=$(awk "BEGIN {printf \"%.0f\", $MEM_VALUE * 1024}") ;;
|
|
MiB|MB) MEM_MB=$(awk "BEGIN {printf \"%.0f\", $MEM_VALUE}") ;;
|
|
KiB|KB) MEM_MB=$(awk "BEGIN {printf \"%.0f\", $MEM_VALUE / 1024}") ;;
|
|
*) MEM_MB=0 ;;
|
|
esac
|
|
|
|
MEM_PCT=$(awk "BEGIN {printf \"%.0f\", $MEM_MB * 100 / $MEM_LIMIT_MB}")
|
|
CPU_RAW=$(echo "$CONTAINER_STATS" | cut -d'|' -f3 | tr -d '%')
|
|
CPU_NORM=$(awk "BEGIN {printf \"%.1f\", $CPU_RAW / $TOTAL_CORES}")
|
|
CPU_INT=$(printf "%.0f" "$CPU_NORM")
|
|
|
|
# Memory check
|
|
if [[ "$MEM_MB" -ge "$MEM_LIMIT_MB" ]]; then
|
|
error "$container — memory ${MEM_VALUE}${MEM_UNIT} exceeded hard limit ${MEM_LIMIT_MB}MB"
|
|
safe_restart "$container" "memory hard limit exceeded"
|
|
((T1_RESTARTS++))
|
|
queue_notify "$container exceeded memory limit on $(hostname) — ${MEM_VALUE}${MEM_UNIT} / ${MEM_LIMIT_MB}MB — restarted" "warning"
|
|
elif [[ "$MEM_PCT" -ge "$SOFT_MEM_THRESHOLD" ]]; then
|
|
warn "$container — memory ${MEM_VALUE}${MEM_UNIT} (${MEM_PCT}% of ${MEM_LIMIT_MB}MB)"
|
|
((T1_WARNINGS++))
|
|
else
|
|
success "$container — memory ${MEM_VALUE}${MEM_UNIT} (${MEM_PCT}%) CPU ${CPU_NORM}%"
|
|
fi
|
|
|
|
# CPU check
|
|
if [[ "$CPU_INT" -ge "$HARD_CPU_THRESHOLD" ]]; then
|
|
CPU_STRIKES=$(get_strikes "${container}_cpu" "$WATCHDOG_STATE_FILE")
|
|
CPU_STRIKES=$(( CPU_STRIKES + 1 ))
|
|
set_strikes "${container}_cpu" "$CPU_STRIKES" "$WATCHDOG_STATE_FILE"
|
|
warn "$container — CPU ${CPU_NORM}% (strike $CPU_STRIKES/$CPU_FAIL_LIMIT)"
|
|
if [[ "$CPU_STRIKES" -ge "$CPU_FAIL_LIMIT" ]]; then
|
|
safe_restart "$container" "CPU threshold exceeded"
|
|
set_strikes "${container}_cpu" 0 "$WATCHDOG_STATE_FILE"
|
|
((T1_RESTARTS++))
|
|
queue_notify "$container CPU ${CPU_NORM}% on $(hostname) — restarted after $CPU_FAIL_LIMIT strikes" "warning"
|
|
fi
|
|
else
|
|
set_strikes "${container}_cpu" 0 "$WATCHDOG_STATE_FILE"
|
|
fi
|
|
done
|
|
fi
|
|
|
|
# ── HTTP Responsiveness ──────────────────────────────────────────────────────────────────────
|
|
if [[ ${#WATCHDOG_CONTAINER_URLS[@]} -gt 0 ]]; then
|
|
echo ""
|
|
echo "━━━ $ICON_NET HTTP Responsiveness ━━━"
|
|
|
|
for container in "${!WATCHDOG_CONTAINER_URLS[@]}"; do
|
|
URL="${WATCHDOG_CONTAINER_URLS[$container]}"
|
|
if curl -sf --max-time "$CURL_TIMEOUT" "$URL" >/dev/null 2>&1; then
|
|
success "$container — responding at $URL"
|
|
set_strikes "${container}_http" 0 "$WATCHDOG_STATE_FILE"
|
|
else
|
|
HTTP_STRIKES=$(get_strikes "${container}_http" "$WATCHDOG_STATE_FILE")
|
|
HTTP_STRIKES=$(( HTTP_STRIKES + 1 ))
|
|
set_strikes "${container}_http" "$HTTP_STRIKES" "$WATCHDOG_STATE_FILE"
|
|
warn "$container — not responding at $URL (strike $HTTP_STRIKES/$RESP_FAIL_LIMIT)"
|
|
((T1_WARNINGS++))
|
|
if [[ "$HTTP_STRIKES" -ge "$RESP_FAIL_LIMIT" ]]; then
|
|
safe_restart "$container" "HTTP unresponsive"
|
|
set_strikes "${container}_http" 0 "$WATCHDOG_STATE_FILE"
|
|
((T1_RESTARTS++))
|
|
queue_notify "$container unresponsive at $URL on $(hostname) — restarted" "warning"
|
|
fi
|
|
fi
|
|
done
|
|
fi
|
|
|
|
# -----------------------------------------------------------------------------------------------
|
|
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
|
# TIER 2 — Global Health Scan
|
|
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
|
# -----------------------------------------------------------------------------------------------
|
|
|
|
T2_RESTARTS=0
|
|
T2_WARNINGS=0
|
|
|
|
if [[ "$WATCHDOG_SCAN_ALL" != "true" ]]; then
|
|
info "Global scan disabled (WATCHDOG_SCAN_ALL=false) — skipping Tier 2"
|
|
else
|
|
echo ""
|
|
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
|
|
echo " $ICON_WATCHDOG TIER 2 — Global Health Scan"
|
|
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
|
|
|
|
ALL_CONTAINERS=$(docker ps --format "{{.Names}}" 2>/dev/null)
|
|
|
|
# ── Unhealthy containers ─────────────────────────────────────────────────────────────────
|
|
if [[ "$WATCHDOG_RESTART_UNHEALTHY" == "true" ]]; then
|
|
echo ""
|
|
echo "━━━ $ICON_HEALTH Unhealthy Status ━━━"
|
|
UNHEALTHY=$(docker ps --filter health=unhealthy --format "{{.Names}}" 2>/dev/null)
|
|
|
|
if [[ -z "$UNHEALTHY" ]]; then
|
|
success "No unhealthy containers"
|
|
else
|
|
while IFS= read -r container; do
|
|
[[ -z "$container" ]] && continue
|
|
[[ -n "${IGNORE_MAP[$container]:-}" ]] && \
|
|
{ info "Skipping $container (ignore list)"; continue; }
|
|
is_skipped "$container" && \
|
|
{ warn "$container — unhealthy but on skip list, manual intervention needed"; continue; }
|
|
|
|
error "$container — Docker health status: unhealthy"
|
|
((T2_WARNINGS++))
|
|
result=0
|
|
safe_restart "$container" "unhealthy health status" || result=$?
|
|
if [[ $result -eq 0 ]]; then
|
|
((T2_RESTARTS++))
|
|
queue_notify "$container unhealthy on $(hostname) — restarted" "warning"
|
|
elif [[ $result -eq 2 ]]; then
|
|
: # skip list message already queued
|
|
fi
|
|
done <<< "$UNHEALTHY"
|
|
fi
|
|
fi
|
|
|
|
# ── OOM killed containers ────────────────────────────────────────────────────────────────
|
|
if [[ "$WATCHDOG_NOTIFY_OOM" == "true" ]]; then
|
|
echo ""
|
|
echo "━━━ $ICON_MEM OOM Kill Check ━━━"
|
|
OOM_FOUND=false
|
|
|
|
while IFS= read -r container; do
|
|
[[ -z "$container" ]] && continue
|
|
[[ -n "${IGNORE_MAP[$container]:-}" ]] && continue
|
|
is_skipped "$container" && continue
|
|
|
|
OOM=$(docker inspect -f '{{.State.OOMKilled}}' "$container" 2>/dev/null)
|
|
if [[ "$OOM" == "true" ]]; then
|
|
error "$container — OOM killed by kernel"
|
|
OOM_FOUND=true
|
|
((T2_WARNINGS++))
|
|
result=0
|
|
safe_restart "$container" "OOM killed" || result=$?
|
|
if [[ $result -eq 0 ]]; then
|
|
((T2_RESTARTS++))
|
|
queue_notify "$container OOM killed on $(hostname) — system under memory pressure — restarted" "warning"
|
|
fi
|
|
fi
|
|
done <<< "$ALL_CONTAINERS"
|
|
|
|
[[ "$OOM_FOUND" == false ]] && success "No OOM kills detected"
|
|
fi
|
|
|
|
# ── Crash loop detection ─────────────────────────────────────────────────────────────────
|
|
if [[ "$WATCHDOG_NOTIFY_CRASHLOOP" == "true" ]]; then
|
|
echo ""
|
|
echo "━━━ $ICON_REBOOT_SMART Crash Loop Detection ━━━"
|
|
CRASH_FOUND=false
|
|
|
|
while IFS= read -r container; do
|
|
[[ -z "$container" ]] && continue
|
|
[[ -n "${IGNORE_MAP[$container]:-}" ]] && continue
|
|
is_skipped "$container" && continue
|
|
|
|
RESTART_COUNT=$(docker inspect -f '{{.RestartCount}}' "$container" 2>/dev/null || echo 0)
|
|
PREV_COUNT=$(grep "^${container}_docker:" "$WATCHDOG_STATE_FILE" 2>/dev/null | cut -d: -f2 || echo 0)
|
|
|
|
# Update baseline
|
|
set_strikes "${container}_docker" "$RESTART_COUNT" "$WATCHDOG_STATE_FILE"
|
|
|
|
if [[ "$RESTART_COUNT" -gt "$PREV_COUNT" && "$RESTART_COUNT" -gt 0 ]]; then
|
|
CRASH_FOUND=true
|
|
((T2_WARNINGS++))
|
|
if [[ "$RESTART_COUNT" -ge "$WATCHDOG_CRASH_LIMIT" ]]; then
|
|
error "$container — crash loop CRITICAL: $RESTART_COUNT total Docker restarts"
|
|
queue_notify "$container crash loop CRITICAL on $(hostname) — $RESTART_COUNT Docker restarts — manual intervention needed" "critical"
|
|
else
|
|
warn "$container — Docker restarted since last check (total: $RESTART_COUNT)"
|
|
queue_notify "$container restarted on $(hostname) — Docker restart count: $RESTART_COUNT" "warning"
|
|
fi
|
|
elif [[ "$RESTART_COUNT" -gt 0 ]]; then
|
|
log "$container — Docker restart count: $RESTART_COUNT (stable)"
|
|
fi
|
|
done <<< "$ALL_CONTAINERS"
|
|
|
|
[[ "$CRASH_FOUND" == false ]] && success "No crash loops detected"
|
|
fi
|
|
|
|
# ── Dead containers ──────────────────────────────────────────────────────────────────────
|
|
if [[ "$WATCHDOG_RESTART_DEAD" == "true" ]]; then
|
|
echo ""
|
|
echo "━━━ $ICON_STOPPED Dead Containers ━━━"
|
|
DEAD=$(docker ps -a --filter status=dead --format "{{.Names}}" 2>/dev/null)
|
|
|
|
if [[ -z "$DEAD" ]]; then
|
|
success "No dead containers"
|
|
else
|
|
while IFS= read -r container; do
|
|
[[ -z "$container" ]] && continue
|
|
[[ -n "${IGNORE_MAP[$container]:-}" ]] && \
|
|
{ info "Skipping $container (ignore list)"; continue; }
|
|
is_skipped "$container" && \
|
|
{ warn "$container — dead but on skip list, manual intervention needed"; continue; }
|
|
|
|
error "$container — status: dead"
|
|
((T2_WARNINGS++))
|
|
RESTART_COUNT=$(get_restart_count "$container")
|
|
if [[ "$RESTART_COUNT" -ge "$WATCHDOG_CONTAINER_RESTART_LIMIT" ]]; then
|
|
add_to_skip_list "$container" "dead state — restarted $RESTART_COUNT times in ${WATCHDOG_CONTAINER_RESTART_WINDOW}h"
|
|
elif [[ "$DRY_RUN" == false ]]; then
|
|
docker rm "$container" >/dev/null 2>&1
|
|
if docker start "$container" >/dev/null 2>&1; then
|
|
success "$container removed from dead state and restarted"
|
|
log_restart "$container"
|
|
((T2_RESTARTS++))
|
|
queue_notify "$container was dead on $(hostname) — removed and restarted" "warning"
|
|
else
|
|
error "$container failed to restart after dead state"
|
|
queue_notify "$container dead and failed to restart on $(hostname) — manual intervention needed" "warning"
|
|
fi
|
|
else
|
|
warn "DRY RUN — would remove and restart $container"
|
|
fi
|
|
done <<< "$DEAD"
|
|
fi
|
|
fi
|
|
|
|
# ── Unexpected exits ─────────────────────────────────────────────────────────────────────
|
|
if [[ "$WATCHDOG_RESTART_CRASHED" == "true" ]]; then
|
|
echo ""
|
|
echo "━━━ $ICON_ERROR Unexpected Exits ━━━"
|
|
CRASHED=$(docker ps -a \
|
|
--filter status=exited \
|
|
--format "{{.Names}}|{{.Status}}" 2>/dev/null | \
|
|
grep -v "Exited (0)")
|
|
|
|
if [[ -z "$CRASHED" ]]; then
|
|
success "No unexpected exits"
|
|
else
|
|
while IFS='|' read -r container status; do
|
|
[[ -z "$container" ]] && continue
|
|
[[ -n "${IGNORE_MAP[$container]:-}" ]] && \
|
|
{ info "Skipping $container (ignore list)"; continue; }
|
|
is_skipped "$container" && \
|
|
{ warn "$container — crashed but on skip list, manual intervention needed"; continue; }
|
|
|
|
# Skip Tier 1 required containers — already handled above
|
|
SKIP=false
|
|
for req in "${WATCHDOG_REQUIRED_CONTAINERS[@]}"; do
|
|
[[ "$container" == "$req" ]] && SKIP=true && break
|
|
done
|
|
[[ "$SKIP" == true ]] && continue
|
|
|
|
error "$container — $status (unexpected exit)"
|
|
((T2_WARNINGS++))
|
|
result=0
|
|
safe_restart "$container" "unexpected exit" || result=$?
|
|
if [[ $result -eq 0 ]]; then
|
|
((T2_RESTARTS++))
|
|
queue_notify "$container crashed on $(hostname) ($status) — restarted" "warning"
|
|
elif [[ $result -eq 2 ]]; then
|
|
: # skip list message already queued
|
|
fi
|
|
done <<< "$CRASHED"
|
|
fi
|
|
fi
|
|
fi
|
|
|
|
# -----------------------------------------------------------------------------------------------
|
|
# Send batched notifications
|
|
# -----------------------------------------------------------------------------------------------
|
|
flush_notify
|
|
|
|
# -----------------------------------------------------------------------------------------------
|
|
# ━━━ $ICON_SUMMARY Summary ━━━
|
|
# -----------------------------------------------------------------------------------------------
|
|
TOTAL_RESTARTS=$(( T1_RESTARTS + T2_RESTARTS ))
|
|
TOTAL_WARNINGS=$(( T1_WARNINGS + T2_WARNINGS ))
|
|
|
|
echo ""
|
|
echo "━━━━━ $ICON_SUMMARY DOCKER WATCHDOG SUMMARY ━━━━━"
|
|
echo "$ICON_WATCHDOG Tier 1 (strict): $T1_RESTARTS restarts / $T1_WARNINGS warnings"
|
|
echo "$ICON_WATCHDOG Tier 2 (global): $T2_RESTARTS restarts / $T2_WARNINGS warnings"
|
|
echo "$ICON_WATCHDOG Total: $TOTAL_RESTARTS restarts / $TOTAL_WARNINGS warnings"
|
|
[[ "$IN_GRACE_PERIOD" == "true" ]] && \
|
|
echo "$ICON_WARN Grace period: active — restarts suppressed"
|
|
echo ""
|
|
if [[ "$DRY_RUN" == true ]]; then
|
|
echo "$ICON_WARN Status: DRY RUN — no changes made"
|
|
elif [[ "$TOTAL_RESTARTS" -gt 0 || "$TOTAL_WARNINGS" -gt 0 ]]; then
|
|
echo "$ICON_WARN Status: $TOTAL_RESTARTS container(s) restarted / $TOTAL_WARNINGS warning(s)"
|
|
else
|
|
echo "$ICON_DONE Status: $ICON_SUCCESS ALL CONTAINERS HEALTHY"
|
|
fi
|
|
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" |