feat(ops): recreate VM containers that are persistently unhealthy - #15
Conversation
|
Thanks — the underlying failure mode is real, but I do not want operators installing a host cron job for this. Could we make this a Compose-managed The sidecar should keep a tiny named-volume state record per container for restart policy: last restart, attempt count, and healthy-since. Suggested behaviour: restart after the sustained unhealthy threshold; exponential retry spacing (for example 5m → 15m → 1h); stop after a bounded number of failed recoveries and log loudly; reset attempts only after the VM has stayed healthy for a meaningful period. There are also two correctness issues in the current script:
Please add behavioural coverage for healthy/starting exclusion, sustained unhealthy restart, image/label scoping, cooldown/backoff, bounded retries, and reset after stable health. Happy to review a follow-up. |
010327e to
5ef74cf
Compare
|
Thanks — agreed on the cron, and this branch now delivers exactly the Compose-managed sidecar you described.
Behavioural coverage ( Compose wiring:
|
|
This is solid — the watchdog is well-scoped and safe. It only touches containers that are actually `unhealthy`, running the `dockurr/windows` image, in this Compose project (self-excluded), and only after a sustained `FailingStreak`, with a capped attempt budget and exponential backoff. So healthy VMs — and running backtests on them — are never interrupted, and it can't restart-loop. stdlib-only Docker client over the socket, no shell/eval, minimal surface. I like it. Two things before I merge:
Resolve the conflict and I'll merge. Nice work. |
5ef74cf to
1d586d1
Compare
|
Both done, plus one fix this branch needed that I found afterwards. 1. Rebased on master. The two commits now sit on
Worth noting the 2. PR description rewritten to match what actually ships. You were right that it was describing a different change: the body still had the host-cron The new description leads with the Docker socket mount, since that is the part of this that deserves review attention rather than the recovery logic — it is root-equivalent access to the host daemon, which is why the script stays stdlib-only with no shell, no I left a short note at the bottom recording that the Compose service supersedes the original cron approach and why, so the history explains itself rather than looking like an unexplained pivot. 3. A busy VM is not a dead VM — this branch was treating it as one. Running the watchdog in anger surfaced a real problem with it, so there is a third commit here now.
The second is routine — a compile, a Strategy Tester run, or a backtest saturates the guest CPU and The fix uses
Raising the timeout instead does not work: Docker allows the script 30s total and it probes every port on the VM. I also made the three fixed paths ( Four new tests in One limitation I did not fix, flagging rather than leaving it to be discovered: the healthy path measures 2.9s for 20 ports, but if many ports are simultaneously slow the script can approach Docker's 30s ceiling (20 × 3s worst case) and be killed — which counts as a failure again. Bounding total runtime, probably by probing in parallel, is a separate change and I did not want to fold it into this one. In practice the CI is green (lint + 430 tests) and the PR shows mergeable. |
|
One small correctness fix before merge:
I reproduced this against the exact PR-head script with a fake Docker client returning one Could dry-run evaluate against a copy of the state and skip persistence, then add a test that asserts the state file remains unchanged? Everything else looks good. |
|
Good catch, and thank you for reproducing it against the PR head rather than describing it — that made it unambiguous. Fixed in working = copy.deepcopy(state) if dry_run else state
action, reason = decide(working, status, streak, now)
if not dry_run:
save_state(cid, working)The Three tests, in
The first two fail against the previous script; I checked rather than assumed. Worth stating what the bug actually cost, because it is nastier than a stale counter: the failure only appears after dry-run is turned off. You would run dry to satisfy yourself the thing was safe, enable it, and find the watchdog had already decided to give up on that VM — refusing to act at precisely the moment it was finally allowed to. A supervisor that is silently disarmed by its own rehearsal is worse than one that never ran. Full suite green: 433 passed, lint clean. |
|
Follow-up from the dry-run review. That fix looks correct. I found two remaining issues from a full pass:
The diff is otherwise clean from a supply-chain perspective: no opaque files, dependency or workflow changes, downloaders, shell execution, or dynamic execution primitives. |
|
Both fixed in 1. Configuration is validated at startupEvery setting now goes through a checked parser, and Bad values fall back to the default so the module still imports (the tests load it directly), but Rejected across the numeric settings: empty, non-integer, zero and negative where those make no sense. Two worth calling out:
17 new tests in 2. Sidecar pinned by digestimage: python:3.12-alpine@sha256:d09d15e60962ca365d1cd544a48773bac9d33f2fb1b00f2aa0deec78ade7dc31That is the multi-arch OCI index (16 platforms), not a single-arch manifest, so it still resolves per-platform while being immutable. Pinned in both You are right that a moving tag is not an acceptable trust boundary for a socket-mounting container. Worth noting the same argument applies to Knock-on: PR #10Your finding is a class, not an instance, so I checked the other two open PRs for it.
I clamped there rather than refusing, on purpose. Full suite green: 450 tests here, lint clean. |
|
Thanks for the follow-up fixes. I found one blocking lifecycle conflict and two smaller scope bugs in the current head. The blocker is the recovery operation itself. The watchdog calls Docker restart on the VM alone ( I ran that exact integration regression against this PR head in a disposable Compose project: That directly contradicts the new watchdog documentation and module docstring, which say a plain Docker restart preserves Wickworks attachment. In production, every successful watchdog recovery would therefore leave Wickworks unable to reach the VM. Please change the recovery mechanism to coordinate the VM and its Wickworks sidecar as one lifecycle operation, and add a regression that exercises the watchdog path against the real topology. Two scope issues also need tightening because this service holds the Docker socket:
The configuration validation fixes look good, and the current unit suite and lint are green. The lifecycle issue is still a merge blocker. |
|
One useful correction to my review: this repository already has the proven production recovery path from the merged Wickworks lifecycle fix. Please rebase onto current ./scripts/recreate-vm.sh mt5
# docker compose up -d --force-recreate --no-deps mt5 wickworksThe real lifecycle regression covers this exact operation. The watchdog implementation needs to trigger the equivalent coordinated lifecycle action, not restart the owner alone. |
1d8850f to
21e7619
Compare
|
You're right, and the branch's own reasoning was wrong. It claimed Rebased onto current Using compose from inside a container has three consequences worth flagging,
Recovery also names the compose service now (from Seven new tests cover the recreate path specifically: that it passes the Full suite in the container test image: 458 passed, 2 skipped. The Not verified end to end: I have not run a real recreate against a live VM from |
|
Follow-up to my "not verified end to end" caveat above — I can now narrow it. Deployed this to our own two-VM host and exercised the recreate path from That is the part I most wanted evidence for: sidecar discovery reads the Also confirmed on that deployment:
Still not demonstrated: a real, non-dry-run recreate. Nothing has gone |
21e7619 to
1c1b830
Compare
recreate-vm.sh recreates with `docker compose up -d --force-recreate`, whose implicit stop uses compose's own --timeout -- 10 SECONDS by default -- rather than the service's declared stop_grace_period. A dockurr/windows guest cannot shut down in ten seconds, so compose stops waiting and goes straight to removing a container that is still running: Error response from daemon: cannot remove container "1d5e3c2f...": container is running: stop the container before removing or force remove The script then exits 1 and the VM is left unhealthy with its network_mode sidecar stranded on a dead netns -- the exact outcome recreate-vm.sh exists to prevent. It is timing-dependent, which is why it can look fine for a while. On the deployment where this was found the script recreated two VMs successfully three times inside one hour, then failed on the fourth attempt when the guest took longer than ten seconds to go down. The targets are now stopped explicitly first with a timeout that matches the grace period, and the same value is passed to `up` so its implicit stop cannot fall back to 10s. RECREATE_STOP_TIMEOUT overrides the 120s default; anything calling this script on a timeout of its own should stay above it. The script had no direct test coverage. tests/test_recreate_vm_script.py covers it through --dry-run, so it needs no Docker daemon: stop-before-up ordering, the timeout default and its override, and the sidecar expansion that is the reason the script exists. Four of the seven fail against the current version. Worth noting for #15: that watchdog delegates recovery to this script, so merging it without this fix ships an automated recovery path that hits the failure above. Dockerfile.test copies a named subset of scripts/ and recreate-vm.sh was not in it, so the new tests could not see the script. It is added to that COPY line; nothing else about the image changes.
|
I tested the current head and found that the recovery cap and backoff do not survive the recovery they trigger.
I reproduced this with
Both Please persist watchdog state by stable service identity, for example Compose project plus Separately, |
dockurr/windows keeps its container up while the Windows guest inside may have crashed, so `restart: unless-stopped` never fires and every terminal API in that VM stays dead until a human intervenes. This adds a compose-managed sidecar that watches Docker health and recovers a VM on its own. Recovery is a COORDINATED RECREATE, not a restart ------------------------------------------------- An earlier revision of this branch used `docker restart` through the Docker API, on the reasoning that keeping the owner's container ID keeps a wickworks sidecar's netns attachment intact. That reasoning is wrong, and tests/integration/test_wickworks_lifecycle.py already proves it: Docker tears the netns down on stop and builds a fresh one on start, so restarting the owner alone strands the sidecar exactly as recreating the owner alone does. Only recreating the owner together with its sidecars repairs the binding. So the watchdog shells out to scripts/recreate-vm.sh -- the helper an operator runs by hand, and the one that lifecycle test covers -- rather than reimplementing sidecar discovery. Two recovery paths that could drift apart is precisely what this avoids. Consequences of using compose from inside a container: - The sidecar image now carries the docker CLI, the compose plugin, bash and PyYAML (Dockerfile.watchdog, base still digest-pinned because this container mounts the root-equivalent Docker socket). - Compose resolves this project's relative bind mounts client-side, so the project has to be mounted through at the SAME absolute path the host uses. run.sh exports MT5_PROJECT_DIR; validate_config() reports it at startup when it is missing and the watchdog refuses to act, rather than falling back to a restart that looks like recovery and is not. - COMPOSE_PROJECT_NAME is passed explicitly. Compose otherwise derives the project from the directory name, and a mismatch would not fail -- it would quietly create a second set of containers beside the running ones. - Recovery names the compose SERVICE, taken from the container's com.docker.compose.service label; a container id means nothing to compose. A VM without that label is skipped rather than guessed at. Watchdog behaviour ------------------ - Scoped to this compose project and the dockurr/windows image, so nginx, wickworks, the log rotator and the watchdog itself are never touched. - Acts only after health has stayed unhealthy for a sustained FailingStreak, so a busy VM mid-backtest is never interrupted. - Per-container state on a named volume, exponential backoff between attempts, a bounded attempt budget, and a reset only after sustained health -- so a VM that crashes again immediately is not thrashed. - --dry-run evaluates against a copy of the state, so dry passes cannot consume the real backoff and attempt budget. Full suite passes in the container test image: 458 passed, 2 skipped.
…d; pin pyyaml by hash Recovery is a recreate, which replaces the container - so state keyed by container id was orphaned by the very recovery that wrote it. The next poll saw a fresh id, loaded a fresh record at attempts=0, and the attempt cap and backoff reset themselves on every recovery they were meant to bound: a persistently broken VM was recovered forever, always at 'attempt 1'. State is now keyed by stable compose identity (project + service label), which survives the recreate. The service label is resolved before state is touched; a container without one is skipped up front, since it can neither be recreated nor tracked. Labels are sanitized before becoming a file name. Two regression tests drive the exact replacement-id scenario from review: the attempt cap and the backoff window must both survive the recreate they triggered, with the same service returning under a new container id each pass. Both fail against the previous script. Also from review: Dockerfile.watchdog installed unpinned pyyaml at build time in an image that mounts the root-equivalent Docker socket. The dependency is now pinned by version and hash (requirements-watchdog.txt, pip --require-hashes: musllinux cp312 wheels for x86_64/aarch64 plus the sdist), same trust argument as the digest-pinned base image.
ae4352c to
c29b289
Compare
|
Both fixed in 1. State keyed by stable compose identityYou found the contradiction at the heart of it: recovery is a recreate, which replaces the container — so state keyed by container id was orphaned by the very action that wrote it. The replacement arrived with a fresh id, loaded a fresh record at State now lives at Your scenario is now a test, twice over, in
Both fail against the previous script; I checked rather than assumed. The pre-existing backoff tests keep their single fixed id, which is exactly why they never caught this — these two are the ones that pin the boundary. 2. pyyaml pinned by version and hash
Merge orderFor all four open PRs: #15 → #16 → #18 → #10. This one first — it is compose/ops-only and overlaps the others in nothing but a Also rebased onto current master: #17 landed on Full suite green (41 watchdog tests plus #17's 7 recreate-script tests, 463 total offline), lint clean. |
|
This is still not ready. I found a real non-dry-run recovery blocker in the current head.
It does not receive When I reproduced the watchdog’s effective environment: Then: Setting This also breaks normal later Compose commands. Required fix:
Two remaining correctness issues:
The current unit suite passes because it does not test the real Compose child environment. Its project-name test asserts only Before another PR, please install and use this as an agent skill, then run it against the exact changed scope.
|
…sy tolerance; continuous healthy clock
The container was given WATCHDOG_PROJECT_DIR but never MT5_PROJECT_DIR, while
docker-compose.yml requires ${MT5_PROJECT_DIR:?} on every compose command. So
recreate-vm.sh's `docker compose` failed at interpolation before it could stop
anything, and no real (non-dry-run) recovery could complete. recreate_env()
now builds the helper's environment explicitly - COMPOSE_PROJECT_NAME and
MT5_PROJECT_DIR from the watchdog's own host path - and run.sh persists the
value to .env (first line, single-quoted) so make down/logs and manual compose
keep working after it exits; run.sh also refuses a stale export from another
checkout, which would otherwise be persisted and then acted on. The compose
files pass the variable through as well.
healthcheck.sh: a port that accepts TCP but never answers HTTP is no longer
healthy forever. The busy tolerance is bounded at HEALTHCHECK_SLOW_GRACE
consecutive checks (default 10), after which the port is reported hung and
DOWN; an answer or a refused connection resets it. An unwritable state dir
degrades to the old tolerance and says so in the verdict.
vm-watchdog.py: healthy_since means continuously healthy - starting, a
sub-threshold unhealthy poll, or no healthcheck all restart the reset clock.
Image filter is an exact repository match, and the watchdog resolves its own
full container id at startup and never selects itself.
Tests: the real recreate-vm.sh runs under the watchdog's exact child
environment with the host variable scrubbed (plus a control proving the
pre-fix environment fails at interpolation); run.sh's actual .env block is
executed; a host integration test drives a real recovery through the built
sidecar on a disposable Compose project. The `assert ... or True` no-op and
the COMPOSE_PROJECT_NAME-only assertion are replaced.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…R too, and must not be silent `docker compose down` interpolates the compose file like every other compose command, so without MT5_PROJECT_DIR it failed at the required-variable check - the very finding this PR round fixes, reproduced by its own harness - and check=False hid that, leaving the disposable project (a socket-mounted watchdog included) running after the suite. The teardown now passes the variable the way an operator's shell does and raises if it fails. The in-container reproduction test also echoes the inner exit code, so its failure is provably the compose run inside the sidecar and not the outer exec. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
|
All fixed in 1. The blocker —
|
…exit status
The check's wall clock was the PRODUCT of the probe timeout and the terminal
count. At PROBE_TIMEOUT_SECONDS=3, a 24-terminal VM whose ports are all silent
took 72s against the compose `timeout: 30s`, so Docker killed the check and
recorded "Health check exceeded timeout (30s)" instead of the verdict. A
supervisor cannot tell that from a dead VM, and it happens exactly when every
port is slow - while the VM is still booting its terminals - so a VM that was
merely starting looked identical to one that had crashed.
One background job per port. The bound is now one port's HOST WALK,
PROBE_TIMEOUT_SECONDS x 3 fallback hosts = 9s (measured at 9s), regardless of
terminal count. Not one probe: each job still tries the leased IP then the two
fallbacks, so raising the timeout to 10 would put us back at 30s. Said so in
both the code and the docs.
The verdicts travel in EXIT STATUSES, not in files, and that is the load-
bearing part. The obvious implementation gives each job a verdict file under a
`mktemp -d`; counter-review reproduced what that does on a bad day, with six
ports all answering HTTP 200 and /tmp full:
scratch-file version: DOWN ports: 6600 6601 6602 6603 6604 6605
sequential baseline: ok all ports up: 6600 ... 6605
Ten of those and the watchdog recreates a healthy VM, destroying two dozen
running backtests, because /tmp filled up. `/` on this host is at 91%. The
sequential loop needed no disk and neither does this: 0 up, 1 busy, 2 hung,
3 dead, 4 busy-but-counter-unwritable, read back with `wait` in port order.
Anything else - a job killed by a signal - is unknown and fails closed as down.
It also removes the trap (which used to delete the workdir and then let the
aggregation fabricate a total outage), and the temp dirs SIGKILLed checks leaked.
The slow/hung counters still use the disk, deliberately off the liveness path:
if they cannot be written the bound is off for that check and the verdict says
so, while every port's up/down is still what its probe found. Verified live.
Also fixed here, both found by auditing this change:
- A port configured twice was probed twice. Two jobs on one counter file is a
race; sequentially it was quieter and worse, incrementing that counter twice
per check so the hung bound fired at HALF the configured grace. Each port is
emitted once now.
- The header said the container is alpine/ash. It is dash.
Test defects fixed: `_run_with_slow_curl` omitted HEALTHCHECK_STATE_DIR and so
read and wrote the real /tmp/healthcheck-slow; the dead-port test counted digit
tokens rather than asserting the set. New coverage for per-port verdict routing
under mixed replies, fail-closed on a job killed before it reports, the disk
failure above, and - after live smoke caught a mangled `-w` format that every
existing test sailed past, because the stub curl ignores its arguments - the
real curl argument vector.
518 passed, lint clean, and both live VMs report `ok all ports up`.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
On 2026-09-07 the mt5 container on our farm exited cleanly and
`restart: unless-stopped` brought it back. Its wickworks sidecar spent the next
two days in the namespace that restart destroyed: FailingStreak 13,700, only
`lo` left, every /rates/ta call 502-ing. The VM reported healthy throughout.
Docker resolves `network_mode: service:<vm>` ONCE, at the sidecar's start, into
an immutable NetworkMode=container:<owner-id>, and builds a fresh namespace
every time the owner starts. A restart therefore strands the sidecar while
changing nothing an outside observer can see: the container id is the same, the
owner is healthy. Nothing about the binding looks wrong.
The failing streak is the point. The sidecar's own healthcheck saw the fault
the whole time; there was no supervisor for it. So a second sweep applies the
SAME rule as the VM sweep - Docker health, past the same streak, the same
backoff and attempt cap - to the netns sidecars, and recreates the offender
alone. `recreate-vm.sh <sidecar>` finds no sidecars of a sidecar, so that is
exactly one container: the documented repair, what an operator does by hand,
and it leaves the VM and its terminals alone.
It acts only while the owner is a running, HEALTHY VM of this project:
- unhealthy owner -> the VM sweep's job; it recreates owner and sidecars
together, which is the only thing that repairs the
binding, and acting here would race it.
- owner not running -> left alone, and this one is load-bearing.
`/containers/json` lists running containers only, so a
STOPPED owner is indistinguishable from a destroyed one.
Recreating a sidecar under a stopped owner cannot work -
the helper stops it first, then `up --no-deps` has no
namespace to join - so it would end up stopped, invisible
to both sweeps and never retried. `docker compose stop
mt5` for maintenance must not cost you the sidecar.
An earlier draft also recreated a sidecar whose owner id was simply absent from
the running set, on the theory that a dead binding is structural evidence
needing no streak. Counter-review killed it twice over: that is the stopped-VM
hazard above, and the incident it was justified by is not caught by it at all,
because a restart keeps the container id. Health-gating throughout is both
safer and the honest description of what recovers the fault. What is left is
that a netns sidecar needs a healthcheck able to SEE the orphaning -
scripts/wickworks-healthcheck.py is the worked example - and the docs now say
so plainly instead of implying this covers a sidecar that cannot.
Owner references are matched by full id, by a 12-character-or-longer prefix and
by name, because `container:<name>` is legal to write by hand and normalised
nowhere; failing to resolve one means leave alone, never act.
WATCHDOG_WATCH_SIDECARS=0 restores the VM-only scope.
The integration suite gains the whole fault on a disposable Compose project
with nothing faked - built sidecar image, real vm-watchdog.py, real
recreate-vm.sh, real compose, real daemon. It restarts the owner, asserts the
fault first (id unchanged, VM healthy, sidecar without eth0), then asserts the
recovery, including that the VM container id did not change. A second test
holds the owner stopped and asserts the sidecar is left running. 6 passed,
twice, no leaks.
548 passed, lint clean.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
Consolidated: I had seven PRs open against you, which is too many, and three of them were the same subject. #20 and #21 are folded into this one and closed. Both only touched Two commits on top of what you last saw (
I ran the process you asked for against each of them separately, before folding. It was worth it — it found two things in my own work that would each have caused the outage they were meant to prevent, and I would rather you hear them from me than find them:
Live smoke caught a third one that no amount of unit testing would have: a mangled
|
|
Thanks for taking the review seriously and folding the related recovery work into one tested path. Merged. |
dockurr/windowskeeps the container "up" while the Windows guest may have crashed internally — an unexpected shutdown (Event 6008), a wedged terminal, an OOM.restart: unless-stoppednever fires, because from Docker's point of view nothing died, so every terminal API in that VM stays dead until a human notices.This adds a Compose-managed
vm-watchdogsidecar that recovers those VMs automatically.What it is
A small service, built from
Dockerfile.watchdogon a digest-pinnedpython:3.12-alpine, that polls the Docker API over a mounted/var/run/docker.sockand — once a VM's health has stayedunhealthyfor a sustained streak — recovers it by running the repo's ownscripts/recreate-vm.sh, which recreates the VM together with everynetwork_mode: service:<vm>sidecar.The footprint worth reviewing is the Docker socket mount — root-equivalent access to the host daemon. So the script is deliberately small and boring: stdlib-only, no third-party Docker client, no
eval, no user-supplied string ever reaches a command. Its Docker client is read-only (list + inspect). The only mutating action it takes is to executerecreate-vm.sh <service>, the same helper an operator runs by hand.Why a recreate, not a restart
A sidecar sharing the VM's netns resolves that binding once, at its own start, into an immutable
NetworkMode=container:<owner-id>. Restarting the owner keeps its id but Docker tears the netns down on stop and builds a fresh one on start, so the sidecar is left holding a dead namespace —tests/integration/test_wickworks_lifecycle.pyproves that for both "recreate owner alone" and "restart owner alone". Only recreating owner and sidecars together repairs it, which is whatrecreate-vm.shdoes (with an explicitstop -t <grace>first, from #17). An earlier revision of this PR useddocker restartand claimed it was safe; it was not.Because the helper runs
docker composefrom inside the sidecar, the project is mounted at the same absolute path the host uses (compose resolves relative bind mounts client-side), and the watchdog hands the helperCOMPOSE_PROJECT_NAMEandMT5_PROJECT_DIRexplicitly (recreate_env()).run.shexportsMT5_PROJECT_DIRand persists it to.env, so every later compose command works too.What it will touch
A VM is recovered only when all of these hold:
unhealthy(neverhealthy, neverstarting)FailingStreak≥WATCHDOG_MIN_FAILING_STREAK(default 10, ≈5 min at the 30s interval)WATCHDOG_IMAGE_FILTER(defaultdockurr/windows, any tag or digest — not a prefix match)So nginx, wickworks, the log rotator and the watchdog are structurally out of scope, and a healthy VM is never interrupted.
Recovery is rate-limited by exponential backoff (5m → 15m → 1h) and capped at
WATCHDOG_MAX_ATTEMPTS(default 3), after which it logs loudly and leaves the VM alone. State is keyed by compose project + service (not container id — the recreate replaces the container) on thevm-watchdog-statevolume. The budget resets only afterWATCHDOG_RESET_SECONDSof continuous health; anystartingorunhealthyobservation restarts that clock.WATCHDOG_DRY_RUNevaluates against a copy of the state and persists nothing. Configuration is validated at startup and the daemon refuses to run (exit 2) on any invalid value, listing all of them.Healthcheck: the probe budget
The check's wall clock was the product of the probe timeout and the terminal
count. At
PROBE_TIMEOUT_SECONDS=3, a 24-terminal VM whose ports are all silenttook 72s against the compose
timeout: 30s, so Docker killed the check andrecorded
Health check exceeded timeout (30s)instead of the verdict. Asupervisor cannot tell that from a dead VM, and it happens exactly while a VM is
booting, so a VM that was merely starting looked identical to one that crashed.
The probes now run concurrently, one background job per port. The bound is
one port's host walk,
PROBE_TIMEOUT_SECONDS × 3fallback hosts = 9s (measuredat 9s), independent of terminal count. Not one probe: raising the timeout to 10
would put it back at 30s, and both the code and the docs say so.
The verdicts travel in exit statuses, not in files. The obvious
implementation gives each job a verdict file under a
mktemp -d. It is fineuntil the disk fills: the write fails, the parent finds no verdict, and its
fail-closed rule reports every terminal on the VM as down. Ten of those and
this watchdog recreates a healthy VM, destroying two dozen running backtests,
because
/tmpfilled up. Reproduced with six ports all answering HTTP 200. Thesequential loop needed no disk and neither does this:
0up,1busy,2hung,
3dead,4busy-but-counter-unwritable, read back withwaitin portorder; anything else is unknown and fails closed as down. That also removes the
signal trap, which used to delete the scratch and then let the aggregation
fabricate a total outage, and the temp dirs SIGKILLed checks leaked.
A port configured twice is probed once. Two terminals on one port is a
misconfiguration, and acting on it twice put two jobs on one counter file and,
sequentially, fired the hung bound at half the configured grace.
Healthcheck: busy is not dead, and hung is not busy
healthcheck.shdistinguishes three states per port: an HTTP answer (up), a refused connection (down), and a completed TCP handshake with no HTTP inside the window (a saturated guest — a compile, a Strategy Tester run). The third is tolerated as busy so a slow batch is never turned into an outage — but only forHEALTHCHECK_SLOW_GRACEconsecutive checks (default 10). A port still silent after that is reported hung and DOWN, so a wedged API that accepts TCP and never serves is recovered in roughly ten minutes instead of never.The sidecars nothing was watching
On 2026-09-07 the
mt5container on our farm exited cleanly andrestart: unless-stoppedbrought it back. Itswickworkssidecar spent thenext two days in the namespace that restart destroyed:
FailingStreak13,700, only
loleft, every/rates/tacall 502-ing. The VM reportedhealthythroughout.network_mode: service:<vm>is resolved once, at the sidecar's own start,and Docker builds a fresh namespace every time the owner starts. A restart
therefore strands the sidecar while changing nothing an outside observer can
see: same container id, healthy owner. #13 documented that lifecycle; nothing
supervised it. The sidecar's own healthcheck saw the fault the whole time.
So a second sweep applies the same rule as the VM sweep — Docker health,
past the same streak, the same backoff and attempt cap — to the netns sidecars,
and recreates the offender alone (
recreate-vm.sh <sidecar>finds nosidecars of a sidecar, so that is exactly one container). It acts only while the
owner is a running, healthy VM of this project:
The last row is load-bearing rather than a default.
/containers/jsonlistsrunning containers only, so a stopped owner is indistinguishable from a
destroyed one, and recreating a sidecar under one cannot work — the helper stops
it first, then
up --no-depshas no namespace to join — so it would end upstopped, invisible to both sweeps, and never retried. A
docker compose stop mt5for maintenance must not cost you the sidecar.An earlier version of this also recreated a sidecar whose owner id was absent
from the running set, on the theory that a dead binding needs no streak.
Counter-review killed it twice: that is the stopped-VM hazard above, and the
incident it was justified by is not caught by it, because a restart keeps the
container id (verified against the daemon). Health-gating throughout is both
safer and the honest description of what recovers this. The real consequence is a
requirement the docs now state plainly: a netns sidecar needs a healthcheck
able to see the orphaning.
scripts/wickworks-healthcheck.pyis the workedexample.
WATCHDOG_WATCH_SIDECARS=0restores the VM-only scope.Files
scripts/vm-watchdog.py— the sidecar (stdlib Docker client, policy,recreate_env, config validation)Dockerfile.watchdog,requirements-watchdog.txt— digest-pinned base; docker CLI + compose plugin; PyYAML pinned by version and hash (--require-hashes)docker-compose.yml.j2/.example— the service, its state volume, the project mountrun.sh— exportsMT5_PROJECT_DIRand writes it to.envscripts/healthcheck.sh— concurrent probes reporting through exit status, busy/hung distinction, per-port dedupe, overridable paths for teststests/test_vm_watchdog.py— policy, scoping, backoff/cap/reset, dry-run, config validation, state identity across recreate, and the realrecreate-vm.shrun under the watchdog's exact child environment (with the host variable scrubbed, plus a control proving the pre-fix environment fails at interpolation)tests/test_healthcheck_behavior.py,tests/test_run_env_persistence.py— the verdict logic and the.envblock, run for real against stubstests/integration/test_vm_watchdog_lifecycle.py— a disposable Compose project: the built sidecar performs a real, non-dry-run recovery through realdocker compose; the sidecar rejoins the recreated VM. Plus the 2026-09-07 fault reproduced as a restart of the owner and then recovered, and a test that holds the owner stopped and asserts the sidecar is left runningdocs/operations.md— "Auto-recovery", "Busy is not dead", "The probe budget" and "The stranded sidecar" sectionsNotes
MT5AutoRebootscheduled task, which reboots on a timer and can interrupt long backtests; operators who disable it still get crash recovery.dockurr/windows, which this repo runs by tag — out of scope here.