28 KiB
Миграция сервера в Timeweb
Контекст и цели
Сервер rivendell-v2 переезжает с виртуальной машины в Yandex Cloud
(158.160.46.255) на VPS в Timeweb.
Причины переезда
- Высокая стоимость. Тариф в Yandex Cloud обходится в ≈ 2 900 ₽/мес за конфигурацию, которая в Timeweb стоит ≈ 2 000 ₽/мес и при этом мощнее по всем параметрам (см. сравнение ниже).
- Упор в потолок RAM. Текущий сервер уже использует ≈ 80 %
доступной памяти на штатной нагрузке (см.
project_server_specs). Любой всплеск (миграции БД, индексация в Outline, бэкап с restic) — и приложения начинают конкурировать за память, появляются OOM-риски. Дальше расти на этом тарифе некуда без значительного увеличения цены. - Медленные диски. Из-за высокой стоимости в 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. Здесь только план и архитектурные решения.
Инвентаризация 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 Яндекса. Простой и достаточный для домашнего сервера механизм:
- Получить OAuth-token в кабинете Яндекса:
https://oauth.yandex.ru/authorize?response_type=token&client_id=1a6990aa636648e9b2ef855fa7bec2fb
(стандартный client_id для
ycCLI, токен с правом доступа к Container Registry). - Положить в
vars/secrets.ymlкакyc_oauth_token(vault). - Переписать
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 - В
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 }}"— это самый чистый вариант, тогда отдельный скрипт вообще не нужен). - То же самое — для локальных 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-машины
-
Заказать 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).
-
Создать пользователя с правами sudo (аналог
major), залить свой SSH-ключ. -
Добавить хост в инвентарь как
server(или временныйtimeweb), убедиться, чтоansible -m pingотвечает. -
Снизить TTL DNS-записей в Yandex 360 до 60–300 секунд за ~24–48 часов до 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.yml→production.yml, откатить временную правкуHOSTS_FILEвtasks.py(теперь сноваproduction.yml). Закоммитить.
Фаза 2: подключение медленного диска
После того как Timeweb-сервер стабилен:
- Заказать дополнительный «холодный» диск в Timeweb, прицепить к VPS.
- Узнать UUID нового устройства (
lsblk -f). - Решить, куда монтировать — варианты:
- Сохранить текущую схему (
/mnt/applicationsна медленном диске целиком). Минус: всё IO приложений уходит на медленный диск. - Лучше: оставить
/mnt/applicationsна быстром SSD, медленный смонтировать как/mnt/coldи под calibre/большие бэкапы делать bind-mount или поменятьdata_dirу нужных приложений.
- Сохранить текущую схему (
- Восстановить в
playbook-system.ymlmount-задачу с новым UUID (через переменную, заведённую на фазе 1). - Прогнать
inv pl -- systemс тегомmount-storage. - Переехать на холодный диск только большие данные. Для calibre
это означает остановить контейнер,
rsyncбиблиотеки книг, поправитьdata_dirвvars, запустить.
Что НЕ менять во время миграции
Чтобы не накапливать изменения в одном переезде:
- Версии docker-образов всех приложений — те же, что в источнике.
- Конфиги приложений — без правок.
- Restic snapshot policy.
- Apprise/notification каналы.
Любые улучшения (healthchecks из docs/drafts/alerts.md,
gitea runner и т.п.) — отдельным циклом после миграции.
Откат
Если на target что-то критично сломалось:
- DNS возвращаем обратно на старый IP.
- Старая VM в YC жива и заглушена → стартуем её, поднимаем
сервисы (
docker compose up -dпод каждым пользователем). - Изучаем, в чём дело на target, лечим, повторяем cutover.
Поэтому шаг «Заморозка источника» отделён от «удаления» — у нас есть «горячее запасное» как минимум на пару дней.
Открытые вопросы
На текущей итерации — нет, все ключевые развилки закрыты:
Auth для cr.yandex→ OAuth-token Яндекса (yc_oauth_tokenв vault,community.docker.docker_loginв плейбуках).Инвентарь→ два отдельных файла, после cutovertimeweb.ymlпереименовывается вproduction.yml.Регион/TZ Timeweb→ совпадает с текущим.IP-whitelist в конфигах→ отсутствует, смена IP безопасна.Объём данных vs 80 ГБ→ 22 ГБ всего, из них calibre 16 ГБ; с запасом влезает в фазе 1, второй диск не на критическом пути.
Возможные вопросы по ходу реализации (выяснятся в процессе):
- Конкретная процедура получения OAuth-token Яндекса (через
oauth.yandex.ruили черезycCLI). - Поведение 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.