Files
pet-project-server/docs/drafts/timeweb.md
T

28 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).


Инвентаризация 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 compose down от соответствующего пользователя или одним проходом):

inv ssh
for user in caddyproxy authelia netdata miniflux rssbridge wakapi \
    dozzle transcriber wanderer memos gitea outline homepage gramps \
    calibre remembos apprise tuwunel goaccess; do
  sudo -iu "$user" bash -c "cd /mnt/applications/$user && docker compose down"
done

(Можно завести вспомогательный плейбук playbook-shutdown-all.yml, если такое будет часто.)

Проверить docker ps, что пусто. Снять флаги cron на бэкап (чтобы финальный backup не побежал во время миграции):

sudo systemctl stop cron

Шаг 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 для всех приложений.
  • Каталоги /mnt/applications/<app>/{data,config,backups}.
  • Шаблоны docker-compose.yml и application-конфиги — отрендерены и лежат на месте.
  • Docker и сети созданы.
  • ufw настроен, fail2ban работает.

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

Два варианта.

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

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

-aAX сохраняет ACL/xattrs и uid/gid (численные значения).

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

Вариант B — restore из restic. Если по сети источник недоступен (например, IP уже закрыли) или хочется проверить, что бэкапы вообще рабочие — восстанавливаемся из YC S3:

sudo /usr/local/sbin/restic-shell.sh
restic restore latest --target /mnt/applications --path /mnt/applications

Рекомендую A с фолбэком на B: rsync быстрее и точнее (с точностью до секунды), restic держим как страховку.

Шаг 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.