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 related parts of the same machinery are documented elsewhere:
- Load-reactive side of the same machinery — the high-load brakes that write
/run/max_load.pidand/run/critical_load.pid, which almost every watchdog here honours — is in Load control. - 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.
- Security-facing member of this same
/var/xdrago/monitor/family,scan_nginx.sh, is reached through this fan-out — it is looped bynginx_guard.sh(one of the watchdogsminute.shspawns), 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 sqlprobe.sh
batch_guard.sh task_guard.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, sqlprobe.sh, batch_guard.sh and
task_guard.sh are launched only if their files are 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 memory pressure | Restarts/starts the down service; on sustained memory pressure (MemAvailable ≤5% free, confirmed across three samples) kills the single largest process that is safe to kill (below); culls runaway wkhtmltopdf between 5–10% free; forces logrotate; rebuilds DHCP allow rules in csf.allow; 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: a live socket PING through the cli, sustained across five spaced probes before the server is called down. Only a connect-level failure counts — refused, missing socket, reset, or a hang past the probe timeout. A WRONGPASS/NOAUTH reply is an auth drift to fix, not an outage, and any other reply the server composed proves it alive. Also Address already in use in Valkey's own log with a missing socket |
Soft service restart first; forced stop/start only if that fails (that last-resort path wipes /var/lib/valkey/* — a disposable cache, and a dataset the server refuses to load back is the one fault a plain restart cannot clear). Paced by a cooldown (30 s) and a flap circuit breaker (below). Honours a site-requested restart sentinel — run-valkey-restart.pid or the older run-redis-restart.pid, either name, and it clears both when it acts — on a qualifying plan or on /etc/boa/.allow.valkey.restart.cnf (its back-compat twin /etc/boa/.allow.redis.restart.cnf counts too), cooldown-only — an explicit request is never refused on automation history. It no longer reads PHP-side logs as detection and never touches PHP-FPM |
redis.sh |
Retired from delivery — the upgrade path no longer fetches it, so new installs never receive it | A box that already has it keeps its local copy, and the init-script dispatch above still runs it there: legacy pre-Valkey logic (PING-verified restart, /var/lib/redis/* wipe, FPM reload on REFUSED/SLOW, run-redis-restart.pid — or the valkey-named sentinel, it counts both — honoured on a qualifying plan or on /etc/boa/.allow.redis.restart.cnf) |
mysql.sh |
Percona MySQL: a live mysqladmin ping, re-probed across a short grace before the server is called down; Too many connections floods, high-load × thread-count, runaway per-user queries, stuck mydumper |
Database-only recovery, never a whole-stack restart: move_sql.sh dbrestart when mysqld is present but silent, move_sql.sh start when it is absent; kills queries past TTL (_SQL_MAX_TTL 3600 s, problematic users _SQL_LOW_MAX_TTL 60 s); flush-hosts. A server that answers at all is never restarted. Paced by a cooldown and a flap circuit breaker (below); 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; honours the plan-gated run-php-fpm-reload.pid APCu-clear sentinel; spawns segfault_alert.pl, the PHP-FPM and nginx segfault reporter, unless /root/.high_traffic.cnf or /root/.giant_traffic.cnf is present (both markers below) |
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 |
sqlprobe.sh |
Read-only SQL sampler (no restart, no config change); _USE_SQLPROBE=NO opts a box out |
Self-throttles to ~5 min and appends one JSONL record — per-second rate deltas between its own samples (buffer pool misses, tmp spill, key reads, queries, connections), pool/connection gauges, MemAvailable/PSI and the mysqld resident size, plus a per-consumer memory ledger read from /proc on each sample: Solr JVM resident size per instance against that JVM's -Xmx, PHP-FPM aggregate resident size with per-master pool and worker counts, nginx / clamd / Valkey resident sizes, and the residual "everything else" figure derived from a whole-box resident-size sweep, alongside the file-cache evidence pair (MemFree beside MemAvailable, Active(file) plus SReclaimable) taken as one matched meminfo read — to /var/log/boa/sqlprobe/<date>.jsonl, the data memorytuner reads. Also refreshes the daily table-data measurement and, once the cache has ≥1 h uptime, keeps two Valkey stores: the peak store, and the demand window — samples, hits, misses and evictions accumulated from same-run counter deltas, alongside the high-water live occupancy seen within the window; at seven days the counters are halved rather than zeroed and the peak is re-anchored to the current sample, and the ceiling loop clears the window outright whenever it acts — the primary input the upgrade-time cache-ceiling loop steps from, with the older peak-based guard as its fallback. Skips samples while backups or cache-droppers run (plus a 20-minute cool-down), during barracuda runs and run-to-* transitions, and whenever mysqld is absent — downtime is mysql.sh's job |
batch_guard.sh |
A local batch self-DoS: the Drupal 7 background_process+background_batch module pair drives batches via self-HTTP POSTs to /bgp-start/background_batch%3A<bid>/… from the box's own address. Under PHP-FPM saturation every POST times out (nginx 499), the module's cron integration re-launches every "stale" process each pass, and the loop feeds itself — the firewall cannot help because it will not ban the box's own IP. Arms on the storm's own signal: many distinct bids looping at once, at or over _BATCH_GUARD_STORM_BIDS_MIN (12) — a bid counts only while still POSTing in the fresh minutes, so a site chaining legitimate batches over the hours counts as ONE — plus a coarse own-IP /bgp-start/-only 499 volume bound at or over _BATCH_GUARD_499_MIN (200; implied by the bids floor at default knobs). Only a cron-relaunch storm loops tens of bids in the same fresh window; healthy use runs a handful of concurrent batches box-wide (a running batch re-launches roughly every 10 s by design, so a lone chatty batch can never arm it) — and never on load level (a storm that pins a single pool can hold a many-core box below any workable per-core load floor indefinitely, so a load gate would leave exactly the affected site degraded and the guard blind); then requires every further stage: per-bid /bgp-start/ repetition (hour-bounded, with wild-ssl fronted duplicates de-duplicated by their forwarded chain), unambiguous vhost→database attribution in nginx's own parse order, a live background_process flywheel row for the bid, and a no-change confirmation of the batch blob fingerprint and queue count across at least eight minutes of continuous service, with the bid still looping in the last minutes |
Deletes only the confirmed bid's rows — the {batch} row conditionally on the very fingerprint the confirmation measured (a batch that moved keeps every row and the pass logs SKIPPED), plus its background_process handle; the drupal_batch queue rows are deliberately left to core cron's own reaper, because deleting them would only tighten the spin of a worker still holding the batch in memory. No service restart: the observed storm drained in about two minutes on row deletion alone. Cooldown-paced (_BATCH_GUARD_COOLDOWN_SECS 600 s) with a flap circuit breaker (below); alerts once per affected site database. _USE_BATCH_GUARD=NO opts a box out; _BATCH_GUARD_DETECT_ONLY=YES keeps detection and alerts but never deletes |
task_guard.sh |
Orphaned task queue wedges — the crashed-task reaper. A hosting task's final status is written only by its runner's own PHP shutdown handler, so a runner killed without reaching it (a host reboot, an interrupted upgrade swapping the live code trees under its own dispatched tasks, a stray signal) leaves its hosting_task revision at PROCESSING forever. The dispatcher counts every such corpse against its concurrency budget for a full 8 hours, so at the default single-task limit one corpse freezes the instance's whole queue ("Maximum number of tasks already running" every minute) and hosting-pause — run by every hostmaster upgrade — waits behind it, its own loop carrying no timeout: on a frontend that has no queue reaper, and on any row that carries no recorded runner PID, that wait ends only when the corpse ages out of the same 8-hour window. Detection is process evidence, never timestamps alone: only the task's current revision counts (superseded revisions stay PROCESSING by design), the row must be older than _TASK_GUARD_GRACE_MINS (10), and the instance's own system user must have no live task runner and no live provision backend — one live process holds the entire instance for that pass, because with per-user evidence the reaper never guesses which row a process belongs to. Works on every deployed hostmaster generation with no Drush bootstrap (hostmaster alias → site path → the site's own drushrc.php → direct SQL), so it keeps healing even when the interrupted upgrade left the instance's Drush tree broken — newer hostmaster frontends also reap their own pid-stamped rows themselves, both on every dispatch pass and from inside hosting-pause's own wait loop, and the two coexist through the conditional per-row update (whoever reaps first wins) |
Marks each orphaned row failed — the truthful outcome, never "Successful" — and writes an explicit task log entry naming the watchdog; the queue then dispatches again on its own within a minute. Nothing is ever re-run: re-firing a crashed migrate or clone blind is exactly what a recovery tool must not do, so the retry stays the operator's call. Stands down on a replication standby, on a finalized migration proxy, while live barracuda activity could restart the database underneath it (process-anchored — a stale run marker left by a killed upgrade must not park the very reaper that heals its fallout), and while a database restart marker is fresh; a live octopus run is deliberately not a stand-down, because its code-tree swap is what strands the tasks and a mid-run reap is what lets the run's own hosting-pause proceed. _USE_TASK_GUARD=NO opts a box out; _TASK_GUARD_DETECT_ONLY=YES keeps detection and alerts but never writes |
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. A missing pidfile alone, with a live master still serving, is treated as an artefact and stood down — but a missing master always heals, because workers inherit the listen sockets and keep serving headless |
Graceful escalation on every restart path, all cooldown-gated (_NGINX_COOLDOWN_SECS 30 s): the recorded master (verified to still be nginx) is asked to QUIT and given a bounded wait before any -9 touches the survivors, then service nginx restart (rotating error.log). Honours the self-service run-nginx-restart.pid sentinel on a qualifying plan or on /etc/boa/.allow.nginx.restart.cnf; a request landing inside a cooldown is kept for the next pass, never discarded |
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); that restart work is skipped while a BOA/Octopus run is in progress — each wrapper arm (autoinit, automini, barracuda, boa in-, octopus) matched by an anchored local execution pattern rather than a bare path substring, so a command line that merely mentions a wrapper (an editor, a checksum, an ssh probe) does not count as a run, plus the chained installer's own legs under either the /var/backups or the /var/opt/boa-dist staging path. Above all of that sits its second role, which runs on every pass regardless of any in-flight run — deliberately, because a barracuda pass re-arms the init scripts and starts Solr outright: it is the watchdog that enforces the xmass Solr hold. While /var/log/boa/.xmass_solr_hold.pid exists the pass instead keeps Solr 9, Solr 7, Jetty 9 and Solr 4 disarmed — exec bit cleared, boot links disabled — and for Solr 9 / Solr 7 / Jetty 9 reaps the JVM by service user (pkill -9 -u, never on a bare service name, which would match the rsync receiver carrying the index; where no such user exists it falls back to a java-anchored process pattern), while Solr 4 is disarmed without a kill. Each service it disarms is recorded in /var/log/boa/.xmass_solr_disarmed.list, and once the marker is gone the next pass re-arms exactly that recorded set — exec bits and boot links — and deletes the list, so a cutover driven by older bytes cannot leave Solr silently dead; anything disarmed by other means (an xtrim-finalised proxy, an operator hand-disarm) never enters the list and stays down. On a replication standby (/root/.standby.cnf) with no hold in force the pass stops there: a box whose index may be arriving by rsync never has Solr started under it, and nothing is re-armed there either. See xmass server move for the hold's own side of the story |
A few load-bearing details worth calling out:
-
system.shanswers a memory emergency by freeing memory, not by taking the site down. Pressure is read fromMemAvailable, which already counts reclaimable cache, and the ≤5% condition must hold across three samples five seconds apart — it stands down the moment one recovers, so a brief allocation burst the kernel absorbs on its own is not an emergency. The response is then a singlekill -9of the largest resident process that is safe to kill. The database, the SSH lifeline and the provision/backup chain (mysqld,sshd,provision,drush,mydumper,duplicity,aegir.sh,backboa,multiback) are never chosen: killing one of those means a broken task or a corrupt dump rather than freed memory. If everything large is exempt, it says so in the log, pages, and leaves the rest to the kernel OOM killer, which picks its victim with far better information than a shell script has. Between 5% and 10% free it only culls runawaywkhtmltopdf(more than 2 instances).Repeats are paced and then stopped, exactly as the database heals are:
_OOM_COOLDOWN_SECS(60 s) between kills, a ledger at/var/log/boa/oom.kill.ledger, and a breaker that latches/run/boa_oom_latched.pidonce_OOM_FLAP_MAX(3) kills have run inside_OOM_FLAP_WINDOW_SECS(3600 s), pages once, and refuses to act again until an operator deletes the latch or_OOM_FLAP_LATCH_MINS(60) passes. The window is floored against the pace the path enforces on itself, because a window too narrow to hold the allowed kills is a breaker that never opens.This replaced a cascade that, on one measured box, killed seven services within three seconds of a single
free -mtsample —wkhtmltopdf, every PHP CLI, nginx, every PHP-FPM pool, Java, New Relic and the cache server with its data files deleted — then restarted Percona through the whole-stack path and never restarted the web tier it had killed. The kernel recorded zero OOM kills during that entire event: it was coping on its own. -
mysql.shonly restarts a database that will not answer. A needless MySQL restart is itself an outage, so the test is a functional one: amysqladmin pingreply of any kind — including an authentication error orToo many connections— proves the server is alive, and a first failed probe is re-tried across a short grace before the server is called down. That grace matters on a host wheremysqld_safesupervises Percona, because it respawns a deadmysqldwithin a second or two, and the socket and PID file are legitimately absent while it does. A busy-but-responsive server is therefore never restarted: high load with a high thread count (_LOAD_THRESHOLD33.0 and_THREAD_THRESHOLD99), and aToo many connectionsflood, both fall through to the real remedies —flush-hostsand the long-query killer — instead. -
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. -
sqlprobe.shnever tunes anything live either. It is the SQL-side sibling: the data collector behind thememorytuneradvisory, which only names_SQL_*_FORCEpin lines — applying them stays the operator's decision. See Percona setup and tuning for the advisory and the measurement stores it keeps fresh. -
batch_guard.shcan only kill what is provably dead — and load level is never consulted, in either direction. It arms on the storm's own two-floor signal (/bgp-start/-specific own-IP 499 volume plus many distinct looping bids), never on load: a pool-sized storm pins one FPM pool and degrades that site while a many-core box's average stays under any workable per-core floor — observed live at 0.69 per core, for over 40 minutes, invisible to the per-core arm gate this design replaced — and in the other direction it never waits for calm either, because its heal is cheap SQL, not a restart. Age is never a criterion: Drupal 7 writes{batch}.timestamponce, at creation, so a long-running live batch and an abandoned one look identical by age, and any age-based cleanup would eventually delete a live one. A bid heals only if it is looping through/bgp-start/right now, is driven by a livebackground_processrow — the very row the module's cron re-launches, which browser-driven and update.php batches never have, so those are structurally out of reach and a forged request wall cannot make a victim site's batches eligible — and its blob fingerprint plus queue count survived at least eight minutes unchanged, with the final DELETE conditional on all three of those same values. It also never gates on the/run/max_load.pidtier markers:second.shsets those even on passes where it declines to pause (the backup/iowait exemption), so a marker gate would disarm the guard for whole backup nights. What it requires instead is service continuity: no candidate confirms unless nginx, every FPM master and mysqld have been up, uninterrupted, since before its first sighting — pidfile birth times and the server's own uptime are the witnesses — because any teardown (the load auto-pause, a watchdog heal, a database restart) freezes every batch blob and would make a live batch read as dead. A teardown that came and went entirely between two passes still invalidates the interval. The affected site's user sees "No active batch." and re-runs their action — acceptable for a loop that is provably dead. The operator dry run is the--detect-onlyflag, which beats any configuration line. -
task_guard.shcan only fail what is provably dead — and it never re-runs anything. The judgement is the process table: an orphaned PROCESSING row is touched only when it is the task's current revision, is past the grace window, and its instance user has no live task runner and no live provision backend. One live process holds the whole instance for that pass — with per-user evidence the reaper never guesses which row a process belongs to, so the failure direction is a delayed heal, never a killed live task. The per-row reset is conditional on the row still being PROCESSING, so the frontend's own dispatcher reaper (on newer hostmaster releases) and the manual queue button always win a race. Rows become failed with a truthful log entry; re-running a crashed task — a migrate or clone in particular — stays the operator's decision. -
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. -
The self-service restart sentinels are gated by plan or by a box-wide marker. A tenant asks for a restart by touching a sentinel in their own control directory —
~/static/control/run-nginx-restart.pidfor the web tier,run-valkey-restart.pid(or the olderrun-redis-restart.pid, either name works) for the cache — and the watchdog acts on it only if the box qualifies. Qualifying is an OR of two tests: a plan grep that findsPOWER,PHANTOM,CLUSTER,ULTRAorMONSTERin any instance's.octopus.cnfunder/root/, or the operator marker —/etc/boa/.allow.nginx.restart.cnffornginx.sh,/etc/boa/.allow.valkey.restart.cnfforvalkey.sh. Neither test is per-account: the plan grep reads every instance's control file, the sentinel scan walks every account'sstatic/controldirectory, and the restart itself cycles the whole service — so one qualifying instance, or one marker, opens the gate box-wide.valkey.shhonours the redis-named marker/etc/boa/.allow.redis.restart.cnfidentically, which is how a box that predates the Valkey rename keeps working; the retiredredis.shknows only that redis-named marker, and tests it twice in the same OR chain — a harmless duplicate arm with no effect either way. All three markers are read from/etc/boaonly, with no/rootfallback; they are operator-created, and nothing in BOA writes or erases them. See Host control files & INI for the one-time/rootto/etc/boarelocation that moved them.
The daily graceful.sh cleanup
graceful.sh is not part of the minute.sh fan-out — cron runs it once a day, at
03:01 — but it is the same auto-healing family, and two operator markers switch parts of
it off, so it belongs here.
A pass stands down entirely while heavy maintenance is paused
(/root/.pause_heavy_tasks_maint.cnf) or while a PHP-idle stack swap holds its quiesce
marker (/run/boa_php_idle_quiesce.pid — honoured only while the owner PID recorded in
it is alive; a dead-owner marker is cleaned up and the pass continues). Proxy nodes are
no longer skipped: since the 2026-08-09 marker narrowing the daily hygiene — including
the rsyslog watchdog the intrusion scanners depend on — runs on a finalized proxy like
any other box. Past them, the whole action body is skipped whenever a BOA install or
upgrade pass is in flight — any of /run/boa_run.pid, /run/boa_wait.pid (the queued
window) or /run/octopus_install_run.pid (the only one the chained install's octopus leg
holds), a running BARRACUDA.sh.txt or OCTOPUS.sh.txt from either the /var/backups or
the /var/opt/boa-dist staging path, a running barracuda or octopus wrapper under
/opt/local/bin or /usr/local/bin, a boa in-* install wrapper from the same two paths,
or a live AegirSetupA/B/C child — or when /root/.skip_cleanup.cnf exists. Those
seven signals are the same _boa_pass_active predicate owl.sh applies, mirrored
deliberately rather than shared. The in-flight test and the marker are one test, so the
marker is simply the operator's way of telling the box to behave, for that night, as
though a BOA run were under way. Unlike the markers converted to
/root/.barracuda.cnf variables in recent releases, this one has no variable form and
deliberately keeps none — the file is the control, because the hosted wipe described
below is a published guarantee that a sticky cnf line would quietly undo.
What a full pass does — and therefore what .skip_cleanup.cnf suppresses in one go:
refresh the Devuan mirror list (ffdevuan), purge the postfix queue
(postsuper -d ALL), restart whichever syslog daemon is installed (rsyslog, sysklogd or
inetutils-syslogd), reset swap and, above 90% RAM usage, drop the disk caches, fetch the
GeoIP city database if it is missing, remove the leftover session files from /opt/tmp,
and run the Speed-Booster purge. A completed pass stamps
/var/log/boa/graceful.done.pid.
Two details in the middle of that list are worth knowing. The swap and cache step runs
only where /dev/disk exists, and the swap reset is skipped outright on
/root/.no.swap.clear.cnf. Where it does run, swapoff -a happens unconditionally but
swapon -a is held back while duplicity is running — so on a box mid-backup the pass
can legitimately leave swap off until the next night.
The Speed-Booster purge has a marker of its own. /root/.giant_traffic.cnf suppresses
that one step and leaves the rest of the pass running. The step lowers the nginx cache
directory depth in /var/aegir/config/server_master/nginx.conf (levels=2:2:2 becomes
levels=2:2, a change it leaves in place), reloads nginx, sweeps everything older than a
day out of /var/lib/nginx/speed/, then reloads nginx a second time — a long walk over a
very large cache plus two reloads, which is precisely what a high-traffic box may want to
skip. It is already gated on no duplicity running and on no /run/speed_cleanup.pid
left by a purge still in progress.
/root/.giant_traffic.cnf has a second, unrelated effect on the watchdog side, and it
shares that one with /root/.high_traffic.cnf: either marker suppresses the
segfault_alert.pl spawn in php.sh, so no PHP-FPM or nginx segfault report is ever
mailed from that box. Both tests are negated — presence is the off switch, absence the
working default. (segfault_alert.pl also exits at once on a box with no /data/u, and
today it only reports; the vhost-quarantine step its own report text describes is
commented out in the script.)
/root/.high_traffic.cnf has no other live effect. The daily cache-server restart, the
locked/fpmcheck skip, buffered nginx logging and the hourly FPM reload that older
changelog entries attach to this name all have no reader left in the tree, so do not
expect them from it.
Hosted boxes overwrite these three markers
BOA's own hosted-fleet normalisation runs inside _update_agents on every five-minute
SKYNET self-update tick (see
The serial & fetch pipeline (SKYNET)),
on boxes whose VM family is HOSTED and that carry the hosting-master Drush alias,
/data/u and /var/xdrago. That block deletes /root/.skip_cleanup.cnf and
/root/.giant_traffic.cnf on every pass, so a touch of either survives only until the
next tick.
/root/.high_traffic.cnf goes the other way. The same block creates it once, when it is
absent, together with /root/.no.swap.clear.cnf, writing an instance count into both
that nothing ever reads back — every test on these files is a presence test, never a
content test. It never re-fires and never removes either file, so on a hosted box
segfault alerting is off permanently and the nightly swap reset never runs. Self-hosted
boxes see none of this.
Cooldowns, double-checks, and load gating
Six 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). The database is the exception to the 30 s figure —_SQL_COOLDOWN_SECSdefaults to 120 s, because a database restart takes far longer to settle than a web-tier one. -
A flap circuit breaker on repeated heals. The cooldown spaces two heals; it cannot see a run of them.
mysql.shtherefore also keeps a restart ledger at/var/log/boa/mysql.restart.ledger— one timestamp per heal, pruned to_SQL_FLAP_WINDOW_SECS(3600 s) and never counted further back than the last boot. Once_SQL_FLAP_MAX(3) heals have run inside that window the circuit opens: the watchdog writes/run/boa_mysql_restart_latched.pid, logsCIRCUIT OPENto/var/log/boa/mysql.incident.log, sends exactly one incident e-mail, and stops restarting the database altogether. A database that has just been restarted three times and is down again is not one a fourth restart will fix, and continuing to try makes the host worse each time.Recovering a latched host is deliberately a human decision: fix the cause, then delete the latch file, and auto-healing resumes immediately with the count back at zero. If nobody does, it re-arms itself anyway after
_SQL_FLAP_LATCH_MINS(60). The file says as much in plain words, so whoever finds it needs no other reference. Note that a server that recovers on its own — amysqld_saferespawn, say — is never counted against the budget, because the watchdog stands down before it acts.The cache server is guarded by the same shape:
valkey.shkeeps its ledger at/var/log/boa/valkey.restart.ledgerand latches/run/boa_valkey_restart_latched.pidonce_VALKEY_FLAP_MAX(3) restarts have run inside_VALKEY_FLAP_WINDOW_SECS(3600 s), pages once, and re-arms after_VALKEY_FLAP_LATCH_MINS(60) — the same operator contract, written into the latch file. Its window is floored generously against the pace heals can actually land at: a pass that has to confirm a wedged server and then walk the whole stop/start sequence overruns the dispatch tick, so consecutive heals quantise to a multiple of it, and a window too narrow to hold the allowed heals would be a breaker that never opens. The machinery is kept inline invalkey.sh, exactly asmysql.shkeeps its own copy and for the same reason: each watchdog is fetched under its own serial, and a box that has taken a new watchdog but not yet a new shared library must not lose its breaker to that skew. (system.sh's memory-pressure path, delivered in lockstep with the library that hosts the shared primitives, consumes those instead — see above.)The batch guard consumes the shared primitives too, but defensively: every call is guarded, so a box holding a new
batch_guard.shagainst an old shared library degrades to cooldown-only pacing — never to a half-armed breaker. Its ledger lives at/var/log/boa/batch_guard.heal.ledger, its latch at/run/boa_batch_guard_latched.pid, on_BATCH_GUARD_FLAP_MAX(3) /_BATCH_GUARD_FLAP_WINDOW_SECS(7200 s) /_BATCH_GUARD_FLAP_LATCH_MINS(60), the window floored against its own five-minute tick. -
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). -
Standing down for a database restart.
move_sql.shstops Nginx and every PHP-FPM pool in order to restart the database, and it does not start them again — these watchdogs are the intended recovery (inside an install or upgrade pass the flow itself brings the tier back withwebserver uponce the hostmaster vhost is ready). Songinx.sh,php.sh,valkey.sh,redis.sh,batch_guard.shandtask_guard.sh(whose probes and heals are all SQL) each stand their whole pass down while a database restart is in flight, which they read from the two markersrunner.shandsystem.shalready honour:/run/mysql_restart_running.pid(written bymove_sql.shand bymycnfup) and/run/boa_mysql_auto_healing.pid(held by the database watchdog for the length of a heal). Without that, each pass saw its service down and started it again mid-teardown, standing the web tier in front of a database that was still coming up: workers pile onto failed connections, and the whole herd then arrives at a cold cache the moment the database returns.Recovery is deferred, not cancelled — the next pass, seconds after the restart finishes, brings the service back. And the markers are honoured only while recent (
_SQL_MUTATION_MAX_MINS, default 15 minutes): a healer killed mid-flight leaves one behind,clear.shonly reaps that one after an hour, and an hour with no web auto-healing would be a worse outcome than the cascade being avoided. Each stood-down pass logs one line to the service's own.incident.log, so the deferral is visible rather than silent. -
Keeping table repair out of the way of database work.
sqlcheck.shwatches the server log for crashed-table markers and hands off tochecksql.sh, which runs a full-servermysqlcheckand then a generated repair script. Both take locks across every database, so both now decline while any of the SQL work markers is present: a backup (boa_sql_backup.pid,boa_sql_cluster_backup.pid), a maintenance operation (boa_sql_maintenance.pid), an auto-heal (boa_mysql_auto_healing.pid) or a restart (mysql_restart_running.pid). A crashed table is not urgent, andsqlcheck.shruns every minute, so the repair simply happens once the window closes.
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 normalisation is uniform —
NO maps to OFF, anything unrecognised 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 |
Send only ALERT-level incidents: in system.sh (its default) and in valkey.sh, where the one ALERT is the circuit breaker latching |
MINI |
The service watchdogs' default. In valkey.sh it now mails genuine cache restarts (and everything CRIT would); on the other service watchdogs it still sends no mail — see note below. Not a distinct value in system.sh (it folds to CRIT) |
ALL |
Sends every incident e-mail, informational notices included |
The important caveat: in most per-service watchdogs (unbound.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). system.sh implements the
OFF / CRIT (= ALERT-only) / ALL ladder. valkey.sh implements the full
four-step ladder: a genuine restart of the cache server mails at the default MINI
and up — a cache restart empties the cache, and while that mail was ALL-only an
operator learnt about a flapping cache server from the load graphs rather than from a
mail — the circuit-breaker page is an ALERT that CRIT still hears, and
informational notices stay ALL-only. (redis.sh, retired from delivery, keeps its
old ALL-only gate on the boxes that still run their local copy.) 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.
One alert per incident, not one per pass
A service that keeps failing used to mail every time a watchdog acted on it: one false
database outage once produced nine near-identical alerts in fourteen minutes, which tells an
operator nothing except that something is wrong somewhere. Each watchdog therefore holds an
alert back when it has already sent one for that class of incident inside a cooldown
(_INCIDENT_EMAIL_COOLDOWN_SECS, default 1800 s), and the alert that does go out says in its
subject how many it stood in for: (+8 more since the last alert).
Nothing is lost by holding one back. The incident is still appended to the service's own
.incident.log either way, and the mail body is the tail of that log, so the next alert
carries the ones that did not go out. A held-back alert also logs its own line, so the
suppression is visible rather than silent.
Classes that are not the same incident keep separate cooldowns, because otherwise a cheap
alert spends the budget an expensive one needs. Each cooldown is a stamp under
/run/incident.<key>.stamp:
| Key | Covers |
|---|---|
<service> |
that watchdog's ordinary incidents (nginx, php, valkey, redis, unbound, java, mysql) |
mysql-circuit |
the database flap circuit breaker opening, so the one guaranteed page is never held behind the heal alerts it exists to explain |
mysql-mydumper |
backup contention, so a busy backup window cannot swallow the page that says the database went down |
valkey-circuit |
the cache flap circuit breaker opening — same contract as mysql-circuit |
valkey-alert |
anything else valkey.sh raises at ALERT level, separated by construction from the routine restart mails on the valkey key |
system-alert |
anything system.sh raises at ALERT level, currently the whole-stack OOM cascade. Derived from the severity the caller already passes, so a future ALERT is separated without anyone having to remember |
system |
system.sh's fifteen routine service notices |
second |
the load controller's pause and terminate pages. A terminate-and-pause pair is one incident, so both share the key and the second mail is held with the first carrying it. This was the one mail-sending script the throttle had not reached, and the one that mails on every pause |
The throttle lives in the shared lock.inc, and every watchdog calls it defensively: a box
still holding an older copy of that library mails exactly as it did before.
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
# did last night's graceful.sh pass complete, and is any of it switched off?
ls -l /var/log/boa/graceful.done.pid 2>/dev/null
ls -l /root/.skip_cleanup.cnf /root/.giant_traffic.cnf /root/.high_traffic.cnf 2>/dev/null
# box-wide self-service restart markers currently in force
ls -l /etc/boa/.allow.nginx.restart.cnf /etc/boa/.allow.valkey.restart.cnf \
/etc/boa/.allow.redis.restart.cnf 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. - Host control files & INI — the full marker catalogue, and
the one-time
/rootto/etc/boarelocation that moved the three service-control markers named above. - Reference appendix — every
_VARoverride named above with its default and source.