Service auto-healing watchdogs
BOA keeps the stack alive without operator intervention through a fleet of short-lived
watchdog scripts that the minute.sh launcher fans out, on the box, every few seconds.
Each watchdog checks one service and — if it finds it down, stuck, or misconfigured —
repairs or restarts it in place. There is no daemon: the watchdogs are spawned, run to
completion, and exit. Cron re-launches minute.sh every minute; the launcher self-loops to
drive a tighter cadence inside the minute.
This page documents the per-service watchdog fan-out (_launch_auto_healing in
aegir/tools/system/minute.sh) and the single-shot monitors under
monitor/check/. The load-reactive side of the same machinery — the high-load brakes
that write /run/max_load.pid and /run/critical_load.pid, which almost every watchdog
here honors — is in Load control. The cadence (how many times per
minute the fan-out runs, and how that is throttled on small or CI boxes) is in
Cron cadence & idle-load throttle. The security-facing member
of this same /var/xdrago/monitor/ family, scan_nginx.sh, is reached through this
fan-out — it is looped by nginx_guard.sh (one of the watchdogs minute.sh spawns), not
run directly — and is documented in the Abuse Guard topic.
Where it runs from
On a box the launchers are deployed under /var/xdrago/; the per-service watchdogs are
under /var/xdrago/monitor/check/. In the repository the same files live under
aegir/tools/system/ and aegir/tools/system/monitor/check/.
cron (every minute)
│
├── /var/xdrago/second.sh ── self-loops; load-control + process guards
│ └── (its own fan-out — see Load control / Process guards)
│
└── /var/xdrago/minute.sh ── self-loops, drives the SERVICE watchdogs
└── _launch_auto_healing() ── spawns, per pass:
system.sh unbound.sh valkey.sh|redis.sh
mysql.sh php.sh fpm_tune.sh
nginx.sh nginx_guard.sh java.sh
└── loops scan_nginx.sh (Abuse Guard)
Each watchdog re-sources /root/.barracuda.cnf on entry (so every _VAR override below is
read fresh each pass) and exits immediately unless
/var/log/boa/reset_no_new_password.pid exists — i.e. nothing runs until the box is a fully
installed BOA system.
What minute.sh does before the fan-out: flood guards
minute.sh is not only a launcher. Before it spawns any watchdog it runs two flood guards
of its own. These protect the box from a runaway monitor or a CSF reload storm spinning up
faster than it drains — exactly the failure mode that would otherwise pin a small box at
high load.
| Guard | Trigger | Action |
|---|---|---|
_second_flood_guard |
more than 4 live second.sh processes |
log to /var/log/boa/sec-count.kill.log, then pkill -9 -f second.sh |
_csf_flood_guard (csf) |
more than 4 live /csf processes |
pkill -9 -f csf, then csf -tf + csf -df to flush temp/deny bans |
_csf_flood_guard (fire) |
more than 7 guest-fire.sh processes |
flush CSF temp bans, pkill -9 -f fire.sh; at more than 9, also purge deny rules |
Both guards are skipped while a BOA run is in progress (/run/boa_run.pid) so they never
fight an upgrade. _csf_flood_guard is additionally gated on the csf binary being present
(/usr/sbin/csf) and no /run/water.pid (the guest-water drain) being active, and it
re-asserts SYNPROXY (synproxy_reassert -p "443 80" --no-quic) when
/etc/csf/csfpost.d/synproxy.sh is present.
The fan-out itself is then run _ITER times with _SLEEP seconds between passes; on a
NORMAL box that is 9 passes 5 s apart, giving ~5 s monitoring granularity across the minute.
See Cron cadence & idle-load throttle for the box-class logic
and the _MONITOR_FANOUT_ITER / _MONITOR_FANOUT_SLEEP overrides.
Re-entrancy: the shared lock and the kill guard
Every watchdog (and the launchers) open with the same _manage_single_lock pattern. It
sources lock.inc from /opt/local/bin/ or /opt/local/lib/ and, when that shared
single-instance library is present, takes its lock. If the library is absent it falls back
to a legacy pgrep -fc guard: if more than 2 copies of the same script are already
running, it logs to /var/log/boa/too.many.log and exits 0. So a slow watchdog can never
stack up more than briefly — the next launch detects the pile-up and backs off instead of
restarting the service underneath a still-running instance.
The per-service watchdogs
_launch_auto_healing spawns each of the following with nohup … &, so they run
concurrently and independently. The Valkey/Redis choice is made by an init-script probe:
valkey.sh runs if /etc/init.d/valkey-server exists, else redis.sh if
/etc/init.d/redis-server exists. fpm_tune.sh is launched only if the file is present
(newer nodes).
| Watchdog | Guards | Action |
|---|---|---|
system.sh |
OS-level health: SSHD, DHCP lease, rsyslog, postfix, cron duplicates, giant syslog, LFD, FTPS (pure-ftpd), vnstat, gpg-agent / dirmngr pile-ups, ClamAV, and system OOM | Restarts/starts the down service; on ≤5% free RAM (from free -mt) does a full OOM cascade (kill php/nginx/php-fpm/java/cache + New Relic, restart Percona via move_sql.sh); culls runaway wkhtmltopdf between 5–10% free; forces logrotate; rebuilds DHCP allow rules in csf.allow; drops page cache when used RAM > 90%; cooldown-gated per service |
unbound.sh |
Local DNS resolver: process+PID liveness, a live host files.boa.io lookup against 127.0.0.1, duplicate masters, and /etc/resolv.conf sanity |
Restarts unbound (cooldown-gated, default 30 s); rewrites a BOA-tagged /etc/resolv.conf |
valkey.sh |
Valkey cache: process + socket PING (auth or NOAUTH), Address already in use, sustained RedisException: Connection refused (>19) and PhpRedis slowlog hits (>19) |
Restart on failed double-check (cooldown 30 s); cold restart wipes /var/lib/valkey/*; on REFUSED/SLOW also reloads all PHP-FPM; honors site-requested run-valkey-restart.pid on qualifying plans |
redis.sh |
Identical logic to valkey.sh for the legacy Redis build (/etc/init.d/redis-server) |
Same: PING-verified restart, /var/lib/redis/* wipe on cold restart, FPM reload on REFUSED/SLOW, plan-gated run-redis-restart.pid |
mysql.sh |
Percona MySQL: master process + mysqld.sock/mysqld.pid liveness, Too many connections floods, high-load × thread-count, runaway per-user queries, stuck mydumper |
Restart via move_sql.sh when down or wedged; kills queries past TTL (_SQL_MAX_TTL 3600 s, problematic users _SQL_LOW_MAX_TTL 60 s); flush-hosts; restarts only when 1-min load > _LOAD_THRESHOLD (33.0) and threads > _THREAD_THRESHOLD (99); also spawns sqlcheck.sh |
php.sh |
PHP-FPM (all installed versions, 56–85): master + wwwNN.fpm.socket + PID liveness, duplicate masters, already listen on / Address already in use socket conflicts, per-pool capacity exhaustion (byte-offset-tails the FPM error logs for new reached max_children setting hits and logs a raise-pm.max_children NOTE), giant logs, oversized fastcgi_temp |
Per-version restart on a sustained, double-checked failure, cooldown-gated (_FPM_COOLDOWN_SECS 30 s); cleans fastcgi_temp; honors the plan-gated run-php-fpm-reload.pid APCu-clear sentinel; spawns segfault_alert.pl (unless /root/.high_traffic.cnf or /root/.giant_traffic.cnf is set) |
fpm_tune.sh |
Read-only FPM sampler (no restart, no config change). Probes each live pool's pm.status and each version's opcache/APCu, plus per-worker USS |
Self-throttles to ~5 min and appends one JSONL record per pool and per version to /var/log/boa/fpm-tune/<date>.jsonl — the data fpmreport reads. Self-installs libfcgi-bin if missing, rate-limited |
nginx.sh |
Nginx web server: master + /run/nginx.pid liveness, multiple masters, zombie/stopped/uninterruptible master or worker states, Cannot allocate memory (OOM), Address already in use |
Full kill + service nginx restart (rotating error.log) on any anomaly, cooldown-gated (_NGINX_COOLDOWN_SECS 30 s); honors plan-gated run-nginx-restart.pid |
nginx_guard.sh |
A keep-warm helper: that the nginx access log is being written at all | Reloads nginx if access.log is missing/empty, then loops scan_nginx.sh (10×, 5 s apart) — i.e. this is the launcher that actually drives the Abuse Guard scanner on the minute tick |
java.sh |
JVM services: Jenkins, Solr 9 / Solr 7 / Solr 4 (Jetty 9), and Jetty Address already in use |
Restarts the down/stale-PID service (cleaning its /tmp scratch and rotating logs); skips entirely while a BOA/Octopus run is in progress |
A few load-bearing details worth calling out:
-
The OOM cascade in
system.shis the heaviest action in the set. At ≤5% free RAM (computed fromfree -mt, notMemAvailable) it killswkhtmltopdf, PHP CLI, nginx, PHP-FPM, Java, the cache server (wiping/var/lib/valkeyor/var/lib/redis) and New Relic, then restarts Percona viamove_sql.sh— a deliberate hard reset for a box seconds from a kernel OOM-kill. Between 5% and 10% free it only culls runawaywkhtmltopdf(more than 2 instances). The threshold is low on purpose: the cascade is disruptive and must fire only in a genuine emergency. -
mysql.shrequires both high load and a high thread count before it restarts Percona (_LOAD_THRESHOLD33.0 and_THREAD_THRESHOLD99). Either alone is normal under bursty traffic; the conjunction is what distinguishes a wedged server from a busy one. A needless MySQL restart is itself an outage, so the defaults are conservative. -
fpm_tune.shnever tunes anything live. It is purely the data collector for FPM capacity sizing; the JSONL it writes is consumed byfpmreport. See PHP-FPM & performance for how that data drives worker and memory sizing. -
nginx_guard.shis the bridge to the security layer.scan_nginx.shis detection-only and post-hoc, and is launched only here (10 spawns, 5 s apart) on thisminute.shfan-out —second.shdoes not run it. See Abuse Guard.
Cooldowns, double-checks, and load gating
Three patterns recur across the watchdogs and are worth understanding once:
-
Double-check before acting. Almost every restart path re-reads the symptom after a short
sleep(2–5 s) and acts only if it persists. This filters out the transient flap — a socket mid-reload, a log line from a restart that already happened — that would otherwise trigger a needless restart. -
Per-service cooldown stamp. A restart writes
date +%sto a/run/<service>-monitor.cooldown(or per-version/run/phpNN-fpm.cooldown) file. A subsequent failure within the cooldown window (default 30 s) logs a skip instead of restarting again, breaking restart storms when a service is crash-looping for a reason a restart cannot fix. Overridable per service:_UNBOUND_COOLDOWN_SECS,_VALKEY_COOLDOWN_SECS,_REDIS_COOLDOWN_SECS,_FPM_COOLDOWN_SECS,_NGINX_COOLDOWN_SECS, and_CRON_/_POSTFIX_/_LFD_COOLDOWN_SECS(insystem.sh). -
Load and run gating. Most watchdogs stand down while
/run/max_load.pidor/run/critical_load.pidexists. Under a load emergency the box is already shedding work via Load control; piling service restarts on top would make recovery slower, not faster. They also stand down during a BOA run (/run/boa_run.pid) and while another auto-heal of the same service is mid-flight (/run/boa_<service>_auto_healing.pid).
Email reporting
When a watchdog takes a corrective action it appends to its own
/var/log/boa/<service>.incident.log and may e-mail ${_MY_EMAIL}, gated by
_INCIDENT_REPORT (sourced from /root/.barracuda.cnf). The normalization is uniform —
NO maps to OFF, anything unrecognized maps to the script's default — but the send
threshold differs by watchdog:
_INCIDENT_REPORT |
Effect |
|---|---|
OFF |
Total silence, no e-mail (legacy NO maps here) |
CRIT |
In system.sh only: send when the alert level is ALERT. The default for system.sh |
MINI |
A valid value on the service watchdogs (their default), but on those scripts it sends no mail — see note below. Not a distinct value in system.sh (it folds to CRIT) |
ALL |
Sends every incident e-mail; the only value that produces mail from the per-service watchdogs |
The important caveat: in the per-service watchdogs (unbound.sh, valkey.sh/redis.sh,
php.sh, nginx.sh, java.sh) the e-mail is sent only when _INCIDENT_REPORT is
ALL — their MINI/CRIT settings still log the incident but mail nothing. mysql.sh
sends on anything other than OFF (its own default is MINI). Only system.sh implements
the full OFF / CRIT (= ALERT-only) / ALL ladder. In every case the corrective action
and the .incident.log entry happen regardless of the e-mail setting.
Reporting is additionally suppressed during the post-boot uptime grace period
(_check_uptime_grace_period, from the shared lock.inc), so a reboot does not spray
"service was down, restarted" mail while the stack is still coming up. See
Load control for the full grace-gate conditions.
Both the mail body and the on-disk history are bounded. Every _incident_email_report
sends only the last 200 lines of the service's incident log (tail -n 200), applied
identically in second.sh and each of the eight mail-sending monitor/check/ watchdogs
(system, nginx, php, mysql, java, unbound, valkey, redis) — an alert shows
the latest entries, never the whole file history. The logs themselves are rotated by a
shipped logrotate policy (aegir/conf/var/logrotate.d.boa.conf, deployed to
/etc/logrotate.d/boa on both the upgrade and INIT paths): /var/log/boa/*.incident.log
is rotated weekly, keeping 4 rotations, with compress + delaycompress, missingok,
notifempty. No postrotate signal or copytruncate is needed — the short-lived
monitors reopen the log on each append, so logrotate's default create-after-rotate is
safe. Full history stays on disk in the rotated files; only the e-mail body is capped.
Verify
# recent auto-heal activity, per service
tail -n 20 /var/log/boa/*.incident.log 2>/dev/null
ls -1 /var/log/boa/*.kill.log 2>/dev/null
# is a watchdog mid-flight, or is the box load-paused?
ls -1 /run/boa_*_auto_healing.pid /run/*_load.pid 2>/dev/null
# cooldown stamps currently in force
ls -1 /run/*-monitor.cooldown /run/phpNN-fpm.cooldown 2>/dev/null
# incident-log rotation policy deployed on this box?
cat /etc/logrotate.d/boa 2>/dev/null
Related
- Load control & auto-pause — the load-reactive brakes
(
max_load.pid/critical_load.pid) that gate these watchdogs, and the uptime grace gate shared with this page. - Cron cadence & idle-load throttle — box-class fan-out cadence
and the
_MONITOR_FANOUT_*/_MONITOR_HEAVY_EVERYknobs. - Process guards & auth scanners — the
second.shside: the_proc_controlservice guards and the auth scanners. - Abuse Guard — the security scanner (
scan_nginx.sh) driven by the same monitor home vianginx_guard.shon thisminute.shfan-out. - PHP-FPM & performance — how the
fpm_tune.shJSONL feedsfpmreportand capacity sizing. - Reference appendix — every
_VARoverride named above with its default and source.