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

533 lines
28 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Миграция сервера в 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`).
---
> Фактическое выполнение переезда — в отдельном файле
> [timeweb-migration-log.md](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:`. Скрипт сводится к:
```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 до 60300 секунд **за
~2448 часов** до 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/<app>/{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://<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.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.