Compare commits

23 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
27 changed files with 1187 additions and 112 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** — фоновая зачистка стиля и структуры.
+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",
]
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"