Compare commits

25 Commits

Author SHA1 Message Date
av 2930842e3f Backups: update ADR after migration
Linting / YAML Lint (push) Waiting to run
Linting / Ansible Lint (push) Waiting to run
2026-06-23 09:38:09 +03:00
av 0f80e66b66 Backups: split restic operations into phases for Intelligent Tiering 2026-06-22 17:58:45 +03:00
av 2b22fde718 Gitea: update to 1.26.4 2026-06-22 17:44:21 +03:00
av c39de421e0 Backups: add restic errors
Linting / YAML Lint (push) Has been cancelled
Linting / Ansible Lint (push) Has been cancelled
2026-06-09 10:23:46 +03:00
av a50b399a85 Add ansible playbooks review 2026-06-09 10:17:32 +03:00
av 94b09be53c Outline: update to 1.8.1 2026-06-09 10:17:07 +03:00
av b637fea882 Memos: update to 0.29.1 2026-06-09 10:16:54 +03:00
av 933a0b9570 GoAccess: update Caddy to 2.11.3
Linting / YAML Lint (push) Has been cancelled
Linting / Ansible Lint (push) Has been cancelled
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-01 17:46:43 +03:00
av 96710360d9 Dozzle: update to 10.6.2
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-01 17:46:40 +03:00
av d9f0d94e1f Caddy: update to 2.11.3
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-01 17:46:37 +03:00
av 9b853d351c Authelia: update to 4.39.20
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-01 17:46:33 +03:00
av 11744f776a Wakapi: update to 2.17.4
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-01 17:45:39 +03:00
av 0df5f358d0 Outline: update to 1.8.0
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-01 17:45:35 +03:00
av 62e2a72e52 Memos: update to 0.29.0
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-01 17:45:32 +03:00
av 7c91f4f355 Gramps: update to 26.6.0
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-01 17:45:26 +03:00
av 68d8bf6a68 Gramps: update to 26.5.3 2026-05-25 09:39:27 +03:00
av e585bfdca2 Gramps: update to 26.5.2
Linting / YAML Lint (push) Has been cancelled
Linting / Ansible Lint (push) Has been cancelled
2026-05-24 16:49:08 +03:00
av 41822e04e8 Gitea: update to 1.26.2 2026-05-24 16:48:50 +03:00
av 21ccc7ac8c Fix style in ADR
Linting / YAML Lint (push) Has been cancelled
Linting / Ansible Lint (push) Has been cancelled
2026-05-24 15:56:53 +03:00
av 81478c2323 Add legacy ADR 2026-05-24 15:40:28 +03:00
av 313b1820be Update readme
Linting / YAML Lint (push) Has been cancelled
Linting / Ansible Lint (push) Has been cancelled
2026-05-24 14:55:23 +03:00
av 2f2c1b0754 Add ADR after migration to timeweb cloud 2026-05-24 14:51:03 +03:00
av e45e1db002 Add architecture decision record templates 2026-05-24 14:35:35 +03:00
av dc49b3497b Migration: stop yandex cloud server
Linting / YAML Lint (push) Has been cancelled
Linting / Ansible Lint (push) Has been cancelled
2026-05-23 17:55:58 +03:00
av 02ea9a3735 Migration: transfer data and run apps 2026-05-23 17:51:07 +03:00
30 changed files with 2164 additions and 825 deletions
+81
View File
@@ -0,0 +1,81 @@
---
name: adr
description: >-
Создаёт и сопровождает Architecture Decision Records (ADR) в docs/adr/.
Используй, когда уже сделанное архитектурное или инфраструктурное
изменение нужно зафиксировать постфактум: выбор инструмента/подхода,
структурное решение, намеренный отказ от очевидного варианта, либо
прямая просьба «записать решение / завести ADR». А также когда старую
ADR нужно пометить заменённой или устаревшей. ADR пишут ПОСТФАКТУМ;
идеи, планы и обсуждения — это drafts, а не ADR.
---
# Работа с ADR
ADR живут в `docs/adr/`. Формат и соглашения — `docs/adr/README.md`,
шаблон — `docs/adr/template.md`. Читай README перед первой записью в
сессии: правила там — источник истины, эта инструкция лишь даёт порядок
действий.
Язык записей — **русский**, стиль — как в `docs/drafts/`: конкретно,
по делу, без воды. Калибровка под личный хобби-сервер: не раздувай
запись, не предлагай корпоративные процессы.
## Сначала реши, нужен ли ADR
Заводи, если изменение **уже сделано** и это: выбор технологии/
инструмента, структурное решение, решение с долгосрочными последствиями
или дорогим откатом, либо намеренный отказ от очевидного варианта.
Не заводи:
- для рутины (бамп версии образа, сервис по накатанной схеме) и того,
что видно из кода/git;
- для идей, планов и того, что ещё не реализовано — это `docs/drafts/`,
а не ADR.
Если сомневаешься — спроси пользователя, не плоди записи молча.
## Создание новой ADR
1. **Дата.** Когда изменение реально сделано. Обычно сегодня
(`date +%F`). Если оформляем задним числом — уточни дату у
пользователя, не подставляй сегодняшнюю вслепую.
2. **Идентификатор и имя файла:** `ADR-ГГГГ-ММ-ДД-kebab-slug.md`
(slug — латиницей). Если за эту дату уже есть ADR — slug просто
должен отличаться; проверь `ls docs/adr/`.
3. **Файл.** Возьми за основу `docs/adr/template.md`.
4. **Заполни** по шаблону:
- Заголовок `# Человеческий заголовок` (без даты/ID в тексте).
- Метаданные: только `- Дата: ГГГГ-ММ-ДД`. **Строку статуса не
добавляй** — у активной записи статуса нет.
- **Контекст** — какая проблема и ограничения вынудили это делать.
- **Рассмотренные варианты** — *опциональная* секция. **Спроси
пользователя, рассматривал ли он какие-то альтернативы.** Если да —
перечисли их с плюсами/минусами (особенно те, что отвергнуты); если
нет (решение было единственным очевидным) — удали секцию целиком.
- **Решение** — что сделано и, **главное, почему**: какое намерение и
причина за этим стоят.
- **Последствия** — `+`/`-` и что нужно сделать как следствие.
5. **Индекс.** Добавь строку в таблицу «Список записей» в
`docs/adr/README.md`, **сверху** (новые сверху). Статус — `—`.
Самое важное в ADR — сохранить **почему**: намерение и причинность, а не
просто «что сделали». Не придумывай причины и альтернативы за
пользователя — если их нет в контексте сессии, обязательно спроси: что
подтолкнуло к изменению и какие варианты рассматривались.
## Замена / устаревание решения
Старые ADR неизменяемы. Если решение пересмотрено:
1. Заведи новую ADR (шаги выше). В её «Контексте» — строка
«Заменяет ADR-ГГГГ-ММ-ДД-slug».
2. В старой ADR добавь строку статуса сразу под датой:
`- Статус: заменено на ADR-ГГГГ-ММ-ДД-slug` (или `- Статус: устарело`,
если замены нет). Тело не трогай.
3. Обнови статус старой записи в индексе `README.md`.
## После записи
Покажи пользователю путь к файлу и кратко содержание. Не коммить без
явной просьбы.
+11 -2
View File
@@ -5,6 +5,9 @@
> В этом проекте не самые оптимальные решения.
> Но они помогают мне поддерживать сервер для моих личных проектов уже много лет.
История и обоснования значимых решений — в [ADR-записях](docs/adr/)
(`docs/adr/`): *почему* приняты те или иные изменения, а не только что сделано.
## Требования
- [uv](https://docs.astral.sh/uv/)
@@ -40,11 +43,17 @@ uv run ansible-galaxy install --role-file requirements.yml
Деплой приложения через ansible:
```bash
uv run ansible-playbook ansible-playbook -i production.yml --diff playbook-gitea.yml
uv run ansible-playbook ansible-playbook -i timeweb.yml --diff playbook-gitea.yml
```
Или через таску invoke:
```bash
./inv pl -- gitea
```
## Удаление приложения <name>
```bash
uv run ansible-playbook -i production.yml --diff playbook-remove-user-and-app.yml --extra-vars user_name=<name>
uv run ansible-playbook -i timeweb.yml --diff playbook-remove-user-and-app.yml --extra-vars user_name=<name>
```
@@ -0,0 +1,54 @@
# Вести историю решений в виде ADR
- Дата: 0000-00-00
> Основополагающая запись о самом процессе ADR. Дата-сентинел
> `0000-00-00` (фактически создана 2026-05-24) — исключение: так запись
> всегда остаётся в самом низу списка и не путается с реальными
> изменениями.
## Контекст
Сервер развивается итеративно: меняются прокси, схема бэкапов, набор
сервисов, провайдер хостинга. Решения принимаются по одному, часто с
неочевидными компромиссами под ресурсы сервера и стоимость. Через
несколько месяцев мотивация забывается, и возникает риск «переоткрыть»
уже отвергнутый вариант или сломать то, что было сделано осознанно.
Журналы в `docs/drafts/` фиксируют хронологию и черновики, но не
обоснование выбора — по ним не видно, какие альтернативы отвергнуты и
почему.
## Рассмотренные варианты
- **ADR (отдельный файл на решение)** — стандартный формат, каждая
запись неизменяема, видно эволюцию через накопление и замену записей.
- **Один changelog-файл** — проще вести, но правки затирают историю
рассуждений, и формат расплывается со временем.
- **Ничего, держать в голове / в git-сообщениях** — нулевые затраты,
но обоснование теряется, а git-история не отвечает на вопрос «почему».
## Решение
Заводим каталог `docs/adr/` с записями в формате ADR (Nygard + блок
«Рассмотренные варианты»). Идентификатор записи — датовый,
`ADR-ГГГГ-ММ-ДД-slug`: дата (когда изменение реально сделано) сразу
видна в списке и позволяет оформлять записи задним числом. ADR пишем
постфактум, поэтому жизненный цикл сведён к двум статусам у потерявших
силу записей — `заменено на` и `устарело`; идеи и планы остаются в
`docs/drafts/`.
Формат и процесс описаны в [`README.md`](README.md), шаблон — в
[`template.md`](template.md). Для единообразия заполнение
автоматизировано скиллом `adr` (`.claude/skills/adr/`).
## Последствия
- `+` Сохраняется обоснование решений и отвергнутые альтернативы.
- `+` Датовый ID даёт хронологию «из коробки» и не мешает оформлять
записи задним числом.
- `+` Единый формат: записи делает человек или агент по одному шаблону.
- `-` Небольшая дисциплина: сделав значимое изменение, нужно не забыть
оформить ADR.
- Скилл `adr` берёт на себя имя файла, шаблон и обновление индекса в
`README.md`, снижая трение.
+50
View File
@@ -0,0 +1,50 @@
# Authelia вместо Keycloak для SSO
- Дата: 2025-05-07
## Контекст
Для SSO/OIDC на сервере стоял Keycloak (заведён годом ранее,
2024-05-25). Проблема — ресурсы: Keycloak съедал больше 500 МБ RAM, что
тяжело для личного сервера с ограниченной оперативной памятью. При этом вся его мощь
избыточна: пользователей меньше десяти, realms / federation / тяжёлый
корпоративный стек не нужны. Изначально взял Keycloak, потому что нужен был
OIDC-сервер для настройки Outline; на тот момент было понятное
руководство по связке OIDC и Keycloak.
Требовался лёгкий по памяти SSO-провайдер с хорошей документацией,
желательно на Go/Rust.
## Рассмотренные варианты
- **Оставить Keycloak.** Отвергнуто: > 500 МБ RAM ради < 10
пользователей, функционал избыточен для личного сервера.
- **Authelia** (выбран). Лёгкая (Go), малое потребление памяти, хорошая
документация. Умеет и OIDC, и forward-auth.
Критерии отбора замены: минимальный расход RAM, хорошая документация,
стек Go/Rust.
## Решение
Заменили Keycloak на Authelia как провайдер аутентификации
(коммиты `a77fefc`, `d1500ea`, `3a23c08`). Authelia используется в трёх
режимах:
- **OIDC** для приложений, которым он нужен (например, Outline).
- **Forward-auth** агент в Caddy — удобно там, где полноценный OIDC
избыточен.
- **Закрытие чувствительных приложений** за единым логином. Раньше для
этого использовался basic auth в Caddy.
## Последствия
- `+` Резко меньше потребление RAM — критично для сервера с дефицитом
памяти.
- `+` Forward-auth закрывает приложения без OIDC проще, чем поднимать
отдельный OIDC-клиент под каждое.
- `+` Единая точка аутентификации вместо разрозненного basic auth в
Caddy.
- `-` Authelia беднее Keycloak по возможностям (нет полноценного интерфейса
управления пользователями, realms, federation) — но для < 10
пользователей это не нужно.
@@ -0,0 +1,42 @@
# Данные приложений на отдельном диске
- Дата: 2025-12-07
## Контекст
Исторически данные приложений лежали прямо в домашних директориях их
системных пользователей (`/home/<app-user>/…`), то есть на системном
диске рядом с ОС. В конце 2025 встал вопрос обновления ОС (Ubuntu 22.04
уже устарела), и стало ясно: пока данные привязаны к системному диску,
любое обновление или пересборка системы рискует этими данными и тяжело
откатывается.
Возникла мысль развязать данные приложений и жизненный цикл ОС.
## Рассмотренные варианты
- **Данные на системном диске** (как было). Просто, но данные связаны с
ОС: обновление/пересборка системы затрагивает и их.
- **Отдельный диск под данные** (выбран). Данные переживают пересборку
ОС, диск можно отцепить от одного сервера и прицепить к другому.
## Решение
Вынесли все данные приложений на отдельный диск, смонтированный в
`/mnt/applications`; каждое приложение держит там свои `data` / `config`
/ `backups`, а `base_dir`/`data_dir` указывают на этот путь
(коммиты `47a6320`, `7e67409`, `ae7c20a`, `8dfd061`).
## Последствия
- `+` Данные развязаны с жизненным циклом ОС — систему можно обновлять
и пересобирать, не трогая данные.
- `+` Диск можно отцепить от старого сервера и прицепить к новому. Это
легло в основу метода обновления ОС
([ADR-2025-12-13](ADR-2025-12-13-os-upgrade-via-server-rebuild.md)).
- `-` Появилась зависимость от монтирования внешнего диска (UUID,
mount-конфигурация): если диск не смонтирован, приложения не
поднимутся. Позже, при переезде в Timeweb, монтирование пришлось
сделать опциональным
([ADR-2026-05-23](ADR-2026-05-23-migrate-to-timeweb.md), фаза 1 — один
диск, фаза 2 — отдельный «холодный» диск под крупные данные).
@@ -0,0 +1,53 @@
# Обновление ОС пересборкой на свежем сервере
- Дата: 2025-12-13
## Контекст
На сервере стояла Ubuntu 22.04, и к концу 2025 пора было обновляться.
Обновлять живую боевую систему «на месте» (`do-release-upgrade`) не
хотелось — это рискованно и тяжело откатывается, если что-то пойдёт не
так на работающем сервере.
## Рассмотренные варианты
- **Обновление «на месте»** (`do-release-upgrade` на живой системе).
Отвергнуто: риск сломать рабочий сервер, нет простого отката.
- **Пересборка на свежем сервере** (выбран). Поднять новый сервер с
целевой ОС, накатать ansible, прицепить диск с данными, развернуть
приложения — старый сервер остаётся нетронутым как точка отката.
Заодно — почистить мусор, накопившийся за время работы прошлого сервера.
## Решение
Обновляем ОС через пересборку на свежем сервере. Метод опирается на три
предпосылки:
- **Деплой без запуска контейнеров.** Сводные плейбуки
(`playbook-all-setup`, `playbook-all-applications`) и тег `run-app`
позволяют раскатать пользователей, каталоги и конфиги, но НЕ запускать
приложения (`--skip-tags run-app`) — данные переносятся в «тихую»
систему (коммиты `5b53cb3`, `48bb8c9`, `67df03e`).
- **Данные на отдельном диске**
([ADR-2025-12-07](ADR-2025-12-07-app-data-on-separate-disk.md)) — диск
с данными прицепляется к новому серверу.
- **Фиксированные uid/gid.** Заранее закрепили uid/gid всех
пользователей приложений (роль `owner`, коммит `c2ea2cd`). Это
критично: иначе при пересоздании пользователей на новом сервере
uid/gid могли бы сдвинуться, и данные приложений на отдельном диске
оказались бы с чужим владельцем.
Порядок: сначала вся подготовка (отдельный диск, перенос данных на него,
фиксация uid/gid), затем пересборка на новом обновлённом сервере. Перенос
прошёл без проблем.
## Последствия
- `+` Обновление ОС без риска для живой системы; откат = вернуться на
старый сервер.
- `+` Получился воспроизводимый процесс миграции — позже переиспользован
при переезде в Timeweb как «холодное переключение» (cold cutover)
([ADR-2026-05-23](ADR-2026-05-23-migrate-to-timeweb.md)).
- `+` Фиксация uid/gid стала постоянным инвариантом проекта.
- `-` Метод требует заранее подготовленных предпосылок (фикс uid/gid +
данные на отдельном диске); без них он не работает.
@@ -0,0 +1,37 @@
# Apprise как шлюз уведомлений
- Дата: 2026-04-04
## Контекст
В первую очередь нужны были уведомления о бэкапах — знать, что ночной
прогон отработал и не сломался. Уведомления слались напрямую в конкретный
канал, привязка была зашита в каждом источнике. Хотелось единый слой,
который абстрагирует каналы доставки — чтобы добавлять или менять канал в
одном месте, а не править каждый источник.
## Рассмотренные варианты
- **Прямая интеграция с каждым каналом** (как было). Каждый источник знает про
конкретный канал; смена канала — правки во многих местах.
- **Apprise** (выбран). Смотрел разные self-hosted шлюзы уведомлений;
apprise выиграл зрелостью и числом готовых интеграций (десятки каналов
из коробки).
## Решение
Подняли apprise отдельным сервисом-шлюзом: источники шлют уведомление по
HTTP в apprise, а он разводит его по настроенным каналам (коммиты
`a0543e1`, `5f619ea`, `6bfb362`). Под ограниченную память сервера apprise
запущен в один воркер (`5e6df11`).
## Последствия
- `+` Каналы доставки абстрагированы за единым шлюзом — добавить или
сменить канал можно в одном месте, не трогая источники.
- `+` Доступ к десяткам интеграций apprise без отдельного кода под
каждую.
- `-` Ещё один сервис в обслуживании (контейнер, память).
- Окупилось при переезде в Timeweb: провайдер заблокировал Telegram, и
переключение уведомлений (сейчас почта, в планах Matrix) локализовано в
шлюзе ([ADR-2026-05-23](ADR-2026-05-23-migrate-to-timeweb.md)).
@@ -0,0 +1,82 @@
# Переезд сервера с Yandex Cloud на Timeweb VPS
- Дата: 2026-05-23
## Контекст
`rivendell-v2` жил на виртуальной машине в Yandex Cloud. Одновременно копились три
проблемы:
- **Цена.** ≈ 2 887 ₽/мес за конфигурацию, которую другие провайдеры
дают дешевле и мощнее.
- **Потолок RAM.** 4 ГБ, ≈ 80 % заняты на штатной нагрузке. Любой
всплеск (миграции БД, индексация Outline, restic) — конкуренция за
память и риск OOM. Расти на этом тарифе дальше — только заметно
дороже.
- **Медленный диск.** Чтобы сдержать цену в YC, использовался дешёвый
HDD вместо SSD/NVMe — страдала отзывчивость (Gitea, Outline, тёплый
старт контейнеров, restic check/forget).
Это личный сервер — допустимы небольшие простои.
## Рассмотренные варианты
- **Остаться в YC, поднять тариф** (больше RAM/SSD на месте). Отвергнуто:
YC уже дороже альтернатив, апгрейд поднимает цену непропорционально
приросту — те же три проблемы решаются дороже, чем переездом.
- **Свой / домашний сервер** (железо под контролем, без ежемесячной
аренды). Отвергнуто: дома нет надёжного аптайма 24/7 (питание,
интернет-канал, железо), а сервисы должны быть всегда доступны.
- **Переезд на Timeweb Cloud VPS** — выбранный вариант.
## Решение
Переносим на Timeweb Cloud VPS: ≈ 1 980 ₽/мес, 8 ГБ RAM (×2), 4 ядра
(×2, гарантия CPU 100 % вместо 50 %), 80 ГБ NVMe вместо 120 ГБ HDD.
Один переезд закрывает все три причины сразу.
Рамки решения:
- Переезжает **только сам сервер с приложениями** (compute). S3 (restic, бэкапы),
Container Registry, Postbox SMTP и DNS-зона `vakhrushev.me` остаются
в Yandex и используются с новой машины.
- Стратегия — **холодное переключение** (cold cutover): погасить сервисы
на источнике, раскатать
ansible на новом сервере без запуска приложений (сохраняя uid/gid), перенести
данные `rsync`'ом, запустить, переключить DNS.
- Диск: фаза 1 — один 80 ГБ NVMe (всего 22 ГБ данных + 17 ГБ системных, влезает с
запасом). «Холодный» второй диск под крупные данные — отдельная
фаза 2, не на критическом пути.
- Источник не удаляется сразу после переключения: держим «холодным
запасным»
пару недель ради отката.
Детальный план — [`../drafts/timeweb.md`](../drafts/timeweb.md),
фактическое выполнение —
[`../drafts/timeweb-migration-log.md`](../drafts/timeweb-migration-log.md).
## Последствия
- `+` 907 ₽/мес (≈ −31 %) при вдвое большем RAM и CPU и NVMe-диске —
закрыты все три исходные проблемы.
- `+` Запас по RAM убирает OOM-риск при всплесках нагрузки.
- `+` Диверсификация по облакам: раньше сервер и данные были в одном
аккаунте Yandex Cloud, теперь сам сервер в Timeweb, а бэкапы (S3) — в
Yandex. Если заблокируют или потеряем доступ к одному провайдеру,
данные остаются доступны через другой.
- `-` Диск меньше (80 ГБ NVMe против 120 ГБ HDD), но сейчас занят
примерно наполовину — запас есть, фаза 2 с холодным диском не срочная.
- `-` Сохраняется зависимость от Yandex Cloud (S3, Container Registry,
Postbox SMTP, DNS) — переезд её не устраняет.
- `-` Timeweb активно блокирует Telegram (в отличие от YC) — интеграция
отвалилась. Затронуты `transcriber`, `remembos` и уведомления о
бэкапах. Ожидаемо; уведомления остались через почту, второй канал
рассматривается через Matrix.
- `-` Из-за тех же блокировок Timeweb перестали обновляться некоторые
RSS-фиды в `miniflux`.
- `-` Для доступа к `cr.yandex` вне YC появился долгоживущий OAuth-токен
Яндекса в vault (`yc_oauth_token`): при утечке он открывает доступ ко
всему аккаунту Яндекса. Сузить можно IAM-ключом сервисного аккаунта —
отдельной итерацией.
- Инвентарь временно раздвоен (`production.yml` + `timeweb.yml`); после
стабилизации источник удаляется, `timeweb.yml``production.yml`.
@@ -0,0 +1,125 @@
# Разнесение restic-операций на фазы под Intelligent Tiering
- Дата: 2026-06-22
## Контекст
Бэкапы restic уже лежат в Yandex Object Storage на стандартном классе
хранения (STANDARD). Yandex выпустил класс «Умное хранилище» (Intelligent
Tiering, IT): объекты автоматически охлаждаются до архивного уровня
(примерно 0,63 ₽/ГБ против 2,38 ₽/ГБ у STANDARD) с сохранением мгновенного
доступа на всех уровнях (анонс:
<https://yandex.cloud/ru/blog/s3-intelligent-tiering>). Данные restic — это
профиль «записал один раз, читаю редко», то есть идеальный кандидат на
охлаждение. Цель — перевести уже лежащие бэкапы со STANDARD на IT и
сэкономить на хранении.
Проблема в том, что любой repack/recompress объекта создаёт *новый*
объект, который входит в IT как «Частый доступ» и заново стартует таймер
охлаждения (30 дней → «Нечастый», ещё 90 → «Архивный»). А наш оркестратор
`backup-all.py` гнал каждую ночь связку `backup → check → forget --prune →
check`. Ночной `prune` перепаковывает data-паки → постоянно сбрасывает
охлаждение → отменяет экономию IT. Нужно было перестроить обслуживание
так, чтобы не мешать охлаждению.
Дополнительное ограничение по миграции существующих данных: по докам
Yandex изменение класса бакета **по умолчанию** не трогает уже загруженные
объекты — они остаются в STANDARD, новый класс применяется только к новым
загрузкам. Перевести уже лежащие бэкапы в IT можно lifecycle-правилом или
copy-in-place (`aws s3 cp --storage-class INTELLIGENT_TIERING`); оба
тарифицируются как операция `TRANSITION`. Перезаливка объектов заново для
restic не годится — это churn, эквивалентный репаку. Управления бакетом
(terraform/aws-cli) в проекте нет, так что миграцию пришлось бы делать
вручную или заводить такое управление.
Идентификатор класса подтверждён доками — `INTELLIGENT_TIERING` (Yandex
поддерживает STANDARD, COLD, ICE, INTELLIGENT_TIERING). Явный min-retention
(12 месяцев со штрафом за раннее удаление) документирован **только для
класса ICE**; для архивного уровня внутри IT такого минимума в доках нет —
значит transition и последующий `prune` для IT низкорисковы. Финальная
проверка — по биллингу после первой реальной миграции.
## Рассмотренные варианты
- **Отдельные скрипты на репозиторий** (`backup.sh`/`check.sh`/`prune.sh`/
`verify.sh` + свои cron-записи, как в исходном гайде). Ближе к гайду
дословно, но теряем оркестратор: авто-дискавери приложений, мультистор
и единые apprise-уведомления. Плюс 4 независимые cron-записи провоцируют
наложение операций (долгий `prune` налезает на ночной `backup`
конфликт restic-локов). Отвергнут.
- **Адаптировать `backup-all.py`** — добавить фазы и расписание внутрь
оркестратора, один ночной триггер. Сохраняет всю существующую
инфраструктуру, фазы идут последовательно в одном процессе → локи не
конфликтуют. **Выбран.**
- **Расписание: простые knobs** (день недели/число/месяцы как поля
конфига) **vs cron-выражения через `croniter`**. Knobs — без
зависимости, но негибко (новая ось → правка кода). Выбран `croniter`:
пакет ставится из apt (`python3-croniter`) тем же механизмом, что и
остальное, а гибкость реальная — поменять «раз в квартал» на «раз в
месяц» = правка одной строки конфига.
- **Перевод существующих данных в IT: copy-in-place vs lifecycle vs
отложить.** Lifecycle в Yandex переводит только «на более холодный»
(STANDARD→COLD→ICE), переход именно в IT им не заявлен — отпал.
Перезаливка объектов для restic не годится (churn ≈ репак). Остаётся
**copy-in-place** (`aws s3 cp --recursive --storage-class
INTELLIGENT_TIERING`, серверная копия «на себя»). **Выбран copy-in-place**
— после того как доки сняли блокер по min-retention. Для будущих записей
отдельно — флип класса бакета по умолчанию на IT (вариант A с
`-o s3.storage-class` в коде не понадобился).
## Решение
Операции restic в `files/backups/backup-all.py` разнесены на фазы с
разной частотой, потому что у них принципиально разная цена для IT:
- `backup` + `forget`**каждый прогон**. `forget` теперь **без
`--prune`**: удаляет только метаданные снапшотов (операция DELETE не
тарифицируется), не репакует data-паки и не сбивает охлаждение.
- `check` (структурный) — еженедельно; `prune` — квартально; `verify`
(`check --read-data-subset`) — помесячно. Расписание задано
cron-выражениями в секции `[schedule]` конфига и вычисляется через
`croniter`. Триггер один ночной, фазы одного прогона идут
последовательно в одном процессе → restic-локи между ними не
конфликтуют. Наложение соседних прогонов гасится `flock -n` в cron.
`prune` тюнингован под IT (`--max-unused 20%`, `--max-repack-size 5G`):
чем меньше холодных паков переписываем, тем дольше держится охлаждение.
Перевод бакетов в IT идёт двумя действиями на каждый бакет: смена класса
**по умолчанию** на IT в консоли (будущие записи restic) + разовая
**copy-in-place** существующих объектов (`aws s3 cp s3://<bucket>/
s3://<bucket>/ --recursive --storage-class INTELLIGENT_TIERING
--metadata-directive COPY`). Класс отдаётся прямо в листинге
(`list-objects-v2 --query 'Contents[].[StorageClass,Key]'`) — им и
проверяем. Грабли: для проверки нельзя `--max-items 1` (клиентская
пагинация aws-cli дописывает в вывод токен `None`) — нужен серверный
`--max-keys`.
Статус миграции: **`rivendell` переведён 2026-06-23** (дефолт бакета = IT
со скриншота, все объекты `config`/`data/` показывают
`INTELLIGENT_TIERING`). `eos` (основная экономия) и `buckland` — следующими,
после нескольких дней наблюдения за биллингом `rivendell`.
Retention оставлен прежним (`--keep-daily 90 --keep-monthly 36`) — это
решение про охлаждение и частоту операций, а не про глубину истории.
## Последствия
- `+` Ночной prune больше не сбрасывает охлаждение — IT реально экономит
на архивном уровне.
- `+` Нет наложения restic-операций: последовательные фазы + `flock`.
- `+` Расписание обслуживания меняется правкой конфига, без релиза кода.
- `-` Новая зависимость на сервере: `python3-croniter` (и явно
зафиксированный `python3-requests`).
- `-` Структурный `check` теперь еженедельный, а не каждую ночь: битый
бэкап может остаться незамеченным до недели. Для хобби-сервера приемлемо.
- `-` Подвох croniter: при суточном триггере поля минут/часов в
выражениях декоративны (держим `* *`) — фаза идёт в момент ночного
прогона, а не во время из выражения.
- `+` Миграция существующих объектов — разовая copy-in-place, без репака
restic: содержимое и ключи паков не меняются, restic остаётся рабочим.
- `-` После перевода объекты стартуют на уровне FREQUENT и охлаждаются
~120 дней — полка экономии устанавливается не сразу.
- Осталось сделать: несколько дней последить за биллингом и бэкапами
`rivendell` (убедиться, что за transition нет штрафа), затем повторить
пару «флип дефолта + copy-in-place» для `eos` и `buckland`.
+72
View File
@@ -0,0 +1,72 @@
# Architecture Decision Records (ADR)
Журнал значимых архитектурных и инфраструктурных решений по серверу.
Одна запись — одно решение. ADR пишем **постфактум**, когда изменение
уже сделано: идеи, планы и обсуждения живут в [`../drafts/`](../drafts),
а в ADR попадает только то, что реализовано. Записи **неизменяемы**:
передумали → не правим старую, а заводим новую и помечаем старую.
Чем ADR отличается от журналов в `../drafts/`: drafts — оперативная
хроника и черновики («что делаю / собираюсь сделать»), ADR — фиксация
выбора и его обоснования («почему сделал так, а не иначе»).
Главная ценность записи — сохранить **почему**: намерение и причинность.
Это важнее аккуратности оформления и полноты остальных секций.
## Когда заводить ADR
- Выбор технологии или инструмента (Caddy vs Nginx, restic vs borg).
- Структурные решения (схема бэкапов, организация плейбуков, сеть).
- Решения с долгосрочными последствиями или дорогим откатом.
- **Намеренный отказ** от очевидного подхода — чтобы потом не
переоткрывать «а почему мы не сделали X».
Не заводить для рутины (бамп версии образа, добавление сервиса по
накатанной схеме) и того, что и так видно из кода и git.
## Соглашения
- **Имя файла = идентификатор:** `ADR-ГГГГ-ММ-ДД-kebab-slug.md`.
Идентификатор — имя без `.md` (например `ADR-2026-05-24-adr-process`).
Slug — латиницей.
- **Дата** — когда изменение реально сделано (можно задним числом, а не
дата оформления записи).
- Несколько ADR за один день различаются по slug.
- **Заголовок в файле:** `# Человеческий заголовок` (без даты и ID — они
в имени файла и в строке «Дата»).
- Секция **«Рассмотренные варианты» — опциональна**: оставляй её, только
если альтернативы реально рассматривались.
- Шаблон новой записи — [`template.md`](template.md).
- Исключение: основополагающая мета-запись о самом процессе ADR
использует дату-сентинел `0000-00-00`, чтобы всегда оставаться в самом
низу списка. Реальные записи такую дату не используют.
## Статусы
Активная запись статуса **не имеет**. Статус появляется, только когда
запись теряет силу, и значений всего два:
- `заменено на ADR-ГГГГ-ММ-ДД-slug` — решение пересмотрено новой ADR.
- `устарело` — решение потеряло смысл и замены нет.
## Замена и устаревание
1. Заводим новую ADR; в её «Контексте» — строка
«Заменяет ADR-ГГГГ-ММ-ДД-slug».
2. В старой ADR добавляем строку `- Статус: заменено на ADR-…` сразу под
датой. Тело не трогаем — это часть истории.
3. Обновляем статус старой записи в индексе ниже.
## Список записей
Новые сверху.
| Дата | Запись | Статус |
| ---------- | ---------------------------------------------------------------------------------------------- | ------ |
| 2026-06-22 | [Разнесение restic-операций на фазы под Intelligent Tiering](ADR-2026-06-22-restic-intelligent-tiering-phases.md) | — |
| 2026-05-23 | [Переезд сервера с Yandex Cloud на Timeweb VPS](ADR-2026-05-23-migrate-to-timeweb.md) | — |
| 2026-04-04 | [Apprise как шлюз уведомлений](ADR-2026-04-04-apprise-notifications.md) | — |
| 2025-12-13 | [Обновление ОС пересборкой на свежем сервере](ADR-2025-12-13-os-upgrade-via-server-rebuild.md) | — |
| 2025-12-07 | [Данные приложений на отдельном диске](ADR-2025-12-07-app-data-on-separate-disk.md) | — |
| 2025-05-07 | [Authelia вместо Keycloak для SSO](ADR-2025-05-07-authelia-sso.md) | — |
| 0000-00-00 | [Вести историю решений в виде ADR](ADR-0000-00-00-record-architecture-decisions.md) | — |
+37
View File
@@ -0,0 +1,37 @@
# Краткий заголовок решения
- Дата: ГГГГ-ММ-ДД
<!-- Строку статуса добавляют позже, только если запись потеряла силу:
- Статус: заменено на ADR-ГГГГ-ММ-ДД-slug
- Статус: устарело
У активной записи строки статуса нет. -->
## Контекст
Что вынудило сделать изменение: проблема, силы и ограничения (ресурсы
сервера, стоимость, время на поддержку, существующая архитектура).
Пиши так, чтобы через год было понятно «почему это вообще делалось»
без чтения переписки.
## Рассмотренные варианты
<!-- Опциональная секция. Оставь, только если варианты реально
рассматривались. Если решение было единственным очевидным —
удали её, а причину объясни в «Решении». -->
- **Вариант A** — суть, плюсы и минусы.
- **Вариант B** — суть, плюсы и минусы.
- **Вариант C** — если отвергнут сразу, коротко почему.
## Решение
Что именно сделано и — главное — **почему**: какое намерение и какая
причина за этим стоят. Если варианты рассматривались — почему выбран
этот, а не остальные.
## Последствия
- `+` что стало лучше, какие возможности открылись.
- `-` чем платим: новые ограничения, риски, регулярная нагрузка на
поддержку.
- Что нужно сделать как следствие (если есть).
+150
View File
@@ -0,0 +1,150 @@
# Ревью плейбуков: best practices и конвенции Ansible
Дата: 2026-05-25. Статус: черновик (заметки по итогам ревью, не план работ).
Проанализированы инвентарь, `ansible.cfg`, роли (`owner`, `eget`, `secrets`) и
репрезентативная выборка плейбуков: `gitea`, `memos`, `wanderer`, `backups`,
`system`, `caddyproxy`, `authelia`, `netdata`, `docker`, `eget`, `all-*`.
Находки отсортированы по влиянию.
## Договорённость по структуре (важно для контекста)
Изначальная рекомендация «вынести общий деплой в одну generic-роль `docker_app`»
**отклонена осознанно** и не должна предлагаться снова:
- приложения реально разные, мелкие отличия больно загонять в единую абстракцию;
- catch-all роль обрастает флагами `when:` и читается хуже, чем N честных плейбуков;
- per-playbook дублирование даёт locality of behavior и возможность обкатать новый
подход на одном сервисе, затем раскатать на остальные.
Правильное направление — **набор маленьких composable-ролей на инвариантных швах**
(как уже сделано с `owner`), а не одна роль на всё. Per-app конфиг остаётся локально
в плейбуке сервиса.
## 1. Extraction только на чистых швах (не мега-роль)
Per-app конфиг (каталоги, шаблоны, env, порты, особенности compose) — оставляем в
плейбуке сервиса. Выносим лишь то, что реально инвариантно и повторилось многократно:
- **Бэкап** — самый чистый шов: `gobackup.yml` + `backup.sh` + `backup-targets` +
интеграция с restic. Механизм одинаков у всех, различается только список целей.
Роль `backup` с параметром «список targets» не трогает индивидуальность сервиса.
- `owner` уже сделан как отдельная composable-роль — это правильный размер абстракции.
## 2. `vars_files` в каждом плейбуке → `group_vars/all/`
В каждом плейбуке повторяется:
```yaml
vars_files:
- vars/secrets.yml
- vars/vars.yml
```
Ansible автоматически подхватывает `group_vars/all.yml` и `group_vars/all/secrets.yml`
(vault) для группы `all`. Перенос `vars/vars.yml``group_vars/all/main.yml` и
`vars/secrets.yml``group_vars/all/vault.yml` убирает boilerplate из всех плейбуков.
Адаптируется по одному плейбуку за раз.
## 3. Нет handlers — `state: restarted` безусловный
Ни в одном плейбуке нет `handlers:`. Вместо этого:
- `playbook-caddyproxy.yml:106`, `playbook-netdata.yml:143`, `playbook-authelia.yml:92`
задача `state: restarted` выполняется **всегда**, рестартит контейнер на каждом
прогоне даже без изменений (не идемпотентно, лишний downtime);
- `playbook-gitea.yml` — рестарта нет вовсе (несогласованность).
Канонический паттерн: шаблон конфига `notify`-ит handler, который рестартит только при
реальном изменении.
```yaml
- name: "Copy docker compose file"
ansible.builtin.template: { ... }
notify: Restart app
handlers:
- name: Restart app
community.docker.docker_compose_v2:
project_src: "{{ base_dir }}"
state: restarted
```
Связанное: в `playbook-memos.yml:76` результат шаблона регистрируется в
`docker_compose_file_result`, но нигде не используется — задумывалось под `when`/`notify`,
не доведено.
Внедряется инкрементально, по одному сервису.
## 4. Идемпотентность и `changed_when`
- **`playbook-netdata.yml:118-125`** — `changed_when: netdata_docker_group_output.rc != 0`
для read-only запроса лишено смысла (помечает «changed» только при ошибке). Должно быть
`changed_when: false`. Лучше заменить `shell: grep docker /etc/group | cut ...` на модуль:
```yaml
- ansible.builtin.getent:
database: group
key: docker
# далее: getent_group['docker'][1]
```
Уйдёт и `set -o pipefail`, и хрупкий парсинг.
- **`playbook-eget.yml:23-78`** — восемь `command` помечены `changed_when: false`, хотя
реально ставят/обновляют бинарники. Прогон всегда рапортует «ok» — теряется честность
`--diff`. Сама роль `eget` делает корректную проверку версии; те же инсталляции через
неё или через проверку версии были бы идемпотентны по-настоящему.
- **`playbook-memos.yml:57-67`** (и аналоги) — сборка `backup-targets` через `lineinfile`
в цикле не удаляет устаревшие строки при изменении списка, а `mode: "0750"` на
файле-списке выглядит как copy-paste. Чище — `template`/`copy: content` со всем списком.
## 5. Роль `owner` — несогласованность с ролью `eget`
- **`roles/owner/tasks/main.yml:2-10`** — валидация аргументов через `fail` + `when`,
причём две задачи с **идентичным именем**. Роль `eget` для того же делает `assert`
(`roles/eget/tasks/main.yml:15`). Привести к одному стилю — `assert` либо современный
`meta/argument_specs.yml` (декларативная валидация).
- **`roles/owner/tasks/main.yml:32,53`** — `with_items`/`with_dict` устарели; конвенция —
`loop`: `loop: "{{ owner_ssh_keys }}"`, `loop: "{{ owner_env_dict | dict2items }}"`.
- У `owner` нет `meta/main.yml` и README, тогда как у `eget` и `secrets` они есть.
- Имена задач в `owner` с точкой на конце (`"Prepare env variables."`), в остальных без —
ansible-lint в строгом профиле это ловит.
## 6. Инвентарь и `become`
- **`production.yml` и `timeweb.yml`** оба объявляют хост с именем `server` под ключом
`ungrouped:`. Хост-специфичные данные (`application_dir`, `mount_external_storage`,
`ansible_host`, `ansible_user`) вписаны инлайн. Конвенциональнее — `host_vars/server.yml`,
хосты в именованной группе. Два инвентаря с одинаковым именем хоста + `hosts: all` =
ошибка `-i` молча уедет не туда.
- `ansible_become: true` глобально в инвентаре — всё бежит под root. Для личного сервера
прагматично; точечный `become`/`become_user` ближе к наименьшим привилегиям. Низкий приоритет.
## 7. Конкретный баг
- **`playbook-wanderer.yml:2`** — `name: "Configure gramps application"`, хотя
`app_name: "wanderer"`. Копипаст из gramps, поправить имя play.
## 8. Мелочи стиля и конфигурации
- **sudoers**: `playbook-backups.yml:52-59` правит `/etc/sudoers` через `lineinfile`.
Конвенция — отдельный файл в `/etc/sudoers.d/` (через `copy`/`template` с
`validate: visudo -cf %s`), а не модификация центрального файла.
- **`.ansible-lint.yml`** содержит только `exclude_paths` — профиль не задан явно.
AGENTS.md утверждает «профиль production»; либо прописать `profile: production`, либо
поправить документацию.
- **`ansible.cfg`** минимален. Стоит добавить `stdout_callback = yaml`,
`interpreter_python = auto_silent`, `force_handlers = true`.
- Несогласованные кавычки и пути: `'directory'` vs `"directory"`, `src: "./files/..."` vs
`src: "files/..."`, одинарные кавычки в `playbook-all-setup.yml` против двойных в остальных.
- `playbook-system.yml:24` — `apt` без `cache_valid_time`, обновляет кэш каждый прогон.
## Приоритеты
1. **#3 handlers** — убирает безусловный рестарт; внедряется по одному сервису.
2. **#1 роль `backup`** — самый чистый шов для extraction; обкатать на одном сервисе.
3. **#4, #7** — быстрые точечные фиксы без структурных изменений.
4. **#2 group_vars** — убирает boilerplate; низкий риск.
5. **#5, #6, #8** — фоновая зачистка стиля и структуры.
+274
View File
@@ -8,6 +8,280 @@
---
## Шаг 14 — VM в YC остановлена (2026-05-23, выполнено)
Через несколько часов после cutover'а — выключил VM `rivendell-v2` в
панели Yandex Cloud (stop, не delete). Источник перешёл в состояние
«холодного запасного».
Формально план рекомендовал держать источник в живых ≥24 часа перед
остановкой (`timeweb.md:464`), но:
- docker и cron на источнике остановлены и `disable`нуты ещё на
Шаге 11 — VM работала вхолостую.
- Ключевые приложения проверены в браузере на target (см. Шаг 13).
- **Stop, не destroy** — состояние VM и диск сохраняются, при
необходимости отката достаточно `Start` в панели + `systemctl
enable --now docker cron` + откат DNS. Прирост к рекавери ~1-2 мин
по сравнению со running idle.
Compute снят со счёта (Timeweb-VM теперь единственный источник
расходов). S3-бакет с restic-бэкапами и Container Registry в YC
**не трогаем** — продолжают использоваться с Timeweb.
### Что осталось
Через неделю-две, если ничего не всплыло:
- Удалить VM `rivendell-v2` и связанные compute-ресурсы (только
compute! S3 и CR — оставляем).
- Удалить `production.yml`, переименовать `timeweb.yml`
`production.yml`, откатить `HOSTS_FILE` в `tasks.py`. Закоммитить.
- Перенести `timeweb.md` и `timeweb-migration-log.md` из
`docs/drafts/` куда-нибудь в архив или удалить — план выполнен,
журнал теряет актуальность.
---
## Шаг 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-машине прогнаны два плейбука без даунтайма источника
+72 -82
View File
@@ -104,7 +104,7 @@ server:
## Configure the authz endpoints.
authz:
forward-auth:
implementation: 'ForwardAuth'
implementation: "ForwardAuth"
# authn_strategies: []
# ext-authz:
# implementation: 'ExtAuthz'
@@ -121,10 +121,10 @@ server:
##
log:
## Level of verbosity for logs: info, debug, trace.
level: 'debug'
level: "debug"
## Format the logs are written as: json, text.
format: 'json'
format: "json"
## File path where the logs will be written. If not set logs are written to stdout.
# file_path: '/config/authelia.log'
@@ -136,7 +136,6 @@ log:
## Telemetry Configuration
##
telemetry:
##
## Metrics Configuration
##
@@ -151,7 +150,7 @@ telemetry:
## Square brackets indicate optional portions of the format. Scheme must be 'tcp', 'tcp4', 'tcp6', 'unix', or 'fd'.
## The default scheme is 'unix' if the address is an absolute path otherwise it's 'tcp'. The default port is '9959'.
## If the path is not specified it defaults to `/metrics`.
address: 'tcp://:9959/metrics'
address: "tcp://:9959/metrics"
## Metrics Server Buffers configuration.
# buffers:
@@ -320,7 +319,6 @@ telemetry:
##
## This configuration tunes the identity validation flows.
identity_validation:
## Reset Password flow. Adjusts how the reset password flow operates.
reset_password:
## Maximum allowed time before the JWT is generated and when the user uses it in the duration common syntax.
@@ -330,7 +328,7 @@ identity_validation:
# jwt_algorithm: 'HS256'
## The secret key used to sign and verify the JWT.
jwt_secret: '{{ identity_validation__jwt_secret }}'
jwt_secret: "{{ identity_validation__jwt_secret }}"
## Elevated Session flows. Adjusts the flow which require elevated sessions for example managing credentials, adding,
## removing, etc.
@@ -408,7 +406,6 @@ identity_validation:
##
## The available providers are: `file`, `ldap`. You must use only one of these providers.
authentication_backend:
## Password Change Options.
password_change:
## Disable both the HTML element and the API for password change functionality.
@@ -606,7 +603,7 @@ authentication_backend:
## Important: Kubernetes (or HA) users must read https://www.authelia.com/t/statelessness
##
file:
path: '/config/users.yml'
path: "/config/users.yml"
# watch: false
# search:
# email: false
@@ -719,33 +716,33 @@ authentication_backend:
access_control:
## Default policy can either be 'bypass', 'one_factor', 'two_factor' or 'deny'. It is the policy applied to any
## resource if there is no policy to be applied to the user.
default_policy: 'deny'
default_policy: "deny"
rules:
## Rules applied to everyone
- domain: 'status.vakhrushev.me'
subject: 'group:admins'
policy: 'two_factor'
- domain: "status.vakhrushev.me"
subject: "group:admins"
policy: "two_factor"
- domain: 'dozzle.vakhrushev.me'
subject: 'group:admins'
policy: 'two_factor'
- domain: "dozzle.vakhrushev.me"
subject: "group:admins"
policy: "two_factor"
- domain: 'goaccess.vakhrushev.me'
subject: 'group:admins'
policy: 'two_factor'
- domain: "goaccess.vakhrushev.me"
subject: "group:admins"
policy: "two_factor"
- domain: 'wanderbase.vakhrushev.me'
subject: 'group:admins'
policy: 'two_factor'
- domain: "wanderbase.vakhrushev.me"
subject: "group:admins"
policy: "two_factor"
- domain: 'remembos.vakhrushev.me'
subject: 'group:admins'
policy: 'two_factor'
- domain: "remembos.vakhrushev.me"
subject: "group:admins"
policy: "two_factor"
- domain: 'rssbridge.vakhrushev.me'
subject: 'group:admins'
policy: 'one_factor'
- domain: "rssbridge.vakhrushev.me"
subject: "group:admins"
policy: "one_factor"
## Domain Regex examples. Generally we recommend just using a standard domain.
# - domain_regex: '^(?P<User>\w+)\.example\.com$'
@@ -826,18 +823,17 @@ access_control:
session:
## The secret to encrypt the session data. This is only used with Redis / Redis Sentinel.
## Secret can also be set using a secret: https://www.authelia.com/c/secrets
secret: '{{ session__secret }}'
secret: "{{ session__secret }}"
## Cookies configures the list of allowed cookie domains for sessions to be created on.
## Undefined values will default to the values below.
cookies:
-
## The name of the session cookie.
name: 'authelia_session'
- ## The name of the session cookie.
name: "authelia_session"
## The domain to protect.
## Note: the Authelia portal must also be in that domain.
domain: 'vakhrushev.me'
domain: "vakhrushev.me"
## Required. The fully qualified URI of the portal to redirect users to on proxies that support redirections.
## Rules:
@@ -845,7 +841,7 @@ session:
## - The above 'domain' option MUST either:
## - Match the host portion of this URI.
## - Match the suffix of the host portion when prefixed with '.'.
authelia_url: 'https://auth.vakhrushev.me'
authelia_url: "https://auth.vakhrushev.me"
## Optional. The fully qualified URI used as the redirection location if the portal is accessed directly. Not
## configuring this option disables the automatic redirection behaviour.
@@ -904,7 +900,7 @@ session:
## Important: Kubernetes (or HA) users must read https://www.authelia.com/t/statelessness
##
redis:
host: 'authelia_redis'
host: "authelia_redis"
port: 6379
## Use a unix socket instead
# host: '/var/run/redis/redis.sock'
@@ -1022,7 +1018,7 @@ storage:
## The encryption key that is used to encrypt sensitive information in the database. Must be a string with a minimum
## length of 20. Please see the docs if you configure this with an undesirable key and need to change it, you MUST use
## the CLI to change this in the database if you want to change it from a previously configured value.
encryption_key: '{{ storage__encryption_key }}'
encryption_key: "{{ storage__encryption_key }}"
##
## Local (Storage Provider)
@@ -1034,7 +1030,7 @@ storage:
##
local:
## Path to the SQLite3 Database.
path: '/data/authelia_storage.sqlite3'
path: "/data/authelia_storage.sqlite3"
##
## MySQL / MariaDB (Storage Provider)
@@ -1212,22 +1208,22 @@ notifier:
## (configure in tls section)
smtp:
## The address of the SMTP server to connect to in the address common syntax.
address: 'smtp://{{ postbox_host }}:{{ postbox_port }}'
address: "smtp://{{ postbox_host }}:{{ postbox_port }}"
## The connection timeout in the duration common syntax.
# timeout: '5 seconds'
## The username used for SMTP authentication.
username: '{{ postbox_user }}'
username: "{{ postbox_user }}"
## The password used for SMTP authentication.
## Can also be set using a secret: https://www.authelia.com/c/secrets
password: '{{ postbox_pass }}'
password: "{{ postbox_pass }}"
## The sender is used to is used for the MAIL FROM command and the FROM header.
## If this is not defined and the username is an email, we use the username as this value. This can either be just
## an email address or the RFC5322 'Name <email address>' format.
sender: 'Authelia <authelia@vakhrushev.me>'
sender: "Authelia <authelia@vakhrushev.me>"
## HELO/EHLO Identifier. Some SMTP Servers may reject the default of localhost.
# identifier: 'localhost'
@@ -1237,7 +1233,7 @@ notifier:
## This address is used during the startup check to verify the email configuration is correct.
## It's not important what it is except if your email server only allows local delivery.
startup_check_address: '{{ smtp__startup_check_address }}'
# startup_check_address: '{{ smtp__startup_check_address }}'
## By default we require some form of TLS. This disables this check though is not advised.
# disable_require_tls: false
@@ -1285,7 +1281,6 @@ notifier:
## Identity Providers
##
identity_providers:
##
## OpenID Connect (Identity Provider)
##
@@ -1294,13 +1289,12 @@ identity_providers:
oidc:
## The hmac_secret is used to sign OAuth2 tokens (authorization code, access tokens and refresh tokens).
## HMAC Secret can also be set using a secret: https://www.authelia.com/c/secrets
hmac_secret: '{{ oidc__hmac_secret }}'
hmac_secret: "{{ oidc__hmac_secret }}"
## The JWK's issuer option configures multiple JSON Web Keys. It's required that at least one of the JWK's
## configured has the RS256 algorithm. For RSA keys (RS or PS) the minimum is a 2048 bit key.
jwks:
-
## Key ID embedded into the JWT header for key matching.
- ## Key ID embedded into the JWT header for key matching.
## Must be an alphanumeric string with 7 or less characters.
## This value is automatically generated if not provided. It's recommended to not configure this.
# key_id: 'example'
@@ -1352,8 +1346,8 @@ identity_providers:
authorization_policies:
outline_policy:
rules:
- policy: 'one_factor'
subject: 'group:outline'
- policy: "one_factor"
subject: "group:outline"
## The lifespans configure the expiration for these token types in the duration common syntax. In addition to this
## syntax the lifespans can be customized per-client.
@@ -1390,34 +1384,32 @@ identity_providers:
## It's recommended you read the documentation before configuration of a registered client.
## See: https://www.authelia.com/c/oidc/registered-clients
clients:
-
client_name: 'Miniflux'
client_id: '{{ oidc__miniflux__client_id }}'
client_secret: '{{ oidc__miniflux__client_secret }}'
- client_name: "Miniflux"
client_id: "{{ oidc__miniflux__client_id }}"
client_secret: "{{ oidc__miniflux__client_secret }}"
redirect_uris:
- 'https://miniflux.vakhrushev.me/oauth2/oidc/callback'
- "https://miniflux.vakhrushev.me/oauth2/oidc/callback"
scopes:
- 'openid'
- 'profile'
- 'email'
- "openid"
- "profile"
- "email"
response_types:
- 'code'
- "code"
grant_types:
- 'authorization_code'
access_token_signed_response_alg: 'none'
userinfo_signed_response_alg: 'none'
token_endpoint_auth_method: 'client_secret_basic'
- "authorization_code"
access_token_signed_response_alg: "none"
userinfo_signed_response_alg: "none"
token_endpoint_auth_method: "client_secret_basic"
-
client_name: 'Wakapi'
client_id: '{{ oidc__wakapi__client_id }}'
client_secret: '{{ oidc__wakapi__client_secret }}'
- client_name: "Wakapi"
client_id: "{{ oidc__wakapi__client_id }}"
client_secret: "{{ oidc__wakapi__client_secret }}"
redirect_uris:
- 'https://wakapi.vakhrushev.me/oidc/authelia/callback'
- "https://wakapi.vakhrushev.me/oidc/authelia/callback"
scopes:
- 'openid'
- 'profile'
- 'email'
- "openid"
- "profile"
- "email"
# response_types:
# - 'code'
# grant_types:
@@ -1425,18 +1417,16 @@ identity_providers:
# access_token_signed_response_alg: 'none'
# userinfo_signed_response_alg: 'none'
# token_endpoint_auth_method: 'client_secret_basic'
-
## The description to show to users when they end up on the consent screen. Defaults to the ID above.
client_name: 'Outline'
- ## The description to show to users when they end up on the consent screen. Defaults to the ID above.
client_name: "Outline"
## The Client ID is the OAuth 2.0 and OpenID Connect 1.0 Client ID which is used to link an application to a
## configuration.
client_id: '{{ oidc__outline__client_id }}'
client_id: "{{ oidc__outline__client_id }}"
## The client secret is a shared secret between Authelia and the consumer of this client.
# yamllint disable-line rule:line-length
client_secret: '{{ oidc__outline__client_secret }}'
client_secret: "{{ oidc__outline__client_secret }}"
## Sector Identifiers are occasionally used to generate pairwise subject identifiers. In most cases this is not
## necessary. It is critical to read the documentation for more information.
@@ -1447,7 +1437,7 @@ identity_providers:
## Redirect URI's specifies a list of valid case-sensitive callbacks for this client.
redirect_uris:
- 'https://outline.vakhrushev.me/auth/oidc.callback'
- "https://outline.vakhrushev.me/auth/oidc.callback"
## Request URI's specifies a list of valid case-sensitive TLS-secured URIs for this client for use as
## URIs to fetch Request Objects.
@@ -1459,9 +1449,9 @@ identity_providers:
## Scopes this client is allowed to request.
scopes:
- 'openid'
- 'profile'
- 'email'
- "openid"
- "profile"
- "email"
## Grant Types configures which grants this client can obtain.
## It's not recommended to define this unless you know what you're doing.
@@ -1480,7 +1470,7 @@ identity_providers:
## The policy to require for this client; one_factor or two_factor. Can also be the key names for the
## authorization policies section.
authorization_policy: 'outline_policy'
authorization_policy: "outline_policy"
## The custom lifespan name to use for this client. This must be configured independent of the client before
## utilization. Custom lifespans are reusable similar to authorization policies.
@@ -1581,7 +1571,7 @@ identity_providers:
## The signing algorithm used for signing the User Info Request responses.
## Please read the documentation before adjusting this option.
## See: https://www.authelia.com/c/oidc/registered-clients#userinfo_signed_response_alg
userinfo_signed_response_alg: 'none'
userinfo_signed_response_alg: "none"
## The signing key id used for signing the User Info Request responses.
## Please read the documentation before adjusting this option.
@@ -1645,7 +1635,7 @@ identity_providers:
## The permitted client authentication method for the Token Endpoint for this client.
## For confidential client types this value defaults to 'client_secret_basic' and for the public client types it
## defaults to 'none' per the specifications.
token_endpoint_auth_method: 'client_secret_post'
token_endpoint_auth_method: "client_secret_post"
## The permitted client authentication signing algorithm for the Token Endpoint for this client when using
## the 'client_secret_jwt' or 'private_key_jwt' token_endpoint_auth_method.
+4 -5
View File
@@ -1,10 +1,9 @@
services:
authelia_app:
container_name: 'authelia_app'
image: 'docker.io/authelia/authelia:4.39.19'
user: '{{ owner_create_result.uid }}:{{ owner_create_result.group }}'
restart: 'unless-stopped'
container_name: "authelia_app"
image: "docker.io/authelia/authelia:4.39.20"
user: "{{ owner_create_result.uid }}:{{ owner_create_result.group }}"
restart: "unless-stopped"
networks:
- "web_proxy_network"
- "monitoring_network"
+288 -70
View File
@@ -3,21 +3,33 @@
Backup script for all applications
Automatically discovers and runs backup scripts for all users,
then creates restic backups and sends notifications.
restic-операции разнесены на фазы с разной частотой (см. секцию [schedule] в config):
- backup, forget -- каждый прогон (forget БЕЗ --prune: только метаданные снапшотов);
- check -- структурная проверка, обычно еженедельно;
- prune -- репак/освобождение места, редко (квартально);
- verify -- check --read-data-subset, помесячно (полное покрытие за год).
Один прогон выполняет фазы строго последовательно, поэтому restic-локи между фазами
не конфликтуют. Наложение соседних прогонов предотвращается flock в cron-задаче.
"""
import argparse
import itertools
import os
import sys
import subprocess
import logging
import os
import pwd
import subprocess
import sys
import time
from abc import ABC
from dataclasses import dataclass
from pathlib import Path
from typing import Dict, List, Optional, Any
import requests
import tomllib
from abc import ABC
from dataclasses import dataclass, field
from datetime import datetime, timedelta
from pathlib import Path
from typing import Any, Dict, List, Optional
import requests
from croniter import croniter
# Default config path
CONFIG_PATH = Path("/etc/backup/config.toml")
@@ -29,6 +41,22 @@ BACKUP_TARGETS_FILE = "backup-targets"
# Used when backup-targets file not exists
BACKUP_DEFAULT_DIR = "backups"
# Retention policy applied by the `forget` phase on every run.
KEEP_DAILY = "90"
KEEP_MONTHLY = "36"
# Фазы в порядке выполнения. backup и forget идут каждый прогон,
# остальные — по расписанию из config.
PHASE_BACKUP = "backup"
PHASE_FORGET = "forget"
PHASE_CHECK = "check"
PHASE_PRUNE = "prune"
PHASE_VERIFY = "verify"
ALWAYS_PHASES = [PHASE_BACKUP, PHASE_FORGET]
SCHEDULED_PHASES = [PHASE_CHECK, PHASE_PRUNE, PHASE_VERIFY]
PHASE_ORDER = ALWAYS_PHASES + SCHEDULED_PHASES
# Configure logging
logging.basicConfig(
level=logging.INFO,
@@ -46,6 +74,42 @@ class Config:
host_name: str
@dataclass
class MaintenanceOptions:
"""Параметры обслуживающих фаз (см. секцию [maintenance] в config)."""
verify_subset: str = "1/12"
prune_max_unused: str = "20%"
prune_max_repack: str = "5G"
@dataclass
class Schedule:
"""Расписание обслуживающих фаз: фаза -> cron-выражение."""
cron: Dict[str, str] = field(default_factory=dict)
def due_phases(self, now: datetime) -> List[str]:
"""Фазы, которые нужно выполнить в этот прогон, в порядке PHASE_ORDER."""
phases = list(ALWAYS_PHASES)
for phase in SCHEDULED_PHASES:
expr = self.cron.get(phase)
if expr and self._due_today(expr, now):
phases.append(phase)
return phases
@staticmethod
def _due_today(expr: str, now: datetime) -> bool:
"""True, если cron-выражение срабатывает где-то в течение сегодняшних суток.
Мы не сравниваем с текущей минутой (триггер один на сутки в фиксированное
время), а проверяем, попадает ли ближайшее срабатывание выражения на сегодня.
"""
start = now.replace(hour=0, minute=0, second=0, microsecond=0)
nxt = croniter(expr, start - timedelta(minutes=1)).get_next(datetime)
return nxt.date() == now.date()
@dataclass
class Application:
path: Path
@@ -54,11 +118,18 @@ class Application:
backup_targets: List[Path]
@dataclass
class BackupResult:
success: bool
error: Optional[str] = None
@dataclass
class StorageRunResult:
name: str
success: bool
duration: float
phases: List[str]
def format_duration(seconds: float) -> str:
@@ -76,8 +147,13 @@ def format_duration(seconds: float) -> str:
class Storage(ABC):
name: str
def backup(self, backup_dirs: List[str]) -> bool:
"""Backup directories"""
def run(
self,
backup_dirs: List[str],
phases: List[str],
maintenance: MaintenanceOptions,
) -> BackupResult:
"""Run the requested phases against this storage."""
raise NotImplementedError()
@@ -101,69 +177,125 @@ class ResticStorage(Storage):
f"Missing storage configuration values for backend ResticStorage: '{self.name}'"
)
def backup(self, backup_dirs: List[str]) -> bool:
if not backup_dirs:
logger.warning("No backup directories found")
return True
def run(
self,
backup_dirs: List[str],
phases: List[str],
maintenance: MaintenanceOptions,
) -> BackupResult:
try:
return self.__backup_internal(backup_dirs)
return self.__run_internal(backup_dirs, phases, maintenance)
except Exception as exc: # noqa: BLE001
logger.error("Restic backup process failed: %s", exc)
return False
logger.error("Restic process failed: %s", exc)
return BackupResult(success=False, error=str(exc))
def __backup_internal(self, backup_dirs: List[str]) -> bool:
logger.info("Starting restic backup for storage '%s'", self.name)
def __build_steps(
self,
backup_dirs: List[str],
phases: List[str],
maintenance: MaintenanceOptions,
) -> List[tuple[str, List[str]]]:
"""Собрать restic-команды для запрошенных фаз в порядке PHASE_ORDER."""
steps: List[tuple[str, List[str]]] = []
for phase in PHASE_ORDER:
if phase not in phases:
continue
if phase == PHASE_BACKUP:
if not backup_dirs:
logger.warning(
"No backup directories found, skipping backup phase for '%s'",
self.name,
)
continue
steps.append(
("backup", ["restic", "backup", "--verbose"] + backup_dirs)
)
elif phase == PHASE_FORGET:
# forget БЕЗ --prune: удаляет только метаданные снапшотов, не репакует
# data-паки и не сбивает охлаждение в Intelligent Tiering.
steps.append(
(
"forget",
[
"restic",
"forget",
"--compact",
"--keep-daily",
KEEP_DAILY,
"--keep-monthly",
KEEP_MONTHLY,
],
)
)
elif phase == PHASE_CHECK:
steps.append(("check", ["restic", "check"]))
elif phase == PHASE_PRUNE:
steps.append(
(
"prune",
[
"restic",
"prune",
"--max-unused",
maintenance.prune_max_unused,
"--max-repack-size",
maintenance.prune_max_repack,
],
)
)
elif phase == PHASE_VERIFY:
steps.append(
(
"verify",
[
"restic",
"check",
f"--read-data-subset={maintenance.verify_subset}",
],
)
)
return steps
def __run_internal(
self,
backup_dirs: List[str],
phases: List[str],
maintenance: MaintenanceOptions,
) -> BackupResult:
logger.info("Starting restic run for storage '%s'", self.name)
logger.info("Destination: %s", self.restic_repository)
logger.info("Phases: %s", ", ".join(phases))
env = os.environ.copy()
env["RESTIC_REPOSITORY"] = self.restic_repository
env["RESTIC_PASSWORD"] = self.restic_password
env.update(self.env)
backup_cmd = ["restic", "backup", "--verbose"] + backup_dirs
result = subprocess.run(backup_cmd, env=env, capture_output=True, text=True)
steps = self.__build_steps(backup_dirs, phases, maintenance)
for step, cmd in steps:
error = self.__run_step(step, cmd, env)
if error is not None:
return BackupResult(success=False, error=f"restic {step}: {error}")
return BackupResult(success=True)
def __run_step(
self, step: str, cmd: List[str], env: Dict[str, str]
) -> Optional[str]:
"""Run a single restic command. Return None on success or error text."""
result = subprocess.run(cmd, env=env, capture_output=True, text=True)
if result.returncode != 0:
logger.error("Restic backup failed: %s", result.stderr)
return False
error = result.stderr.strip() or result.stdout.strip() or "no output"
logger.error("Restic %s failed: %s", step, error)
return error
logger.info("Restic backup completed successfully")
check_cmd = ["restic", "check"]
result = subprocess.run(check_cmd, env=env, capture_output=True, text=True)
if result.returncode != 0:
logger.error("Restic check failed: %s", result.stderr)
return False
logger.info("Restic check completed successfully")
forget_cmd = [
"restic",
"forget",
"--compact",
"--prune",
"--keep-daily",
"90",
"--keep-monthly",
"36",
]
result = subprocess.run(forget_cmd, env=env, capture_output=True, text=True)
if result.returncode != 0:
logger.error("Restic forget/prune failed: %s", result.stderr)
return False
logger.info("Restic forget/prune completed successfully")
result = subprocess.run(check_cmd, env=env, capture_output=True, text=True)
if result.returncode != 0:
logger.error("Final restic check failed: %s", result.stderr)
return False
logger.info("Final restic check completed successfully")
return True
logger.info("Restic %s completed successfully", step)
return None
class Notifier(ABC):
@@ -300,6 +432,9 @@ class BackupManager:
config: Config,
storages: List[Storage],
notifiers: List[Notifier],
schedule: Schedule,
maintenance: MaintenanceOptions,
forced_phases: Optional[List[str]] = None,
):
self.errors: List[str] = []
self.warnings: List[str] = []
@@ -307,6 +442,10 @@ class BackupManager:
self.config = config
self.storages = storages
self.notifiers = notifiers
self.schedule = schedule
self.maintenance = maintenance
self.forced_phases = forced_phases
self.active_phases: List[str] = []
self.archive_duration: float = 0.0
self.storage_results: List[StorageRunResult] = []
@@ -315,8 +454,17 @@ class BackupManager:
logger.info("Starting backup process")
logger.info(f"Found {len(applications)} application directories")
# Какие фазы выполняем в этот прогон: либо принудительно из CLI, либо по расписанию.
if self.forced_phases is not None:
self.active_phases = self.forced_phases
logger.info("Phases (forced): %s", ", ".join(self.active_phases))
else:
self.active_phases = self.schedule.due_phases(datetime.now())
logger.info("Phases (scheduled): %s", ", ".join(self.active_phases))
archive_start = time.monotonic()
# Process each user's backup
# Archive phase (per-app backup scripts) нужна только если будем делать restic backup.
if PHASE_BACKUP in self.active_phases:
for app in applications:
app_dir = str(app.path)
username = app.owner
@@ -331,6 +479,8 @@ class BackupManager:
continue
self._run_app_backup(str(app.backup_script), app_dir, username)
else:
logger.info("Backup phase not active, skipping per-app archive scripts")
self.archive_duration = time.monotonic() - archive_start
logger.info(
"Archive phase finished in %s", format_duration(self.archive_duration)
@@ -352,31 +502,37 @@ class BackupManager:
for storage in self.storages:
storage_start = time.monotonic()
try:
backup_result = storage.backup(backup_dirs)
backup_result = storage.run(
backup_dirs, self.active_phases, self.maintenance
)
except Exception as exc: # noqa: BLE001
logger.error(
"Storage '%s' raised an unexpected error: %s", storage.name, exc
)
backup_result = False
backup_result = BackupResult(success=False, error=str(exc))
storage_duration = time.monotonic() - storage_start
self.storage_results.append(
StorageRunResult(
name=storage.name,
success=backup_result,
success=backup_result.success,
duration=storage_duration,
phases=list(self.active_phases),
)
)
logger.info(
"Storage '%s' finished in %s (success=%s)",
storage.name,
format_duration(storage_duration),
backup_result,
backup_result.success,
)
if not backup_result:
self.errors.append(f"Storage '{storage.name}' backup failed")
if not backup_result.success:
error_msg = f"Storage '{storage.name}' backup failed"
if backup_result.error:
error_msg += f": {backup_result.error}"
self.errors.append(error_msg)
# Determine overall success
overall_success = overall_success and backup_result
overall_success = overall_success and backup_result.success
# Send notification
self._send_notification(overall_success)
@@ -436,6 +592,7 @@ class BackupManager:
"""Send notification to Notifiers"""
host = self.config.host_name
phases_text = ", ".join(self.active_phases) if self.active_phases else ""
if success and not self.errors:
title = f"{host}: бекап успешно завершен"
@@ -459,6 +616,7 @@ class BackupManager:
items = "".join(f"<li>{e}</li>" for e in self.errors)
message += f"<p>❌ Ошибки:</p><ul>{items}</ul>"
message += f"<p>🔧 Фазы restic: {phases_text}</p>"
message += f"<p>⏱ Время архивации: {format_duration(self.archive_duration)}</p>"
if self.storage_results:
items = "".join(
@@ -474,8 +632,21 @@ class BackupManager:
logger.error(f"Failed to send notification: {str(e)}")
def parse_phases(raw: str) -> List[str]:
"""Разобрать CLI-список фаз, вернуть их в порядке PHASE_ORDER."""
requested = {p.strip() for p in raw.split(",") if p.strip()}
unknown = requested - set(PHASE_ORDER)
if unknown:
raise ValueError(
f"Unknown phases: {', '.join(sorted(unknown))}. "
f"Allowed: {', '.join(PHASE_ORDER)}"
)
return [p for p in PHASE_ORDER if p in requested]
def initialize(
config_path: Path,
forced_phases: Optional[List[str]] = None,
) -> tuple[ApplicationFinder, BackupManager]:
try:
with config_path.open("rb") as config_file:
@@ -513,18 +684,65 @@ def initialize(
if not notifiers:
raise ValueError("At least one notification backend must be configured")
schedule_raw = raw_config.get("schedule") or {}
if not isinstance(schedule_raw, dict):
raise ValueError("'schedule' must be a table in config.toml")
schedule = Schedule(
cron={
phase: str(schedule_raw[phase])
for phase in SCHEDULED_PHASES
if phase in schedule_raw
}
)
maintenance_raw = raw_config.get("maintenance") or {}
if not isinstance(maintenance_raw, dict):
raise ValueError("'maintenance' must be a table in config.toml")
defaults = MaintenanceOptions()
maintenance = MaintenanceOptions(
verify_subset=str(maintenance_raw.get("verify_subset", defaults.verify_subset)),
prune_max_unused=str(
maintenance_raw.get("prune_max_unused", defaults.prune_max_unused)
),
prune_max_repack=str(
maintenance_raw.get("prune_max_repack", defaults.prune_max_repack)
),
)
config = Config(host_name=host_name)
app_finder = ApplicationFinder(roots)
backup_manager = BackupManager(
config=config, storages=storages, notifiers=notifiers
config=config,
storages=storages,
notifiers=notifiers,
schedule=schedule,
maintenance=maintenance,
forced_phases=forced_phases,
)
return app_finder, backup_manager
def main() -> None:
parser = argparse.ArgumentParser(description="Run application backups via restic")
parser.add_argument(
"--config",
type=Path,
default=CONFIG_PATH,
help=f"Path to config.toml (default: {CONFIG_PATH})",
)
parser.add_argument(
"--phases",
help=(
"Comma-separated phases to run, overriding the schedule "
f"(allowed: {', '.join(PHASE_ORDER)}). Useful for manual maintenance runs."
),
)
args = parser.parse_args()
try:
app_finder, backup_manager = initialize(CONFIG_PATH)
forced_phases = parse_phases(args.phases) if args.phases else None
app_finder, backup_manager = initialize(args.config, forced_phases)
applications = app_finder.find_applications()
backup_manager.warnings.extend(app_finder.warnings)
success = backup_manager.run_backup_process(applications)
+19
View File
@@ -4,6 +4,25 @@ roots = [
"{{ application_dir }}"
]
# Расписание обслуживающих фаз restic.
# Триггер — один ночной запуск (cron в playbook-backups.yml), поэтому в выражениях
# значимы ТОЛЬКО поля дня месяца / месяца / дня недели. Минуты и часы держим как
# "* *" — они декоративны: фаза всё равно выполняется в момент ночного прогона,
# а не во время, указанное в выражении.
# День недели по cron-конвенции: 0 = воскресенье.
# backup и forget выполняются КАЖДЫЙ прогон и в расписании не участвуют.
[schedule]
check = "* * * * 0" # структурный restic check — по воскресеньям
verify = "* * 5 * *" # check --read-data-subset — 5-го числа
prune = "* * 1 1,4,7,10 *" # restic prune — 1-го числа янв/апр/июл/окт
# Параметры обслуживания. Подобраны под Yandex Object Storage Intelligent Tiering:
# редкий prune с высоким --max-unused минимизирует репак и не сбивает охлаждение объектов.
[maintenance]
verify_subset = "1/12" # порция данных за один verify -> полное покрытие за год
prune_max_unused = "20%" # выше дефолтных 5% -> меньше репака -> дольше держится охлаждение
prune_max_repack = "5G" # потолок объёма перезаписи за один prune
[storage.yandex_cloud_s3]
type = "restic"
restic_repository = "{{ restic_repository }}"
+1 -2
View File
@@ -1,7 +1,6 @@
services:
caddyproxy:
image: caddy:2.11.2
image: caddy:2.11.3
restart: unless-stopped
container_name: "caddyproxy"
ports:
+1 -2
View File
@@ -1,7 +1,6 @@
services:
dozzle_app:
image: amir20/dozzle:v10.6.0
image: amir20/dozzle:v10.6.2
container_name: dozzle_app
restart: unless-stopped
volumes:
+1 -2
View File
@@ -1,7 +1,6 @@
services:
gitea_app:
image: gitea/gitea:1.26.1
image: gitea/gitea:1.26.4
restart: unless-stopped
container_name: gitea_app
ports:
+1 -2
View File
@@ -1,5 +1,4 @@
services:
goaccess_processor:
build: .
image: local/goaccess-jq:1.10.2
@@ -26,7 +25,7 @@ services:
- "web_proxy_network"
goaccess_app:
image: caddy:2.11.2
image: caddy:2.11.3
container_name: goaccess_app
restart: unless-stopped
user: "{{ app_owner_uid }}:{{ app_owner_gid }}"
+1 -2
View File
@@ -1,9 +1,8 @@
# See versions: https://github.com/gramps-project/gramps-web/pkgs/container/grampsweb
services:
gramps_app: &gramps_app
image: ghcr.io/gramps-project/grampsweb:26.5.1
image: ghcr.io/gramps-project/grampsweb:26.6.0
container_name: gramps_app
depends_on:
- gramps_redis
+1 -1
View File
@@ -3,7 +3,7 @@
services:
memos_app:
image: neosmemo/memos:0.28.0
image: neosmemo/memos:0.29.1
container_name: memos_app
restart: unless-stopped
user: "{{ owner_create_result.uid }}:{{ owner_create_result.group }}"
+1 -1
View File
@@ -3,7 +3,7 @@ services:
# See sample https://github.com/outline/outline/blob/main/.env.sample
outline_app:
image: outlinewiki/outline:1.7.1
image: outlinewiki/outline:1.8.1
container_name: outline_app
user: "{{ owner_create_result.uid }}:{{ owner_create_result.group }}"
restart: unless-stopped
+1 -1
View File
@@ -3,7 +3,7 @@
services:
wakapi_app:
image: ghcr.io/muety/wakapi:2.17.3
image: ghcr.io/muety/wakapi:2.17.4
container_name: wakapi_app
restart: unless-stopped
user: '{{ owner_create_result.uid }}:{{ owner_create_result.group }}'
+3 -1
View File
@@ -94,6 +94,8 @@
name: "restic backup"
minute: "0"
hour: "1"
job: "{{ backup_all_script }} 2>&1 | logger -t backup"
# flock -n: не запускать новый прогон, если предыдущий (например, затянувшийся
# квартальный prune) ещё идёт — защита от наложения restic-операций.
job: "flock -n /var/lock/backup-all.lock {{ backup_all_script }} 2>&1 | logger -t backup"
cron_file: "ansible_restic_backup"
user: "root"
+2
View File
@@ -15,7 +15,9 @@
- htop
- jq
- make
- python3-croniter
- python3-pip
- python3-requests
- sqlite3
- tree
+2
View File
@@ -7,10 +7,12 @@ requires-python = ">=3.12"
dependencies = [
"ansible>=13.2.0",
"ansible-lint>=25.12.2",
"croniter>=6.0.0",
"invoke>=2.2.1",
"mypy>=1.19.1",
"requests>=2.32.5",
"ruff>=0.15.2",
"types-croniter>=6.0.0",
"types-requests>=2.32.4.20260107",
"yamllint>=1.37.1",
]
+1 -1
View File
@@ -9,7 +9,7 @@ from invoke.context import Context
from invoke.exceptions import Exit
from invoke.tasks import task
HOSTS_FILE = "production.yml"
HOSTS_FILE = "timeweb.yml"
VARS_FILE = "vars/vars.yml"
AUTHELIA_DOCKER = "docker run --rm -v $PWD:/data authelia/authelia:4.39.4 authelia"
Generated
+46
View File
@@ -272,6 +272,18 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" },
]
[[package]]
name = "croniter"
version = "6.2.2"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "python-dateutil" },
]
sdist = { url = "https://files.pythonhosted.org/packages/df/de/5832661ed55107b8a09af3f0a2e71e0957226a59eb1dcf0a445cce6daf20/croniter-6.2.2.tar.gz", hash = "sha256:ba60832a5ec8e12e51b8691c3309a113d1cf6526bdf1a48150ce8ec7a532d0ab", size = 113762, upload-time = "2026-03-15T08:43:48.112Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/d0/39/783980e78cb92c2d7bdb1fc7dbc86e94ccc6d58224d76a7f1f51b6c51e30/croniter-6.2.2-py3-none-any.whl", hash = "sha256:a5d17b1060974d36251ea4faf388233eca8acf0d09cbd92d35f4c4ac8f279960", size = 45422, upload-time = "2026-03-15T08:43:46.626Z" },
]
[[package]]
name = "cryptography"
version = "46.0.3"
@@ -593,10 +605,12 @@ source = { virtual = "." }
dependencies = [
{ name = "ansible" },
{ name = "ansible-lint" },
{ name = "croniter" },
{ name = "invoke" },
{ name = "mypy" },
{ name = "requests" },
{ name = "ruff" },
{ name = "types-croniter" },
{ name = "types-requests" },
{ name = "yamllint" },
]
@@ -605,10 +619,12 @@ dependencies = [
requires-dist = [
{ name = "ansible", specifier = ">=13.2.0" },
{ name = "ansible-lint", specifier = ">=25.12.2" },
{ name = "croniter", specifier = ">=6.0.0" },
{ name = "invoke", specifier = ">=2.2.1" },
{ name = "mypy", specifier = ">=1.19.1" },
{ name = "requests", specifier = ">=2.32.5" },
{ name = "ruff", specifier = ">=0.15.2" },
{ name = "types-croniter", specifier = ">=6.0.0" },
{ name = "types-requests", specifier = ">=2.32.4.20260107" },
{ name = "yamllint", specifier = ">=1.37.1" },
]
@@ -631,6 +647,18 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/a0/e3/59cd50310fc9b59512193629e1984c1f95e5c8ae6e5d8c69532ccc65a7fe/pycparser-2.23-py3-none-any.whl", hash = "sha256:e5c6e8d3fbad53479cab09ac03729e0a9faf2bee3db8208a550daf5af81a5934", size = 118140, upload-time = "2025-09-09T13:23:46.651Z" },
]
[[package]]
name = "python-dateutil"
version = "2.9.0.post0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "six" },
]
sdist = { url = "https://files.pythonhosted.org/packages/66/c0/0c8b6ad9f17a802ee498c46e004a0eb49bc148f2fd230864601a86dcf6db/python-dateutil-2.9.0.post0.tar.gz", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3", size = 342432, upload-time = "2024-03-01T18:36:20.211Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/ec/57/56b9bcc3c9c6a792fcbaf139543cee77261f3651ca9da0c93f5c1221264b/python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427", size = 229892, upload-time = "2024-03-01T18:36:18.57Z" },
]
[[package]]
name = "pytokens"
version = "0.3.0"
@@ -886,6 +914,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/6d/78/097c0798b1dab9f8affe73da9642bb4500e098cb27fd8dc9724816ac747b/ruff-0.15.2-py3-none-win_arm64.whl", hash = "sha256:cabddc5822acdc8f7b5527b36ceac55cc51eec7b1946e60181de8fe83ca8876e", size = 10941649, upload-time = "2026-02-19T22:32:18.108Z" },
]
[[package]]
name = "six"
version = "1.17.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/94/e7/b2c673351809dca68a0e064b6af791aa332cf192da575fd474ed7d6f16a2/six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81", size = 34031, upload-time = "2024-12-04T17:35:28.174Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050, upload-time = "2024-12-04T17:35:26.475Z" },
]
[[package]]
name = "subprocess-tee"
version = "0.4.2"
@@ -895,6 +932,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/4d/ab/e3a3be062cd544b2803760ff707dee38f0b1cb5685b2446de0ec19be28d9/subprocess_tee-0.4.2-py3-none-any.whl", hash = "sha256:21942e976715af4a19a526918adb03a8a27a8edab959f2d075b777e3d78f532d", size = 5249, upload-time = "2024-06-17T19:51:15.949Z" },
]
[[package]]
name = "types-croniter"
version = "6.2.2.20260518"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/20/02/f03a44ded34e6abb125c339647b070f2705a0583782f5638d62ab958cdc2/types_croniter-6.2.2.20260518.tar.gz", hash = "sha256:aceb426b9187bb9255b89d17713d07ac034a2b96b437bfdd5d3a56b46b4eb656", size = 12120, upload-time = "2026-05-18T06:03:11.885Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/37/3d/f12c417944d00c42db71de3e334f36a69cafa6767ff3fb705c9e1d101e53/types_croniter-6.2.2.20260518-py3-none-any.whl", hash = "sha256:85018c7ce091428d3643be239ad348e27f9a8fb77ca94335cc39ebeb9403b240", size = 9743, upload-time = "2026-05-18T06:03:10.608Z" },
]
[[package]]
name = "types-requests"
version = "2.32.4.20260107"