# Журнал миграции в Timeweb Хронология фактического переезда. План и архитектурные решения — в [timeweb.md](timeweb.md). Здесь только то, что реально сделано, с датами. Новые записи — сверху. --- ## Шаг 14 — VM в YC остановлена (2026-05-23, выполнено) Через несколько часов после cutover'а — выключил VM `rivendell-v2` в панели Yandex Cloud (stop, не delete). Источник перешёл в состояние «холодного запасного». Формально план рекомендовал держать источник в живых ≥24 часа перед остановкой (`timeweb.md:464`), но: - docker и cron на источнике остановлены и `disable`нуты ещё на Шаге 11 — VM работала вхолостую. - Ключевые приложения проверены в браузере на target (см. Шаг 13). - **Stop, не destroy** — состояние VM и диск сохраняются, при необходимости отката достаточно `Start` в панели + `systemctl enable --now docker cron` + откат DNS. Прирост к рекавери ~1-2 мин по сравнению со running idle. Compute снят со счёта (Timeweb-VM теперь единственный источник расходов). S3-бакет с restic-бэкапами и Container Registry в YC **не трогаем** — продолжают использоваться с Timeweb. ### Что осталось Через неделю-две, если ничего не всплыло: - Удалить VM `rivendell-v2` и связанные compute-ресурсы (только compute! S3 и CR — оставляем). - Удалить `production.yml`, переименовать `timeweb.yml` → `production.yml`, откатить `HOSTS_FILE` в `tasks.py`. Закоммитить. - Перенести `timeweb.md` и `timeweb-migration-log.md` из `docs/drafts/` куда-нибудь в архив или удалить — план выполнен, журнал теряет актуальность. --- ## Шаг 13 — приложения подняты на target, cutover завершён (2026-05-23, выполнено) После rsync'а (Шаг 12) — финальный прогон ансибла без `--skip-tags`, поэтапно по приложениям. К ~16:30 DNS уже указывал на target (Шаг переключения 15:45 + TTL 20 мин, пропагация подтверждена в 16:20), так что Caddy при старте сразу пошёл за LE-сертификатами без задержек. Прогоны делал поштучно через `inv pl -- ` (после Шага переключения `HOSTS_FILE = "timeweb.yml"` в `tasks.py`), не всем сразу — чтобы видеть каждый плейбук чисто. ### Что подтверждено работающим в браузере - `vakhrushev.me` — homepage отдаёт страницу. - `auth.vakhrushev.me` — Authelia, логин работает. - `matrix.vakhrushev.me` — Tuwunel поднялся, Element подключается. - `git.vakhrushev.me` — Gitea, репозитории и issue tracker на месте. - `outline.vakhrushev.me` — документы видны. - `gramps.vakhrushev.me` — генеалогическое дерево открывается. - `wakapi.vakhrushev.me` — статистика времени видна. - `status.vakhrushev.me` — Netdata собирает и рисует метрики. Точечно зашёл в outline / gramps / wakapi / gitea — данные на месте, ничего не потерялось при rsync'е. ### Отложенные на «потом по ходу дела» проверки - `miniflux`, `memos`, `remembos`, `wanderer`, `calibre`, `rssbridge`, `dozzle`, `goaccess` — открыть и убедиться, что отдают свои данные. - **SMTP-test** — reset-password из gitea/authelia. Проверит, что Postbox после разблокировки в панели Timeweb принимает наши письма. - **Backup-cron в 1:00** — самый поздний smoke-тест системы. Покажет, что `backup-all.py` отработал на target, restic пишет в S3 с новым `host_name`, apprise шлёт уведомление. - `docker pull cr.yandex/...` руками — повторная проверка OAuth-аутентификации. ### Отклонения от плана сегодня 1. **VPS пересоздан в СПб** (Шаг 8) — первая выдача попала на гипервизор с битой сетью. 2. **Docker Hub rate limit** на pull'е netdata — anonymous лимит подсети Timeweb уже выбран соседями. Лечится ручным `sudo docker login` на target (через free-аккаунт + PAT). **Backlog:** добавить `community.docker.docker_login` для `docker.io` в `playbook-docker.yml`, по аналогии с cr.yandex (Шаг 3). Креды в vault как `dockerhub_username` / `dockerhub_token`. 3. **Postbox SMTP не доступен извне YC** — оказалось, что в плане (`timeweb.md:81`) предпосылка «Postbox доступен извне YC по тем же credentials» неверна. Yandex Cloud Postbox дропает SMTP от не-YC источников; 443 при этом отвечает. Дополнительно Timeweb по умолчанию **сам** блокирует egress SMTP (25/465/587) — toggle в панели Timeweb снимает блок, после чего Postbox отвечает баннером. Authelia в exit-loop'е поднялась после рестарта. Запись в auto- memory `project_timeweb_smtp_block.md` — пригодится при следующих миграциях. 4. **Bug ordering в `playbook-goaccess.yml`** (см. Шаг 9, фикс зашит) — латентный bug, проявившийся только на чистой машине. ### Что осталось до полной заморозки По плану (`timeweb.md:464-473`): - **≥ 24 часа** держим источник в выключенном состоянии (docker уже остановлен, daemon отключён через `disable`), как горячее запасное. - Если за сутки ничего не всплыло — выключить VM в YC. - Подождать ещё неделю-две — на всякий случай. - Удалить VM и связанные compute-ресурсы. **S3-бакет с restic-бэкапами и Container Registry — оставляем**, они продолжают использоваться. - Удалить `production.yml`, переименовать `timeweb.yml` → `production.yml`, откатить `HOSTS_FILE = "production.yml"` в `tasks.py`. Закоммитить. --- ## Шаг 12 — rsync данных с источника на target (2026-05-23, выполнено) Перенос `/mnt/applications/` на YC → `/srv/applications/` на Timeweb после заморозки источника (Шаг 11). Это финальный канал переноса данных — основной для всех приложений, единственный для `caddyproxy`, `remembos`, `transcriber` (у которых нет backup-механизма, см. Шаг 7b). ### Пилотный прогон на remembos Прежде чем гнать всё дерево, проверил рецепт на самом маленьком приложении (~35 КБ всего): ```bash sudo -E rsync -aAX --info=progress2 --delete --rsync-path="sudo rsync" \ -e "ssh -o StrictHostKeyChecking=accept-new" \ major@158.160.46.255:/mnt/applications/remembos/ \ /srv/applications/remembos/ ``` Проверка после прогона: ``` $ sudo ls -la /srv/applications/remembos/ drwxr-x--- 4 remembos remembos 4096 Apr 30 13:22 . drwxr-x--- 2 remembos remembos 4096 Feb 12 17:22 config drwxr-x--- 2 remembos remembos 4096 May 23 12:41 data -rw-r----- 1 remembos remembos 494 Apr 30 13:22 docker-compose.yml ``` Owner отрисован именами (`remembos:remembos`, не numeric `1103:1103`) — значит на обеих сторонах ансибл создал юзера с одним и тем же uid, mapping сошёлся. Mode (750) и mtime сохранены. ### Засада с agent-forwarding'ом под sudo Первая попытка упала с `Permission denied (publickey)`. Причина: rsync запускается через `sudo` на target, а sudo по дефолту чистит `SSH_AUTH_SOCK` из env (`Defaults env_reset` в /etc/sudoers) — ssh внутри sudo не видит проброшенный agent, пытается парольную аутентификацию, проваливается. Лечится разрешением sudo проносить именно эту переменную: ```bash echo 'Defaults env_keep += "SSH_AUTH_SOCK"' | sudo tee -a /etc/sudoers.d/major sudo visudo -cf /etc/sudoers.d/major ``` Безопасно: сокет агента принадлежит `major`, root к нему имеет доступ по определению; мы просто говорим sudo не вычищать переменную с путём к нему. После этого `sudo -E rsync …` отрабатывает. ### Полный прогон по всем приложениям ```bash sudo -E rsync -aAX --info=progress2 --delete --exclude='lost+found' \ --rsync-path="sudo rsync" \ -e "ssh -o StrictHostKeyChecking=accept-new" \ major@158.160.46.255:/mnt/applications/ \ /srv/applications/ ``` ### Что делает каждый флаг - **`sudo -E`** — локальный rsync на target запускается под root (нужно, чтобы писать файлы с любым owner'ом / mode); `-E` сохраняет env, в первую очередь `SSH_AUTH_SOCK` для agent forwarding. - **`-a`** (`--archive`) — собирательный флаг `-rlptgoD`: recursive + symlinks как symlinks + permissions + times + group + owner + special files. Базовое «копировать всё как есть». - **`-A`** — сохранить POSIX ACL. - **`-X`** — сохранить extended attributes (xattrs), включая security-атрибуты типа capabilities или SELinux-меток. - **`--info=progress2`** — совокупный прогресс по всему transfer'у, а не per-file (для больших деревьев читабельнее). - **`--delete`** — стереть на target всё, чего нет на источнике. Безопасно в нашем случае: после rsync'а прогоняем ансибл, он перерендерит конфиги и пересоздаст любые отсутствующие структурные каталоги. Стирается, по сути, только содержимое, отрендеренное плейбуком на Шаге 9 без `run-app`. - **`--exclude='lost+found'`** — на YC `/mnt/applications/` это mount point внешнего диска, в его корне может лежать системный `lost+found`. Нам он не нужен и на target такого монтирования больше нет (`mount_external_storage: false`). - **`--rsync-path="sudo rsync"`** — критично: на удалённой стороне (источнике) rsync запускается через sudo. Иначе он стартует под `major`, у которого нет прав читать чужие `/mnt/applications//` (mode 750, owner — приложение). У `major` на источнике NOPASSWD sudo, так что sudo прокатывает молча. - **`-e "ssh -o StrictHostKeyChecking=accept-new"`** — кастомная команда транспорта. По умолчанию rsync запускает чистый `ssh`; мы добавляем флаг для автопринятия host key источника (на target `known_hosts` ещё пустой). - **`major@158.160.46.255:/mnt/applications/`** — источник. Trailing slash важен: «копировать содержимое каталога», а не сам каталог. Без слэша получили бы `/srv/applications/applications/...`. - **`/srv/applications/`** — назначение. Trailing slash для симметрии — содержимое кладётся в существующий каталог, созданный ансиблом на Шаге 9. ### Результат ``` 22,613,081,829 99% 7.11MB/s 0:50:34 (xfr#21837, to-chk=0/31024) ``` - Объём — ~22.6 ГБ, файлов — 31 024. - Длительность — 50 минут 34 секунды, средняя скорость ~7 МБ/с (предсказуемо для YC↔Timeweb). - `du -s` после прогона: источник 22 088 224 КБ, target 22 164 172 КБ — разница ~76 МБ (0.34%). Это не рассинхрон данных, а разница в аллокации блоков ФС и метаданных между источником и target (разные inode-таблицы, journal, group descriptors). Содержимое файлов совпадает — rsync'у на это указали checksum'ы, errors не было. Окно даунтайма с момента стопа docker'а (Шаг 11) до конца rsync'а — около часа. С учётом параллельно запущенного DNS-переключения (Шаг между 11 и 12, 15:45) к моменту запуска приложений на target пропагация уже прошла (16:20). --- ## Шаг 11 — источник заморожен (docker + cron остановлены) (2026-05-23, выполнено) Сразу после финального бэкапа (Шаг 10) — отключил docker и cron на источнике, чтобы зафиксировать состояние данных перед rsync'ом и исключить случайные записи в `/mnt/applications/` во время переноса. ```bash sudo systemctl stop docker.service docker.socket sudo systemctl disable docker.service docker.socket sudo systemctl stop cron ``` `disable` — страховка от автостарта docker'а при возможной перезагрузке источника (если вернёмся для отката или проверки). `cron stop` — чтобы ночной `backup-all.py` не запустился впустую без работающего daemon'а. С этого момента источник «мёртв» для пользователей — окно даунтайма открыто. Следующий шаг — переключить DNS и параллельно гнать rsync. --- ## Шаг 10 — финальный бэкап на источнике (2026-05-23, выполнено) Прогнал `backup-all.py` на источнике, пока docker ещё жив (он нужен для `pg_dump` и других in-container backup-команд внутри `backup.sh`-скриптов отдельных приложений). ```bash sudo /usr/local/sbin/backup-all.py 2>&1 | tee /tmp/final-backup.log ``` Свежий restic-снапшот в `yandex_cloud_s3` зафиксирован — страховочный канал на случай, если rsync пойдёт криво (для приложений с `backup.sh` можно будет восстановить из S3; для `caddyproxy`, `remembos`, `transcriber` страховки нет, для них только rsync). После прогона можно гасить docker без риска потерять backup-окно. --- ## Шаг 9 — раскатана база и приложения без запуска (2026-05-23, выполнено) На свежей Timeweb-машине прогнаны два плейбука без даунтайма источника (контейнеры на target не запускались). ### 9a. Системная база ```bash uv run ansible -i timeweb.yml -m ping server # pong uv run ansible-playbook -i timeweb.yml --diff playbook-all-setup.yml ``` После прогона на target поднято: apt-пакеты (`geerlingguy.security`), docker + сети (`web_proxy_network`, `monitoring_network`), eget с инструментами (restic, rclone, btop, zellij и др.), ufw (порты 22, 2222, 80, 443), fail2ban, backup-инфра (`backup-all.py`, resticprofile, cron). Заодно `geerlingguy.security` отключил root по SSH и `PasswordAuthentication` — root-канал закрыт, доступ только через `major` + ключ. Перепроверено `ssh major@<новый-ip>` — работает. ### 9b. Application-плейбуки без запуска контейнеров ```bash uv run ansible-playbook -i timeweb.yml --diff \ --skip-tags run-app \ playbook-all-applications.yml ``` На target созданы все ``-пользователи с правильными uid/gid (совпадают с источником, см. таблицу в [плане](timeweb.md)), каталоги `/srv/applications//{data,config,backups}`, отрендерены `docker-compose.yml` и application-конфиги. Контейнеры **не** запускались — это шаг 5 cutover'а (после rsync'а данных). OAuth-аутентификация в `cr.yandex` (из Шага 3) сработала с Timeweb-айпишника без замечаний — `community.docker.docker_login` в плейбуках homepage и transcriber прошёл. ### Обнаруженный латентный bug ordering'а в goaccess На fresh-install упала задача `playbook-goaccess.yml:55 «Ensure caddy access log exists before goaccess starts»` — пыталась туч'ить файл в `/var/log/caddy/`, который к этому моменту не существовал. Причина: каталог создаётся в `playbook-caddyproxy.yml`, а в `playbook-all-applications.yml` goaccess идёт **раньше** caddyproxy (caddyproxy специально последний, чтобы стартовать после backends). На предыдущем сервере не проявлялось — каталог уже существовал от прошлых прогонов. Фикс: добавил в `playbook-goaccess.yml` явное создание `caddy_logs_dir` перед touch'ем `access.log`. Owner/mode выставит caddyproxy при своём прогоне, идемпотентность сохранена. **Backlog (после миграции):** `caddy_logs_dir` — shared-ресурс между плеями (caddyproxy пишет, goaccess читает), концептуально это provisioning-time забота. Вынести его создание в `playbook-system.yml` (или в отдельный shared-resources плей в `playbook-all-setup.yml`) и убрать дубль из goaccess/caddyproxy. Делать после переезда отдельным PR, не во время миграции. --- ## Шаг 8 — VPS заказан, пользователь `major` создан (2026-05-23, выполнено) Заказан Cloud VPS в Timeweb по тарифу из плана (4 × 3.3 ГГц, 8 ГБ RAM, 80 ГБ NVMe, Ubuntu 24.04 LTS), ДЦ Санкт-Петербург. Первая выданная VPS попала на гипервизор с битой сетью: TCP-handshake проходил нормально, но первый data-сегмент в любой TCP-сессии не доставлялся ни в одну сторону. Подтверждено: - `nc -l 12345` на сервере не получал данные от клиента, при этом клиент видел `Connection succeeded`; - strace зависшего `sshd: [accepted]`-child показывал `read(socket, ..., 1) = ERESTARTSYS`, далее `SIGALRM` через 120 сек по `LoginGraceTime` → exit (т.е. sshd ушёл в `read()` за клиентским баннером и не дождался); - `iptables -S` / `nft list ruleset` / `ufw status` — пусто, локального firewall нет; - исходящие соединения с VM (`curl http://example.com`) работали штатно — ломались только входящие data-сегменты после handshake. Ребут и переустановка ОС из панели не помогли. Пересоздал VPS в ДЦ СПб с новым IP — заработало с первой попытки. Потеря времени ~1 час; на будущее: при таком паттерне сразу пересоздаём в другом ДЦ, глубже диагностику не ведём (это однозначно проблема сети провайдера). ### Bootstrap пользователя `major` На свежей VPS только root по SSH-ключу. Поднял пользователя аналогично YC-серверу — sudo через NOPASSWD, вход только по ключу. Дальше `geerlingguy.security` + `roles/owner` пересоздадут пользователя идемпотентно с теми же uid/gid и приклеят политику sshd при первом прогоне ансибла. ```bash # 1. Создать пользователя с home и bash, добавить в sudo useradd -m -s /bin/bash major usermod -aG sudo major # 2. NOPASSWD-политика sudo echo 'major ALL=(ALL) NOPASSWD: ALL' > /etc/sudoers.d/major chmod 0440 /etc/sudoers.d/major visudo -cf /etc/sudoers.d/major # должно сказать "parsed OK" # 3. SSH-ключ (тот же, что залит для root при создании VPS) install -d -m 700 -o major -g major /home/major/.ssh install -m 600 -o major -g major \ /root/.ssh/authorized_keys \ /home/major/.ssh/authorized_keys ``` Проверка с локальной машины: ```bash ssh major@<новый-ip> sudo whoami # root, без пароля ``` Прошло. Root-доступ по SSH пока оставлен как резервный канал — первый прогон ансибла отключит его через `geerlingguy.security` (`PermitRootLogin no`, `PasswordAuthentication no`). --- ## Шаг 7 — `run-app` тег + унификация registry (2026-05-22, выполнено) По итогам аудита подготовительных задач выявлены и закрыты две несостыковки: ### 7a. Пропущенный `run-app` тег в remembos В `playbook-remembos.yml:73` была задача `Restart docker compose services if config changed but not docker-compose.yml` (условный рестарт через `state: restarted`, триггер — изменение `config.toml` без изменения `docker-compose.yml`), у неё не было тега `run-app`. На cutover'е при `--skip-tags run-app` основной запуск пропустился бы (правильно), а эта условная задача всё равно сработала бы (потому что её `when:` истинно при первом деплое — конфиг создаётся), попыталась бы рестартануть несуществующий compose-стек и упала. Тег добавлен. ### 7b. Унификация `registry_url` в docker_login `playbook-homepage.yml` и `playbook-transcriber.yml` использовали хардкод `registry_url: "cr.yandex"`, а `playbook-remembos.yml` — `'{{ yc_container_registry }}'` из vault. Привёл к одному виду: теперь во всех трёх — `"{{ yc_container_registry }}"` из vault. `docker_registry_prefix` в `vars/homepage.yml` и `vars/transcriber.yml` не трогал — там полный image-prefix вида `cr.yandex/`, это отдельная концепция (есть отдельный vault-var `yc_container_registry_repository`, используемый в `files/remembos/docker-compose.template.yml`). Если позже захочется унифицировать целиком — это отдельная итерация. ### Аудит бэкапов: gap'ы по `caddyproxy`, `remembos`, `transcriber` Эти три приложения имеют состояние в `data_dir`, но не имеют ни `backup.template.sh`, ни ansible-генерируемого `backup-targets`. Для миграции это закрывается через **rsync** на cutover'е — данные переносятся напрямую, без зависимости от restic-снапшотов: - `caddyproxy/data/` — TLS-сертификаты Let's Encrypt (важно, чтобы не упереться в rate-limit LE при перевыпуске ~17 сертов). - `remembos/data/` — user data (memos-токен, telegram tokens). - `transcriber/data/` — пользовательские транскрипции. Это означает: на этапе rsync (шаг 4 cutover'а в плане) **нельзя** полагаться только на restic-restore — для этих трёх апов rsync — единственный канал. Для остальных приложений (которые имеют `backup.sh` или `backup-targets`) можно при необходимости использовать restic как фолбэк, но rsync всё равно остаётся основным методом. Долгосрочно — добавить им backup-механизм отдельной итерацией после миграции. Сейчас это сверх сферы. --- ## Шаг 6 — `vars/vars.yml` загружается во всех плейбуках (2026-05-22, выполнено) Сегодняшний коммит `8378f0e` («Migration: expose some public vars») вынес общие переменные (`application_dir`, `host_name`, `primary_user`, `primary_user_uid`, `primary_user_gid`, `bin_prefix`, `apprise_external_port`, `apprise_external_url`, `caddy_logs_dir`) из vault в `vars/vars.yml`. Но большая часть плейбуков загружала только `vars/secrets.yml` — на текущем сервере они работали лишь потому, что inventory дублирует `application_dir` как override. На чистом Timeweb-инвентаре без override они бы упали с undefined. Прошёлся по всем плейбукам, добавил `- vars/vars.yml` сразу после `- vars/secrets.yml`: ``` playbook-authelia.yml playbook-netdata.yml playbook-calibre.yml playbook-outline.yml playbook-docker.yml playbook-remembos.yml playbook-dozzle.yml playbook-rssbridge.yml playbook-eget.yml playbook-transcriber.yml playbook-gitea.yml playbook-transcriber-registry.yml playbook-gramps.yml playbook-tuwunel.yml playbook-homepage.yml playbook-ufw.yml playbook-homepage-registry.yml playbook-upgrade.yml playbook-memos.yml playbook-wakapi.yml playbook-miniflux.yml playbook-wanderer.yml ``` (21 файл — все «обычные» плейбуки, которые ещё не подключали vars.yml.) Aggregator'ы `playbook-all-applications.yml` и `playbook-all-setup.yml` не трогал — у них нет собственных `vars_files`, они используют `import_playbook`, каждый импортируемый плейбук уже сам подключает `vars.yml`. `yamllint` чист. Идемпотентность проверить отдельным прогоном. Проверить прогоном `inv pl -- all-applications` (или хотя бы `inv pl -- gitea outline miniflux`) на текущем сервере — diff ожидается пустой. --- ## Шаг 5 — переезд default application_dir на /srv (2026-05-22, выполнено) `/mnt` по FHS — место для точек монтирования внешних дисков; на системном диске Timeweb (фаза 1) это семантически неверно. Поменяли дефолт на `/srv/applications` (FHS: «data for services provided by this system»), для текущего YC-сервера сделали override в инвентаре. Изменения: - `vars/vars.yml` — `application_dir: "/srv/applications"` (комментарий обновлён). - `production.yml` — у хоста `server` добавлен override `application_dir: "/mnt/applications"`. - `playbook-system.yml` — добавлен `vars/vars.yml` в `vars_files`, захардкоженный `/mnt/applications` в задачах `Create directory for mount` и `Mount external storages` заменён на `{{ application_dir }}`. - `playbook-remove-user-and-app.yml` — то же самое (`vars/vars.yml` в `vars_files` + `{{ (application_dir, user_name) | path_join }}`). - `tasks.py` — новый helper `_application_dir()` читает значение сначала из inventory (override), затем из `vars/vars.yml`. `login_as_app` больше не содержит `/mnt/applications`. Что остаётся хардкодом — только `/mnt/applications` в `production.yml` как override, и это правильно. На Timeweb-инвентаре (когда появится) можно либо не задавать `application_dir` вовсе (применится дефолт `/srv/applications`), либо задать явно — для читаемости. Проверить прогоном `inv pl -- system` на текущем сервере (Yandex Cloud) — ничего не должно поменяться, потому что inventory override возвращает `/mnt/applications` и mount всё ещё включён. Diff ожидается пустой. ### Восстановление restic-снапшотов после смены путей Старые снапшоты записаны с путями `/mnt/applications/`. На Timeweb данные должны лежать в `/srv/applications/`. У restic нет встроенного «remap path» при restore, поэтому делается в два шага: восстановить во временный каталог, затем `rsync` на новое место с сохранением uid/gid (приложения уже созданы playbook'ом с теми же uid/gid, см. шаг про подготовку target). Пример — восстановить gitea на Timeweb-машине: ```bash sudo /usr/local/sbin/restic-shell.sh # Распакуем нужную поддиректорию во временный каталог restic restore latest \ --target /tmp/restic-restore \ --include /mnt/applications/gitea # Перенесём данные на новый путь, сохранив владельца/группу/ACL/xattr sudo rsync -aAX --info=progress2 \ /tmp/restic-restore/mnt/applications/gitea/ \ /srv/applications/gitea/ sudo rm -rf /tmp/restic-restore ``` Несколько приложений за один проход: ```bash restic restore latest \ --target /tmp/restic-restore \ --include /mnt/applications/gitea \ --include /mnt/applications/outline \ --include /mnt/applications/miniflux for app in gitea outline miniflux; do sudo rsync -aAX --info=progress2 \ "/tmp/restic-restore/mnt/applications/$app/" \ "/srv/applications/$app/" done sudo rm -rf /tmp/restic-restore ``` Альтернатива через `restic mount` (если не хочется промежуточной копии — данные мапятся как FUSE-FS): ```bash sudo mkdir -p /mnt/restic-snapshots restic mount /mnt/restic-snapshots & sudo rsync -aAX \ /mnt/restic-snapshots/snapshots/latest/mnt/applications/gitea/ \ /srv/applications/gitea/ sudo fusermount -u /mnt/restic-snapshots ``` После переезда новые снапшоты будут записываться уже с путями `/srv/applications/` — никаких трюков для текущих бэкапов не нужно. --- ## Шаг 4 — условное монтирование внешнего диска (2026-05-22, выполнено) Задача `Mount external storages` в `playbook-system.yml` теперь выполняется только при включённом флаге `mount_external_storage` (default `false`). Сам UUID диска оставлен захардкоженным в плейбуке — параметризовать не стали, потому что для Timeweb (фаза 1) монтирование вообще не нужно, а для фазы 2 пока неизвестно, какой UUID получится у второго диска. Изменения: - `playbook-system.yml` — у задачи mount добавлен `when: mount_external_storage | default(false) | bool`. - `production.yml` (инвентарь YC) — у хоста `server` добавлен `mount_external_storage: true`, чтобы текущее поведение сохранилось. В будущем `timeweb.yml` просто не будет задавать эту переменную — mount пропустится, `/mnt/applications` останется обычной директорией на системном диске. На фазе 2 (подключение медленного диска в Timeweb) UUID в `playbook-system.yml` придётся поменять и включить флаг — это осознанный шаг, не автоматизировано. Проверено прогоном `inv pl -- system` на текущем сервере (Yandex Cloud) — задача mount по-прежнему выполняется, `/mnt/applications` смонтирован, изменений нет. --- ## Шаг 3 — переключение auth на cr.yandex (2026-05-22, выполнено) Заменена аутентификация в Yandex Container Registry с YC-metadata service на OAuth-token из vault. Изменения: - `files/yandex-docker-registry-auth.sh` — **удалён**. - `playbook-homepage.yml` — задача `ansible.builtin.script: yandex-docker-registry-auth.sh` заменена на `community.docker.docker_login` с `username: oauth`, `password: "{{ yc_oauth_token }}"`. - `playbook-transcriber.yml` — то же самое. Локальные push-плейбуки (`playbook-homepage-registry.yml`, `playbook-transcriber-registry.yml`) не трогал — там нет auth-задачи в принципе, локальный docker аутентифицируется вручную (`yc container registry configure-docker` или `docker login`). Если позже захочется унифицировать — можно добавить тот же `docker_login` с `delegate_to: 127.0.0.1`. Проверено прогоном `inv pl -- homepage` и `inv pl -- transcriber` на текущем сервере (Yandex Cloud) — ошибок нет, контейнеры работают. Значит и на Timeweb заработает (единственная разница — исходящий IP, а OAuth-токен в YC принимается извне). --- ## Шаг 2 — OAuth-token для cr.yandex (2026-05-22, выполнено) В `vars/secrets.yml` добавлена (или обновлена) переменная `yc_oauth_token` со свежим OAuth-токеном Яндекса. Токен будет использоваться для логина в `cr.yandex` с новой машины Timeweb (вместо текущего скрипта `files/yandex-docker-registry-auth.sh`, который завязан на YC metadata service `169.254.169.254` и работает только внутри YC). Сам код переключения на `community.docker.docker_login` пока не вносится — это следующая итерация. Сейчас токен просто положен в vault, чтобы не делать этого в день cutover'а под прессом. --- ## Шаг 1 — снижение TTL DNS (2026-05-22, выполнено) В админке Yandex 360 для зоны `vakhrushev.me` уменьшен TTL A-записей с **21 600 с (6 ч)** до **1 200 с (20 мин)**. Это даёт запас по времени на распространение изменений после смены IP в день cutover'а — старые кэширующие резолверы перестанут отдавать старый адрес максимум через 20 минут (вместо 6 часов). Делается **заранее**, потому что само снижение TTL тоже распространяется по кэшам по правилам старого TTL — то есть после правки нужно подождать ≥ 6 часов, чтобы новое значение TTL само успело прижиться. Раньше cutover'а нужно сделать с большим запасом — день в день не сработает.