Перенос сервера V7 — что нужно знать, чтобы не сломать

Составил CTO-агент 23.09.2026. Все числа — замер этого сервера сегодня, не по памяти. Читать целиком до первой команды: три мины ниже ломают перенос необратимо, и каждая выглядит как «всё прошло нормально».

Сервер держит боевой контур падел-клуба: деньги (счета, касса, накладные), переписку с клиентами и сотрудниками, 28 живых служб, 503 активных крона. Клиенты получают сообщения с этой машины.


1. Три мины. Прочитать до начала

Мина 1: секреты невозможно восстановить из бэкапа на чистой машине

196 паролей (базы, API-токены, ключи дверей) лежат в pass и зашифрованы GPG-ключом. В бэкапе есть gpg-key.asc.enc — но passphrase к нему лежит внутри pass, который этим же ключом и открывается. Круг замкнут: на чистом сервере распаковать нечем.

Проверено сегодня фактом: связка работает только потому, что ключ уже есть в этом ~/.gnupg.

Что делать: перенести ~/.gnupg (232 КБ) и ~/.password-store (4 МБ) напрямую, архивом с сохранением прав, первым шагом. Не через восстановление из .enc.

# на старом
tar -czf /tmp/secrets.tgz -C /home/user .gnupg .password-store
# на новом, после копирования
tar -xzf /tmp/secrets.tgz -C /home/user && chmod 700 /home/user/.gnupg
pass show hub/gpg-passphrase >/dev/null && echo "pass работает"

Без этого шага не поднимется ни одна служба: пароль базы у каждой только в pass. Секреты не выводить в терминал, в чаты и в логи — только файлом между машинами.

Мина 2: код части служб существует только на этом диске

Четыре каталога нельзя восстановить из git — там либо нет репозитория, либо служба работает из ветки, а не из main:

Каталог Состояние Кто от него зависит
projects/CustomerSuccess git нет вообще, 100 МБ служба v7-ai-comm (порт 5181)
projects/v7-photo-bot git нет вообще, 216 КБ служба v7-photo-bot
projects/backend-invoices-live ветка feat/invoices-api, не main v7-invoices-api (5185), v7-guide-api (5186)
projects/backend-tournament-ops ветка feat/tournament-ops, не main v7-tournament-ops-api (5187)

Что делать: копировать projects/ файлами (rsync), а не «клонировать репозитории заново». Если склонировать из git и переключиться на main — код инструментов, через которые проводят накладные и турниры, исчезнет с диска.

У нас это уже происходило: git checkout в общем каталоге снёс с диска работающий invoices-api, потому что его код жил только в ветке. Процесс продолжал работать из памяти, и любой рестарт убил бы инструмент. Не делать git checkout / switch / reset --hard в каталогах, на которых сидят службы.

Мина 3: два сервера одновременно = двойные сообщения клиентам и двойные записи

503 активных строки crontab, 38 из них пишут наружу: напоминания о турнирах, поздравления, дайджесты, рассылки, синхронизации в чужие системы.

Если новый сервер запустит кроны, пока старый ещё жив: - клиенты получат по два сообщения (дедуп у сервисов не спасёт — состояние дедупа локальное на каждой машине, файлы состояния разъедутся); - квота MatchPoint API — 500 запросов в сутки на всех — будет съедена вдвое, и синхронизации начнут падать; - два писателя на одну таблицу дают дубли. Свежий пример с этой машины: два загрузчика банковской выписки с разными ключами дедупа дали 92 дубля в проводках.

Что делать: на новом сервере до момента переключения crontab пустой и systemctl --user службы не включены в автозапуск. Порядок: остановить кроны на старом → перенести → проверить → включить на новом. Двух активных машин не должно быть ни минуты.


2. Порядок переноса

Проверка после каждого шага обязательна: «должно работать» не считается, нужен факт.

Шаг 0. На новом сервере — база. Ubuntu, docker, caddy, pass, gpg, python3, node, git, postgresql-client.

Два uid — обязательное условие, а не пожелание: пользователь user с тем же uid (здесь 1000) и служебный caddy с uid 996. Причина в шаге 3: права доступа раздаются не только владельцем файла, но и именованными ACL (user:caddy:r-x, включая наследуемые default:), а перенос идёт с --numeric-ids, то есть по числу, а не по имени. caddy — системный пользователь, его uid назначается по порядку установки пакетов, и на свежей машине он почти наверняка будет другим. Тогда право на чтение либо достанется постороннему пользователю с этим номером, либо Caddy молча потеряет доступ к файлам, которые обязан отдавать. Проверить до переноса данных:

id -u user; id -u caddy         # ждём 1000 и 996; не совпало — исправить ДО шага 3
# ACL на целевой ФС должны работать (проверять записью, а не опциями монтирования):
T=$(mktemp); setfacl -m u:nobody:r "$T" && getfacl -p "$T" | grep user:nobody:r; rm -f "$T"

ufw включить здесь же, ДО запуска любых служб. Сейчас наружу открыты только 22, 80, 443; порты бэкендов (5180–5187, 8788 и остальные) закрыты именно файрволом, а не только тем, что службы слушают 127.0.0.1. Если поднять контейнеры и юниты раньше файрвола, между их старом и настройкой ufw будет окно, когда порт денежных API торчит наружу без прикрытия. Проверка: sudo ufw status → active, и в списке ровно 22/80/443.

Шаг 1. Секреты (мина 1). Проверка: pass ls | wc -l = 196.

Шаг 1б. Ключи доступа и мелкие конфиги. ~/.ssh (76 КБ, 14 файлов, 4 пары ключей: forge-rw, hub-backup-deploy, hub-skills-deploy, do-migration) — без них не работает ни один git push/pull и останавливается бэкап. Хосты из ~/.ssh/config: git.forge-stack.io, gitlab-backup, gitlab-skills. Плюс три файла ~/.config/hub-gitlab-namespace, hub-session-accounts, hub-tenant — по ним определяется, какие репозитории и знания подтягиваются на эту машину.

rsync -aHAX --numeric-ids /home/user/.ssh/ new:/home/user/.ssh/
chmod 700 /home/user/.ssh
chmod 600 /home/user/.ssh/*        # всем файлам 600 — публичным ключам и config не вредит
ssh -T git@git.forge-stack.io -p 2222   # проверка: ключ принят

Шаг 2. Включить linger — иначе службы не стартуют до первого входа в систему и поднимутся только когда кто-то залогинится:

sudo loginctl enable-linger user
loginctl show-user user | grep Linger   # ждём Linger=yes

Шаг 3. Данные и код (мина 2). Объёмы: hub 13 ГБ, projects 14 ГБ.

rsync -aHAX --numeric-ids \
  /home/user/{hub,projects,bin,.claude,.config,.gitconfig,.git-hooks-global}/ …

--numeric-ids и -A важны: часть файлов лежит под чужими uid и с именованными ACL (см. шаг 0); без них сломаются права дверей и служб.

.gitconfig и .git-hooks-global — не забыть, это предохранитель, а не настройка. В .gitconfig прописан core.hooksPath=/home/user/.git-hooks-global, и на нём держится pre-commit проверка на утечку секретов во всех репозиториях. Без этих двух путей на новой машине секрет можно закоммитить, и ничто не возразит: по виду «всё работает», а гейта нет. Проверка: git config --global core.hooksPath/home/user/.git-hooks-global.

Проверки после шага:

git -C /home/user/projects/backend-invoices-live branch --show-current   # feat/invoices-api
getfacl -p /home/user/projects/v7padel-site | grep -E '^(default:)?user:caddy'
# ↑ обязано показать user:caddy:r-x И default:user:caddy:r-x — ровно как на старом

Шаг 4. База данных. Контейнер v7-postgres, образ postgres:16-alpine, порт слушает только 127.0.0.1:5432. Данные — bind-каталог /home/user/v7-postgres/data (294 МБ, владелец uid 70, права 700).

Надёжнее дампом, чем копированием файлов:

# старый (роли — глобальные, их забывают чаще всего)
docker exec v7-postgres pg_dumpall -U v7admin --roles-only > /tmp/roles.sql
docker exec v7-postgres pg_dump -U v7admin -Fc v7_main > /tmp/v7_main.dump
# новый
docker exec -i v7-postgres psql -U v7admin -d postgres < /tmp/roles.sql
docker exec -i v7-postgres pg_restore -U v7admin -d v7_main --clean --if-exists < /tmp/v7_main.dump

Базы: v7_main 157 МБ (боевая), v7_restore_check 23 МБ, v7_coretest 8 МБ. 20 ролей v7* — у каждой службы своя узкая роль, и права выданы точечно (до таблицы и до колонки). Без --roles-only службы получат «permission denied» и встанут молча. Проверка: SELECT count(*) FROM pg_roles WHERE rolname LIKE 'v7%' = 20.

Числа uid 70 / права 700 выше даны для понимания, что именно нельзя ломать, если кто-то всё же полезет копировать каталог файлами. При переносе дампом этот каталог тащить не нужно вовсе: новый bind-каталог создаст сам контейнер на новой машине, с правильным владельцем. Копирование файлами — только при полной остановке обеих баз, и тогда обязательно sudo rsync -aHAX --numeric-ids.

Шаг 5. Службы и таймеры. ~/.config/systemd/user/: 68 .service + 41 .timer, из них 39 таймеров включено — это вторая, помимо crontab, система расписаний, и про неё забывают: там сидят выгрузка банковской выписки, обновление дверей Caddy, сторож бэкапа. Таймеры — тоже «пишут наружу», на них распространяется запрет из мины 3.

systemctl --user daemon-reload
systemctl --user list-timers --no-legend | wc -l   # ждём 39 ПОСЛЕ включения, не до

Включать по одной, снизу вверх по зависимостям; после каждой — curl её порта. Карта портов (все только на 127.0.0.1):

Порт Служба Что держит
5180 v7-capex-api капитальные расходы
5181 v7-ai-comm переписка с клиентами (код без git!)
5182 v7-admin-cash-api касса администраторов
5183 v7-opex-api операционные расходы
5184 v7-bank-api банковские выписки, остаток счёта
5185 v7-invoices-api накладные (из ветки!)
5186 v7-guide-api справочник (из ветки!)
5187 v7-tournament-ops-api турниры (из ветки!)
8788 v7-people-api кабинеты сотрудников
8765, 8791, 8792, 8799 мосты и вспомогательные переписка по людям

Плюс 10 мостов-служб v7-<имя>-bridge (по сотруднику), работающих из projects/v7padel-backend/people-api.

Шаг 6. Caddy — переносится ОТДЕЛЬНО и под sudo. /etc/caddy/Caddyfile2931 строка, 258 блоков handle, права root:caddy 0640. Админ-порт 2019 локальный.

Шаг 3 его НЕ забирает, и это провалится тихо. rsync из шага 3 идёт от имени user, а user не входит в группу caddy (проверено) — файл ему недоступен даже на чтение. rsync домашних каталогов отработает зелёным, Caddy останется со старым конфигом или без него, и обнаружится это только на проверке ниже, если её не забыть. Поэтому конфиг везётся явной командой:

# со старого, под sudo — иначе Permission denied
sudo cat /etc/caddy/Caddyfile > /tmp/Caddyfile.copy    # затем scp на новый
# на новом
sudo install -o root -g caddy -m 0640 /tmp/Caddyfile.copy /etc/caddy/Caddyfile
sudo rm -f /tmp/Caddyfile.copy      # не оставлять копию: см. ниже, в файле живые токены

Caddyfile — секретный файл, обращаться как с паролями. В нём открытым текстом лежат живые токены дверей сотрудников (параметры вида t=…, у меня замер даёт 40 вхождений). Он не в pass, но по чувствительности равен ~/.password-store: не класть в общие каталоги, не коммитить, промежуточную копию удалять сразу.

Здесь легко открыть боевой API наружу. Блоки handle применяются строго в порядке файла, и правила аутентификации стоят ниже общих путей. Однажды маршрут, вставленный на четыре строки выше гейта, отдал /api/invoices публично — поймали через две минуты по логам. При переносе: файл копировать целиком, порядок строк не менять, блоки не сортировать и не «причёсывать».

Проверка обязательна, и «сайт открылся» ею не считается: запрос к закрытому пути без авторизации должен дать 401/403.

Проверять ЧЕРЕЗ ПУБЛИЧНЫЙ ХОСТ, не через 127.0.0.1. Запрос на localhost минует Caddy целиком и тестирует только бэкенд — то есть ровно тот слой, который и так работал бы. Если проверка защиты идёт по localhost, она покажет зелёное даже при снятой двери. В Caddyfile 30 хостов-дверей; ручной обход гарантированно пропустит одну — а одна пропущенная дверь это и есть тот инцидент с маршрутом выше гейта.

sudo caddy validate --config /etc/caddy/Caddyfile

# негативный тест по ВСЕМ дверям одной командой: без токена ждём 401/403
for h in $(sudo grep -oE '^[a-z0-9-]+\.rex\.v7padel\.club' /etc/caddy/Caddyfile | sort -u); do
  printf '%-34s %s\n' "$h" "$(curl -s -o /dev/null -w '%{http_code}' --max-time 10 "https://$h/")"
done
# любая 200 без токена — СТОП и разбор, дальше не идти.
# ЕДИНСТВЕННОЕ ЗАКОННОЕ ИСКЛЮЧЕНИЕ: evgeniy-kz.rex.v7padel.space отдаёт 200 публично
# ПО РЕШЕНИЮ ВЛАДЕЛЬЦА от 21.09 — статический хост под внешнюю финмодель, намеренно
# изолирован (без auth-guard, без бэкенда, без пути с нашего сайта); защита — секретность
# адреса, причина и откат записаны в Caddyfile рядом с блоком. Все прочие 200 — дефект.
# Список исключений ровно из одной строки: не «тоже наверное можно».

Готовые инструменты. Baseline старого сервера уже снят 23.09 07:43-07:50 — файл hub/docs/plans/260923-0745-baseline-pre-migration.md. На новом сервере повторить те же команды и сверить с этими числами:

python3 ~/bin/v7-doors-diff-audit.py --check
# ожидается «ИТОГ doors-diff: OK — дельты прав нет» при 30 дверях, 8 ключах,
# 263 ACCESS-записях. Хосты и пути от машины не зависят, поэтому ЛЮБАЯ дельта =
# Caddyfile или auth-guard.js доехали не той версией либо не полностью.

NODE_PATH=/home/user/.npm/_npx/e41f203b7505f1fb/node_modules node ~/bin/v7-cabinet-human-smoke.js
# ожидается 10/10 кабинетов (alex, maxim, liza, olya, dariaadmin, kirsanova,
# borodaenko, mihail, selin, malika). Меньше 10 — регрессия, разбирать по конкретному
# кабинету, а не списывать на «не доехало».

Шаг 7. Кроны — последними (мина 3). 503 активных строки; бэкап лежит в ~/.cache/hub-backup/rex/crontab.txt (678 строк с комментариями). Ставить только после того, как старый сервер их уже не выполняет.

Переключение домена — не замок. Домены проксируются через Cloudflare, и смена A-записи меняет только то, куда идут ПОСЕТИТЕЛИ. Расписания шлют сообщения людям сами, независимо от DNS: пока на старом сервере живы crontab и таймеры, он продолжает писать клиентам, даже когда домен уже смотрит на новый. Единственный настоящий замок против двойной отправки — снятый crontab и остановленные таймеры на старом сервере, а не переключённый домен. Не путать эти два события.

Перед включением — сверка файлов дедупа. Защита от повторных сообщений держится на файлах состояния внутри hub/ и people-data/ (истории отправок, журналы дедупа, «первый раз увиден»). Они переезжают вместе с hub, но это надо подтвердить, а не предположить — побайтовой сверкой до и после переноса:

# на старом (после остановки расписаний) и на новом — суммы обязаны совпасть
find ~/hub/people-data ~/hub/data -name '*dedup*' -o -name '*first-seen*' | head -5 | xargs md5sum

Расхождение здесь означает, что кто-то получит поздравление или напоминание второй раз.

Шаг 8. Хуки рабочих сессий. В ~/.claude/settings.json живут предохранители: PreToolUse (гейт деплоя и запрет checkout в каталогах живых служб), UserPromptSubmit (стоп-латч владельца), PreCompact. Они переезжают вместе с .claude, но проверить, что файл на месте, стоит: без них снимаются защиты, которые уже спасали этот контур.


3. Окно простоя, дельта и откат

Добавлено после самопроверки плана — без этого перенос теряет данные тихо.

Дельта обязательна: одного rsync недостаточно

Пока копируются 27 ГБ (hub 26 711 файлов, projects 175 429), старый сервер продолжает работать и ПИСАТЬ: входящие сообщения клиентов ложатся в катушку hub/people-data/_shared/wa-webhook-spool.jsonl (1005 записей, порядка 15 в сутки), пишутся кассовые записи, леджеры отправок, журналы дедупа. Всё, что придёт ПОСЛЕ копирования и ДО переключения, пропадёт без следа — и пропадёт именно то, что дороже всего: сообщения людей и деньги.

Поэтому копирование делится на два захода:

  1. Предварительный rsync заранее, пока старый живёт и работает штатно. Занимает сколько занимает — никто не ждёт.
  2. Финальная дельта после остановки старого — та же команда ещё раз, передаёт только изменившееся, идёт минуты. Только после неё включать новый.

Порядок окна: предварительный rsync → остановить расписания и службы на старом → дельта → дамп базы (тоже после остановки, иначе снимете состояние «вчера») → проверки → включение нового → DNS.

Полнота переноса — проверяется, а не предполагается

rsync на 27 ГБ может оборваться и вернуть ненулевой код, который легко просмотреть в потоке.

# повторить ту же команду с --dry-run: должно быть 0 файлов к передаче
rsync -aHAX --numeric-ids --dry-run --stats /home/user/hub/ new:/home/user/hub/ | tail -20
# и грубая сверка счётом файлов на обеих машинах:
find /home/user/hub -type f | wc -l        # старый даёт 26711 на 23.09
find /home/user/projects -type f | wc -l   # старый даёт 175429

~/.claude — 1,7 ГБ, из них основное это транскрипты рабочих сессий. Обязательны только settings.json (предохранители) и projects/*/memory/; остальное можно довезти потом и не держать на нём окно простоя.

Откат: старый сервер не разбирать

У переноса обязан быть путь назад, иначе первая же несошедшаяся проверка ставит выбор между «жить с поломкой» и «восстанавливать наобум».


4. Чего не делать


5. После переезда: гигиена секретов

Исполнитель работает с машиной целиком, то есть в процессе имеет доступ к материалу секретов: ~/.password-store (196 записей), ~/.gnupg и Caddyfile с живыми токенами дверей. Это нормально для работы, но из этого следуют три обязательства.

Что делает исполнитель: 1. Не оставлять копий: промежуточные архивы (/tmp/secrets.tgz, /tmp/Caddyfile.copy, дампы базы в /tmp) удалить сразу после распаковки — shred -u или rm. 2. Не выводить значения секретов в терминал, историю команд, логи, скриншоты и переписку. Проверять наличие — pass show <путь> >/dev/null && echo ok, без печати значения. 3. По окончании передать нам список того, к чему был доступ (какие файлы секретов открывались/копировались, оставались ли копии где-либо ещё). Одной строкой, честно.

Что делаем мы после его работы (не задача исполнителя, ему это знать только для контекста): ротация паролей и токенов, к материалу которых была доступна третья сторона — пароли 20 ролей базы, 40 токенов дверей в Caddyfile, внешние API-токены. Ротация идёт после успешного переезда и приёмки, по одному сервису с проверкой, чтобы не смешивать две причины поломки.


6. Приёмка: перенос закончен, когда это показано фактами

Не «сервисы запустились», а проверка тем способом, каким это видит потребитель:

  1. pass ls | wc -l196.
  2. loginctl show-user user | grep LingerLinger=yes.
  3. systemctl --user list-units --type=service --state=running | wc -l28.
  4. SELECT count(*) FROM pg_roles WHERE rolname LIKE 'v7%'20; размер v7_main157 МБ.
  5. Ветки живых деревьев: backend-invoices-live = feat/invoices-api, backend-tournament-ops = feat/tournament-ops.
  6. Каталоги без git на месте: projects/CustomerSuccess (100 МБ), projects/v7-photo-bot.
  7. Каждый порт из таблицы отвечает; и отдельно — все 30 дверей через публичный хост без токена дают 401/403 (скрипт в шаге 6), ни одной 200.
  8. git config --global core.hooksPath/home/user/.git-hooks-global (гейт секретов жив).
  9. getfacl на projects/v7padel-site показывает user:caddy:r-x и default:user:caddy:r-x.
  10. sudo ufw status → active, наружу только 22/80/443.
  11. v7-doors-diff-audit.py --check → «дельты прав нет» (30 дверей, 8 ключей, 263 ACCESS); v7-cabinet-human-smoke10/10 кабинетов. Baseline старого сервера: hub/docs/plans/260923-0745-baseline-pre-migration.md.
  12. Одна страница кабинета сотрудника открывается за его дверью и показывает данные, а не пустой экран.
  13. crontab -l | grep -vc '^#'503 (и только после остановки старого сервера).
  14. Старый сервер: кроны сняты, службы остановлены — подтвердить до включения нового.

  15. Входящий путь жив: катушка hub/people-data/_shared/wa-webhook-spool.jsonl на новом сервере содержит не меньше записей, чем на старом в момент остановки (на 23.09 — 1005), и после включения прирастает на новых сообщениях. Записей меньше — дельта не доехала, сообщения людей потеряны.

  16. Полнота: повторный rsync --dry-run по каждому перенесённому каталогу показывает ноль файлов к передаче.
  17. Откат возможен: старый сервер цел, данные на месте, назван человек, принимающий решение «включаем / откатываемся».

Если какой-то пункт не сходится — сказать об этом прямо, а не «вероятно, подтянется». Тихий недоезд здесь дороже задержки: половина этого контура — деньги и переписка с людьми, и расхождение всплывёт через дни, чужими цифрами.


Внутренний документ V7. Адрес не индексируется и не публикуется — не пересылайте его дальше исполнителя. Страница снимается после приёмки переноса.