38 KiB
Журнал миграции в Timeweb
Хронология фактического переезда. План и архитектурные решения — в 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 -- <app> (после Шага
переключения 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-аутентификации.
Отклонения от плана сегодня
- VPS пересоздан в СПб (Шаг 8) — первая выдача попала на гипервизор с битой сетью.
- 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. - 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- memoryproject_timeweb_smtp_block.md— пригодится при следующих миграциях. - 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 КБ всего):
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 проносить именно эту переменную:
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 … отрабатывает.
Полный прогон по всем приложениям
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/<app>/(mode 750, owner — приложение). Уmajorна источнике NOPASSWD sudo, так что sudo прокатывает молча.-e "ssh -o StrictHostKeyChecking=accept-new"— кастомная команда транспорта. По умолчанию rsync запускает чистыйssh; мы добавляем флаг для автопринятия host key источника (на targetknown_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/ во время переноса.
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-скриптов отдельных приложений).
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. Системная база
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-плейбуки без запуска контейнеров
uv run ansible-playbook -i timeweb.yml --diff \
--skip-tags run-app \
playbook-all-applications.yml
На target созданы все <app>-пользователи с правильными uid/gid
(совпадают с источником, см. таблицу в плане), каталоги
/srv/applications/<app>/{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 при первом
прогоне ансибла.
# 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
Проверка с локальной машины:
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/<org-id>,
это отдельная концепция (есть отдельный 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добавлен overrideapplication_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/<app>. На
Timeweb данные должны лежать в /srv/applications/<app>. У restic
нет встроенного «remap path» при restore, поэтому делается в два
шага: восстановить во временный каталог, затем rsync на новое
место с сохранением uid/gid (приложения уже созданы playbook'ом с
теми же uid/gid, см. шаг про подготовку target).
Пример — восстановить gitea на Timeweb-машине:
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
Несколько приложений за один проход:
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):
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/<app> — никаких трюков для текущих бэкапов не
нужно.
Шаг 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'а нужно сделать с большим запасом — день в день не сработает.