658 lines
36 KiB
Markdown
658 lines
36 KiB
Markdown
# Журнал миграции в Timeweb
|
||
|
||
Хронология фактического переезда. План и архитектурные решения —
|
||
в [timeweb.md](timeweb.md). Здесь только то, что реально сделано,
|
||
с датами.
|
||
|
||
Новые записи — сверху.
|
||
|
||
---
|
||
|
||
## Шаг 13 — приложения подняты на target, cutover завершён (2026-05-23, выполнено)
|
||
|
||
После rsync'а (Шаг 12) — финальный прогон ансибла без `--skip-tags`,
|
||
поэтапно по приложениям. К ~16:30 DNS уже указывал на target (Шаг
|
||
переключения 15:45 + TTL 20 мин, пропагация подтверждена в 16:20),
|
||
так что Caddy при старте сразу пошёл за LE-сертификатами без задержек.
|
||
|
||
Прогоны делал поштучно через `inv pl -- <app>` (после Шага
|
||
переключения `HOSTS_FILE = "timeweb.yml"` в `tasks.py`), не всем
|
||
сразу — чтобы видеть каждый плейбук чисто.
|
||
|
||
### Что подтверждено работающим в браузере
|
||
|
||
- `vakhrushev.me` — homepage отдаёт страницу.
|
||
- `auth.vakhrushev.me` — Authelia, логин работает.
|
||
- `matrix.vakhrushev.me` — Tuwunel поднялся, Element подключается.
|
||
- `git.vakhrushev.me` — Gitea, репозитории и issue tracker на месте.
|
||
- `outline.vakhrushev.me` — документы видны.
|
||
- `gramps.vakhrushev.me` — генеалогическое дерево открывается.
|
||
- `wakapi.vakhrushev.me` — статистика времени видна.
|
||
- `status.vakhrushev.me` — Netdata собирает и рисует метрики.
|
||
|
||
Точечно зашёл в outline / gramps / wakapi / gitea — данные на месте,
|
||
ничего не потерялось при rsync'е.
|
||
|
||
### Отложенные на «потом по ходу дела» проверки
|
||
|
||
- `miniflux`, `memos`, `remembos`, `wanderer`, `calibre`, `rssbridge`,
|
||
`dozzle`, `goaccess` — открыть и убедиться, что отдают свои данные.
|
||
- **SMTP-test** — reset-password из gitea/authelia. Проверит, что
|
||
Postbox после разблокировки в панели Timeweb принимает наши письма.
|
||
- **Backup-cron в 1:00** — самый поздний smoke-тест системы. Покажет,
|
||
что `backup-all.py` отработал на target, restic пишет в S3 с новым
|
||
`host_name`, apprise шлёт уведомление.
|
||
- `docker pull cr.yandex/...` руками — повторная проверка
|
||
OAuth-аутентификации.
|
||
|
||
### Отклонения от плана сегодня
|
||
|
||
1. **VPS пересоздан в СПб** (Шаг 8) — первая выдача попала на
|
||
гипервизор с битой сетью.
|
||
2. **Docker Hub rate limit** на pull'е netdata — anonymous лимит
|
||
подсети Timeweb уже выбран соседями. Лечится ручным
|
||
`sudo docker login` на target (через free-аккаунт + PAT).
|
||
**Backlog:** добавить `community.docker.docker_login` для
|
||
`docker.io` в `playbook-docker.yml`, по аналогии с cr.yandex (Шаг
|
||
3). Креды в vault как `dockerhub_username` / `dockerhub_token`.
|
||
3. **Postbox SMTP не доступен извне YC** — оказалось, что в плане
|
||
(`timeweb.md:81`) предпосылка «Postbox доступен извне YC по тем же
|
||
credentials» неверна. Yandex Cloud Postbox дропает SMTP от не-YC
|
||
источников; 443 при этом отвечает. Дополнительно Timeweb по
|
||
умолчанию **сам** блокирует egress SMTP (25/465/587) — toggle в
|
||
панели Timeweb снимает блок, после чего Postbox отвечает баннером.
|
||
Authelia в exit-loop'е поднялась после рестарта. Запись в auto-
|
||
memory `project_timeweb_smtp_block.md` — пригодится при следующих
|
||
миграциях.
|
||
4. **Bug ordering в `playbook-goaccess.yml`** (см. Шаг 9, фикс
|
||
зашит) — латентный bug, проявившийся только на чистой машине.
|
||
|
||
### Что осталось до полной заморозки
|
||
|
||
По плану (`timeweb.md:464-473`):
|
||
|
||
- **≥ 24 часа** держим источник в выключенном состоянии (docker уже
|
||
остановлен, daemon отключён через `disable`), как горячее запасное.
|
||
- Если за сутки ничего не всплыло — выключить VM в YC.
|
||
- Подождать ещё неделю-две — на всякий случай.
|
||
- Удалить VM и связанные compute-ресурсы. **S3-бакет с
|
||
restic-бэкапами и Container Registry — оставляем**, они продолжают
|
||
использоваться.
|
||
- Удалить `production.yml`, переименовать `timeweb.yml` →
|
||
`production.yml`, откатить `HOSTS_FILE = "production.yml"` в
|
||
`tasks.py`. Закоммитить.
|
||
|
||
---
|
||
|
||
## Шаг 12 — rsync данных с источника на target (2026-05-23, выполнено)
|
||
|
||
Перенос `/mnt/applications/` на YC → `/srv/applications/` на Timeweb
|
||
после заморозки источника (Шаг 11). Это финальный канал переноса
|
||
данных — основной для всех приложений, единственный для `caddyproxy`,
|
||
`remembos`, `transcriber` (у которых нет backup-механизма, см. Шаг 7b).
|
||
|
||
### Пилотный прогон на remembos
|
||
|
||
Прежде чем гнать всё дерево, проверил рецепт на самом маленьком
|
||
приложении (~35 КБ всего):
|
||
|
||
```bash
|
||
sudo -E rsync -aAX --info=progress2 --delete --rsync-path="sudo rsync" \
|
||
-e "ssh -o StrictHostKeyChecking=accept-new" \
|
||
major@158.160.46.255:/mnt/applications/remembos/ \
|
||
/srv/applications/remembos/
|
||
```
|
||
|
||
Проверка после прогона:
|
||
|
||
```
|
||
$ sudo ls -la /srv/applications/remembos/
|
||
drwxr-x--- 4 remembos remembos 4096 Apr 30 13:22 .
|
||
drwxr-x--- 2 remembos remembos 4096 Feb 12 17:22 config
|
||
drwxr-x--- 2 remembos remembos 4096 May 23 12:41 data
|
||
-rw-r----- 1 remembos remembos 494 Apr 30 13:22 docker-compose.yml
|
||
```
|
||
|
||
Owner отрисован именами (`remembos:remembos`, не numeric `1103:1103`)
|
||
— значит на обеих сторонах ансибл создал юзера с одним и тем же uid,
|
||
mapping сошёлся. Mode (750) и mtime сохранены.
|
||
|
||
### Засада с agent-forwarding'ом под sudo
|
||
|
||
Первая попытка упала с `Permission denied (publickey)`. Причина:
|
||
rsync запускается через `sudo` на target, а sudo по дефолту чистит
|
||
`SSH_AUTH_SOCK` из env (`Defaults env_reset` в /etc/sudoers) — ssh
|
||
внутри sudo не видит проброшенный agent, пытается парольную
|
||
аутентификацию, проваливается.
|
||
|
||
Лечится разрешением sudo проносить именно эту переменную:
|
||
|
||
```bash
|
||
echo 'Defaults env_keep += "SSH_AUTH_SOCK"' | sudo tee -a /etc/sudoers.d/major
|
||
sudo visudo -cf /etc/sudoers.d/major
|
||
```
|
||
|
||
Безопасно: сокет агента принадлежит `major`, root к нему имеет доступ
|
||
по определению; мы просто говорим sudo не вычищать переменную с путём
|
||
к нему. После этого `sudo -E rsync …` отрабатывает.
|
||
|
||
### Полный прогон по всем приложениям
|
||
|
||
```bash
|
||
sudo -E rsync -aAX --info=progress2 --delete --exclude='lost+found' \
|
||
--rsync-path="sudo rsync" \
|
||
-e "ssh -o StrictHostKeyChecking=accept-new" \
|
||
major@158.160.46.255:/mnt/applications/ \
|
||
/srv/applications/
|
||
```
|
||
|
||
### Что делает каждый флаг
|
||
|
||
- **`sudo -E`** — локальный rsync на target запускается под root
|
||
(нужно, чтобы писать файлы с любым owner'ом / mode); `-E` сохраняет
|
||
env, в первую очередь `SSH_AUTH_SOCK` для agent forwarding.
|
||
- **`-a`** (`--archive`) — собирательный флаг `-rlptgoD`: recursive +
|
||
symlinks как symlinks + permissions + times + group + owner +
|
||
special files. Базовое «копировать всё как есть».
|
||
- **`-A`** — сохранить POSIX ACL.
|
||
- **`-X`** — сохранить extended attributes (xattrs), включая
|
||
security-атрибуты типа capabilities или SELinux-меток.
|
||
- **`--info=progress2`** — совокупный прогресс по всему transfer'у,
|
||
а не per-file (для больших деревьев читабельнее).
|
||
- **`--delete`** — стереть на target всё, чего нет на источнике.
|
||
Безопасно в нашем случае: после rsync'а прогоняем ансибл, он
|
||
перерендерит конфиги и пересоздаст любые отсутствующие структурные
|
||
каталоги. Стирается, по сути, только содержимое, отрендеренное
|
||
плейбуком на Шаге 9 без `run-app`.
|
||
- **`--exclude='lost+found'`** — на YC `/mnt/applications/` это mount
|
||
point внешнего диска, в его корне может лежать системный
|
||
`lost+found`. Нам он не нужен и на target такого монтирования
|
||
больше нет (`mount_external_storage: false`).
|
||
- **`--rsync-path="sudo rsync"`** — критично: на удалённой стороне
|
||
(источнике) rsync запускается через sudo. Иначе он стартует под
|
||
`major`, у которого нет прав читать чужие `/mnt/applications/<app>/`
|
||
(mode 750, owner — приложение). У `major` на источнике NOPASSWD
|
||
sudo, так что sudo прокатывает молча.
|
||
- **`-e "ssh -o StrictHostKeyChecking=accept-new"`** — кастомная
|
||
команда транспорта. По умолчанию rsync запускает чистый `ssh`; мы
|
||
добавляем флаг для автопринятия host key источника (на target
|
||
`known_hosts` ещё пустой).
|
||
- **`major@158.160.46.255:/mnt/applications/`** — источник. Trailing
|
||
slash важен: «копировать содержимое каталога», а не сам каталог.
|
||
Без слэша получили бы `/srv/applications/applications/...`.
|
||
- **`/srv/applications/`** — назначение. Trailing slash для
|
||
симметрии — содержимое кладётся в существующий каталог,
|
||
созданный ансиблом на Шаге 9.
|
||
|
||
### Результат
|
||
|
||
```
|
||
22,613,081,829 99% 7.11MB/s 0:50:34 (xfr#21837, to-chk=0/31024)
|
||
```
|
||
|
||
- Объём — ~22.6 ГБ, файлов — 31 024.
|
||
- Длительность — 50 минут 34 секунды, средняя скорость ~7 МБ/с
|
||
(предсказуемо для YC↔Timeweb).
|
||
- `du -s` после прогона: источник 22 088 224 КБ, target 22 164 172 КБ
|
||
— разница ~76 МБ (0.34%). Это не рассинхрон данных, а разница в
|
||
аллокации блоков ФС и метаданных между источником и target (разные
|
||
inode-таблицы, journal, group descriptors). Содержимое файлов
|
||
совпадает — rsync'у на это указали checksum'ы, errors не было.
|
||
|
||
Окно даунтайма с момента стопа docker'а (Шаг 11) до конца rsync'а —
|
||
около часа. С учётом параллельно запущенного DNS-переключения
|
||
(Шаг между 11 и 12, 15:45) к моменту запуска приложений на target
|
||
пропагация уже прошла (16:20).
|
||
|
||
---
|
||
|
||
## Шаг 11 — источник заморожен (docker + cron остановлены) (2026-05-23, выполнено)
|
||
|
||
Сразу после финального бэкапа (Шаг 10) — отключил docker и cron на
|
||
источнике, чтобы зафиксировать состояние данных перед rsync'ом и
|
||
исключить случайные записи в `/mnt/applications/` во время переноса.
|
||
|
||
```bash
|
||
sudo systemctl stop docker.service docker.socket
|
||
sudo systemctl disable docker.service docker.socket
|
||
sudo systemctl stop cron
|
||
```
|
||
|
||
`disable` — страховка от автостарта docker'а при возможной
|
||
перезагрузке источника (если вернёмся для отката или проверки).
|
||
`cron stop` — чтобы ночной `backup-all.py` не запустился впустую без
|
||
работающего daemon'а.
|
||
|
||
С этого момента источник «мёртв» для пользователей — окно даунтайма
|
||
открыто. Следующий шаг — переключить DNS и параллельно гнать rsync.
|
||
|
||
---
|
||
|
||
## Шаг 10 — финальный бэкап на источнике (2026-05-23, выполнено)
|
||
|
||
Прогнал `backup-all.py` на источнике, пока docker ещё жив (он нужен
|
||
для `pg_dump` и других in-container backup-команд внутри
|
||
`backup.sh`-скриптов отдельных приложений).
|
||
|
||
```bash
|
||
sudo /usr/local/sbin/backup-all.py 2>&1 | tee /tmp/final-backup.log
|
||
```
|
||
|
||
Свежий restic-снапшот в `yandex_cloud_s3` зафиксирован — страховочный
|
||
канал на случай, если rsync пойдёт криво (для приложений с
|
||
`backup.sh` можно будет восстановить из S3; для `caddyproxy`,
|
||
`remembos`, `transcriber` страховки нет, для них только rsync).
|
||
|
||
После прогона можно гасить docker без риска потерять backup-окно.
|
||
|
||
---
|
||
|
||
## Шаг 9 — раскатана база и приложения без запуска (2026-05-23, выполнено)
|
||
|
||
На свежей Timeweb-машине прогнаны два плейбука без даунтайма источника
|
||
(контейнеры на target не запускались).
|
||
|
||
### 9a. Системная база
|
||
|
||
```bash
|
||
uv run ansible -i timeweb.yml -m ping server # pong
|
||
uv run ansible-playbook -i timeweb.yml --diff playbook-all-setup.yml
|
||
```
|
||
|
||
После прогона на target поднято: apt-пакеты (`geerlingguy.security`),
|
||
docker + сети (`web_proxy_network`, `monitoring_network`), eget с
|
||
инструментами (restic, rclone, btop, zellij и др.), ufw (порты 22,
|
||
2222, 80, 443), fail2ban, backup-инфра (`backup-all.py`,
|
||
resticprofile, cron).
|
||
|
||
Заодно `geerlingguy.security` отключил root по SSH и
|
||
`PasswordAuthentication` — root-канал закрыт, доступ только через
|
||
`major` + ключ. Перепроверено `ssh major@<новый-ip>` — работает.
|
||
|
||
### 9b. Application-плейбуки без запуска контейнеров
|
||
|
||
```bash
|
||
uv run ansible-playbook -i timeweb.yml --diff \
|
||
--skip-tags run-app \
|
||
playbook-all-applications.yml
|
||
```
|
||
|
||
На target созданы все `<app>`-пользователи с правильными uid/gid
|
||
(совпадают с источником, см. таблицу в [плане](timeweb.md)), каталоги
|
||
`/srv/applications/<app>/{data,config,backups}`, отрендерены
|
||
`docker-compose.yml` и application-конфиги. Контейнеры **не**
|
||
запускались — это шаг 5 cutover'а (после rsync'а данных).
|
||
|
||
OAuth-аутентификация в `cr.yandex` (из Шага 3) сработала с
|
||
Timeweb-айпишника без замечаний — `community.docker.docker_login` в
|
||
плейбуках homepage и transcriber прошёл.
|
||
|
||
### Обнаруженный латентный bug ordering'а в goaccess
|
||
|
||
На fresh-install упала задача
|
||
`playbook-goaccess.yml:55 «Ensure caddy access log exists before
|
||
goaccess starts»` — пыталась туч'ить файл в `/var/log/caddy/`, который
|
||
к этому моменту не существовал. Причина: каталог создаётся в
|
||
`playbook-caddyproxy.yml`, а в `playbook-all-applications.yml`
|
||
goaccess идёт **раньше** caddyproxy (caddyproxy специально последний,
|
||
чтобы стартовать после backends). На предыдущем сервере не проявлялось
|
||
— каталог уже существовал от прошлых прогонов.
|
||
|
||
Фикс: добавил в `playbook-goaccess.yml` явное создание
|
||
`caddy_logs_dir` перед touch'ем `access.log`. Owner/mode выставит
|
||
caddyproxy при своём прогоне, идемпотентность сохранена.
|
||
|
||
**Backlog (после миграции):** `caddy_logs_dir` — shared-ресурс между
|
||
плеями (caddyproxy пишет, goaccess читает), концептуально это
|
||
provisioning-time забота. Вынести его создание в `playbook-system.yml`
|
||
(или в отдельный shared-resources плей в `playbook-all-setup.yml`) и
|
||
убрать дубль из goaccess/caddyproxy. Делать после переезда отдельным
|
||
PR, не во время миграции.
|
||
|
||
---
|
||
|
||
## Шаг 8 — VPS заказан, пользователь `major` создан (2026-05-23, выполнено)
|
||
|
||
Заказан Cloud VPS в Timeweb по тарифу из плана (4 × 3.3 ГГц, 8 ГБ RAM,
|
||
80 ГБ NVMe, Ubuntu 24.04 LTS), ДЦ Санкт-Петербург.
|
||
|
||
Первая выданная VPS попала на гипервизор с битой сетью: TCP-handshake
|
||
проходил нормально, но первый data-сегмент в любой TCP-сессии не
|
||
доставлялся ни в одну сторону. Подтверждено:
|
||
|
||
- `nc -l 12345` на сервере не получал данные от клиента, при этом
|
||
клиент видел `Connection succeeded`;
|
||
- strace зависшего `sshd: [accepted]`-child показывал
|
||
`read(socket, ..., 1) = ERESTARTSYS`, далее `SIGALRM` через 120 сек
|
||
по `LoginGraceTime` → exit (т.е. sshd ушёл в `read()` за клиентским
|
||
баннером и не дождался);
|
||
- `iptables -S` / `nft list ruleset` / `ufw status` — пусто, локального
|
||
firewall нет;
|
||
- исходящие соединения с VM (`curl http://example.com`) работали
|
||
штатно — ломались только входящие data-сегменты после handshake.
|
||
|
||
Ребут и переустановка ОС из панели не помогли. Пересоздал VPS в ДЦ СПб
|
||
с новым IP — заработало с первой попытки. Потеря времени ~1 час; на
|
||
будущее: при таком паттерне сразу пересоздаём в другом ДЦ, глубже
|
||
диагностику не ведём (это однозначно проблема сети провайдера).
|
||
|
||
### Bootstrap пользователя `major`
|
||
|
||
На свежей VPS только root по SSH-ключу. Поднял пользователя
|
||
аналогично YC-серверу — sudo через NOPASSWD, вход только по ключу.
|
||
Дальше `geerlingguy.security` + `roles/owner` пересоздадут пользователя
|
||
идемпотентно с теми же uid/gid и приклеят политику sshd при первом
|
||
прогоне ансибла.
|
||
|
||
```bash
|
||
# 1. Создать пользователя с home и bash, добавить в sudo
|
||
useradd -m -s /bin/bash major
|
||
usermod -aG sudo major
|
||
|
||
# 2. NOPASSWD-политика sudo
|
||
echo 'major ALL=(ALL) NOPASSWD: ALL' > /etc/sudoers.d/major
|
||
chmod 0440 /etc/sudoers.d/major
|
||
visudo -cf /etc/sudoers.d/major # должно сказать "parsed OK"
|
||
|
||
# 3. SSH-ключ (тот же, что залит для root при создании VPS)
|
||
install -d -m 700 -o major -g major /home/major/.ssh
|
||
install -m 600 -o major -g major \
|
||
/root/.ssh/authorized_keys \
|
||
/home/major/.ssh/authorized_keys
|
||
```
|
||
|
||
Проверка с локальной машины:
|
||
|
||
```bash
|
||
ssh major@<новый-ip>
|
||
sudo whoami # root, без пароля
|
||
```
|
||
|
||
Прошло. Root-доступ по SSH пока оставлен как резервный канал — первый
|
||
прогон ансибла отключит его через `geerlingguy.security`
|
||
(`PermitRootLogin no`, `PasswordAuthentication no`).
|
||
|
||
---
|
||
|
||
## Шаг 7 — `run-app` тег + унификация registry (2026-05-22, выполнено)
|
||
|
||
По итогам аудита подготовительных задач выявлены и закрыты две
|
||
несостыковки:
|
||
|
||
### 7a. Пропущенный `run-app` тег в remembos
|
||
|
||
В `playbook-remembos.yml:73` была задача
|
||
`Restart docker compose services if config changed but not
|
||
docker-compose.yml` (условный рестарт через `state: restarted`,
|
||
триггер — изменение `config.toml` без изменения `docker-compose.yml`),
|
||
у неё не было тега `run-app`. На cutover'е при
|
||
`--skip-tags run-app` основной запуск пропустился бы (правильно), а
|
||
эта условная задача всё равно сработала бы (потому что её `when:`
|
||
истинно при первом деплое — конфиг создаётся), попыталась бы
|
||
рестартануть несуществующий compose-стек и упала. Тег добавлен.
|
||
|
||
### 7b. Унификация `registry_url` в docker_login
|
||
|
||
`playbook-homepage.yml` и `playbook-transcriber.yml` использовали
|
||
хардкод `registry_url: "cr.yandex"`, а `playbook-remembos.yml` —
|
||
`'{{ yc_container_registry }}'` из vault. Привёл к одному виду:
|
||
теперь во всех трёх — `"{{ yc_container_registry }}"` из vault.
|
||
|
||
`docker_registry_prefix` в `vars/homepage.yml` и `vars/transcriber.yml`
|
||
не трогал — там полный image-prefix вида `cr.yandex/<org-id>`,
|
||
это отдельная концепция (есть отдельный vault-var
|
||
`yc_container_registry_repository`, используемый в
|
||
`files/remembos/docker-compose.template.yml`). Если позже захочется
|
||
унифицировать целиком — это отдельная итерация.
|
||
|
||
### Аудит бэкапов: gap'ы по `caddyproxy`, `remembos`, `transcriber`
|
||
|
||
Эти три приложения имеют состояние в `data_dir`, но не имеют ни
|
||
`backup.template.sh`, ни ansible-генерируемого `backup-targets`.
|
||
Для миграции это закрывается через **rsync** на cutover'е — данные
|
||
переносятся напрямую, без зависимости от restic-снапшотов:
|
||
|
||
- `caddyproxy/data/` — TLS-сертификаты Let's Encrypt (важно, чтобы
|
||
не упереться в rate-limit LE при перевыпуске ~17 сертов).
|
||
- `remembos/data/` — user data (memos-токен, telegram tokens).
|
||
- `transcriber/data/` — пользовательские транскрипции.
|
||
|
||
Это означает: на этапе rsync (шаг 4 cutover'а в плане) **нельзя**
|
||
полагаться только на restic-restore — для этих трёх апов rsync —
|
||
единственный канал. Для остальных приложений (которые имеют
|
||
`backup.sh` или `backup-targets`) можно при необходимости использовать
|
||
restic как фолбэк, но rsync всё равно остаётся основным методом.
|
||
|
||
Долгосрочно — добавить им backup-механизм отдельной итерацией после
|
||
миграции. Сейчас это сверх сферы.
|
||
|
||
---
|
||
|
||
## Шаг 6 — `vars/vars.yml` загружается во всех плейбуках (2026-05-22, выполнено)
|
||
|
||
Сегодняшний коммит `8378f0e` («Migration: expose some public vars»)
|
||
вынес общие переменные (`application_dir`, `host_name`, `primary_user`,
|
||
`primary_user_uid`, `primary_user_gid`, `bin_prefix`,
|
||
`apprise_external_port`, `apprise_external_url`, `caddy_logs_dir`) из
|
||
vault в `vars/vars.yml`. Но большая часть плейбуков загружала только
|
||
`vars/secrets.yml` — на текущем сервере они работали лишь потому, что
|
||
inventory дублирует `application_dir` как override. На чистом
|
||
Timeweb-инвентаре без override они бы упали с undefined.
|
||
|
||
Прошёлся по всем плейбукам, добавил `- vars/vars.yml` сразу после
|
||
`- vars/secrets.yml`:
|
||
|
||
```
|
||
playbook-authelia.yml playbook-netdata.yml
|
||
playbook-calibre.yml playbook-outline.yml
|
||
playbook-docker.yml playbook-remembos.yml
|
||
playbook-dozzle.yml playbook-rssbridge.yml
|
||
playbook-eget.yml playbook-transcriber.yml
|
||
playbook-gitea.yml playbook-transcriber-registry.yml
|
||
playbook-gramps.yml playbook-tuwunel.yml
|
||
playbook-homepage.yml playbook-ufw.yml
|
||
playbook-homepage-registry.yml playbook-upgrade.yml
|
||
playbook-memos.yml playbook-wakapi.yml
|
||
playbook-miniflux.yml playbook-wanderer.yml
|
||
```
|
||
|
||
(21 файл — все «обычные» плейбуки, которые ещё не подключали vars.yml.)
|
||
|
||
Aggregator'ы `playbook-all-applications.yml` и `playbook-all-setup.yml`
|
||
не трогал — у них нет собственных `vars_files`, они используют
|
||
`import_playbook`, каждый импортируемый плейбук уже сам подключает
|
||
`vars.yml`.
|
||
|
||
`yamllint` чист. Идемпотентность проверить отдельным прогоном.
|
||
|
||
Проверить прогоном `inv pl -- all-applications` (или хотя бы
|
||
`inv pl -- gitea outline miniflux`) на текущем сервере — diff
|
||
ожидается пустой.
|
||
|
||
---
|
||
|
||
## Шаг 5 — переезд default application_dir на /srv (2026-05-22, выполнено)
|
||
|
||
`/mnt` по FHS — место для точек монтирования внешних дисков; на
|
||
системном диске Timeweb (фаза 1) это семантически неверно. Поменяли
|
||
дефолт на `/srv/applications` (FHS: «data for services provided by
|
||
this system»), для текущего YC-сервера сделали override в инвентаре.
|
||
|
||
Изменения:
|
||
|
||
- `vars/vars.yml` — `application_dir: "/srv/applications"`
|
||
(комментарий обновлён).
|
||
- `production.yml` — у хоста `server` добавлен override
|
||
`application_dir: "/mnt/applications"`.
|
||
- `playbook-system.yml` — добавлен `vars/vars.yml` в `vars_files`,
|
||
захардкоженный `/mnt/applications` в задачах
|
||
`Create directory for mount` и `Mount external storages` заменён
|
||
на `{{ application_dir }}`.
|
||
- `playbook-remove-user-and-app.yml` — то же самое (`vars/vars.yml`
|
||
в `vars_files` + `{{ (application_dir, user_name) | path_join }}`).
|
||
- `tasks.py` — новый helper `_application_dir()` читает значение
|
||
сначала из inventory (override), затем из `vars/vars.yml`. `login_as_app`
|
||
больше не содержит `/mnt/applications`.
|
||
|
||
Что остаётся хардкодом — только `/mnt/applications` в `production.yml`
|
||
как override, и это правильно.
|
||
|
||
На Timeweb-инвентаре (когда появится) можно либо не задавать
|
||
`application_dir` вовсе (применится дефолт `/srv/applications`), либо
|
||
задать явно — для читаемости.
|
||
|
||
Проверить прогоном `inv pl -- system` на текущем сервере (Yandex
|
||
Cloud) — ничего не должно поменяться, потому что inventory override
|
||
возвращает `/mnt/applications` и mount всё ещё включён. Diff ожидается
|
||
пустой.
|
||
|
||
### Восстановление restic-снапшотов после смены путей
|
||
|
||
Старые снапшоты записаны с путями `/mnt/applications/<app>`. На
|
||
Timeweb данные должны лежать в `/srv/applications/<app>`. У restic
|
||
нет встроенного «remap path» при restore, поэтому делается в два
|
||
шага: восстановить во временный каталог, затем `rsync` на новое
|
||
место с сохранением uid/gid (приложения уже созданы playbook'ом с
|
||
теми же uid/gid, см. шаг про подготовку target).
|
||
|
||
Пример — восстановить gitea на Timeweb-машине:
|
||
|
||
```bash
|
||
sudo /usr/local/sbin/restic-shell.sh
|
||
|
||
# Распакуем нужную поддиректорию во временный каталог
|
||
restic restore latest \
|
||
--target /tmp/restic-restore \
|
||
--include /mnt/applications/gitea
|
||
|
||
# Перенесём данные на новый путь, сохранив владельца/группу/ACL/xattr
|
||
sudo rsync -aAX --info=progress2 \
|
||
/tmp/restic-restore/mnt/applications/gitea/ \
|
||
/srv/applications/gitea/
|
||
|
||
sudo rm -rf /tmp/restic-restore
|
||
```
|
||
|
||
Несколько приложений за один проход:
|
||
|
||
```bash
|
||
restic restore latest \
|
||
--target /tmp/restic-restore \
|
||
--include /mnt/applications/gitea \
|
||
--include /mnt/applications/outline \
|
||
--include /mnt/applications/miniflux
|
||
|
||
for app in gitea outline miniflux; do
|
||
sudo rsync -aAX --info=progress2 \
|
||
"/tmp/restic-restore/mnt/applications/$app/" \
|
||
"/srv/applications/$app/"
|
||
done
|
||
sudo rm -rf /tmp/restic-restore
|
||
```
|
||
|
||
Альтернатива через `restic mount` (если не хочется промежуточной
|
||
копии — данные мапятся как FUSE-FS):
|
||
|
||
```bash
|
||
sudo mkdir -p /mnt/restic-snapshots
|
||
restic mount /mnt/restic-snapshots &
|
||
sudo rsync -aAX \
|
||
/mnt/restic-snapshots/snapshots/latest/mnt/applications/gitea/ \
|
||
/srv/applications/gitea/
|
||
sudo fusermount -u /mnt/restic-snapshots
|
||
```
|
||
|
||
После переезда новые снапшоты будут записываться уже с путями
|
||
`/srv/applications/<app>` — никаких трюков для текущих бэкапов не
|
||
нужно.
|
||
|
||
---
|
||
|
||
## Шаг 4 — условное монтирование внешнего диска (2026-05-22, выполнено)
|
||
|
||
Задача `Mount external storages` в `playbook-system.yml` теперь
|
||
выполняется только при включённом флаге `mount_external_storage`
|
||
(default `false`). Сам UUID диска оставлен захардкоженным в
|
||
плейбуке — параметризовать не стали, потому что для Timeweb (фаза 1)
|
||
монтирование вообще не нужно, а для фазы 2 пока неизвестно, какой
|
||
UUID получится у второго диска.
|
||
|
||
Изменения:
|
||
|
||
- `playbook-system.yml` — у задачи mount добавлен
|
||
`when: mount_external_storage | default(false) | bool`.
|
||
- `production.yml` (инвентарь YC) — у хоста `server` добавлен
|
||
`mount_external_storage: true`, чтобы текущее поведение
|
||
сохранилось.
|
||
|
||
В будущем `timeweb.yml` просто не будет задавать эту переменную —
|
||
mount пропустится, `/mnt/applications` останется обычной директорией
|
||
на системном диске.
|
||
|
||
На фазе 2 (подключение медленного диска в Timeweb) UUID в
|
||
`playbook-system.yml` придётся поменять и включить флаг — это
|
||
осознанный шаг, не автоматизировано.
|
||
|
||
Проверено прогоном `inv pl -- system` на текущем сервере (Yandex
|
||
Cloud) — задача mount по-прежнему выполняется, `/mnt/applications`
|
||
смонтирован, изменений нет.
|
||
|
||
---
|
||
|
||
## Шаг 3 — переключение auth на cr.yandex (2026-05-22, выполнено)
|
||
|
||
Заменена аутентификация в Yandex Container Registry с YC-metadata
|
||
service на OAuth-token из vault.
|
||
|
||
Изменения:
|
||
|
||
- `files/yandex-docker-registry-auth.sh` — **удалён**.
|
||
- `playbook-homepage.yml` — задача `ansible.builtin.script:
|
||
yandex-docker-registry-auth.sh` заменена на
|
||
`community.docker.docker_login` с `username: oauth`, `password:
|
||
"{{ yc_oauth_token }}"`.
|
||
- `playbook-transcriber.yml` — то же самое.
|
||
|
||
Локальные push-плейбуки (`playbook-homepage-registry.yml`,
|
||
`playbook-transcriber-registry.yml`) не трогал — там нет auth-задачи
|
||
в принципе, локальный docker аутентифицируется вручную
|
||
(`yc container registry configure-docker` или `docker login`).
|
||
Если позже захочется унифицировать — можно добавить тот же
|
||
`docker_login` с `delegate_to: 127.0.0.1`.
|
||
|
||
Проверено прогоном `inv pl -- homepage` и `inv pl -- transcriber` на
|
||
текущем сервере (Yandex Cloud) — ошибок нет, контейнеры работают.
|
||
Значит и на Timeweb заработает (единственная разница — исходящий IP,
|
||
а OAuth-токен в YC принимается извне).
|
||
|
||
---
|
||
|
||
## Шаг 2 — OAuth-token для cr.yandex (2026-05-22, выполнено)
|
||
|
||
В `vars/secrets.yml` добавлена (или обновлена) переменная
|
||
`yc_oauth_token` со свежим OAuth-токеном Яндекса. Токен будет
|
||
использоваться для логина в `cr.yandex` с новой машины Timeweb
|
||
(вместо текущего скрипта `files/yandex-docker-registry-auth.sh`,
|
||
который завязан на YC metadata service `169.254.169.254` и
|
||
работает только внутри YC).
|
||
|
||
Сам код переключения на `community.docker.docker_login` пока не
|
||
вносится — это следующая итерация. Сейчас токен просто положен в
|
||
vault, чтобы не делать этого в день cutover'а под прессом.
|
||
|
||
---
|
||
|
||
## Шаг 1 — снижение TTL DNS (2026-05-22, выполнено)
|
||
|
||
В админке Yandex 360 для зоны `vakhrushev.me` уменьшен TTL
|
||
A-записей с **21 600 с (6 ч)** до **1 200 с (20 мин)**. Это даёт
|
||
запас по времени на распространение изменений после смены IP в
|
||
день cutover'а — старые кэширующие резолверы перестанут отдавать
|
||
старый адрес максимум через 20 минут (вместо 6 часов).
|
||
|
||
Делается **заранее**, потому что само снижение TTL тоже
|
||
распространяется по кэшам по правилам старого TTL — то есть после
|
||
правки нужно подождать ≥ 6 часов, чтобы новое значение TTL само
|
||
успело прижиться. Раньше cutover'а нужно сделать с большим
|
||
запасом — день в день не сработает.
|