- docs/ai/: stable, repo-verified context - README, architecture, tech-stack, edge-cases, plan (confirmed active work only), migration-tofu (the blue-green OpenTofu migration runbook and per-service findings), legacy-warning, links. - AGENTS.md: slimmed to a working contract that points at docs/ai instead of restating it; CLAUDE.md is an adapter that @-includes it. - README.md, ansible/README.md, ansible/roles/README.md, roles/lxc_docker_host/README.md: bring wording in line with the current control plane (Makefile entry point, registry, tofu, memoir-bot gone). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012uoq5AVK8mkBgg83Mq6o5V
59 KiB
Переход provisioning LXC на OpenTofu
Пошаговый план миграции. Рассчитан на исполнителя, который этот разговор не видел: всё нужное либо здесь, либо по ссылкам на файлы репозитория.
Стратегия — blue-green: боевые контейнеры не импортируются в Tofu и не переконфигурируются на месте. Вместо этого рядом создаётся новый контейнер, туда переносятся данные, затем переключается адрес, а старый контейнер некоторое время стоит остановленным как откат.
0. Как начать сессию по этому плану
Открыть Claude Code в корне репозитория и дать примерно такой промпт, подставив нужный сервис:
Работаем по docs/ai/migration-tofu.md — переход provisioning LXC на OpenTofu
по схеме blue-green. Прочитай его целиком, а также tofu/README.md и
AGENTS.md.
Делаем сервис №1 из раздела 5 (emergency-bot, CT 148). Идём строго по
процедуре раздела 4, по шагам, не забегая вперёд.
Правила:
- разрушающие шаги (pct stop, pct destroy, tofu-destroy, смена адреса)
выполняешь только после моего явного подтверждения;
- старый контейнер не удаляем, он остаётся откатом;
- make validate должен быть зелёным до и после;
- если реальность разошлась с планом — останавливайся и говори, не
придумывай обход.
Между сервисами сессию имеет смысл начинать заново: контекст одного переезда следующему не нужен, а свежий контекст надёжнее.
1. Что уже сделано — не переделывать
tofu/с провайдеромbpg/proxmox, целиmake tofu-init | tofu-plan | tofu-apply CONFIRM=1 | tofu-destroy CONFIRM=1.- Аутентификация:
root@pamпо паролю из корневого.env(PROXMOX_ROOT_USER,PROXMOX_ROOT_PASSWORD). Выбор режима автоматический, печатается в stderr. Подробности и матрица возможностей —tofu/README.md. - Цели
tofu-*сами поднимают SSH-туннель черезru-vps: провайдер ходит в API по HTTPS и ProxyJump не умеет. - Реестр
homelab_servicesпрограммно потребляют:reverse-proxy.yml,ru-vps-base.yml,pve-backup-jobs.yml(списки VMID поbackup.job),roles/backup_audit(VMID поmonitoring.backup_audit_vmid),validate.yml. make validate— read-only gate, сверяет реестр сpct configпо hostname, IP, cores, memory, swap. Падает при расхождении.make lintвоспроизводит CI (линтеры из корня + syntax-check всех плейбуков).
Что проверено на пилоте (VMID 199, снесён)
В режиме root@pam Tofu выставляет декларативно всё, что нужно этой
инфраструктуре:
dev0: deny-write=0,path=/dev/net/tun,uid=0,gid=0,mode=0660
features: fuse=1,keyctl=1,nesting=1
mp0: data:199/vm-199-disk-1.raw,mp=/opt/pilot-data,size=4G
Повторный plan даёт No changes. Следствия:
- правка
/etc/pve/lxc/<vmid>.confчерезlineinfile(девять контейнеров) заменяется декларацией в Tofu, но КАКОЙ именно — зависит от устройства:/dev/fuse— штатным флагомfeatures { fuse = true },/dev/net/tun— блокомdevice_passthrough. Оба варианта видны в выводе пилота выше:features: fuse=1,...иdev0: ...path=/dev/net/tun; - шаг
pct set <vmid> --features nesting=1,keyctl=1больше не нужен; lifecycle { ignore_changes = [features] }не нужен;- apply одной фазой.
Важно: всё это работает ТОЛЬКО под root@pam по паролю. API-токен, даже
принадлежащий root, проверку не проходит — в Proxmox она буквальная
($authuser eq 'root@pam', PVE/LXC.pm:1658), а при токенной аутентификации
$authuser равен полному user@realm!tokenname (PVE/HTTPServer.pm:86).
Ключевое упрощение
Если при переезде сохранить IP, меняется только VMID. А все потребители
VMID уже выводят его из реестра автоматически (backup jobs, backup audit).
Поэтому cutover — это правка одного поля vmid в services.yml, а не
синхронная правка шести файлов. Ради этого и делалась генерация.
2. Инварианты
- Последовательным обязан быть только cutover. Инвариант изначально
звучал как «один сервис за раз»; 2026-09-02 он уточнён после параллельного
прогона шести сервисов. Разделение такое:
- Параллелится безопасно: подготовка (снятие эталона, описание в Tofu),
создание новых контейнеров (один пакетный
apply, Tofu сам разводит ресурсы) и конфигурация на временных адресах (шаг 4.3). Всё это не трогает боевые контейнеры и полностью откатывается точечнымtofu destroyнужного ресурса. - Остаётся строго последовательным: перенос данных (4.4), переключение
адреса (4.5) и правка реестра (4.6) — по одному сервису, с явным
подтверждением человека на каждый разрушающий шаг.
Причины, по которым
applyфизически не параллелится: состояние Tofu одно и локальное, а целиmake tofu-*поднимают SSH-туннель на фиксированный порт 18006 с фиксированным control-сокетом. Два одновременных прогона столкнутся.
- Параллелится безопасно: подготовка (снятие эталона, описание в Tofu),
создание новых контейнеров (один пакетный
- Старый контейнер останавливается, но не удаляется — минимум неделю. Это единственный быстрый откат.
make validateзелёный до и после каждого переезда.- Перед стартом каждого сервиса — свежий бэкап PBS этого VMID.
- VMID и IP выведенных контейнеров не переиспользовать сразу: в PBS остаются цепочки по VMID, в кэшах — адреса.
- CT 120
pbsне мигрировать. Это цель бэкапов, на которую опирается план отката всех остальных. Онunmanagedи таким остаётся. - Не менять одновременно provisioning и рантайм сервиса без необходимости.
Если переводишь
docker runнаcompose_service— это отдельное осознанное решение по конкретному сервису, а не часть переезда по умолчанию.
3. Предпосылки перед первым переездом
- Вывод
memoir-bot— live-шаги оператора выполнены 2026-09-02: deploy keySecondBrainотозван, монитор в Uptime Kuma снят,pct stop 142выполнен (подтвержденоpct listна mini-pc). Это была репетиция удаления старого контейнера.pct destroy 142сознательно отложен — см.plan.mdи общий инвариант о паузе перед удалением. make validate— зелёный (проверено 2026-09-02, 12 сервисов, drift нет).make backup-audit— прогнан 2026-09-02, все хостыok, без ошибок.tofu/terraform.tfstate— решено оставить только локальным на время переезда сервиса №1. Осознанный риск: state небольшой, при потере можно re-import всё созданное заново. Постоянное решение (например, offsite restic-профиль) — отдельная задача, не блокирует старт.- Свободное место проверено. На 2026-09-02: cloud-pc
data— 816 ГиБ свободно, mini-pclocal-lvm— 116 ГиБ. Запаса хватает на любой сервис, включая grimmory (64 ГиБ).
Пул временных адресов и VMID
Свободны на 2026-09-02: IP 192.168.1.6-9, 11-19, 21, 22, 26, 33, 36-40,
VMID 151+ и 199. Временный адрес обязан лежать в 192.168.1.5-40 —
только этот диапазон ru-vps маршрутизирует в LAN через tun0, иначе новый
контейнер будет недоступен по ProxyJump.
Шаблон local:vztmpl/debian-13-standard_13.1-2_amd64.tar.zst присутствует на
обеих нодах — проверено.
4. Процедура переезда одного сервиса
Обозначения: OLD — текущий VMID, NEW — временный VMID, IP — боевой адрес,
TMPIP — временный адрес.
4.1 Подготовка
make validateиmake backup-audit— оба зелёные.- Снять эталон:
ssh <node> sudo pct config OLD— сохранить вывод. Это источник правды для того, что нужно воспроизвести. - Запустить свежий бэкап:
ssh <node> sudo vzdump OLD --storage pbs --mode snapshot.- Успех проверять наличием снапшота, а не кодом возврата. Исторически
на 2026-09-02 client-side prune давал
missing Datastore.Modify|Datastore.Prune, из-за чегоvzdumpпечаталBackup of VM ... failedи возвращал ненулевой код после успешной выгрузки. Это объяснение для старых логов; сейчас client-side prune из плейбуков убран, поэтому любой новый non-zero надо считать проблемой. Проверять так:ssh cloud-pc 'sudo pvesm list pbs | grep ct/<OLD>'. Подробности —plan.md, раздел про prune. - Если у сервиса Docker с fuse-overlayfs, а rootfs на каталоговом
хранилище (
data) — сначала остановить сервис. Такой rootfs не поддерживает снапшоты, vzdump уходит в режимsuspendс rsync и падает наvar/lib/docker/fuse-overlayfs/.../merged: Permission denied. Проверено на gitea 2026-09-02: с остановленным сервисом бэкап проходит. У сервисов наlocal-lvm(vaultwarden) проблемы нет — там снапшот. - У сервиса с bind mount в бэкап попадает только rootfs. vzdump пишет
excluding bind mount point mpN (...) from backup (not a volume). До переезда это касалось gitea: его репозитории и БД в PBS не попадали вообще. После перехода на volume попадают.
- Успех проверять наличием снапшота, а не кодом возврата. Исторически
на 2026-09-02 client-side prune давал
4.2 Описание в Tofu
- Завести ресурс в
tofu/(напримерtofu/services.tf), воспроизведя эталон:vm_id = NEW, hostname как у боевого,TMPIP, cores, memory, swap, rootfs на том же datastore и того же размера,features, декларацию для каждого устройства изlxc.mount.entry,mount_pointдля каждогоmpN,startup.order,start_on_boot,unprivileged,console { type = "shell" }(эталонcmode— см. ниже)./dev/fuse— этоfeatures.fuse, а НЕdevice_passthrough. Ловушка: в эталоне/dev/fuseвыглядит как пара сырых строк (lxc.cgroup2.devices.allow: c 10:229 rwm+lxc.mount.entry), и её хочется механически перевести вdevice_passthrough. Так делать не надо:device_passthrough(dev0:в PVE 8.2+) предназначен для сырых character-устройств вида/dev/net/tun, а у/dev/fuseесть штатный флаг PVE, проверенный на пилоте. См.tofu/pilot.tf.example:17-21. Следствие:pct configнового контейнера покажетfeatures: fuse=1,..., то есть будет текстуально отличаться от эталона при том же эффекте — это ожидаемо, а не drift.- Bind mount заменять на volume. Если у сервиса
mpN: /host/path,mp=...(сейчас так только у gitea), в новом контейнере это должен бытьmount_point { volume = "<datastore>", size = "...", path = "..." }. Данные переносятся на шаге 4.4, а не монтированием того же каталога: два контейнера, пишущие в один каталог, повредят данные. console { type = "shell" }обязателен.roles/pve_lxcвсем контейнерам ставитcmode: shell; провайдер отслеживает это через блокconsole, и без явного объявления следующийtofu-planпредложит откатить его на дефолт Proxmoxtty— тот же класс drift, что иkeyctlв гибридной схеме. Подробности и история находки —tofu/README.md.
make tofu-plan— убедиться, что план ровно1 to add.make tofu-apply CONFIRM=1.- Сверить:
ssh <node> sudo pct config NEWпротив эталона из шага 2. Отличаться должны толькоvmidи адрес (плюс явно выписанные Proxmox-дефолты видаcpulimit,protection,template,tty— эталон их просто не показывал, реальное поведение то же самое).cmode: shellдолжен совпасть с эталоном уже на этом шаге, если блокconsoleобъявлен в шаге 4 — правкой постфактум черезpct setне чинить, это создаёт drift (см. шаг 4 выше иtofu/README.md).
4.3 Настройка
-
Добавить временную запись в
ansible/inventory/hosts.yml:<name>-newсansible_host: TMPIPиexpected_lan_ip: TMPIP, и такую же запись вansible/ssh_config(Host-блок + в агрегированные строки ProxyJump, root, общие опции).- Во время cutover использовалась временная группа
lxc_migration_new; она не входила вservers, поэтому незавершённый переезд не краснилmake checkиmake status. - Транспорт задавался только в
group_vars/lxc_migration_new/main.yml, а не блокомvars:в самомhosts.yml. У Ansible inline-переменные группы из inventory-файла имеют приоритет НИЖЕ, чемgroup_vars/all/, поэтомуansible_user: ansibleоттуда перебивает inlineroot, и подключение падает сPermission denied. Каталогgroup_vars/<группа>/— наоборот, вышеall. Найдено 2026-09-02.
- Во время cutover использовалась временная группа
-
Прогнать конфигурационную часть существующего плейбука против нового контейнера:
ansible-playbook playbooks/pve-<name>.yml \ -e pve_config_target=<name>-new --limit <name>-newОдного
--limit <name>-newНЕДОСТАТОЧНО, и это не особенность отдельного сервиса. Ansible пересекает лимит с паттерном play, а не подменяет его. Паттерн конфигурационного play — литеральное имя боевого хоста (hosts: gitea), пересечение сgitea-newпусто, и play молча получаетhosts (0). Проверено 2026-09-02 через--list-hostsна всех плейбуках сразу: ноль хостов во ВСЕХ play, а не только в создающих. Прошлая сессия (emergency-bot) сочла это особенностью того сервиса — на самом деле это общее свойство.Поэтому у конфигурационных play заведён переопределяемый таргет:
hosts: "{{ pve_config_target | default('<name>') }}"Он есть в
pve-docker-test.yml,pve-gitea.yml,pve-vaultwarden.yml,pve-grimmory.yml,gyro.ymlиuptime-kuma.yml. По умолчанию поведение не меняется — без-eпаттерн равен прежнему литералу.--limitпри этом всё равно обязателен: он отсекает play создания контейнера и play-стражи, которые таргетят ноду (cloud-pc/mini-pc) илиlocalhost. Без него плейбук пойдёт делатьpct createи править/etc/pve/lxc/<OLD>.confна боевом VMID.Перед каждым прогоном сверяться с
--list-hosts. Ожидание: у play создания и стражейhosts (0), у конфигурационного — ровноhosts (1): <name>-new. Это и есть предохранитель, а не формальность.Особые случаи:
monitoring— конфигурации вpve-monitoring.ymlнет вообще, там только создание. Настраиваетplaybooks/uptime-kuma.yml(рольuptime_kuma).playbooks/monitoring.yml(замороженный Prometheus) НЕ запускать.gyro— конфигурация вplaybooks/gyro.yml, нужен пароль Ansible Vault (make gyro/--ask-vault-pass). Роль читаетhost_vars/gyro/. Во время cutover использовался временный host_vars shim; после перевода CT 156 на боевой IP он удалён.
-
Проверить, что сервис поднялся на
TMPIP(его health-эндпоинт или порт). У сервисов с данными новый контейнер на этом шаге работает на ПУСТОМ состоянии — данные приезжают только на шаге 4.4. Пустой Gitea, пустой Vaultwarden с экраном создания учётки, пустая Uptime Kuma, свежая схема Flyway у grimmory — это ожидаемый результат шага 4.3, а не поломка. Поднимать новую копию на боевых данных до остановки старой НЕЛЬЗЯ: две живые копии над одной SQLite или одной MariaDB повредят состояние.
4.4 Перенос данных
- Остановить сервис на СТАРОМ контейнере (
systemctl stop <unit>), сам контейнер пока не трогать. - Финальная синхронизация. Способ зависит от сервиса — см. раздел 5.
- Запустить сервис на новом, проверить на
TMPIP.
4.5 Переключение
ssh <node> sudo pct stop OLD.- В Tofu поменять адрес нового контейнера с
TMPIPнаIP,make tofu-apply CONFIRM=1. - Перезапустить контейнер, если адрес не подхватился на живую.
- Если конфигурация сервиса содержит его собственный адрес — прогнать
плейбук заново сразу после смены адреса. Из пакета 2026-09-02 это
касается только
grimmory(ports: <ip>:6060:6060и правилаGRIMMORY-FILTER): его compose всё ещё содержит TMPIP, и сервис не поднимется, пока файл не перегенерируют. Проверять просто:ss -ltnна новом контейнере — если сокет висит на конкретном адресе, а не на0.0.0.0/*, повторный прогон обязателен. У gitea, vaultwarden и uptime-kuma сокеты wildcard, им это не нужно. Прогон уже без-e pve_config_target: адрес стал боевым, и имя хоста само резолвится в новый контейнер.
- Если конфигурация сервиса содержит его собственный адрес — прогнать
плейбук заново сразу после смены адреса. Из пакета 2026-09-02 это
касается только
- Health-check на боевом
IP. - Если сервис публичный (
proxyв реестре) —make reverse-proxyи проверить домен снаружи. При сохранённом IP апстрим не меняется, но прогон подтвердит. - Обновить
~/.ssh/known_hosts— host key нового контейнера другой:ssh-keygen -R <IP>иssh-keygen -R <name>. Вssh_configстоитStrictHostKeyChecking accept-new, который принимает НОВЫЕ ключи, но не изменившиеся, поэтому без чистки подключение будет отвергнуто.- Заодно закрыть ControlMaster:
ssh -F ansible/ssh_config -O exit <host>. Мастер переживает cutover живым, но его туннель ведёт в уже остановленный контейнер; новые сессии мультиплексируются поверх мёртвого соединения и виснут сConnection timed out during banner exchange. Симптом выглядит как сетевая проблема, хотя контейнер полностью доступен с ноды. Разовый обход для диагностики —-o ControlPath=none. Найдено 2026-09-02 на docker-test. - У gitea SSH host-ключи git-over-ssh лежат в переносимых данных
(
<data>/ssh/ssh_host_*), поэтому порт 2222 после переезда отдаёт ТОТ ЖЕ ключ иknown_hostsgit-клиентов остаётся валидным. Менять нужно только ключ sshd самого контейнера (порт 22). - Про gyro и gitea: ранее записанное предупреждение неверно, проверено
2026-09-02. Утверждалось, что
roles/gyroпинит host key[192.168.1.25]:2222и после переезда gitea сломается. На самом деле задачаRemove obsolete Gitea known hostстоит сstate: absent— она УДАЛЯЕТ устаревшую запись, а не создаёт её. Пинитсяgyro_git_known_hosts_name: github.com, иgyro_repo_url—git@github.com:.... Gyro клонирует с GitHub, к gitea отношения не имеет. Никаких дополнительных действий после переезда gitea не нужно.
- Заодно закрыть ControlMaster:
4.5a Offsite restic: профиль надо переустановить на новом контейнере
Шаг, которого в плане не было. Найден 2026-09-02 на vaultwarden.
Если offsite-профиль restic выполняется ВНУТРИ контейнера сервиса (а не на
ноде), то на новом контейнере его нет: там нет ни restic, ни rclone, ни
systemd-таймера. Бэкап тихо перестаёт выполняться, и make backup-audit этого
не ловит — он ставит только свой таймер аудита.
Проверять так:
ssh <name> 'command -v restic rclone; systemctl list-timers --all | grep restic'
Чинить так:
make offsite-restic EXTRA="--limit <name>"
Затем обязательно проверить всю цепочку, а не только наличие таймера:
ssh <name> 'systemctl start homelab-restic-offsite-<name>.service'
ssh <name> 'journalctl -u homelab-restic-offsite-<name>.service -n 30 --no-pager'
Успех выглядит как подключение к репозиторию с уже существующими снапшотами и
Finished ... Deactivated successfully.
Кого это касается:
vaultwarden— профиль внутри LXC. Сделано и проверено 2026-09-02.grimmory— профиль тоже внутри LXC (hosts: grimmory). Понадобится то же.gitea— профиль выполняется на cloud-pc и указывает на/opt/data/gitea. Там задача другая: после переезда на volume этот путь исчезает, профиль надо переписать на выполнение внутри контейнера, по образцу vaultwarden.
4.6 Реестр и потребители
- В
ansible/inventory/group_vars/all/services.ymlпоменятьvmid: OLDнаvmid: NEW. Если менялсяprovisioner— поправить и его наtofu. - Убрать временные записи
<name>-newизhosts.ymlиssh_config. - Прогнать зависящие цели:
make backup-jobs(списки VMID выведутся заново),make backup-audit(перерендерит скрипт аудита с новым VMID). make validate— должен быть зелёный.make status— контейнер UP, юниты active.
4.7 Завершение
- Оставить
OLDостановленным минимум неделю. - Затем
pct destroy OLDи решить судьбу его цепочки бэкапов в PBS. - Удалить из
playbooks/pve-<name>.ymlчасть, создающую контейнер (первый play сpct create/ рольюpve_lxc, правками/etc/pve/lxc/*.confиpct set --features). Оставить конфигурационную часть.
5. Порядок сервисов и специфика
Порядок выбран по возрастанию риска. Не менять без причины.
| # | Сервис | VMID | Узел | Почему здесь |
|---|---|---|---|---|
| 1 | emergency-bot |
mini-pc | ПЕРЕЕХАЛ 2026-09-02. Полностью шаблонный, персистентных данных нет | |
| 2 | docker-test |
cloud-pc | ПЕРЕЕХАЛ 2026-09-02 | |
| 3 | gitea |
cloud-pc | ПЕРЕЕХАЛ 2026-09-02; bind mount → volume, данные впервые попали в PBS | |
| 4 | vaultwarden |
mini-pc | ПЕРЕЕХАЛ 2026-09-02 | |
| 5 | monitoring |
cloud-pc | ПЕРЕЕХАЛ 2026-09-02; замороженный стек не переносился | |
| 6 | gyro |
mini-pc | CUTOVER COMPLETED 2026-09-02; CT156 live on 192.168.1.35, CT150 held as rollback | |
| 7 | grimmory |
cloud-pc | ПЕРЕЕХАЛ 2026-09-02; MariaDB через dump/restore | |
| 8 | adguard |
mini-pc | ПЕРЕЕХАЛ 2026-09-03; данные /opt/adguard, DNS-провал ~2 мин | |
| 9 | mihomo |
mini-pc | ПЕРЕЕХАЛ 2026-09-03; первый боевой device_passthrough /dev/net/tun | |
| 10 | ovpn-mini |
mini-pc | ПЕРЕЕХАЛ 2026-09-03; туннель-петля при cutover, см. 5.10 | |
| — | hermes-ai |
147 | cloud-pc | заморожен и недостижим по SSH, см. раздел 7 |
| — | pbs |
120 | cloud-pc | не мигрировать |
Все мигрируемые сервисы (1-10) переехали на OpenTofu. Осталось: hermes-ai
(заморожен), pbs (не мигрируется). Пост-миграционная уборка раздела 6
(удаление roles/pve_lxc, стрип creation plays у 8 плейбуков, правки
architecture.md) — отдельная задача, ещё не сделана; частично разблокирована.
Специфика по сервисам
1. emergency-bot. ПЕРЕЕХАЛ 2026-09-02: OLD 148 остановлен, NEW 151 боевой
на 192.168.1.32. Данных нет вообще: roles/emergency_bot разворачивает
emergency_bot.py.j2, .env.j2 и юнит из шаблонов. Шаг 4.4 пропущен целиком.
Бэкапа у него нет (backup: none) и это не мешает — он воспроизводим из
репозитория. Нужны EMERGENCY_* в корневом .env. Хороший первый заход
именно потому, что ошибиться почти негде — тем не менее, реальность разошлась
с планом в двух местах, оба задокументированы там, где их искать:
playbooks/pve-emergency-bot.ymlне содержит конфигурационной части. У этого сервиса она в отдельномplaybooks/emergency-access.yml, причём два из четырёх play там таргетятmini-pcиru-vps, а не сам контейнер (доверие reverse-SSH черезauthorized_keyсexclusive: true— полная замена ключа, не добавление). Шаг 4.3.9 в его буквальном виде (--limit <name>-newна существующем плейбуке) для этого сервиса не работает:hosts:там — жёсткие имена, не группа, и полный прогон противmini-pc/ru-vpsдо cutover преждевременно переключил бы доверие. Кроме того,emergency_bot.py.j2использует TelegramgetUpdates(не webhook) — запуск второй копии с тем жеEMERGENCY_BOT_TOKEN, что и боевая, ловит 409 conflict. Решение для шага 4.3 при следующем сервисе с похожей архитектурой: деплоить только play(-и), таргетящие сам новый контейнер, через одноразовый playbook сhosts: <name>-newи заведомо нерабочим, но валидным по формату токеном/credential — так проверяется факт деплоя (юнит стартует, зависимости стоят, egress работает) без конфликта с боевым инстансом. Полный прогон с реальными credentials — уже после cutover, когда исходный playbook с жёсткимhosts: <name>сам начинает резолвиться в новый контейнер (адрес не менялся, значит--limitне нужен вообще).cmode— провайдер отслеживает его через блокconsole, но по умолчанию блок не объявлен. Подробности и правильная декларация —tofu/README.mdи шаг 4.2 выше.
2. docker-test (145). Тоже без данных. Проверяет проброс /dev/fuse
через features.fuse на реальном сервисе (не через device_passthrough —
см. уточнение в шаге 4.2).
3. gitea (141 -> 153). Главный приз и самый содержательный шаг.
-
Было:
mp0: /opt/data/gitea,mp=/opt/gitea/data— bind mount каталога ноды. Стало и подтверждено на живом контейнере 2026-09-02:mp0: data:153/vm-153-disk-1.raw,mp=/opt/gitea/data,size=32G— независимый volume. Внутри виден как отдельная ФС/dev/loop10, ext4, 32 ГиБ. Ради этого миграция и затевалась. -
Данные:
/opt/data/giteaна cloud-pc (2.3 ГБ) → внутрь нового контейнера. Каталогиgit/(репозитории),gitea/(в т.ч. SQLitegitea/gitea.db, ~37 МиБ) иssh/. На ноде всё принадлежит101000:101000— это host-side отображение uid 1000 внутри unprivileged LXC. Последовательность составлена по итогам разведки, но НЕ исполнялась:ssh gitea 'systemctl stop gitea' # OLD, адрес ещё боевой ssh cloud-pc "sudo sqlite3 /opt/data/gitea/gitea/gitea.db \ 'PRAGMA integrity_check;'" # ожидается ok # tar по SSH: rsync не годится, его нет на свежем контейнере, # а tar есть в любом Debian ssh cloud-pc 'sudo tar -C /opt/data/gitea -cf - .' \ | ssh gitea-new 'tar -C /opt/gitea/data -xf -' # volume создан с нуля, поэтому владельца выставить явно ИЗНУТРИ # контейнера — так результат не зависит от offset idmap ssh gitea-new 'chown -R 1000:1000 /opt/gitea/data' ssh gitea-new "sqlite3 /opt/gitea/data/gitea/gitea.db 'PRAGMA integrity_check;'" ssh gitea-new 'systemctl start gitea'Копирование внутри одного узла упирается в скорость диска, не сети: порядка 1-3 минут на 2.3 ГБ. Общее окно простоя
git.ada-dev.ru— 5-10 минут. -
Offsite restic-профиль
giteaвыполняется на cloud-pc, а не внутри LXC, и указывает на/opt/data/gitea. После переезда на volume этот путь исчезнет — профиль вplaybooks/offsite-restic-yadisk.ymlпридётся переписать на новый источник. Не забыть, иначе offsite-бэкап Gitea тихо перестанет работать. -
НЕВЕРНО, снято 2026-09-02: тамroles/gyroпинит SSH host key gitea — обновить.state: absent(удаление устаревшей записи), а пинится github.com. Подробности — в шаге 4.5.19. -
Публичный домен
git.ada-dev.ru— после cutovermake reverse-proxy.
4. vaultwarden (140 -> 154). SQLite /opt/vaultwarden/data/db.sqlite3,
публичный домен pass.ada-dev.ru.
Разведка 2026-09-02: каталог данных 2.9 МБ; db.sqlite3 с живыми -wal/-shm,
icon_cache, rsa_key.pem, config.json. Каталогов attachments/ и sends/
нет, эти возможности не используются. config.json содержит ADMIN_TOKEN и
SMTP, Ansible ими не управляет; они переедут вместе с каталогом. Это
ожидаемо, но файл не должен попасть в репозиторий.
Offsite-профиль restic здесь выполняется ВНУТРИ контейнера
(hosts: vaultwarden, offsite_source_path: /opt/vaultwarden/data), а не на
ноде, и адрес при переезде не меняется, поэтому правки профиля не требуется
в отличие от gitea.
Оба контейнера на mini-pc, поэтому копирование идёт через pct exec без
промежуточного файла. Последовательность составлена по итогам разведки, но НЕ
исполнялась:
ssh mini-pc 'sudo pgrep -x vzdump' # ожидается пусто
ssh mini-pc 'sudo pct exec 140 -- systemctl stop vaultwarden'
# .backup сливает WAL в один файл и заодно проверяет источник
ssh mini-pc "sudo pct exec 140 -- sqlite3 -cmd 'PRAGMA busy_timeout=30000;' \
/opt/vaultwarden/data/db.sqlite3 \".backup '/opt/vaultwarden/data/db.sqlite3.migrate'\""
ssh mini-pc 'sudo pct exec 140 -- sqlite3 \
/opt/vaultwarden/data/db.sqlite3.migrate "PRAGMA integrity_check;"'
ssh mini-pc 'sudo sh -c "pct exec 140 -- tar --exclude=./db.sqlite3 \
--exclude=./db.sqlite3-wal --exclude=./db.sqlite3-shm \
-cf - -C /opt/vaultwarden/data . | pct exec 154 -- tar -xf - -C /opt/vaultwarden/data"'
ssh mini-pc 'sudo pct exec 154 -- mv /opt/vaultwarden/data/db.sqlite3.migrate \
/opt/vaultwarden/data/db.sqlite3'
ssh mini-pc 'sudo pct exec 154 -- sqlite3 /opt/vaultwarden/data/db.sqlite3 \
"PRAGMA integrity_check;"'
ssh mini-pc 'sudo pct exec 140 -- rm /opt/vaultwarden/data/db.sqlite3.migrate'
ssh mini-pc 'sudo pct exec 154 -- systemctl restart vaultwarden'
Объём крошечный, простой на данных порядка полуминуты: время съедают stop/restart и проверки, а не копирование.
5. monitoring (146 -> 155). Замороженный Prometheus-стек в новый
контейнер не разворачивать.
Данные Uptime Kuma лежат НЕ в /opt/monitoring, как утверждалось раньше.
Разведка 2026-09-02: это два независимых каталога верхнего уровня.
| Путь | Размер | Что это |
|---|---|---|
/opt/uptime-kuma |
27 МБ | активный сервис, переносить целиком |
/opt/uptime-kuma/data/kuma.db |
25.6 МиБ | мониторы и уведомления |
/opt/monitoring |
1.7 ГБ | замороженный стек, почти всё — TSDB Prometheus |
Переносить нужно только /opt/uptime-kuma. TSDB замороженного стека переносить
незачем: читать его в новом контейнере будет нечему.
Конфигурация нового контейнера — playbooks/uptime-kuma.yml. У
pve-monitoring.yml конфигурационной части нет вообще, только создание.
playbooks/monitoring.yml не запускать.
Последовательность составлена по итогам разведки, но НЕ исполнялась:
ssh cloud-pc 'sudo pct exec 146 -- sqlite3 /opt/uptime-kuma/data/kuma.db \
"PRAGMA wal_checkpoint(TRUNCATE);"'
ssh cloud-pc 'sudo pct exec 146 -- systemctl stop uptime-kuma'
ssh cloud-pc 'sudo pct exec 146 -- sqlite3 /opt/uptime-kuma/data/kuma.db \
"PRAGMA integrity_check;"'
ssh cloud-pc 'sudo sh -c "pct exec 146 -- tar -C /opt -cpf - uptime-kuma \
| pct exec 155 -- tar -C /opt -xpf -"'
ssh cloud-pc 'sudo pct exec 155 -- sqlite3 /opt/uptime-kuma/data/kuma.db \
"PRAGMA integrity_check;"'
ssh cloud-pc 'sudo pct exec 155 -- systemctl start uptime-kuma'
Вторую копию нельзя поднимать на боевых мониторах до остановки первой: Uptime Kuma активно пробит сервисы и шлёт уведомления, две копии дадут дубликаты алертов.
Уменьшение контейнера — отдельное решение, не часть переезда. Цифры для него, снятые 2026-09-02: боевой CT 146 занимает 173 МБ RAM из 4096 и 4.7 ГБ из 24; новый CT 155 без Prometheus-стека — 184 МБ RAM и 1.8 ГБ диска.
В контейнере нет curl — для ручных HTTP-проверок использовать wget.
6. gyro (150 -> 156). CUTOVER COMPLETED 2026-09-02. CT 156 живёт на
неизменном production IP 192.168.1.35, provisioned by Tofu. CT 150
остановлен и удерживается как rollback минимум на неделю; его PBS chain не
удалялся. Файл фаервола 156.fw уже копия 150.fw, cluster firewall по-
прежнему disabled. Timer gyro.timer active, last oneshot succeeded.
Данных для переноса не было, и TMPIP-specific bind/re-run не требовались.
7. grimmory (149 -> 157). Самый болезненный, и единственный, где
конфигурационный play пришлось чинить.
Найденный баг и его исправление (2026-09-02). В конфигурационном play боевой адрес был зашит в трёх местах, из-за чего play невозможно было прогнать против любого другого контейнера:
ports: "192.168.1.34:6060:6060"в compose — Docker не биндит чужой адрес и роняетgrimmory.serviceещё до старта приложения;- правила
GRIMMORY-FILTER(--ctorigdst 192.168.1.34) — фильтровали бы не тот адрес; - URL задачи
Wait for Grimmory health endpoint. Это было опаснее всего: задача выполняется на самом целевом хосте, поэтому даже после починки биндинга она уходила бы по сети в БОЕВОЙ grimmory, получала 200 и давала ложно-положительный результат, ничего не проверив в новом контейнере.
Исправлено через grimmory_bind_ip: "{{ expected_lan_ip }}" — ту же идиому,
что использует roles/uptime_kuma. На боевом хосте переменная равна
192.168.1.34, поэтому рендер не изменился: --check --diff с --limit grimmory даёт changed=0, включая обе задачи, которые пишут адрес в файлы.
Следствие для шага 4.5, которого в плане не было. Grimmory — единственный
из шести сервисов пакета, кто биндится на конкретный адрес (ss -ltn на новых
контейнерах: gitea 0.0.0.0:3000, vaultwarden 0.0.0.0:80, uptime-kuma
*:3001, grimmory 192.168.1.16:6060). После смены адреса на боевой compose
всё ещё будет содержать TMPIP, и сервис не поднимется. Плейбук ОБЯЗАН быть
прогнан заново сразу после cutover — см. шаг 4.5.16.
Состояние после конфигурации на TMPIP (проверено): оба юнита active,
оба контейнера healthy, health=200 на 192.168.1.16, прогон идемпотентен
(changed=0).
Образы совпали с боевым по digest — grimmory/grimmory:v3.2.4@sha256:dfa7afdf…
и lscr.io/linuxserver/mariadb:11.4.8@sha256:91de7f70…. Это требование
legacy-warning.md: code-only downgrade после Flyway-миграции запрещён.
Flyway: схемы сошлись. На боевом и на новом контейнере одинаково —
MAX(version)=144, 142 миграции, все успешны. То есть тот же образ на пустой
базе приходит ровно к боевой версии схемы, и restore дампа новых миграций не
вызовет. Холодный старт с нуля занимает около 5 минут — health-check ретраится,
это нормально.
Перенос данных. /opt/grimmory — 311 МБ: books/ 139 МБ (библиотека),
data/ 5.6 МБ (обложки), mariadb/ 167 МБ. Файлы MariaDB копировать НЕЛЬЗЯ,
нужен dump/restore. Рабочий рецепт дампа уже есть в
tasks/offsite-restic-profile.yml (mariadb-dump --single-transaction --routines --events). Последовательность составлена по итогам разведки, но НЕ
исполнялась:
# приложение стоп, MariaDB оставить живой
ssh grimmory 'cd /opt/grimmory && docker compose stop grimmory'
ssh grimmory 'set -a; . /opt/grimmory/.env; set +a; \
docker exec -e MYSQL_PWD="$DB_PASSWORD" grimmory-mariadb mariadb-dump \
--user=grimmory --single-transaction --routines --events \
--databases grimmory > /opt/grimmory/backup-staging/grimmory.sql'
# библиотека и обложки
ssh cloud-pc 'sudo sh -c "pct exec 149 -- tar -C /opt/grimmory -cpf - books data \
| pct exec 157 -- tar -C /opt/grimmory -xpf -"'
# дамп на новый и restore
ssh cloud-pc 'sudo sh -c "pct exec 149 -- cat /opt/grimmory/backup-staging/grimmory.sql \
| pct exec 157 -- tee /opt/grimmory/backup-staging/grimmory.sql >/dev/null"'
ssh grimmory-new 'cd /opt/grimmory && docker compose stop grimmory'
ssh grimmory-new 'set -a; . /opt/grimmory/.env; set +a; \
docker exec -i -e MYSQL_PWD="$DB_PASSWORD" grimmory-mariadb mariadb \
--user=grimmory grimmory < /opt/grimmory/backup-staging/grimmory.sql'
ssh grimmory-new 'systemctl restart grimmory'
Дамп не содержит --add-drop-database. На новом контейнере схема уже
создана Flyway с нуля, поэтому перед restore её нужно либо очистить, либо
добавить эту опцию в дамп. Отдельно стоит знать, что restore-путь в этом
репозитории НИКОГДА не проверялся: edge-cases.md отмечает, что аудит
проверяет непустоту дампа, но не импортирует его.
Эталон для сверки после restore (снят с боевого 2026-09-02):
book 31, author 30, book_file 40, book_metadata 31, category 87,
reading_sessions 68, shelf 3, library 1, users 1, opds_user_v2 2,
koreader_user 1, flyway_schema_history 142.
После cutover — проверка OPDS на живой читалке, а не только curl'ом. Быстрая проверка заголовков:
curl -sS -D- -o /dev/null https://books.ada-dev.ru/api/v1/opds # atom+xml, без Content-Encoding
curl -sS -D- -o /dev/null https://books.ada-dev.ru/api/v1/opds/search.opds
curl -sS https://books.ada-dev.ru/api/v1/healthcheck
Затем в KOReader: добавить каталог, увидеть список полок, скачать книгу целиком, проверить синхронизацию прогресса.
8. adguard (144 → 158). ПЕРЕЕХАЛ 2026-09-03. OLD 144 остановлен (откат
≥ неделя, до ~2026-09-10). Данные /opt/adguard/{conf,work} (~313 МБ,
conf/AdGuardHome.yaml несёт хэш пароля, DNS rewrites, upstream, клиентов)
перенесены целиком через pct exec … tar (оба контейнера на mini-pc).
- Свежий AdGuard на пустом
conf/не проходит health-гейт плейбука. Он уходит в setup-wizard: порт 3000 отдаёт 302, порт 80 — connection reset, аpve-adguard.ymlждётhttp://127.0.0.1/[200,302]и падает после 24 ретраев. Поэтому для adguard данные пред-заливаются ДО шага 4.3 (config play), а не после. После пред-заливки прогон идемпотентен (ok=13 changed=0), фильтрация подтверждена:doubleclick.net → 0.0.0.0. - Ноды используют DNS роутера (192.168.1.1), контейнеры — 1.1.1.1 (из
pct confignameserver). Провал .28 задел только DHCP-клиентов LAN и рабочую станцию (у неё fallback на роутер). Окно ~2 мин, ночью. Роутер не трогали. - Сокеты wildcard (
0.0.0.0), повторный прогон после смены адреса не нужен.
9. mihomo (143 → 159). ПЕРЕЕХАЛ 2026-09-03. OLD 143 остановлен (откат
≥ неделя). Первый боевой сервис с двумя механизмами проброса устройств:
/dev/fuse через features.fuse, /dev/net/tun через блок
device_passthrough (dev0: path=/dev/net/tun,mode=0660) — оба под root@pam,
проверены здесь на живом сервисе (до этого device_passthrough был только на
пилоте).
- Данные
/opt/mihomo(~80 КБ):config/config.yaml,config/cache.db,config/providers/main.yaml.config.yamlсодержит URL подписки прокси- провайдера — секрет, копируетсяpct exec, в репозиторий не попадает. Задача «Install default mihomo config if missing» идёт сforce: false, перенесённый конфиг не перетирается. - Все сокеты wildcard (
0.0.0.0) — повторный прогон после смены адреса не нужен. Прокси реально проверен:curl -x .27:7890 …/generate_204 → 204. ru-vps-mihomo-harden.ymlповторного прогона НЕ требует: он таргетитhosts: ru-vps, работает со скриптом/usr/local/sbin/ru-vps-mihomo-hardenна ru-vps, адрес mihomo нигде в нём не зашит, и он заCONFIRM-гейтом.- Зависимые (
bash_config_proxy_*у hermes-ai,emergency_telegram_proxy,uptime_kuma_http_proxy, правило gyro-фаерволаOUT ACCEPT 192.168.1.27:7890,prometheus.yml.j2) все ссылаются на .27 — адрес сохранён, правок не нужно.
10. ovpn-mini (132 → 160). ПЕРЕЕХАЛ 2026-09-03 из локальной сети. OLD 132
остановлен (откат ≥ неделя). /dev/net/tun через device_passthrough.
features — только nesting (как в эталоне). Конфигурации в pve-ovpn-mini.yml
нет — шлюз настраивает openvpn-vps-mini.yml (роль openvpn_gateway, группа
vpn_openvpn). Единственные данные — /etc/openvpn/homelab/static.key, общий
с ru-vps; LAN-адрес ovpn-mini ни в одном шаблоне роли не фигурирует
(masquerade по -o eth0), поэтому туннель не зависит от смены адреса.
- Петля транспорта при cutover.
-F ansible/ssh_configдо нод PVE идёт ProxyJump через ru-vps, а ru-vps достаёт LAN ЧЕРЕЗ туннель, который терминирует ovpn-mini.make tofu-*строит SSH-туннель к PVE API тем же путём.pct stop 132кладёт туннель →make tofu-applyбольше не достаёт API. Разрыв: сначала поднять шлюз на НОВОМ контейнере ещё на TMPIP (одноразовый плейбукhosts: ru-vpsslurp ключа +hosts: <name>-newрольopenvpn_gateway), туннель встаёт с TMPIP-адреса →make tofu-applyснова работает → сменить адрес на боевой → повторныйopenvpn-vps-mini.yml(идемпотентен,changed=0на обоих концах). Прямой доступ к нодам во время провала —ssh -o ProxyJump=none <ansible-user>@192.168.1.{5,10}из LAN. ssh_config: уovpn-miniтеперьProxyJump none(в индивидуальном Host-блоке, побеждает по «первое значение опции»). Путь через ru-vps закольцовывался бы на его же туннель. Из LAN хост доступен напрямую; вне LAN управление — консоль ноды (pct exec) или заранее поднятый туннель.- НЕ запускать OLD 132 как диагностику. Его
pct configвсё ещё держитip=192.168.1.23/24; параллельный старт с CT 160 даёт конфликт .23 и роняет туннель на 1-2 мин (проверено случайно 2026-09-03, восстановилось само). make openvpn-check— 7/7 ok. Кворум кластера не затронут (qdevice ходит напрямую нода → ru-vps:5403, не через туннель).- Косметика:
polkit.serviceна CT 160 вfailed(status=217/USER— минимальный LXC-шаблон без нужного окружения polkit). На OpenVPN не влияет, не чинилось.
6. После завершения всех переездов
- Удалить
roles/pve_lxc— он станет не нужен. - Убрать из
services.ymlполеprovisionerсо значениямиpct_ssh/pve_lxcлибо заменить наtofu. - Обновить
docs/ai/architecture.md: раздел «Provisioning Flow» описывает два пути черезpctи API — оба исчезнут. - Обновить
legacy-warning.md: пункт про два provisioner-пути потеряет смысл. - Расширить
validate.yml: сейчас он сверяет hostname, IP, cores, memory, swap. После миграции имеет смысл добавить features, устройства и mount points — ровно те поля, которыми теперь управляет Tofu. - Решить, нужен ли
roles/lxc_docker_host— он до сих пор ни к чему не подключён.
7. Известные проблемы вне миграции
Не блокируют переезд, но про них надо знать.
hermes-ai(147) недостижим по SSH. Прозрачный прокси заворачивает вhermes-tunвсё, что не пришло сlo, включая ответные пакеты входящих соединений (ip rule9002). Ansible до хоста не достучится, управление — только черезpct exec. Сервис заморожен,make checkпоказывает его DOWN ожидаемо. Мигрировать не раньше, чем починится сеть.resticprofile-check@profile-defaultна ru-vps падает еженедельно: профильdefaultбез репозитория. Реальныйprofile-servicesработает. Шум в секции FAILED отчётаmake status.- Gitea runner зарегистрирован и опрашивает Gitea, но метка
ru-vpsне совпадает сruns-on: ubuntu-latestв workflow, поэтому CI не выполняется. Раннер монтирует/var/run/docker.sockи/opt/servicesна запись — перед включением CI это стоит пересмотреть. homelab_pve_egress_ipдинамический. При смене домашнего адреса qdevice замолчит. Видно в секции CLUSTER QUORUM отчётаmake status.bootstrap-pve-api-token.ymlперезаписывает корневой.envцеликом — вместе сPROXMOX_ROOT_PASSWORD,MONITORING_*иEMERGENCY_*. У задачи естьbackup: true, но восстанавливать придётся руками.- Устаревшие правила UFW на ru-vps (
3128,1080,993,7892) — за переключателемzt_cleanup_unrelated_stale_rulesвplaybooks/ru-vps-zerotier-decommission.yml, по умолчанию выключен.
8. Откат
До шага 4.5.14 (пока старый контейнер работает): просто не переключаться.
Удалить новый контейнер make tofu-destroy CONFIRM=1 или точечно.
После переключения, но до pct destroy: остановить новый, вернуть адрес
старому не нужно — он не менялся, pct start OLD возвращает всё как было.
Затем откатить vmid в services.yml и прогнать make backup-jobs,
make backup-audit, make validate.
После pct destroy OLD: только восстановление из PBS. Именно поэтому
шаг 4.1.3 (свежий бэкап) обязателен, а шаг 4.7.25 (неделя ожидания) не
сокращается.