Emergency server help: get in touch

MariaDB Watchdog Script: Restart MariaDB or MySQL Only When It Is Really Hung

A Bash watchdog for MariaDB and MySQL: checks that the server answers SELECT 1 within a time limit, saves diagnostics, and restarts only when you allow it and only when it is safe: not during startup or crash recovery, not during a backup, at most twice an hour. Report-only by default.

Version
1.0.0
Last updated
October 18, 2026
Language
Bash
Tested on
1.0.0 in a test container on 18 Oct 2026: Ubuntu 24.04 with MariaDB 10.11.14; the hang was real (server process frozen with SIGSTOP), systemctl replaced by a stand-in script, so the systemd path on a real server was not part of the test; 42 checks passed (report-only, grace period, backup hold, stop timeout then kill and start, restart limit, down server left alone, too many connections, wrong login, overlapping runs, webhook); 14 more with a fake client run in CI; bash -n and ShellCheck 0.9.0 clean.
Testing level
Fixture-tested
License
MIT
Pricing
Free

Short answer: the MariaDB watchdog mariadb-watchdog.sh is a free Bash script that checks every minute whether MariaDB or MySQL still answers a real query (SELECT 1) within a time limit. A server that is running but does not answer is treated as hung. After three failed checks in a row it saves diagnostics and, only if you added --restart, restarts the service and waits until the server answers again. Without --restart it only reports. It never restarts during startup or crash recovery, while a backup is running, or more than twice an hour, and it never restarts because of a wrong password or “Too many connections”.

A hung database is worse than a crashed one. The process is still there, so systemctl status says “active (running)”, port 3306 still accepts connections and the panel’s service monitor stays green, but every PHP page waits until it times out. A bash one-liner that restarts MariaDB whenever a check fails causes the opposite problem: it restarts during InnoDB crash recovery, in the middle of a nightly mysqldump, or in a loop that never lets the server finish starting. This script restarts only when the hang is real and repeated, and only when restarting is safe to try.

What the MariaDB watchdog checks

  • A real query, not a ping. It runs SELECT 1 through the mariadb (or mysql) client with a hard time limit (--timeout, 10 seconds by default). mysqladmin ping can succeed while queries hang, so the script does not rely on it.
  • What kind of failure it is. No answer within the limit means HUNG; no mariadbd/mysqld process means DOWN; ERROR 1040 Too many connections means BUSY; ERROR 1045 Access denied is a configuration problem. Only HUNG (and DOWN, if you allow it) can lead to a restart.
  • Failures in a row. The count is kept in /var/lib/mariadb-watchdog/failures. One slow moment never triggers anything: by default three consecutive failed runs are needed (--fails), so with a once-a-minute cron job a hang has to last about three minutes.
  • Diagnostics first. Before any restart (or, in report-only mode, instead of it) it saves ps, free, df, the last 200 journal lines, the error log tail, kernel OOM messages and, if the server still answers on a fresh connection, SHOW FULL PROCESSLIST and SHOW ENGINE INNODB STATUS under /var/log/mariadb-watchdog/<date-time>/. That is the evidence you need to find out why it hung.

The safety guards before a restart

  • Report-only by default. Without --restart the script never stops, starts or kills anything. It logs and alerts “would restart now” and exits 2.
  • Startup and crash recovery. If the server process started less than --grace seconds ago (300 by default), it is left alone. InnoDB crash recovery after an unclean stop can take minutes, and restarting in the middle of it only starts it again.
  • systemd is busy. If systemd reports the unit as activating, deactivating or reloading, the watchdog does not interfere.
  • Backups. If mariadb-backup, mariabackup, xtrabackup, mysqldump, mariadb-dump or mydumper is running, the restart is held and you get an alert instead. --ignore-backups turns this off.
  • Restart loops. At most --max-restarts restarts (2) per --window (one hour). After that it only alerts, because a server that hangs again right after a restart needs a person, not a third restart.
  • Wrong credentials and full connection tables. “Access denied” exits 3 and never restarts: restarting would not fix a password. “Too many connections” is reported as BUSY and not counted as a hang, because a restart drops every connected client and the cause is usually a client or max_connections; add --restart-on-busy only if you really want that.
  • A down server stays down. If MariaDB is not running at all, someone may have stopped it on purpose. The script alerts and does nothing unless you also pass --start-if-down.
  • No overlapping runs. A lock file prevents a second run while the first is still waiting for a hung server.

How the restart is done

With systemd the script runs systemctl stop and waits up to --stop-timeout seconds (300). A hung server often never finishes a clean shutdown, and the MariaDB unit’s own stop timeout can be 15 minutes (TimeoutStopSec=900 in the unit that ships with MariaDB 10.11; see MariaDB and systemd), so if the process is still there after that time the script sends SIGKILL through systemctl kill, waits for the process to disappear and then runs systemctl start. It then checks SELECT 1 every 5 seconds for up to --wait-up seconds (300) and reports whether the server answers again.

A killed server is an unclean stop: InnoDB replays its redo log on the next start (that is normal and is why the grace period exists), and MyISAM or Aria tables that were being written may need mariadb-check afterwards. On a server without systemd, give your own command with --restart-cmd.

Before you run it

  • Run it as root on the database server. It needs to see the server process and run systemctl.
  • It logs in like any client. On cPanel, root’s /root/.my.cnf already holds the login. On DirectAdmin, pass --defaults-file /usr/local/directadmin/conf/my.cnf. On Debian and Ubuntu, root usually logs in through the unix socket without a password.
  • Check what already watches the database. cPanel’s service monitor (chkservd) and DirectAdmin’s service monitor can restart MySQL too. Two tools that both restart the same service work against each other, so decide which one acts and let the other only alert.
  • A watchdog treats the symptom. When it fires, read the saved diagnostics: a slow query holding locks, a full disk, swapping, the OOM killer or an exhausted connection table are the usual causes. Our MySQL crashed runbook and the MySQL health snapshot script help with the next step, and “Too many connections” is covered in MySQL ERROR 1040 fixes for cPanel.

Install and first run

curl -fsSL -o /usr/local/sbin/mariadb-watchdog.sh https://scr.srvscripts.com/mariadb-watchdog/mariadb-watchdog.sh
chmod 700 /usr/local/sbin/mariadb-watchdog.sh
/usr/local/sbin/mariadb-watchdog.sh            # one check, report only

A healthy server prints:

OK: answered SELECT 1 (pid 3879)

Leave it in report-only mode for a few days first (see the cron line below without --restart) and look at what it would have done. Then add --restart.

Run it from cron

# /etc/cron.d/mariadb-watchdog
* * * * * root /usr/local/sbin/mariadb-watchdog.sh --restart --quiet --email ops@example.com

--quiet prints nothing while everything is fine, so cron does not mail you every minute. Every problem and action also goes to syslog with the tag mariadb-watchdog (journalctl -t mariadb-watchdog). --webhook URL posts the same alert as JSON with both a text and a content field, which Slack-style and Discord-style incoming webhooks accept.

If you prefer one long-running process, --interval 60 keeps checking every 60 seconds; run it from a systemd service with Restart=always.

Options

  • --restart: restart the service when it is hung (default: report only).
  • --start-if-down: with --restart, also start a server that is not running.
  • --fails N: failed checks in a row before acting (default 3).
  • --timeout SEC: seconds to wait for an answer per check (default 10).
  • --grace SEC: no restart within SEC of the server starting (default 300).
  • --max-restarts N and --window SEC: at most N restarts per window (default 2 per 3600 seconds).
  • --stop-timeout SEC: a hung server that has not stopped after SEC is killed (default 300).
  • --wait-up SEC: after a restart, wait up to SEC for an answer (default 300).
  • --service NAME: the systemd unit; by default the first of mariadb, mysql or mysqld that exists.
  • --restart-cmd CMD: run CMD instead of the systemd stop/start.
  • --restart-on-busy, --ignore-backups: relax the two guards described above.
  • --defaults-file FILE, --socket PATH: how the client logs in.
  • --state-dir DIR, --log-dir DIR: where the counter, the restart history and the diagnostics go.
  • --email ADDR, --webhook URL: alerts (need mail or sendmail, and curl).
  • --interval SEC: keep running; --quiet: print only problems and actions.

Exit codes: 0 healthy; 1 failing but below --fails, or held by a guard; 2 restarted, restart needed (report-only) or refused by the restart limit; 3 wrong options or credentials, or the restart did not bring the server back.

How we tested it

We ran version 1.0.0 on 18 October 2026 in a test container (Ubuntu 24.04, MariaDB 10.11.14). The hang was real: we froze the server process with kill -STOP, so it kept running and kept its socket but answered nothing, which is what a hung server looks like from outside. The container has no systemd, so systemctl was replaced by a small stand-in script that stops, kills and starts mariadbd the same way; the systemd path on a real server was not part of this test.

42 checks passed, including: a healthy server (exit 0); a wrong login (exit 3, no restart); three failed checks before any action; report-only mode leaving the frozen server untouched and saving diagnostics; the grace period and a running mariadb-dump each holding the restart; a frozen server that ignored the stop request being killed after --stop-timeout, started again and answering; the restart limit stopping a third restart; a stopped server left alone without --start-if-down; “Too many connections” reported as BUSY; overlapping runs skipped; and a JSON webhook alert. A further 14 checks with a fake client run on every change in the GitHub repository’s CI. bash -n and ShellCheck 0.9.0 are clean.

The tests found one real bug before release: a server started by the watchdog could inherit its lock file, so every later run thought another check was still going and skipped. The script now closes the lock for every command it starts.

The script

mariadb-watchdog.shDownload
#!/usr/bin/env bash
# MariaDB Watchdog: Restart MariaDB or MySQL Only When It Is Really Hung (v1.0.0) - from srvScripts.com
# Source, docs and updates: https://srvscripts.com/scripts/mariadb-watchdog/
# Copyright (c) 2026 srvScripts.com. MIT licence: if you copy, share or adapt this script, keep this notice and credit srvScripts.com.
#
# mariadb-watchdog.sh
# Checks that MariaDB or MySQL answers a real query (SELECT 1) within a time
# limit. A server that is running but does not answer is "hung". After a
# number of failed checks in a row it saves diagnostics and, only when you
# pass --restart, restarts the service and waits until it answers again.
#
# Report-only by default: without --restart it never restarts or starts anything.
#
# Safety guards before any restart:
#   - the failure must repeat on --fails consecutive runs (default 3)
#   - no restart within --grace seconds of the server starting (crash recovery)
#   - no restart while systemd says the service is starting or stopping
#   - no restart while a backup tool is running (mariadb-backup, xtrabackup,
#     mysqldump, mariadb-dump, mydumper) unless --ignore-backups
#   - at most --max-restarts restarts per --window seconds, then alert only
#   - "Too many connections" is reported, not treated as a hang, unless
#     --restart-on-busy; "Access denied" is a configuration error and never
#     causes a restart
#
# Exit codes: 0 = healthy, 1 = failing but below --fails (or guarded),
#             2 = restart done, needed (report-only) or refused by the limit,
#             3 = usage, environment or credential error, or restart failed.

set -uo pipefail

VERSION="1.0.0"
TIMEOUT=10
FAILS=3
RESTART=0
START_IF_DOWN=0
RESTART_ON_BUSY=0
IGNORE_BACKUPS=0
GRACE=300
MAX_RESTARTS=2
WINDOW=3600
WAIT_UP=300
STOP_TIMEOUT=300
SERVICE=""
RESTART_CMD=""
STATE_DIR="/var/lib/mariadb-watchdog"
LOG_DIR="/var/log/mariadb-watchdog"
DEFAULTS_FILE=""
SOCKET=""
EMAIL=""
WEBHOOK=""
INTERVAL=0
QUIET=0

usage() {
    cat <<'EOF'
Usage: mariadb-watchdog.sh [options]

Checks that MariaDB/MySQL answers SELECT 1 in time. Report-only unless --restart.

Options:
  --restart              Restart the service when it is hung (default: report only)
  --start-if-down        With --restart, also start a server that is not running
  --fails N              Failed checks in a row before acting (default: 3)
  --timeout SEC          Seconds to wait for an answer per check (default: 10)
  --grace SEC            No restart within SEC of the server starting (default: 300)
  --max-restarts N       At most N restarts per --window (default: 2)
  --window SEC           Window for --max-restarts (default: 3600)
  --wait-up SEC          After a restart, wait up to SEC for an answer (default: 300)
  --stop-timeout SEC     A hung server that has not stopped after SEC is killed (default: 300)
  --service NAME         systemd unit (default: auto: mariadb, mysql or mysqld)
  --restart-cmd CMD      Run CMD instead of "systemctl restart <unit>"
  --restart-on-busy      Treat "Too many connections" as a failure too
  --ignore-backups       Allow a restart while a backup tool is running
  --defaults-file FILE   Client option file with the login (passed as --defaults-extra-file)
  --socket PATH          Server socket to connect to
  --state-dir DIR        Failure counter and restart history (default: /var/lib/mariadb-watchdog)
  --log-dir DIR          Diagnostics saved before a restart (default: /var/log/mariadb-watchdog)
  --email ADDR           Mail alerts to ADDR (needs mail or sendmail)
  --webhook URL          POST alerts as JSON to URL (Slack, Discord, Teams-style; needs curl)
  --interval SEC         Keep running and check every SEC seconds (default: run once)
  --quiet                Print only problems and actions
  -h, --help             Show this help
  -V, --version          Show script version

Examples:
  mariadb-watchdog.sh                                  # one check, report only
  mariadb-watchdog.sh --restart --email ops@example.com
  * * * * * root /usr/local/sbin/mariadb-watchdog.sh --restart --quiet   # cron, every minute
  mariadb-watchdog.sh --defaults-file /usr/local/directadmin/conf/my.cnf --restart
EOF
}

die() { echo "Error: $*" >&2; exit 3; }
need_num() {
    if ! [[ "$2" =~ ^[0-9]+$ ]] || [[ "$2" -lt "$3" ]]; then die "$1 needs a whole number >= $3"; fi
}

while [[ $# -gt 0 ]]; do
    case "$1" in
        --restart) RESTART=1; shift ;;
        --start-if-down) START_IF_DOWN=1; shift ;;
        --restart-on-busy) RESTART_ON_BUSY=1; shift ;;
        --ignore-backups) IGNORE_BACKUPS=1; shift ;;
        --fails)        need_num "$1" "${2:-}" 1; FAILS="$2"; shift 2 ;;
        --timeout)      need_num "$1" "${2:-}" 1; TIMEOUT="$2"; shift 2 ;;
        --grace)        need_num "$1" "${2:-}" 0; GRACE="$2"; shift 2 ;;
        --max-restarts) need_num "$1" "${2:-}" 0; MAX_RESTARTS="$2"; shift 2 ;;
        --window)       need_num "$1" "${2:-}" 60; WINDOW="$2"; shift 2 ;;
        --wait-up)      need_num "$1" "${2:-}" 5; WAIT_UP="$2"; shift 2 ;;
        --stop-timeout) need_num "$1" "${2:-}" 10; STOP_TIMEOUT="$2"; shift 2 ;;
        --interval)     need_num "$1" "${2:-}" 10; INTERVAL="$2"; shift 2 ;;
        --service)      [[ "${2:-}" =~ ^[A-Za-z0-9@._-]+$ ]] || die "--service needs a unit name"; SERVICE="$2"; shift 2 ;;
        --restart-cmd)  [[ -n "${2:-}" ]] || die "--restart-cmd needs a command"; RESTART_CMD="$2"; shift 2 ;;
        --defaults-file) [[ -r "${2:-}" ]] || die "--defaults-file needs a readable file"; DEFAULTS_FILE="$2"; shift 2 ;;
        --socket)       [[ -n "${2:-}" ]] || die "--socket needs a path"; SOCKET="$2"; shift 2 ;;
        --state-dir)    [[ -n "${2:-}" ]] || die "--state-dir needs a directory"; STATE_DIR="${2%/}"; shift 2 ;;
        --log-dir)      [[ -n "${2:-}" ]] || die "--log-dir needs a directory"; LOG_DIR="${2%/}"; shift 2 ;;
        --email)        [[ "${2:-}" == *@* ]] || die "--email needs an address"; EMAIL="$2"; shift 2 ;;
        --webhook)      [[ "${2:-}" =~ ^https?:// ]] || die "--webhook needs an http(s) URL"; WEBHOOK="$2"; shift 2 ;;
        --quiet) QUIET=1; shift ;;
        -h|--help) usage; exit 0 ;;
        -V|--version) echo "mariadb-watchdog.sh $VERSION"; exit 0 ;;
        *) usage >&2; die "unknown option: $1" ;;
    esac
done

command -v timeout >/dev/null || die "timeout (GNU coreutils) is required"
CLIENT="$(command -v mariadb || command -v mysql || true)"
[[ -n "$CLIENT" ]] || die "no mariadb or mysql client found"
mkdir -p "$STATE_DIR" "$LOG_DIR" 2>/dev/null || die "cannot create $STATE_DIR or $LOG_DIR (run as root, or use --state-dir/--log-dir)"
chmod 700 "$STATE_DIR" "$LOG_DIR" 2>/dev/null || true
FAILFILE="$STATE_DIR/failures"
HISTFILE="$STATE_DIR/restarts"
LOCKFILE="$STATE_DIR/lock"

CONN=()
[[ -n "$DEFAULTS_FILE" ]] && CONN+=("--defaults-extra-file=$DEFAULTS_FILE")
[[ -n "$SOCKET" ]] && CONN+=("--socket=$SOCKET")

HAVE_SYSTEMD=0
if command -v systemctl >/dev/null && [[ -d /run/systemd/system ]]; then HAVE_SYSTEMD=1; fi
if [[ -z "$SERVICE" && $HAVE_SYSTEMD -eq 1 ]]; then
    for u in mariadb mysql mysqld; do
        if systemctl list-unit-files "$u.service" --no-legend 2>/dev/null | grep -q "^$u\.service"; then SERVICE="$u"; break; fi
    done
fi

say()  { [[ $QUIET -eq 1 ]] || echo "$*"; }
note() { echo "$*"; command -v logger >/dev/null && logger -t mariadb-watchdog -- "$*" 2>/dev/null; return 0; }

alert() {
    local host msg
    host="$(hostname -f 2>/dev/null || hostname)"
    msg="[$host] mariadb-watchdog: $1"
    if [[ -n "$EMAIL" ]]; then
        if command -v mail >/dev/null; then
            printf '%s\n' "$msg" | mail -s "mariadb-watchdog on $(hostname)" "$EMAIL" 2>/dev/null || note "alert mail failed"
        elif command -v sendmail >/dev/null; then
            printf 'To: %s\nSubject: mariadb-watchdog on %s\n\n%s\n' "$EMAIL" "$(hostname)" "$msg" | sendmail -t 2>/dev/null || note "alert mail failed"
        else note "no mail or sendmail command: alert not mailed"; fi
    fi
    if [[ -n "$WEBHOOK" ]]; then
        local j; j="$(printf '%s' "$msg" | sed 's/\\/\\\\/g; s/"/\\"/g' | tr -d '\000-\037')"
        curl -fsS -m 15 -H 'Content-Type: application/json' -d "{\"text\":\"$j\",\"content\":\"$j\"}" "$WEBHOOK" >/dev/null 2>&1 || note "webhook alert failed"
    fi
}

server_pid() { pgrep -o -x mariadbd 2>/dev/null || pgrep -o -x mysqld 2>/dev/null || true; }
unit_state() {
    local st=""
    [[ $HAVE_SYSTEMD -eq 1 && -n "$SERVICE" ]] && st="$(systemctl is-active "$SERVICE" 2>/dev/null | head -n1)"
    echo "${st:-unknown}"
}

# probe: sets STATUS (OK|HUNG|BUSY|ERROR|DOWN|DENIED) and DETAIL
probe() {
    local pid out rc err
    pid="$(server_pid)"
    if [[ -z "$pid" ]]; then STATUS="DOWN"; DETAIL="no mariadbd or mysqld process (systemd: $(unit_state))"; return; fi
    err="$(mktemp)"
    out="$(timeout -k 3 "$TIMEOUT" "$CLIENT" "${CONN[@]+"${CONN[@]}"}" --connect-timeout="$TIMEOUT" -N -B -e 'SELECT 1' 2>"$err")"; rc=$?
    local first; first="$(head -n1 "$err")"; rm -f "$err"
    local pst; pst="$(ps -o stat= -p "$pid" 2>/dev/null | tr -d ' ')"
    if [[ $rc -eq 0 && "$out" == "1" ]]; then STATUS="OK"; DETAIL="answered SELECT 1 (pid $pid)"
    elif [[ $rc -eq 124 || $rc -eq 137 ]]; then STATUS="HUNG"; DETAIL="no answer within ${TIMEOUT}s (pid $pid, process state ${pst:-?})"
    elif [[ "$first" == *"1040"* || "$first" == *"Too many connections"* ]]; then STATUS="BUSY"; DETAIL="too many connections (pid $pid): $first"
    elif [[ "$first" == *"1045"* || "$first" == *"Access denied"* ]]; then STATUS="DENIED"; DETAIL="$first"
    else STATUS="ERROR"; DETAIL="query failed, exit $rc (pid $pid, state ${pst:-?}): ${first:-no error text}"; fi
}

backup_running() { pgrep -a -f '(^|/)(mariadb-backup|mariabackup|xtrabackup|innobackupex|mysqldump|mariadb-dump|mydumper)( |$)' 2>/dev/null | head -n1; }

restarts_in_window() {
    local now n=0 t; now="$(date +%s)"
    [[ -f "$HISTFILE" ]] || { echo 0; return; }
    while read -r t; do [[ "$t" =~ ^[0-9]+$ ]] && (( now - t < WINDOW )) && n=$((n + 1)); done < "$HISTFILE"
    echo "$n"
}

diagnostics() {
    local d; d="$LOG_DIR/$(date +%Y%m%d-%H%M%S)"; mkdir -p "$d" && chmod 700 "$d"
    local pid; pid="$(server_pid)"
    {
        echo "status: $STATUS - $DETAIL"; date
        [[ -n "$pid" ]] && ps -o pid,stat,etimes,rss,vsz,pcpu,nlwp,cmd -p "$pid"
        echo; uptime; echo; free -m 2>/dev/null; echo; df -h /var/lib/mysql 2>/dev/null
    } > "$d/system.txt" 2>&1
    timeout -k 2 5 "$CLIENT" "${CONN[@]+"${CONN[@]}"}" --connect-timeout=5 -e 'SHOW FULL PROCESSLIST' > "$d/processlist.txt" 2>&1 || echo "(no answer)" >> "$d/processlist.txt"
    timeout -k 2 5 "$CLIENT" "${CONN[@]+"${CONN[@]}"}" --connect-timeout=5 -e 'SHOW ENGINE INNODB STATUS\G' > "$d/innodb-status.txt" 2>&1 || echo "(no answer)" >> "$d/innodb-status.txt"
    if [[ $HAVE_SYSTEMD -eq 1 && -n "$SERVICE" ]]; then timeout 10 journalctl -u "$SERVICE" -n 200 --no-pager > "$d/journal.txt" 2>&1 || true; fi
    for f in /var/log/mysqld.log /var/log/mariadb/mariadb.log /var/log/mysql/error.log "/var/lib/mysql/$(hostname).err"; do
        [[ -r "$f" ]] && tail -n 200 "$f" > "$d/error-log-$(basename "$f").txt" 2>/dev/null
    done
    { dmesg -T 2>/dev/null || dmesg 2>/dev/null; } | grep -iE 'out of memory|oom|killed process|mysqld|mariadbd' | tail -n 30 > "$d/kernel.txt" 2>/dev/null || true
    echo "$d"
}

do_restart() {
    local what="$1" rc=0
    date +%s >> "$HISTFILE"
    # 9>&-: a daemon started from here must not inherit (and hold) the watchdog lock
    if [[ -n "$RESTART_CMD" ]]; then timeout 900 bash -c "$RESTART_CMD" 9>&-; rc=$?
    elif [[ $HAVE_SYSTEMD -eq 1 && -n "$SERVICE" ]]; then
        if [[ "$what" == "restart" ]]; then
            # A hung server may never finish a clean shutdown, and the unit's own stop timeout can be 15 minutes.
            timeout "$STOP_TIMEOUT" systemctl stop "$SERVICE" 9>&- || true
            if [[ -n "$(server_pid)" ]]; then
                note "server did not stop within ${STOP_TIMEOUT}s: killing it (InnoDB runs crash recovery on the next start)"
                systemctl kill --signal=SIGKILL "$SERVICE" 2>/dev/null || kill -9 "$(server_pid)" 2>/dev/null
                local k=0; while [[ -n "$(server_pid)" && $k -lt 30 ]]; do sleep 1; k=$((k + 1)); done
                [[ -z "$(server_pid)" ]] || { note "server process survived SIGKILL; not starting a second copy"; return 3; }
            fi
        fi
        timeout 900 systemctl start "$SERVICE" 9>&-; rc=$?
    else note "no systemd unit found: use --service or --restart-cmd"; return 3; fi
    [[ $rc -eq 0 ]] || { note "$what command failed with exit $rc"; return 3; }
    local waited=0
    while (( waited < WAIT_UP )); do
        sleep 5; waited=$((waited + 5)); probe
        [[ "$STATUS" == "OK" ]] && { note "server answers again after ${waited}s"; return 0; }
    done
    note "server still not answering ${WAIT_UP}s after $what ($STATUS: $DETAIL)"; return 3
}

check_once() {
    probe
    local fails=0
    [[ -f "$FAILFILE" ]] && read -r fails < "$FAILFILE" 2>/dev/null
    [[ "$fails" =~ ^[0-9]+$ ]] || fails=0

    case "$STATUS" in
        OK)
            if [[ $fails -gt 0 ]]; then note "recovered after $fails failed check(s): $DETAIL"; else say "OK: $DETAIL"; fi
            echo 0 > "$FAILFILE"; return 0 ;;
        DENIED)
            note "login refused, check the credentials (never a reason to restart): $DETAIL"
            echo 0 > "$FAILFILE"; return 3 ;;
        BUSY)
            if [[ $RESTART_ON_BUSY -eq 0 ]]; then
                note "BUSY: $DETAIL (not a hang; raise max_connections or find the client that holds connections)"
                echo 0 > "$FAILFILE"; return 1
            fi ;;
    esac

    fails=$((fails + 1)); echo "$fails" > "$FAILFILE"
    note "$STATUS ($fails/$FAILS): $DETAIL"
    [[ $fails -lt $FAILS ]] && return 1

    local action="restart"
    if [[ "$STATUS" == "DOWN" ]]; then
        if [[ $START_IF_DOWN -eq 0 || $RESTART -eq 0 ]]; then
            note "server is DOWN and was not started (that needs --restart --start-if-down)"
            alert "server is DOWN: $DETAIL (not starting it: needs --restart --start-if-down)"; return 2
        fi
        action="start"
    fi

    local pid up st bk
    pid="$(server_pid)"
    if [[ -n "$pid" ]]; then
        up="$(ps -o etimes= -p "$pid" 2>/dev/null | tr -d ' ')"
        if [[ "$up" =~ ^[0-9]+$ && $up -lt $GRACE ]]; then note "server started ${up}s ago (grace ${GRACE}s): not restarting during startup or crash recovery"; return 1; fi
    fi
    st="$(unit_state)"
    if [[ "$st" == "activating" || "$st" == "deactivating" || "$st" == "reloading" ]]; then note "systemd says $SERVICE is $st: not interfering"; return 1; fi
    if [[ $IGNORE_BACKUPS -eq 0 ]]; then
        bk="$(backup_running)"
        if [[ -n "$bk" ]]; then note "a backup is running ($bk): not restarting (use --ignore-backups to allow)"; alert "$STATUS for $fails checks, restart held because a backup is running: $DETAIL"; return 1; fi
    fi
    local done_n; done_n="$(restarts_in_window)"
    if [[ $done_n -ge $MAX_RESTARTS ]]; then
        note "restart limit reached ($done_n in ${WINDOW}s): alerting only"
        alert "$STATUS for $fails checks and the restart limit is reached ($done_n in ${WINDOW}s): $DETAIL. Needs a person."; return 2
    fi

    local dir; dir="$(diagnostics)"
    if [[ $RESTART -eq 0 ]]; then
        note "would $action $SERVICE now (report-only mode; add --restart). Diagnostics: $dir"
        alert "$STATUS for $fails checks: $DETAIL. Report-only mode, nothing restarted. Diagnostics: $dir"; return 2
    fi
    note "${action}ing ${SERVICE:-the server} after $fails failed checks. Diagnostics: $dir"
    if do_restart "$action"; then
        echo 0 > "$FAILFILE"
        alert "$STATUS for $fails checks ($DETAIL). ${action^} done and the server answers again. Diagnostics: $dir"; return 2
    fi
    alert "$STATUS for $fails checks ($DETAIL). ${action^} did NOT bring it back. Diagnostics: $dir"; return 3
}

exec 9>"$LOCKFILE" || die "cannot open $LOCKFILE"
if ! flock -n 9; then say "another mariadb-watchdog run is still going: skipping"; exit 1; fi

if [[ $INTERVAL -gt 0 ]]; then
    say "mariadb-watchdog $VERSION: checking every ${INTERVAL}s (restart: $([[ $RESTART -eq 1 ]] && echo on || echo off))"
    while true; do check_once; sleep "$INTERVAL"; done
fi
check_once
exit $?
Version 1.0.0 · SHA-256 590c02287d8a02770bb0938b677341956c4b5dbc398d88f1689dc83a10c14d97
Download and verify on Linux or macOS
curl -fsSL -o mariadb-watchdog.sh https://scr.srvscripts.com/mariadb-watchdog/mariadb-watchdog.sh && curl -fsSL https://scr.srvscripts.com/mariadb-watchdog/mariadb-watchdog.sh.sha256 | sha256sum -c
Download and verify in Windows PowerShell
Invoke-WebRequest -Uri 'https://scr.srvscripts.com/mariadb-watchdog/mariadb-watchdog.sh' -OutFile 'mariadb-watchdog.sh'; if ((Get-FileHash 'mariadb-watchdog.sh' -Algorithm SHA256).Hash -eq '590C02287D8A02770BB0938B677341956C4B5DBC398D88F1689DC83A10C14D97') { 'OK: the file is intact' } else { 'MISMATCH: do not run this file' }
Copy the whole line. In Windows PowerShell, curl and sha256sum are not the Linux tools, so use the PowerShell line there.
Also on GitHub: github.com/srvscripts/scripts

Frequently asked questions

Why not just use mysqladmin ping in a loop?

mysqladmin ping (mariadb-admin ping) only checks that the server accepts a connection and answers a protocol ping. A server whose threads are all stuck behind a lock can still do that. A real query with a time limit is a better test of “can my website get an answer”.

Should a watchdog restart the database at all?

As a last resort, yes: a restart turns hours of a hung site into a few minutes of downtime, and the saved diagnostics let you fix the cause afterwards. That is why the script needs repeated failures, keeps a restart limit and alerts every time.

Will a forced restart lose data?

Committed InnoDB transactions are safe: after a kill, InnoDB recovers them from its redo log on the next start. Transactions that were not committed are rolled back. Non-transactional MyISAM or Aria tables that were being written at that moment can need a repair. If your databases still use MyISAM, convert them to InnoDB.

Does it work with MySQL instead of MariaDB?

Yes. It looks for a mariadbd or mysqld process, uses the mariadb or mysql client, and finds a mariadb, mysql or mysqld systemd unit.

Share this script

Changelog

  • 1.0.0 — First release.

Free website test

Is your website set up right?

Check SSL, security headers, redirects, robots.txt, sitemap, llms.txt and security.txt in one test. It takes about 30 seconds.