feat: supervise the daemon, and restart one that has lost every peer
Build docker image and push to registry.bitdeals.org / main-build-job (push) Successful in 7m12s

A PyBitmessage daemon that has dropped to zero network connections does not find
its way back. It can sit there for days — testnet1 did, and every BitDeals deal
completed on that stand since 2026-08-30 left its escrow unspent, because the
guarantor never received a CH3 the node could no longer publish. A daemon that
has just started, by contrast, dials hard and reconnects within seconds. The
cure was already known; what was missing was anything to notice and apply it.

Docker will not: it reacts to a process exiting, never to a healthcheck, and
health-driven restarts exist only under Swarm. Doing it from outside means
handing a container the docker socket, which is root on the host and a poor
trade for a relay with a published port. So the container supervises itself.

`run.sh` no longer ends at `exec gosu bitmessage pybitmessage -d`. That made the
daemon PID 1, and it is a bad PID 1: daemonize() double-forks and parks the
grandfather in `while True: time.sleep(1)`, the final child SIGTERMs it to say
"ready", and PID 1 drops that signal for want of a handler. The grandfather slept
for ever; `docker stop` therefore reached the real daemon only as the SIGKILL ten
seconds later, cutting a startup VACUUM in half — the way a node gets trapped
retrying one it can never finish; and a daemon that died on its own left the
container Up around a corpse, because what PID 1 was doing had nothing to do with
whether the daemon lived.

Away from PID 1 that grandfather does die on the ready signal — measured here:
the start call returns at once with status 143 and leaves exactly one
pybitmessage process behind. That makes starting the daemon an ordinary blocking
call, and the supervisor ordinary shell: a trap that stops the daemon through its
own API, a restart when it is gone, and the peer rule.

What the supervisor does not do is act on a daemon whose API is not answering at
all. That is the trapped-VACUUM node; a restart does not cure it and cuts the
next VACUUM short as well. watchdog.py reports that case as its own exit code so
the loop can leave it to a person. The healthcheck is untouched: it reports, and
does not act.

Four settings, on by default: BITMESSAGE_WATCHDOG, and _PERIOD, _AFTER,
_COOLDOWN. All validated at start, where a typo is visible, rather than hours
later as a supervisor that spins or one that never acts.

Callers must raise the stop grace period — 90s in compose, or --stop-timeout 90.
A clean shutdown took 17.5 s on a small database and grows with it, so under
Docker's default ten the daemon is killed mid-write anyway and the supervisor
buys nothing. An image cannot set this for itself.
This commit is contained in:
2026-09-04 09:53:07 +00:00
parent 58e3f22982
commit 88b5b896e1
5 changed files with 309 additions and 1 deletions
+29
View File
@@ -84,6 +84,10 @@ docker run -d \
|-e BITMESSAGE_TRUSTED_PEER|`host:port` единственного пира, к которому узел подключается наружу; больше ни к кому. По умолчанию: пусто — узел выбирает пиров сам. См. «Замечания»|
|-e BITMESSAGE_SEND_OUTGOING|Подключается ли узел наружу вообще: `True` или `False`. По умолчанию: `True`. При `False` получается узел, который только принимает входящие, — центр приватного контура|
|-e BITMESSAGE_KNOWN_NODES|Список `host:port` через запятую; записывается в `knownnodes.dat` при каждом старте вместо того, что там было. По умолчанию: пусто — файл не трогается. Заодно выключает бутстрап по DNS, см. «Замечания»|
|-e BITMESSAGE_WATCHDOG|Перезапускать ли демона, потерявшего всех пиров: `True` или `False`. По умолчанию: `True`. См. «Замечания»|
|-e BITMESSAGE_WATCHDOG_PERIOD|Секунд между проверками числа пиров. По умолчанию: `60`|
|-e BITMESSAGE_WATCHDOG_AFTER|Сколько проверок подряд без пиров нужно, чтобы вмешаться. По умолчанию: `5` — то есть пять минут при периоде по умолчанию|
|-e BITMESSAGE_WATCHDOG_COOLDOWN|Минимальный промежуток между двумя перезапусками, в секундах. По умолчанию: `900`. `0` снимает ограничение|
# Замечания
@@ -96,6 +100,31 @@ docker run -d \
- Контейнер становится здоровым, когда у демона появилось сетевое соединение,
— на новом узле это несколько минут. Начальный период ожидания заодно
покрывает стартовый `VACUUM` файла `messages.dat`.
- **Демона без пиров перезапускаем, молчащего — нет.** Демон, потерявший всех
пиров, сам обратно дорогу не находит: он может простоять на нуле соединений
сколько угодно, — а только что запущенный ищет пиров напористо и находит.
Поэтому контейнер присматривает за собственным демоном: раз в
`BITMESSAGE_WATCHDOG_PERIOD` спрашивает у API число соединений и после
`BITMESSAGE_WATCHDOG_AFTER` нулей подряд останавливает демона через его же API
и поднимает заново. *Контейнер* при этом не пересоздаётся и никто снаружи не
участвует, так что соседний узел сохраняет адреса и видит лишь моргнувший API.
Демона, у которого API не отвечает вовсе, сторож не трогает намеренно. Это
узел, застрявший на стартовом `VACUUM`, которого он не может закончить;
перезапуск его не лечит, а лишь обрывает следующий `VACUUM` на середине.
Проверка здоровья показывает `unhealthy` в обоих случаях, но вмешиваться стоит
только в первый, а во втором нужен человек.
**Задайте контейнеру `stop_grace_period: 90s`** (compose) или
`--stop-timeout 90` (`docker run`). Корректное закрытие `messages.dat` не
укладывается в десять секунд, отпущенные Docker по умолчанию, — замерено около
тринадцати на небольшой базе, и растёт вместе с файлом, — так что без этого
демона убьёт SIGKILL на середине записи, ровно то, ради предотвращения чего
супервизор и заведён. Сам образ это задать не может, только вызывающая сторона.
`BITMESSAGE_WATCHDOG=False` выключает правило о пирах. Супервизор остаётся в
любом случае: он же корректно останавливает демона по `docker stop` и
поднимает того, кто умер сам, вместо контейнера, стоящего вокруг трупа.
- **P2P-порт публикуется только как `8444:8444`.** Пирам демон сообщает порт из
собственной конфигурации (`port` в `keys.dat`), а не тот, в который вы его
отобразили, поэтому `8555:8444` объявляет сети порт, где никого нет. Для