Files
pet-project-server/docs/drafts/timeweb-migration-log.md
T
av a3e53b21e6
Linting / YAML Lint (push) Has been cancelled
Linting / Ansible Lint (push) Has been cancelled
Migration: fix application order
2026-05-23 15:30:33 +03:00

22 KiB
Raw Blame History

Журнал миграции в Timeweb

Хронология фактического переезда. План и архитектурные решения — в timeweb.md. Здесь только то, что реально сделано, с датами.

Новые записи — сверху.


Шаг 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.ymlapplication_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/<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'а нужно сделать с большим запасом — день в день не сработает.