30 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-демон целиком — это атомарно гасит все
контейнеры за один вызов, не зависит от текущего списка приложений
и шлёт корректный 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.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.