Files
pet-project-server/docs/drafts/timeweb.md
T
av 7d711425fd
Linting / YAML Lint (push) Has been cancelled
Linting / Ansible Lint (push) Has been cancelled
Migration: update steps
2026-05-22 21:06:16 +03:00

30 KiB
Raw Blame History

Миграция сервера в Timeweb

Контекст и цели

Сервер rivendell-v2 переезжает с виртуальной машины в Yandex Cloud (158.160.46.255) на VPS в Timeweb.

Причины переезда

  1. Высокая стоимость. Тариф в Yandex Cloud обходится в ≈ 2 900 ₽/мес за конфигурацию, которая в Timeweb стоит ≈ 2 000 ₽/мес и при этом мощнее по всем параметрам (см. сравнение ниже).
  2. Упор в потолок RAM. Текущий сервер уже использует ≈ 80 % доступной памяти на штатной нагрузке (см. project_server_specs). Любой всплеск (миграции БД, индексация в Outline, бэкап с restic) — и приложения начинают конкурировать за память, появляются OOM-риски. Дальше расти на этом тарифе некуда без значительного увеличения цены.
  3. Медленные диски. Из-за высокой стоимости в YC приходится использовать дешёвый HDD-том вместо SSD/NVMe — это заметно снижает отзывчивость приложений (особенно Gitea, Outline, тёплый старт контейнеров, рестики check/forget). На Timeweb за меньшие деньги получаем NVMe.

Переезд решает все три проблемы одновременно: дешевле, больше RAM, быстрее диск.

Сравнение тарифов

Параметр Yandex Cloud Timeweb Cloud VPS
CPU Intel Cascade Lake, vCPU 2, гарантия 50 % 4 × 3.3 ГГц
RAM 4 ГБ 8 ГБ
Диск 120 ГБ HDD 80 ГБ NVMe
Публичный IP да да
Цена/месяц 2 887 ₽ 1 980 ₽

Итого: 907 ₽/мес (≈ 31 %), при этом ×2 RAM (закрывает причину 2), ×2 ядер, гарантия CPU 100 % вместо 50 %, NVMe вместо HDD (закрывает причину 3). Минус — диск меньше (80 ГБ против 120 ГБ HDD), что и стало основанием для фазы 2 с подключением второго «холодного» диска под крупные данные.

Переезжает только compute (VM с приложениями). Остальные сервисы Yandex Cloud остаются на месте и продолжают использоваться с новой машины:

  • Container Registrycr.yandex/crplfk0168i4o8kd7ade для образов homepage-nginx и transcriber.
  • Object Storage (S3) — restic-репозиторий yandex_cloud_s3.
  • Postbox SMTPpostbox.cloud.yandex.net (gitea, gramps, wakapi, outline, authelia, apprise).
  • Yandex 360 / DNS-зона vakhrushev.me — там же управляются записи и почтовый домен.

Параметры даунтайма — мягкие, это личная машина. Стратегия — «cold cutover»: остановить сервисы на источнике, раскатать ansible на target без запуска приложений, перенести данные с сохранением uid/gid, запустить сервисы на target, переключить DNS.

Конфигурация target — Cloud VPS Timeweb с одним диском 80 ГБ на первой фазе. Позднее (отдельной фазой) будет подключён второй «медленный» диск под крупные данные (calibre, бэкапы, возможно outline).


Фактическое выполнение переезда — в отдельном файле timeweb-migration-log.md. Здесь только план и архитектурные решения.


Инвентаризация YC-зависимостей в коде

Компонент Где Что делать при переезде
production.yml ansible_host: 158.160.46.255, ansible_user: major Заменить на новый IP/пользователя Timeweb
files/yandex-docker-registry-auth.sh Логин в cr.yandex через YC metadata service (169.254.169.254) Не работает вне YC. Перейти на static OAuth-token / IAM-token (новый скрипт + секрет в vault)
playbook-system.yml (mount-storage) UUID 3942bffd-… монтируется в /mnt/applications Фаза 1: отключить mount или сделать UUID переменной vault. Фаза 2 (после подключения медленного диска): включить заново с новым UUID
files/backups/config.template.toml [storage.yandex_cloud_s3] + AWS_* ключи Не меняем. Тот же бакет/ключи продолжают работать. Меняется только host_name (для подписи снапшотов и нотификаций) — он уже шаблонится
SMTP (postbox_host/port/user/pass) gitea, gramps, wakapi, outline, authelia, apprise Не меняем. Postbox SMTP доступен извне YC по тем же credentials
files/backups/rclone.template.conf (pr86keedav) WebDAV-копия restic — внешний сервис Не меняем
Caddy tls anwinged@ya.ru ACME Не меняется, ACME перевыпустит сертификаты после смены IP

Никаких других hardcoded YC-эндпоинтов в плейбуках / шаблонах нет — SSH, ufw, fail2ban, docker, eget, restic, Caddy полностью переносимы.


UID / GID — критично для rsync

UID/GID каждого приложения зафиксированы в плейбуках и в vars/homepage.yml / vars/transcriber.yml. Роль owner создаёт группы и пользователей с явно указанными gid/uid (roles/owner/tasks/main.yml). Это значит:

  • Если на новой машине сначала раскатать все плейбуки (без запуска приложений), пользователи получатся с теми же uid/gid.
  • Тогда rsync -aAX (с сохранением owner) корректно ляжет на target.
  • Дополнительный maping uid не нужен.

Список приложений с uid/gid (для сверки и для документации):

caddyproxy   1010 / 1011
authelia     1011 / 1012
netdata      1012 / 1013
miniflux     1013 / 1014
rssbridge    1014 / 1015
wakapi       1015 / 1016
dozzle       1016 / 1017
transcriber  1017 / 1018
wanderer     1018 / 1019
memos        1019 / 1020
gitea        1005 / 1006
outline      1007 / 1008
homepage     1008 / 1009
gramps       1009 / 1010
calibre      1102 / 1102
remembos     1103 / 1103
apprise      1104 / 1104
tuwunel      1105 / 1105
goaccess     1106 / 1106

(Возможные пересечения uid одного приложения и gid другого существуют, но Linux держит их в разных пространствах имён — не страшно.)


Подготовка кода проекта

Делается до аренды Timeweb-машины, отдельным PR (или сериями коммитов на отдельной ветке). Цель — чтобы тот же ansible работал и на источнике, и на target без условных хаков.

1. Заменить YC-specific docker registry auth

files/yandex-docker-registry-auth.sh сейчас использует metadata service (169.254.169.254). Это работает только внутри YC VM, поэтому на Timeweb его надо заменить.

Решение — OAuth-token Яндекса. Простой и достаточный для домашнего сервера механизм:

  1. Получить OAuth-token в кабинете Яндекса: https://oauth.yandex.ru/authorize?response_type=token&client_id=1a6990aa636648e9b2ef855fa7bec2fb (стандартный client_id для yc CLI, токен с правом доступа к Container Registry).
  2. Положить в vars/secrets.yml как yc_oauth_token (vault).
  3. Переписать files/yandex-docker-registry-auth.sh как шаблон (.template.sh) и рендерить через ansible.builtin.template вместо script:. Скрипт сводится к:
    #!/usr/bin/env sh
    set -eu
    echo "{{ yc_oauth_token }}" | \
        docker login --username oauth --password-stdin cr.yandex
    
    Альтернатива — не рендерить, а передавать токен в скрипт аргументом или через переменную окружения, чтобы не светить его в системе.
  4. В playbook-homepage.yml и playbook-transcriber.yml поменять ansible.builtin.script: на ansible.builtin.template: + ansible.builtin.command: (либо использовать модуль community.docker.docker_login напрямую с username: oauth, password: "{{ yc_oauth_token }}" — это самый чистый вариант, тогда отдельный скрипт вообще не нужен).
  5. То же самое — для локальных push-плейбуков playbook-homepage-registry.yml и playbook-transcriber-registry.yml.

Рекомендую вариант с community.docker.docker_login — это убирает shell-скрипт целиком и сильно проще.

Минусы OAuth-token: токен живёт долго и даёт доступ ко всему аккаунту Яндекса. Для личного сервера приемлемо; если позже захочется минимизировать blast radius — заменить на IAM-key сервисного аккаунта (отдельная итерация после миграции).

Затронутые места: files/yandex-docker-registry-auth.sh (удалить или переписать), playbook-homepage.yml, playbook-transcriber.yml, playbook-homepage-registry.yml, playbook-transcriber-registry.yml, vars/secrets.yml (новый ключ yc_oauth_token).

2. Сделать опциональным монтирование внешнего диска

Сейчас playbook-system.yml жёстко монтирует UUID 3942bffd-… в /mnt/applications. На Timeweb этого диска нет.

Минимальная правка — вытащить UUID в переменную (storage_uuid) и обернуть mount-задачу when: storage_uuid is defined. В vars/secrets.yml или vars/vars.yml для текущего сервера задать UUID, для Timeweb (фаза 1) — не задавать. На фазе 2 (когда придёт медленный диск) — задать новый UUID.

Альтернатива: вынести параметры в инвентарь (production.ymlhost_vars/server.yml).

При этом сама директория /mnt/applications должна создаваться в любом случае — playbook уже это делает, надо лишь убедиться, что задача «Create directory for mount» не зависит от mount-задачи.

3. Параметризовать инвентарь

На время перехода — два отдельных файла: текущий production.yml остаётся как есть, рядом появляется новый timeweb.yml с настройками Timeweb-машины. Все ansible-команды во время миграции явно указывают -i timeweb.yml. После того, как переезд закончен и старая машина выключена — production.yml просто удаляется, timeweb.yml переименовывается в production.yml.

tasks.py использует yq для извлечения ansible_host / ansible_user из инвентаря (_yq(".ungrouped.hosts.server…")) — путь к файлу зашит константой HOSTS_FILE = "production.yml". Варианты:

  • На время миграции временно поменять HOSTS_FILE = "timeweb.yml" в локальном коммите (или через env override), потом откатить — после переименования всё снова работает.
  • Принять, что inv ssh / zj / btop / login работают только с активным сервером (тем, что в production.yml), а к старой машине во время миграции ходим напрямую через ssh major@158.160.46.255.

Первый вариант чище. Достаточно одной строчки правки.

4. Прочее

  • README.md — обновить инструкцию по DNS и упомянуть Timeweb.
  • Удалить (или пометить deprecated) yandex-метаданные в комментариях yandex-docker-registry-auth.sh.
  • Проверить, что у всех application-плейбуков задача с community.docker.docker_compose_v2: state: present помечена тегом run-app — это позволит раскатывать --skip-tags run-app для подготовки target без запуска контейнеров. Сейчас тег run-app есть в большинстве плейбуков, но надо пройтись и убедиться, что он покрывает все контейнеры (включая calibre, dozzle, remembos, transcriber, tuwunel, wanderer, memos).

Подготовка target-машины

  1. Заказать Cloud VPS в Timeweb:

    • Ubuntu LTS (та же мажорная версия, что и сейчас — упростит совместимость пакетов).
    • 4 GB RAM (текущий лимит ≈ 3.8 GiB, см. project_server_specs), можно взять чуть с запасом — 4–6 GB, иначе netdata + tuwunel + outline начнут давить.
    • 2 vCPU.
    • SSD 80 ГБ.
    • Снять/настроить firewall провайдера (или отключить, т.к. у нас свой ufw).
  2. Создать пользователя с правами sudo (аналог major), залить свой SSH-ключ.

  3. Добавить хост в инвентарь как server (или временный timeweb), убедиться, что ansible -m ping отвечает.

  4. Снизить TTL DNS-записей в Yandex 360 до 60300 секунд за ~2448 часов до cutover.


Cutover (план дня X)

Предусловия: код выкатан, target-машина пингуется по ansible, TTL DNS снижены.

Шаг 1. Финальный бэкап на источнике

inv ssh
sudo /usr/local/sbin/backup-all.py 2>&1 | tee /tmp/final-backup.log

Убедиться, что в логе все приложения отработали успешно и в S3 появился свежий restic-snapshot (на случай отката или потери данных при rsync).

Шаг 2. Остановить все приложения на источнике

Останавливаем docker-демон целиком — это атомарно гасит все контейнеры за один вызов, не зависит от текущего списка приложений и шлёт корректный SIGTERM (с грейс-периодом ~15 сек) каждому, что функционально эквивалентно docker compose down по всем стекам.

inv ssh
sudo systemctl stop docker.service docker.socket
sudo systemctl disable docker.service docker.socket  # страховка от автостарта при ребуте
sudo systemctl stop cron                              # чтобы ночной backup-cron не побежал

Финальный бэкап (шаг 1) обязательно должен пройти до этого момента — backup-all.py запускает скрипты приложений, которые делают docker compose exec ... pg_dump ...; без работающего daemon это сломается.

disable — страховка: если по какой-то причине старая машина перезагрузится во время rsync (или мы вернёмся на источник для проверки/отката), docker не поднимется автоматически и сервисы не начнут писать в данные, которые мы уже считаем «фиксированной копией». В случае отката — enable + start обратно.

Проверить, что docker ps сейчас отвечает «daemon not running» (или вернёт пустой список — зависит от того, как inv ssh пройдёт до/после стопа). Если нужно убедиться, что контейнеры реально ушли — ps auxf | grep -E "containerd|docker" | grep -v grep.

Шаг 3. Раскатать инфраструктуру на target БЕЗ запуска приложений

# 1) системная база
uv run ansible-playbook -i timeweb.yml --diff playbook-all-setup.yml

# 2) приложения (создаём пользователей, каталоги, конфиги,
#    но НЕ запускаем контейнеры)
uv run ansible-playbook -i timeweb.yml --diff \
    --skip-tags run-app \
    playbook-all-applications.yml

Цель — после этого на target есть:

  • Корректные uid/gid для всех приложений.
  • Каталоги /srv/applications/<app>/{data,config,backups} (на Timeweb дефолт изменён с /mnt/applications; см. журнал шаг 5).
  • Шаблоны docker-compose.yml и application-конфиги — отрендерены и лежат на месте.
  • Docker и сети созданы.
  • ufw настроен, fail2ban работает.

Шаг 4. Перенос данных

Пути меняются: на YC данные лежат в /mnt/applications/<app>, на Timeweb — в /srv/applications/<app>. Rsync делает remap сам (потому что мы указываем источник и приёмник явно). Для трёх приложений без backup-механизма (caddyproxy, remembos, transcriber) rsync — единственный канал переноса, restic для них не альтернатива.

Вариант A — rsync напрямую (основной путь). С target-машины тянем данные со старой:

sudo rsync -aAX --info=progress2 --delete \
    --exclude='lost+found' \
    major@158.160.46.255:/mnt/applications/ \
    /srv/applications/

-aAX сохраняет ACL/xattrs и uid/gid (численные значения). Численные uid/gid на target совпадают с источником, потому что плейбуки на обеих машинах создают пользователей с одинаковыми явно заданными app_owner_uid/gid.

Каждое приложение можно тянуть отдельно — удобнее наблюдать прогресс и можно частично пересинхронизировать в случае ошибок:

sudo rsync -aAX --info=progress2 --delete \
    major@158.160.46.255:/mnt/applications/gitea/ \
    /srv/applications/gitea/

Вариант B — restore из restic (страховка). Если по сети источник недоступен или хочется проверить, что бэкапы вообще рабочие. Подробный пример (с учётом смены /mnt/srv) — в журнале миграции, шаг 5.

Для caddyproxy, remembos, transcriber использовать B нельзя — у них нет архивации, в restic-снапшоте данных просто нет. Только A.

Рекомендую A как основной метод, B держим как страховку для приложений, у которых есть восстановимый снапшот.

Шаг 5. Запуск приложений на target

Раскатываем application-плейбуки ещё раз — теперь без --skip-tags:

uv run ansible-playbook -i timeweb.yml --diff \
    playbook-all-applications.yml

Этот же запуск проверит идемпотентность шаблонов (не должно быть diff'ов кроме docker-up).

После старта — проверить:

  • docker ps — все контейнеры в healthy.
  • Локально (по IP) curl http://<target-ip> — Caddy отвечает (на редирект, т.к. сертификаты ещё не выпущены под этим IP).
  • Логи Caddy — выпуск сертификатов запустится после смены DNS, не раньше. Это нормально.

Шаг 6. Переключение DNS

В Yandex 360 admin (admin.yandex.ru/domains/vakhrushev.me) поменять A-записи для всех subdomain'ов на новый IP. Перечень поддоменов (из Caddyfile.template):

vakhrushev.me           (apex)
matrix.vakhrushev.me
auth.vakhrushev.me
status.vakhrushev.me
git.vakhrushev.me
outline.vakhrushev.me
gramps.vakhrushev.me
miniflux.vakhrushev.me
wakapi.vakhrushev.me
wanderer.vakhrushev.me
memos.vakhrushev.me
remembos.vakhrushev.me
calibre.vakhrushev.me
wanderbase.vakhrushev.me
rssbridge.vakhrushev.me
dozzle.vakhrushev.me
goaccess.vakhrushev.me

После смены — подождать пока TTL разойдётся, проверить через dig +short <hostname> с независимой машины.

Caddy сам пойдёт за сертификатами Let's Encrypt — следить за его логами (docker logs caddyproxy_app -f).

Шаг 7. Проверка после cutover

Чеклист (примерно по приоритету):

  • vakhrushev.me отвечает 200, отдаёт homepage.
  • auth.vakhrushev.me — Authelia, можно залогиниться.
  • git.vakhrushev.me — Gitea, репозитории на месте, ssh-доступ (порт 2222 в ufw уже открыт).
  • outline.vakhrushev.me — открывается, документы на месте.
  • matrix.vakhrushev.me — Tuwunel/Element подключается; federation проверяется через https://federationtester.matrix.org/.
  • miniflux.vakhrushev.me, wakapi.vakhrushev.me, memos.vakhrushev.me, gramps.vakhrushev.me, remembos.vakhrushev.me, wanderer.vakhrushev.me, calibre.vakhrushev.me, rssbridge.vakhrushev.me, dozzle.vakhrushev.me, goaccess.vakhrushev.me — открываются, данные на месте.
  • Netdata status.vakhrushev.me — собирает метрики.
  • Backup-cron — следующий запуск (1:00) проходит успешно, приходит уведомление в apprise.
  • SMTP — отправить тестовое письмо из gitea/authelia (триггер reset password).
  • Container Registry — docker pull cr.yandex/... на новой машине проходит (это значит, что наша новая аутентификация через OAuth/IAM работает).

Шаг 8. Заморозка источника

Когда всё подтверждено стабильным (≥ 24 часа):

  • Остановить и выключить старую VM в YC.
  • Подождать неделю-две на случай отката.
  • Удалить VM и связанные ресурсы (только compute! S3-бакет с restic-бэкапами и Container Registry остаются).
  • Удалить production.yml, переименовать timeweb.ymlproduction.yml, откатить временную правку HOSTS_FILE в tasks.py (теперь снова production.yml). Закоммитить.

Фаза 2: подключение медленного диска

После того как Timeweb-сервер стабилен:

  1. Заказать дополнительный «холодный» диск в Timeweb, прицепить к VPS.
  2. Узнать UUID нового устройства (lsblk -f).
  3. Решить, куда монтировать — варианты:
    • Сохранить текущую схему (/mnt/applications на медленном диске целиком). Минус: всё IO приложений уходит на медленный диск.
    • Лучше: оставить /mnt/applications на быстром SSD, медленный смонтировать как /mnt/cold и под calibre/большие бэкапы делать bind-mount или поменять data_dir у нужных приложений.
  4. Восстановить в playbook-system.yml mount-задачу с новым UUID (через переменную, заведённую на фазе 1).
  5. Прогнать inv pl -- system с тегом mount-storage.
  6. Переехать на холодный диск только большие данные. Для calibre это означает остановить контейнер, rsync библиотеки книг, поправить data_dir в vars, запустить.

Что НЕ менять во время миграции

Чтобы не накапливать изменения в одном переезде:

  • Версии docker-образов всех приложений — те же, что в источнике.
  • Конфиги приложений — без правок.
  • Restic snapshot policy.
  • Apprise/notification каналы.

Любые улучшения (healthchecks из docs/drafts/alerts.md, gitea runner и т.п.) — отдельным циклом после миграции.


Откат

Если на target что-то критично сломалось:

  1. DNS возвращаем обратно на старый IP.
  2. Старая VM в YC жива и заглушена → стартуем её, поднимаем сервисы (docker compose up -d под каждым пользователем).
  3. Изучаем, в чём дело на target, лечим, повторяем cutover.

Поэтому шаг «Заморозка источника» отделён от «удаления» — у нас есть «горячее запасное» как минимум на пару дней.


Открытые вопросы

На текущей итерации — нет, все ключевые развилки закрыты:

  • Auth для cr.yandex → OAuth-token Яндекса (yc_oauth_token в vault, community.docker.docker_login в плейбуках).
  • Инвентарь → два отдельных файла, после cutover timeweb.yml переименовывается в production.yml.
  • Регион/TZ Timeweb → совпадает с текущим.
  • IP-whitelist в конфигах → отсутствует, смена IP безопасна.
  • Объём данных vs 80 ГБ → 22 ГБ всего, из них calibre 16 ГБ; с запасом влезает в фазе 1, второй диск не на критическом пути.

Возможные вопросы по ходу реализации (выяснятся в процессе):

  • Конкретная процедура получения OAuth-token Яндекса (через oauth.yandex.ru или через yc CLI).
  • Поведение Caddy при первом выпуске сертификатов после смены DNS — убедиться, что rate-limit Let's Encrypt не упрётся (≈ 17 поддоменов выпускаются сразу, лимит LE — 50 сертификатов в неделю на registered domain, запас есть).
  • Federation Matrix после смены IP — обычно достаточно того, что apex vakhrushev.me отдаёт .well-known/matrix/server, но стоит проверить через federationtester.matrix.org сразу после cutover.