Skip to content

Усилить устойчивость переноса мессенджеров на удалённый VPS #66

Description

@boffart

Устойчивость переноса мессенджеров на удалённый VPS

Назначение документа

Документ фиксирует инцидент 10 июля 2026 года с удалённым запуском WhatsApp и Telegram в ModuleCTIClient, выполненное оперативное восстановление и проект полного усиления механизма remote offload.

Цель доработки: после любого одиночного сбоя система автоматически сходится либо к полностью локальному, либо к полностью удалённому размещению каждого канала. Один messenger area UUID никогда не должен одновременно работать на MikoPBX и VPS.

Архитектурный контекст

В штатной схеме:

  • локальный monitord управляет локальными демонами и SSH-туннелем;
  • WorkerRemoteTunnel выполняет провижининг VPS, запускает удалённый monitord и продвигает state machine миграции;
  • локальный {spool}/custom_config.json хранит фактическое размещение area (local или remote) и изменяется Go-кодом monitord;
  • {spool}/remote_state.json хранит durable cursor PHP-оркестратора;
  • удалённый monitord запускает chatsd, tgd и maxd на VPS;
  • reverse-forward 127.0.0.1:4222 и 127.0.0.1:8222 предоставляют удалённым демонам локальные NATS и license API;
  • local-forward на 127.0.0.1:8226 предоставляет локальному monitord удалённый manager API 127.0.0.1:8225.

Наблюдаемые симптомы

Пользователь включил remote_telegram, но Telegram продолжал отображаться и работать локально. Web-интерфейс показывал длительную миграцию и блокировал удалённые настройки. Затем появилась ошибка SSH-туннеля:

forwards: remote-listen 127.0.0.1:4222: ssh: tcpip-forward request denied by peer

Архив логов показал дополнительные симптомы:

remote provision: ssh failed (uname): rc=255
remote provision: scp push failed: chatsd
ssh_tunnel: local-forward dial 127.0.0.1:8225 failed:
ssh: rejected: connect failed (Connection refused)

Ошибки SSH и провижининга повторялись длительное время. В отдельные периоды локальные и удалённые экземпляры tgd одновременно обслуживали одинаковые UUID.

Подтверждённое состояние до восстановления

Telegram

Локальный custom_config.json содержал 10 актуальных Telegram-area и указывал для них location: local (у одной legacy-записи поле отсутствовало, что также нормализуется в local). Локально работали 10 процессов tgd.

remote_state.json содержал:

{
  "tg": {
    "migrating": false,
    "resuming": false,
    "parked": true,
    "fail_count": 0,
    "last_error": "migration failed (VPS unreachable), still on local"
  }
}

Из-за parked: true reconcileMigrations() намеренно пропускал Telegram до ручного повторного переключения опции.

На VPS в удалённом custom_config.json оставались шесть Telegram-area. Их реальные UUID удалены из публичного документа; ниже приведены обезличенные обозначения:

telegram-area-01
telegram-area-02
telegram-area-03
telegram-area-04
telegram-area-05
telegram-area-06

Это были orphan-записи незавершённой миграции. Локальный rollback вернул сервис в local, но не смог очистить удалённую сторону, поскольку VPS в тот момент был недоступен.

WhatsApp и SSH-туннель

Во время повторного запуска миграции WhatsApp состояние застыло в:

{
  "chats": {
    "migrating": true,
    "resuming": true,
    "fail_count": 5,
    "last_error": ""
  }
}

На MikoPBX одновременно работали два идентичных процесса:

16620 .../ModuleCTIClient/bin/monitord -c /etc/custom_modules/ModuleCTIClient/monitord.json
16622 .../ModuleCTIClient/bin/monitord -c /etc/custom_modules/ModuleCTIClient/monitord.json

Оба процесса имели PPID=1 и одинаковое время старта. Оба пытались владеть одним SSH-туннелем и одинаковыми reverse-forward. Один SSH-сеанс занимал remote listen 127.0.0.1:4222, второй получал tcpip-forward request denied by peer.

Это подтверждённая непосредственная причина ошибки туннеля и зависшей стадии resuming.

Первопричина инцидента

Инцидент возник из сочетания четырёх дефектов защиты:

  1. monitord не обеспечивал строгое singleton-владение процессом и SSH-туннелем. Два экземпляра смогли одновременно стартовать.
  2. Ошибка reverse-forward не классифицировалась как локальный конфликт владельцев и не приводила к автоматическому устранению дубля.
  3. Rollback миграции при недоступном VPS не сохранял обязательство очистить удалённые daemon entries после восстановления связи.
  4. Удалённый custom_config.json мог продолжать самостоятельно запускать orphan-процессы, хотя локальный authoritative state уже считал сервис локальным.

Продолжительная недоступность SSH и частичный провижининг (scp push failed) были триггерами, но не должны были приводить к split-brain при наличии полных защитных механизмов.

Совпадение SHA-256 локального и удалённого бинарников monitord исключило несовместимость версий как причину данного инцидента.

Выполненное оперативное восстановление

Восстановление выполнялось через промежуточный Mac и SSH-доступ к MikoPBX и VPS.

  1. На VPS проверено отсутствие работающих monitord и tgd перед очисткой.

  2. Создана резервная копия удалённого runtime state:

    {remote_bin_dir}/spool/custom_config.json.backup.{timestamp}
    
  3. Удалённый custom_config.json, содержавший только шесть orphan-записей Telegram, очищен до валидного состояния:

    {
      "custom_daemons": []
    }
  4. На MikoPBX остановлены два конфликтующих экземпляра monitord.

  5. Запущен ровно один локальный monitord.

  6. Проверен статус туннеля:

    {
      "configured": true,
      "connected": true,
      "state": "connected",
      "last_error": "",
      "attempts": 1
    }
  7. State machine автоматически завершила WhatsApp и Telegram миграции.

  8. После восстановления локально не осталось chatsd и tgd; на VPS работали 8 chatsd и 10 tgd.

  9. Для chats и tg подтверждено конечное состояние:

    migrating=false
    resuming=false
    parked=false
    fail_count=0
    last_error=""
    

Выбранный подход к доработке

Рекомендуется единое владение жизненным циклом в Go monitord:

  • локальный monitord является единственным владельцем SSH-туннеля;
  • PHP публикует desired state и управляет durable migration transaction;
  • локальный custom_config.json является authoritative source размещения;
  • состояние VPS является производным и полностью восстанавливается reconciliation-процедурой;
  • удалённый custom_config.json не имеет права сохранять процессы, отсутствующие в локальном desired state.

Точечное усиление только PHP-кода не рекомендуется: оно не устраняет гонку между несколькими Go-процессами. Отдельный coordinator daemon также не рекомендуется из-за избыточной сложности и новой точки отказа.

Требования к реализации

1. Singleton local monitord

  • До открытия HTTP/NATS/SSH monitord должен получать эксклюзивный OS lock.
  • Lock должен быть привязан к конкретному config/work directory, чтобы независимые установки не конфликтовали.
  • Второй экземпляр должен завершаться с отдельным exit code и структурированной записью duplicate_instance.
  • PID-файл используется только для диагностики; корректность обеспечивается flock/эквивалентной системной блокировкой, а не проверкой существования PID.
  • Все пути запуска (startAllServices, reconcile restart, watchdog, upgrade) должны использовать один idempotent launcher.

2. Lease владельца SSH-туннеля

  • VPS хранит owner ID, owner label, lease timestamp и connection/session identity.
  • Новый владелец не захватывает активный lease другого узла.
  • Просроченный lease можно забрать только после проверки отсутствия живого SSH-сеанса или успешного fencing старого владельца.
  • Конфликт lease отличается от обычной сетевой ошибки в status API и UI.
  • tcpip-forward request denied by peer должен запускать диагностику владельца порта и controlled recovery, а не бесконечный reconnect storm.

3. Preflight перед миграцией

До SUPPRESS необходимо проверить:

  • SSH-аутентификацию тем же ключом и параметрами, которые использует worker;
  • архитектуру VPS x86_64;
  • существование и права remote_bin_dir;
  • свободное место для бинарников и session DB;
  • возможность создать и удалить тестовый файл;
  • доступность либо возможность запуска remote monitord;
  • версии/хеши обязательных бинарников;
  • отсутствие конфликтующего tunnel owner;
  • готовность reverse-forward 4222/8222 и local-forward 8226;
  • наличие remote config для выбранного сервиса.

Неуспешный preflight не должен останавливать локальные messenger daemons.

4. Транзакция миграции по area

Для каждой area сохраняются:

transaction_id
service
area
source
destination
phase
attempt
started_at
updated_at
last_error_code
last_error_detail
cleanup_required

Допустимая последовательность:

PREFLIGHT -> SUPPRESS_SOURCE -> COPY -> CREATE_DESTINATION
-> HEALTHCHECK_DESTINATION -> COMMIT_LOCATION -> CLEANUP_SOURCE

До COMMIT_LOCATION authoritative side остаётся source. Ошибка до commit возвращает source в работу. Ошибка после commit не должна запускать source без отдельного fencing destination.

5. Ownership token и защита от split-brain

  • Каждая запущенная messenger area получает generation/ownership token.
  • Token хранится в authoritative state и передаётся удалённому manager API.
  • Демон может стартовать только при совпадении текущего token.
  • При смене стороны generation увеличивается.
  • Старый процесс с предыдущим token должен self-fence после потери lease или при следующей проверке.
  • Manager API отклоняет создание дубликата area с другим активным owner.

6. Durable cleanup queue

Если rollback или commit не может удалить процессы/файлы на другой стороне, создаётся durable cleanup item:

service, area, side, generation, action, attempts, next_attempt_at, last_error

Очередь обрабатывается после каждого восстановления SSH и периодически в steady state. Cleanup считается завершённым только после проверки отсутствия процесса и daemon entry.

7. Authoritative reconciliation VPS

При подключённом туннеле worker сравнивает:

  • локальный authoritative custom_config;
  • remote_state и cleanup queue;
  • фактический удалённый manager API inventory;
  • процессы на VPS.

Reconcile должен:

  • удалить remote orphan entries;
  • остановить remote orphan processes;
  • создать недостающие remote entries только для committed remote area;
  • не переносить area самопроизвольно без migration transaction;
  • сообщать drift до и после исправления;
  • быть идемпотентным.

Удалённый custom_config.json рассматривается как cache/runtime projection, а не самостоятельный источник истины.

8. Состояния ошибок и UI

Вместо одной надписи «Идёт миграция» API и UI должны различать:

  • preflight;
  • остановку источника;
  • копирование;
  • запуск назначения;
  • ожидание health-check;
  • commit;
  • rollback;
  • cleanup orphan;
  • tunnel conflict;
  • parked/manual action required.

Для ошибки показываются service, area (если применимо), этап, время, стабильный error code и безопасное рекомендуемое действие. Защищённые настройки блокируются только пока изменение действительно нарушает активную транзакцию; cleanup не должен без необходимости блокировать несвязанные сервисы.

9. Наблюдаемость

  • Структурированные события с transaction_id, service, area, phase, source, destination.
  • Отдельные метрики: duplicate process, tunnel lease conflict, migration duration, rollback count, cleanup backlog, drift count.
  • Status endpoint возвращает число локальных/удалённых/мигрирующих/orphan area.
  • Диагностический bundle включает remote_state.json, локальный и удалённый inventory, tunnel owner/lease и sanitized configs без приватного ключа.
  • Повторяющиеся ошибки логируются с rate limit и счётчиком, а не создают несколько строк в секунду.

10. Самоочистка legacy state

  • Area, отсутствующая в authoritative inventory и не имеющая активной transaction/cleanup item, удаляется из remote_state после grace period.
  • Перед удалением выполняется проверка обеих сторон.
  • Это устраняет исторические area-записи, которых уже нет в authoritative inventory.

Тестовый план

Unit-тесты

  • второй monitord не может получить singleton lock;
  • stale PID без lock не препятствует старту;
  • активный lock не считается stale даже при неправильном PID-файле;
  • классификация SSH/forward/lease ошибок;
  • переходы migration transaction и запрет недопустимых переходов;
  • generation token увеличивается только при новой миграции;
  • cleanup queue сохраняется после рестарта;
  • нормализация legacy remote_state.

Интеграционные тесты

  • два одновременных запуска monitord;
  • порт 4222 занят неизвестным SSH-сеансом;
  • обрыв SSH на каждом этапе migration transaction;
  • недоступный remote manager до и после copy;
  • частично скопированная session DB;
  • успешный remote start без ответа health-check;
  • crash PHP worker и обоих monitord на каждом этапе;
  • восстановление VPS с orphan custom_config;
  • одновременное изменение WhatsApp и Telegram;
  • module upgrade при активном remote размещении;
  • повторное включение после parked;
  • отсутствие split-brain при сетевом partition.

Fault-injection сценарий инцидента

Автоматизированный сценарий должен воспроизводить исходный инцидент:

  1. Запустить миграцию нескольких Telegram-area.
  2. После создания части remote entries разорвать SSH.
  3. Перезапустить локальный stack дважды параллельно.
  4. Восстановить VPS с сохранённым удалённым custom_config.
  5. Проверить, что второй local monitord не стартует.
  6. Проверить, что source остаётся единственным владельцем до commit.
  7. Проверить автоматическую очистку orphan entries.
  8. Восстановить SSH и убедиться в конечном размещении всех area на одной стороне.

Этапы реализации

  1. Singleton monitord и единый idempotent launcher.
  2. Tunnel lease, диагностика владельца reverse-forward и controlled recovery.
  3. Durable migration transaction с error codes.
  4. Ownership generation/token и fencing.
  5. Durable cleanup queue и authoritative VPS reconciliation.
  6. Расширенные status API, UI и diagnostic bundle.
  7. Fault-injection и интеграционные тесты.
  8. Миграция legacy state и эксплуатационная документация.

Каждый этап должен поставляться отдельно и сохранять совместимость с PHP 7.4–8.4 и поддерживаемыми версиями MikoPBX/Phalcon.

Критерии приёмки

  • В каждый момент времени существует не более одного локального monitord для одного work directory.
  • Один area UUID не работает одновременно локально и удалённо.
  • Неуспешный preflight не вызывает простой локального сервиса.
  • После обрыва SSH state machine автоматически приходит к однозначному состоянию или показывает конкретное manual action.
  • После восстановления VPS orphan entries и процессы удаляются автоматически.
  • remote_state, local inventory и VPS inventory сходятся без ручного редактирования JSON.
  • Занятый reverse-forward диагностируется как owner conflict с owner ID, а не как абстрактная ошибка SSH.
  • Повторный старт, restart и upgrade идемпотентны.
  • UI показывает текущий этап, сервис, число area и конкретную причину блокировки.
  • Fault-injection сценарий исходного инцидента проходит без split-brain.

Эксплуатационные ограничения

  • Ручное редактирование custom_config.json допустимо только как аварийная процедура при остановленном владельце файла.
  • Приватный SSH-ключ не должен попадать в логи, status API или diagnostic bundle.
  • Автоматический cleanup обязан проверять ownership generation, чтобы не остановить актуальный процесс после новой миграции.
  • При невозможности доказать безопасное владение система выбирает остановку назначения и сохранение authoritative source, а не параллельный запуск.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Fields

    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions