Project 2: Service Health Monitor & Alerter

Build the monitoring agent you would actually trust on-call: plugin-style checks, flap damping, deduplicated alerts, recovery notices, and a dead-man switch.

advanced 50 min lesson hands-on task included

Anyone can write a script that checks disk space. The difference between that and a monitor people keep enabled is entirely in the alerting discipline: not alerting on a blip, not alerting twice, and saying when it is over.


The Requirements

#RequirementWhy it matters
1Pluggable checks — add one without touching the coreChecks change weekly; the engine should not
2Every alert carries diagnostic contextAn alert without evidence just makes someone log in
3Flap damping: N consecutive failuresA single blip must not page anyone
4Deduplication with a cooldownRepeating every 5 minutes gets the channel muted
5Recovery notificationsOtherwise nobody knows it is over
6State survives restartsDamping and dedup need memory
7Dead-man switchDetects the monitor itself dying
8Alert delivery never breaks the runA failed webhook must not abort the checks

Step 1: Core Engine

#!/usr/bin/env bash
#
# monitor.sh — run health checks, alert with damping and deduplication.
#
set -Eeuo pipefail

readonly SCRIPT_NAME=${0##*/}
readonly SCRIPT_DIR=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)
readonly HOSTNAME_FQDN=$(hostname -f 2>/dev/null || hostname)

readonly STATE_DIR="${MONITOR_STATE_DIR:-/var/lib/monitor}"
readonly FAIL_THRESHOLD="${MONITOR_FAIL_THRESHOLD:-3}"   # consecutive failures before alerting
readonly COOLDOWN="${MONITOR_COOLDOWN:-3600}"            # seconds between repeat alerts
readonly DRY_RUN="${MONITOR_DRY_RUN:-0}"

mkdir -p "$STATE_DIR"

log()  { printf '%s [%-5s] %s\n' "$(date -Iseconds)" "$1" "${*:2}" >&2; }
info() { log INFO "$@"; }
warn() { log WARN "$@"; }

# Sanitise a check name into something safe for a filename
_key() { printf '%s' "${1//[^a-zA-Z0-9_-]/_}"; }

The _key helper matters: check names like disk:/var/lib become filenames, and an unsanitised / creates a directory traversal rather than a state file.


Step 2: State — Damping and Deduplication

# Track consecutive failures; return 0 when the threshold is newly reached
record_failure() {
    local name; name=$(_key "$1")
    local f="${STATE_DIR}/fail-${name}"
    local count
    count=$(( $(cat "$f" 2>/dev/null || echo 0) + 1 ))
    printf '%s\n' "$count" > "$f"
    (( count >= FAIL_THRESHOLD ))
}

# Clear the counter; return 0 if this is a RECOVERY from an alerted state
record_success() {
    local name; name=$(_key "$1")
    local f="${STATE_DIR}/fail-${name}"
    local a="${STATE_DIR}/alerted-${name}"
    rm -f "$f"
    if [[ -f $a ]]; then
        rm -f "$a"
        return 0        # was alerting, now healthy → recovery
    fi
    return 1
}

# Cooldown: return 0 only if we are allowed to alert now
should_alert() {
    local name; name=$(_key "$1")
    local a="${STATE_DIR}/alerted-${name}"
    if [[ -f $a ]]; then
        local age=$(( $(date +%s) - $(stat -c %Y "$a" 2>/dev/null || stat -f %m "$a") ))
        (( age < COOLDOWN )) && return 1
    fi
    touch "$a"
    return 0
}

Three files per check, and the whole alerting policy falls out of them: fail-* counts consecutive failures, alerted-* exists while an alert is outstanding and its mtime is the cooldown clock.


Step 3: Notification

notify() {
    local severity=$1 title=$2 body=$3

    if (( DRY_RUN )); then
        info "DRY-RUN alert [${severity}] ${title}: ${body}"
        return 0
    fi

    local colour
    case $severity in
        critical) colour="#dc2626" ;;
        warning)  colour="#f59e0b" ;;
        resolved) colour="#10b981" ;;
        *)        colour="#6b7280" ;;
    esac

    if [[ -n ${SLACK_WEBHOOK_URL:-} ]]; then
        local payload
        payload=$(jq -n \
            --arg c "$colour" \
            --arg t "[${severity^^}] ${HOSTNAME_FQDN}: ${title}" \
            --arg b "$body" \
            '{attachments:[{color:$c, title:$t, text:$b, ts:(now|floor)}]}')

        curl -fsS --max-time 10 -X POST \
             -H 'Content-Type: application/json' \
             -d "$payload" "$SLACK_WEBHOOK_URL" >/dev/null \
            || warn "slack delivery failed"
    fi

    if [[ -n ${ALERT_EMAIL:-} ]] && command -v mail >/dev/null; then
        printf '%s\n' "$body" | mail -s "[${severity}] ${HOSTNAME_FQDN}: ${title}" "$ALERT_EMAIL" \
            || warn "email delivery failed"
    fi
}

Every delivery path ends in || warn. Under set -e an unguarded curl failure would abort the whole run — so the check that found a genuine problem would exit before reporting anything else.


Step 4: Checks as Plugins

Each check is a function returning 0 for healthy, non-zero for failing, and printing its evidence to stdout.

check_disk() {
    local threshold="${DISK_THRESHOLD:-85}"
    local failed=0
    while read -r pct mount; do
        if (( pct >= threshold )); then
            local top
            top=$(du -xh --max-depth=2 "$mount" 2>/dev/null | sort -rh | head -3 |
                  awk '{printf "%s (%s); ", $2, $1}')
            echo "${mount} at ${pct}% [threshold ${threshold}%] — largest: ${top}"
            failed=1
        fi
    done < <(df -hP -x tmpfs -x devtmpfs -x overlay |
             awk 'NR>1 {gsub(/%/,"",$5); print $5, $6}')
    return "$failed"
}

check_memory() {
    local threshold="${MEM_THRESHOLD:-90}"
    local total avail used_pct
    read -r total avail < <(free -m | awk '/^Mem:/ {print $2, $7}')
    used_pct=$(( (total - avail) * 100 / total ))
    (( used_pct < threshold )) && return 0

    local top
    top=$(ps -eo comm,rss --sort=-rss | awk 'NR>1 && NR<=4 {printf "%s(%dMB) ", $1, $2/1024}')
    echo "memory ${used_pct}% used [threshold ${threshold}%] — top: ${top}"
    return 1
}

check_service() {
    local svc=$1
    systemctl is-active --quiet "$svc" && return 0
    local state logs
    state=$(systemctl is-active "$svc" 2>&1 || true)
    logs=$(journalctl -u "$svc" -n 3 --no-pager -o cat 2>/dev/null | tr '\n' '; ')
    echo "service ${svc} is ${state} — recent: ${logs}"
    return 1
}

check_http() {
    local url=$1 expect="${2:-200}"
    local code time_total
    read -r code time_total < <(
        curl -sS -o /dev/null -w '%{http_code} %{time_total}' \
             --max-time 10 "$url" 2>/dev/null || echo "000 0"
    )
    [[ $code == "$expect" ]] && return 0
    echo "${url} returned ${code} (expected ${expect}) after ${time_total}s"
    return 1
}

check_cert_expiry() {
    local host=$1 days="${CERT_WARN_DAYS:-14}"
    local end_date end_epoch remaining
    end_date=$(echo | openssl s_client -servername "$host" -connect "${host}:443" 2>/dev/null |
               openssl x509 -noout -enddate 2>/dev/null | cut -d= -f2) || return 0
    [[ -n $end_date ]] || return 0
    end_epoch=$(date -d "$end_date" +%s 2>/dev/null || date -j -f '%b %d %T %Y %Z' "$end_date" +%s)
    remaining=$(( (end_epoch - $(date +%s)) / 86400 ))
    (( remaining > days )) && return 0
    echo "TLS certificate for ${host} expires in ${remaining} days"
    return 1
}

Every message names the resource, the measurement, the threshold, and the top contributors. That is the difference between an alert someone acts on and one they acknowledge and forget.


Step 5: The Runner

run_check() {
    local name=$1; shift
    local output rc=0

    output=$("$@" 2>&1) || rc=$?

    if (( rc == 0 )); then
        if record_success "$name"; then
            notify resolved "${name} recovered" "${name} is healthy again."
            info "RECOVERED ${name}"
        else
            info "OK ${name}"
        fi
        return 0
    fi

    if record_failure "$name"; then
        if should_alert "$name"; then
            notify critical "$name" "$output"
            warn "ALERTED ${name}: ${output}"
        else
            info "SUPPRESSED ${name} (cooldown active)"
        fi
    else
        local n; n=$(cat "${STATE_DIR}/fail-$(_key "$name")")
        info "FAILING ${name} (${n}/${FAIL_THRESHOLD}) — damping"
    fi
    return 1
}

main() {
    exec 200>"/var/lock/${SCRIPT_NAME%.sh}.lock"
    flock -n 200 || { info "already running; skipping"; exit 0; }

    local failures=0

    run_check "disk"          check_disk                                  || (( failures++ )) || true
    run_check "memory"        check_memory                                || (( failures++ )) || true
    run_check "svc-nginx"     check_service nginx                         || (( failures++ )) || true
    run_check "svc-postgres"  check_service postgresql                    || (( failures++ )) || true
    run_check "http-app"      check_http "http://localhost:8080/health"   || (( failures++ )) || true
    run_check "cert-www"      check_cert_expiry "www.example.com"         || (( failures++ )) || true

    # Dead-man switch: ping only on a completed run
    if [[ -n ${HEALTHCHECK_URL:-} ]]; then
        curl -fsS --max-time 10 "$HEALTHCHECK_URL" >/dev/null || warn "heartbeat failed"
    fi

    info "run complete: ${failures} checks failing"
    return 0        # ALWAYS 0 — cron mail is not the alerting channel
}

if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then
    main "$@"
fi

Two design decisions worth defending:

return 0 regardless of failures. Alerts go through notify. If the script also exits non-zero, cron mails you a second copy of everything, and people start filtering cron mail — including the message telling them the monitor itself broke.

The heartbeat only fires at the end. A dead-man switch that pings at the start cannot detect a run that hangs halfway.


Step 6: Deploy It

# /etc/systemd/system/monitor.service
[Unit]
Description=Health monitor
After=network-online.target

[Service]
Type=oneshot
EnvironmentFile=/etc/monitor/env        # secrets live here, mode 0600
ExecStart=/usr/local/bin/monitor.sh
TimeoutStartSec=120
# /etc/systemd/system/monitor.timer
[Unit]
Description=Run health monitor every 5 minutes

[Timer]
OnBootSec=2min
OnUnitActiveSec=5min
RandomizedDelaySec=30

[Install]
WantedBy=timers.target
sudo install -m 0600 /dev/stdin /etc/monitor/env <<'EOF'
SLACK_WEBHOOK_URL=https://hooks.slack.com/services/...
HEALTHCHECK_URL=https://hc-ping.com/...
DISK_THRESHOLD=85
EOF
sudo systemctl enable --now monitor.timer
journalctl -u monitor.service -f

OnUnitActiveSec rather than OnCalendar means the next run is scheduled 5 minutes after the last one finished, which cannot overlap. EnvironmentFile with mode 0600 keeps the webhook out of the unit file and out of ps.


Verifying Your Work

# 1. Damping — one failure must be silent
MONITOR_DRY_RUN=1 DISK_THRESHOLD=0 ./monitor.sh    # "FAILING disk (1/3)"
MONITOR_DRY_RUN=1 DISK_THRESHOLD=0 ./monitor.sh    # "FAILING disk (2/3)"
MONITOR_DRY_RUN=1 DISK_THRESHOLD=0 ./monitor.sh    # "ALERTED disk"

# 2. Deduplication — the 4th run must be suppressed
MONITOR_DRY_RUN=1 DISK_THRESHOLD=0 ./monitor.sh    # "SUPPRESSED disk"

# 3. Recovery
MONITOR_DRY_RUN=1 ./monitor.sh                     # "RECOVERED disk"

# 4. Locking
./monitor.sh & ./monitor.sh; wait                  # second: "already running"

# 5. State inspection
ls -la /var/lib/monitor/

Extensions worth building: emit Prometheus textfile-collector metrics alongside the alerts so you get graphs for free; add a --check NAME flag to run one check; add severity tiers so disk-at-95% pages while disk-at-85% only posts to Slack; write the check functions into separate files under checks.d/ and source them in a loop, so adding a check is adding a file.

The lesson to take away: the checks are the easy half. Everything that makes this usable — damping, dedup, recovery, exit-zero — exists to protect the humans receiving the alerts. An alerting channel people mute is worse than no monitoring at all, because it creates the belief that something is watching.