Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
2930842e3f
|
|||
|
0f80e66b66
|
|||
|
2b22fde718
|
@@ -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`.
|
||||
@@ -63,6 +63,7 @@
|
||||
|
||||
| Дата | Запись | Статус |
|
||||
| ---------- | ---------------------------------------------------------------------------------------------- | ------ |
|
||||
| 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) | — |
|
||||
|
||||
+258
-46
@@ -3,8 +3,17 @@
|
||||
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 logging
|
||||
import os
|
||||
@@ -14,11 +23,13 @@ import sys
|
||||
import time
|
||||
import tomllib
|
||||
from abc import ABC
|
||||
from dataclasses import dataclass
|
||||
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")
|
||||
@@ -30,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,
|
||||
@@ -47,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
|
||||
@@ -66,6 +129,7 @@ class StorageRunResult:
|
||||
name: str
|
||||
success: bool
|
||||
duration: float
|
||||
phases: List[str]
|
||||
|
||||
|
||||
def format_duration(seconds: float) -> str:
|
||||
@@ -83,8 +147,13 @@ def format_duration(seconds: float) -> str:
|
||||
class Storage(ABC):
|
||||
name: str
|
||||
|
||||
def backup(self, backup_dirs: List[str]) -> BackupResult:
|
||||
"""Backup directories"""
|
||||
def run(
|
||||
self,
|
||||
backup_dirs: List[str],
|
||||
phases: List[str],
|
||||
maintenance: MaintenanceOptions,
|
||||
) -> BackupResult:
|
||||
"""Run the requested phases against this storage."""
|
||||
raise NotImplementedError()
|
||||
|
||||
|
||||
@@ -108,44 +177,104 @@ class ResticStorage(Storage):
|
||||
f"Missing storage configuration values for backend ResticStorage: '{self.name}'"
|
||||
)
|
||||
|
||||
def backup(self, backup_dirs: List[str]) -> BackupResult:
|
||||
if not backup_dirs:
|
||||
logger.warning("No backup directories found")
|
||||
return BackupResult(success=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)
|
||||
logger.error("Restic process failed: %s", exc)
|
||||
return BackupResult(success=False, error=str(exc))
|
||||
|
||||
def __backup_internal(self, backup_dirs: List[str]) -> BackupResult:
|
||||
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)
|
||||
|
||||
check_cmd = ["restic", "check"]
|
||||
steps = [
|
||||
("backup", ["restic", "backup", "--verbose"] + backup_dirs),
|
||||
("check", check_cmd),
|
||||
(
|
||||
"forget/prune",
|
||||
[
|
||||
"restic",
|
||||
"forget",
|
||||
"--compact",
|
||||
"--prune",
|
||||
"--keep-daily",
|
||||
"90",
|
||||
"--keep-monthly",
|
||||
"36",
|
||||
],
|
||||
),
|
||||
("final check", check_cmd),
|
||||
]
|
||||
steps = self.__build_steps(backup_dirs, phases, maintenance)
|
||||
|
||||
for step, cmd in steps:
|
||||
error = self.__run_step(step, cmd, env)
|
||||
@@ -303,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] = []
|
||||
@@ -310,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] = []
|
||||
|
||||
@@ -318,22 +454,33 @@ 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
|
||||
for app in applications:
|
||||
app_dir = str(app.path)
|
||||
username = app.owner
|
||||
logger.info(f"Processing backup for app: {app_dir} (user {username})")
|
||||
# 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
|
||||
logger.info(f"Processing backup for app: {app_dir} (user {username})")
|
||||
|
||||
if app.backup_script is None:
|
||||
warning_msg = (
|
||||
f"No backup script found for app: {app_dir} (user {username})"
|
||||
)
|
||||
logger.warning(warning_msg)
|
||||
self.warnings.append(warning_msg)
|
||||
continue
|
||||
if app.backup_script is None:
|
||||
warning_msg = (
|
||||
f"No backup script found for app: {app_dir} (user {username})"
|
||||
)
|
||||
logger.warning(warning_msg)
|
||||
self.warnings.append(warning_msg)
|
||||
continue
|
||||
|
||||
self._run_app_backup(str(app.backup_script), app_dir, username)
|
||||
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)
|
||||
@@ -355,7 +502,9 @@ 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
|
||||
@@ -367,6 +516,7 @@ class BackupManager:
|
||||
name=storage.name,
|
||||
success=backup_result.success,
|
||||
duration=storage_duration,
|
||||
phases=list(self.active_phases),
|
||||
)
|
||||
)
|
||||
logger.info(
|
||||
@@ -442,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}: бекап успешно завершен"
|
||||
@@ -465,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(
|
||||
@@ -480,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:
|
||||
@@ -519,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)
|
||||
|
||||
@@ -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,6 +1,6 @@
|
||||
services:
|
||||
gitea_app:
|
||||
image: gitea/gitea:1.26.2
|
||||
image: gitea/gitea:1.26.4
|
||||
restart: unless-stopped
|
||||
container_name: gitea_app
|
||||
ports:
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -15,7 +15,9 @@
|
||||
- htop
|
||||
- jq
|
||||
- make
|
||||
- python3-croniter
|
||||
- python3-pip
|
||||
- python3-requests
|
||||
- sqlite3
|
||||
- tree
|
||||
|
||||
|
||||
@@ -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",
|
||||
]
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user