From 3545905cbd5c02c48606882ab1fe2c6bb20760e8 Mon Sep 17 00:00:00 2001 From: Anton Vakhrushev Date: Fri, 22 May 2026 19:55:54 +0300 Subject: [PATCH] Migration: add draft for timeweb migration --- docs/drafts/timeweb.md | 526 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 526 insertions(+) create mode 100644 docs/drafts/timeweb.md diff --git a/docs/drafts/timeweb.md b/docs/drafts/timeweb.md new file mode 100644 index 0000000..0ff6e94 --- /dev/null +++ b/docs/drafts/timeweb.md @@ -0,0 +1,526 @@ +# Миграция сервера в 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 Registry** — `cr.yandex/crplfk0168i4o8kd7ade` для образов + `homepage-nginx` и `transcriber`. +- **Object Storage (S3)** — restic-репозиторий `yandex_cloud_s3`. +- **Postbox SMTP** — `postbox.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 в кабинете Яндекса: + + (стандартный 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:`. Скрипт сводится к: + ```sh + #!/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.yml` → `host_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 до 60–300 секунд **за + ~24–48 часов** до cutover. + +--- + +## Cutover (план дня X) + +Предусловия: код выкатан, target-машина пингуется по ansible, TTL +DNS снижены. + +### Шаг 1. Финальный бэкап на источнике + +```bash +inv ssh +sudo /usr/local/sbin/backup-all.py 2>&1 | tee /tmp/final-backup.log +``` + +Убедиться, что в логе все приложения отработали успешно и в S3 +появился свежий restic-snapshot (на случай отката или потери +данных при rsync). + +### Шаг 2. Остановить все приложения на источнике + +Аккуратно остановить контейнеры каждого приложения (через +`docker compose down` от соответствующего пользователя или одним +проходом): + +```bash +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 не побежал во время миграции): + +```bash +sudo systemctl stop cron +``` + +### Шаг 3. Раскатать инфраструктуру на target БЕЗ запуска приложений + +```bash +# 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//{data,config,backups}`. +- Шаблоны `docker-compose.yml` и application-конфиги — отрендерены + и лежат на месте. +- Docker и сети созданы. +- ufw настроен, fail2ban работает. + +### Шаг 4. Перенос данных + +Два варианта. + +**Вариант A — rsync напрямую (быстрее).** С target-машины тянем +данные со старой: + +```bash +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: + +```bash +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`: + +```bash +uv run ansible-playbook -i timeweb.yml --diff \ + playbook-all-applications.yml +``` + +Этот же запуск проверит идемпотентность шаблонов (не должно быть +diff'ов кроме docker-up). + +После старта — проверить: + +- `docker ps` — все контейнеры в healthy. +- Локально (по IP) `curl http://` — 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 ` с независимой машины. + +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 проверяется через + . +- [ ] `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.yml` → + `production.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.