Skip to content

Commit 1d586d1

Browse files
committed
feat(ops): compose-managed vm-watchdog sidecar, replacing the host cron watchdog
psyb0t's review: the crash-recovery failure mode is real, but a host cron job is the wrong vehicle — it needs a machine-specific checkout path and cron setup, and the fixed cooldown cannot prevent a restart loop. This replaces scripts/watchdog.sh with a Compose-managed vm-watchdog sidecar that ships in docker compose up -d. - scripts/vm-watchdog.py: stdlib-only loop that polls Docker health through the mounted unix socket. Scoped to this compose project (self-discovered from its own labels) plus the dockurr/windows image, so it only ever restarts VM containers. Uses Docker's State.Health.FailingStreak as the source of truth, keeps a per-container state record on a named volume (last restart, attempts, healthy-since), enforces exponential backoff (5m -> 15m -> 1h), stops after WATCHDOG_MAX_ATTEMPTS failed recoveries with a loud log, and resets the attempt budget only after the VM has stayed healthy for a meaningful period. Fixes the two correctness issues in the old script by construction: the loop never exits with a "recovered" error, and backoff is exponential, not a fixed cooldown. Uses docker restart (not recreate), preserving the owner container ID and therefore wickworks sidecar netns attachment. - docker-compose.yml.j2/.example: vm-watchdog service (socket + script mounts, named vm-watchdog-state volume), like the log-rotator, one per project. - tests/test_vm_watchdog.py: behavioural coverage for healthy/starting exclusion, sustained-unhealthy restart, image/label scoping, exponential backoff, bounded retries, reset after stable health, dry-run, and project self-discovery — against a fake Docker transport, so it runs offline. - docs/operations.md: Auto-recovery section rewritten for the sidecar; cron instructions removed. - Dockerfile.test: COPY the new script so the offline suite exercises it.
1 parent c15546e commit 1d586d1

7 files changed

Lines changed: 681 additions & 179 deletions

File tree

Dockerfile.test

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ COPY tests ./tests
2525
# docker-compose.yml.j2 is here because the compose-generation test renders the
2626
# REAL template — a stub would assert nothing about what actually ships.
2727
COPY requirements-api.txt requirements-mcpunifier.txt docker-compose.yml.example docker-compose.yml.j2 run.sh ./
28-
COPY scripts/config_helper.py scripts/start.bat scripts/check_health.py scripts/healthcheck.sh scripts/verify_binaries.py scripts/wickworks-healthcheck.py ./scripts/
28+
COPY scripts/config_helper.py scripts/start.bat scripts/check_health.py scripts/healthcheck.sh scripts/verify_binaries.py scripts/wickworks-healthcheck.py scripts/vm-watchdog.py ./scripts/
2929
COPY assets/binaries.lock.json ./assets/
3030

3131
ENV PYTHONPATH=/app

docker-compose.yml.example

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -91,6 +91,30 @@ services:
9191
- ./scripts/rotate-logs.sh:/rotate.sh:ro
9292
command: ["sh", "/rotate.sh"]
9393

94+
# VM crash watchdog. dockurr/windows keeps the container up while the
95+
# Windows guest may have crashed internally, so restart: unless-stopped
96+
# never fires and every terminal API in that VM stays dead. This sidecar
97+
# polls Docker health through the socket and restarts a VM only after its
98+
# health has stayed unhealthy for a sustained FailingStreak, with
99+
# exponential backoff and bounded retries; state lives on a named volume.
100+
# Runs inside the compose project (docker compose up -d), no host cron.
101+
# See scripts/vm-watchdog.py.
102+
vm-watchdog:
103+
image: python:3.12-alpine
104+
restart: unless-stopped
105+
command: ["python", "-u", "/vm-watchdog.py"]
106+
volumes:
107+
- /var/run/docker.sock:/var/run/docker.sock
108+
- ./scripts/vm-watchdog.py:/vm-watchdog.py:ro
109+
- vm-watchdog-state:/state
110+
environment:
111+
WATCHDOG_STATE_DIR: /state
112+
logging:
113+
driver: json-file
114+
options:
115+
max-size: "10m"
116+
max-file: "3"
117+
94118
# nginx is the single entry point for all terminal APIs. Routes
95119
# /<broker>/<account>/... to mt5:<terminal_port> (per-terminal Python
96120
# API process inside the Windows VM, reachable via mt5 container's
@@ -198,3 +222,8 @@ services:
198222
# - NET_RAW
199223
# depends_on:
200224
# - nginx
225+
226+
volumes:
227+
# Persistent per-container watchdog state (last restart, attempts,
228+
# healthy-since) so backoff survives the watchdog's own restarts.
229+
vm-watchdog-state:

docker-compose.yml.j2

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -120,6 +120,30 @@ services:
120120
- ./scripts/rotate-logs.sh:/rotate.sh:ro
121121
command: ["sh", "/rotate.sh"]
122122

123+
# VM crash watchdog. dockurr/windows keeps the container up while the
124+
# Windows guest may have crashed internally, so restart: unless-stopped
125+
# never fires and every terminal API in that VM stays dead. This sidecar
126+
# polls Docker health through the socket and restarts a VM only after its
127+
# health has stayed unhealthy for a sustained FailingStreak, with
128+
# exponential backoff and bounded retries; state lives on a named volume.
129+
# Runs inside the compose project (docker compose up -d), no host cron.
130+
# See scripts/vm-watchdog.py.
131+
vm-watchdog:
132+
image: python:3.12-alpine
133+
restart: unless-stopped
134+
command: ["python", "-u", "/vm-watchdog.py"]
135+
volumes:
136+
- /var/run/docker.sock:/var/run/docker.sock
137+
- ./scripts/vm-watchdog.py:/vm-watchdog.py:ro
138+
- vm-watchdog-state:/state
139+
environment:
140+
WATCHDOG_STATE_DIR: /state
141+
logging:
142+
driver: json-file
143+
options:
144+
max-size: "10m"
145+
max-file: "3"
146+
123147
{% if enable_mcpunifier|default(true) %}
124148
# Unified MCP endpoint. One MCP session that reaches every terminal, with
125149
# broker/account as tool parameters, instead of one endpoint per terminal.
@@ -229,3 +253,8 @@ services:
229253
# - NET_RAW
230254
# depends_on:
231255
# - nginx
256+
257+
volumes:
258+
# Persistent per-container watchdog state (last restart, attempts,
259+
# healthy-since) so backoff survives the watchdog's own restarts.
260+
vm-watchdog-state:

docs/operations.md

Lines changed: 29 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -262,35 +262,45 @@ OOM — leaves the **container** up while the **API** is dead, so
262262

263263
### VM crash watchdog
264264

265-
`scripts/watchdog.sh` is the host-side recovery for exactly that case. Run it
266-
from cron or systemd every few minutes; it is idempotent and safe to overlap:
267-
268-
```bash
269-
# every 5 minutes
270-
*/5 * * * * /home/monster/apps/mt-backtest-manager/docker/mt5-httpapi/scripts/watchdog.sh >> /var/log/mt5-httpapi-watchdog.log 2>&1
271-
```
265+
`vm-watchdog` is a Compose-managed sidecar for exactly that case. It is part of
266+
the project (`docker compose up -d` brings it up with everything else) — no
267+
host cron, no systemd unit, no machine-specific checkout path. It polls Docker
268+
health through the mounted unix socket and uses Docker's own
269+
`State.Health.FailingStreak` as the source of truth.
272270

273271
Behavior:
274272

275-
- Discovers VM containers by compose labels
276-
(`com.docker.compose.project=mt5-httpapi` + `com.docker.compose.service`) and
277-
verifies the image (`dockurr/windows`), so it only ever restarts the VM
278-
containers — never nginx, wickworks, the log rotator, or other sidecars.
273+
- Scopes itself to this Compose project (`com.docker.compose.project`,
274+
discovered from its own container labels) and to the `dockurr/windows` VM
275+
image, so it only ever restarts the VM containers — never nginx, wickworks,
276+
the log rotator, or other sidecars.
279277
- Restarts a container **only** after its Docker health has stayed `unhealthy`
280278
for `WATCHDOG_MIN_FAILING_STREAK` consecutive healthcheck failures (default
281279
`10`, i.e. ~5 minutes at the default 30s interval). A container that is
282280
healthy or still starting is never touched, so running backtests on a working
283281
VM are never interrupted — the healthcheck stays green the whole time a
284282
terminal is serving.
285-
- Refuses to restart a container that was (re)started less than
286-
`WATCHDOG_RESTART_COOLDOWN_SECONDS` ago (default `300`), so a VM that crashes
287-
again immediately after recovery is not restarted into a loop.
283+
- Keeps a tiny state record per container on a named volume
284+
(`/state/<container-id>.json`): last restart, attempt count, and when the VM
285+
was last observed healthy.
286+
- Enforces exponential backoff between recovery attempts
287+
(`WATCHDOG_BACKOFF_ATTEMPTS`, default `300,900,3600` — 5m → 15m → 1h), so a
288+
VM that crashes again immediately after recovery is not restarted into a
289+
loop.
290+
- Stops after `WATCHDOG_MAX_ATTEMPTS` consecutive failed recoveries (default
291+
`3`) and logs loudly, instead of threshing forever.
292+
- Resets the attempt budget only after the VM has stayed healthy for
293+
`WATCHDOG_RESET_SECONDS` (default `1800`), so a VM that recovered then crashed
294+
later gets a fresh budget.
288295
- `WATCHDOG_DRY_RUN=1` (or `--dry-run`) prints what it would do without
289-
touching any container — useful to sanity-check a cron line before enabling it.
290-
291-
Environment overrides: `WATCHDOG_COMPOSE_PROJECT`, `WATCHDOG_IMAGE_FILTER`,
292-
`WATCHDOG_MIN_FAILING_STREAK`, `WATCHDOG_RESTART_COOLDOWN_SECONDS`,
293-
`WATCHDOG_DRY_RUN`.
296+
touching any container.
297+
298+
Environment overrides: `WATCHDOG_INTERVAL_SECONDS`, `WATCHDOG_MIN_FAILING_STREAK`,
299+
`WATCHDOG_IMAGE_FILTER`, `WATCHDOG_BACKOFF_ATTEMPTS`, `WATCHDOG_MAX_ATTEMPTS`,
300+
`WATCHDOG_RESET_SECONDS`, `WATCHDOG_COMPOSE_PROJECT`, `WATCHDOG_STATE_DIR`,
301+
`WATCHDOG_DOCKER_SOCKET`, `WATCHDOG_DRY_RUN`. It uses `docker restart` (not
302+
recreate), so the VM's container ID — and therefore a wickworks sidecar's netns
303+
attachment — is preserved.
294304

295305
This complements the in-VM `MT5AutoReboot` scheduled task, which reboots on a
296306
fixed timer and can interrupt long-running backtests; operators who disable that

0 commit comments

Comments
 (0)