From 40bf9f9887ab76d554ca9c53c57149a5674cd5e7 Mon Sep 17 00:00:00 2001 From: neon Date: Tue, 10 Feb 2026 01:17:06 +0300 Subject: [PATCH] =?UTF-8?q?=D0=94=D0=BE=D0=B1=D0=B0=D0=B2=D0=B8=D0=BB=20?= =?UTF-8?q?=D0=BC=D0=BE=D0=B4=D1=83=D0=BB=D1=8C=20=D0=B4=D0=BB=D1=8F=20?= =?UTF-8?q?=D1=80=D0=B0=D0=B1=D0=BE=D1=82=D1=8B=20=D1=81=20ZFS=20=D1=81?= =?UTF-8?q?=D0=BD=D0=B0=D0=BF=D1=88=D0=BE=D1=82=D0=B0=D0=BC=D0=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- config.yaml | 35 +++++ modules/ssh_base.py | 16 +- requirements.txt | 3 + zfs_backup.py | 358 ++++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 410 insertions(+), 2 deletions(-) create mode 100644 config.yaml create mode 100755 zfs_backup.py diff --git a/config.yaml b/config.yaml new file mode 100644 index 0000000..6d6e6c6 --- /dev/null +++ b/config.yaml @@ -0,0 +1,35 @@ +# Конфигурация ZFS Backup v1.1 +# Запуск: python zfs_backup.py --config=config.yaml + +# Параметры SSH по умолчанию (опционально) +ssh_defaults: + port: 22222 + username: root + pkey_file: /root/.ssh/id_rsa + host_keys: "~/.ssh/known_hosts" + +# Список серверов для бэкапа +servers: + - name: gwo2.mps.cln.su + pools: + - source_pool: zp2 + datasets: + - containers/smb2 + target_pool: fast-backup + - source_pool: zp0 + datasets: + - containers/www + - containers/voip + target_pool: fast-backup + snapshot_name: dd-mm-yyyy + retention_days: 90 + + # Пример второго сервера (раскомментируйте при необходимости): + # - name: backup-server2.example.com + # pools: + # - source_pool: tank + # datasets: + # - data/vms + # target_pool: backup + # snapshot_name: dd-mm-yyyy + # retention_days: 30 diff --git a/modules/ssh_base.py b/modules/ssh_base.py index 7d8313b..1d2d481 100644 --- a/modules/ssh_base.py +++ b/modules/ssh_base.py @@ -21,6 +21,12 @@ if not logger.handlers: logger.addHandler(handler) logger.setLevel(logging.INFO) +# Ожидаемые «ошибки» ZFS при бэкапе — не логируем WARNING при suppress_warnings=True +KNOWN_ZFS_ERRORS = ( + "dataset already exists", + "not an earlier snapshot from the same fs", +) + class SSHBase: """ @@ -115,7 +121,7 @@ class SSHBase: logger.error(f"Неожиданная ошибка при подключении к {self.hostname}:{self.port}: {e}") raise Exception(f"Неожиданная ошибка при подключении: {e}") - def cmd(self, command: str, sleep: float = 0.1, out_to_print: bool = False) -> List[str]: + def cmd(self, command: str, sleep: float = 0.1, out_to_print: bool = False, suppress_warnings: bool = False) -> List[str]: """ Выполнение команды на удаленном сервере @@ -123,6 +129,8 @@ class SSHBase: command: Команда для выполнения sleep: Задержка после выполнения команды (секунды) out_to_print: Не используется (оставлено для обратной совместимости) + suppress_warnings: Если True и stderr содержит ожидаемые ZFS-сообщения (KNOWN_ZFS_ERRORS), + не логировать WARNING (для чистых логов бэкапа). Returns: list: [stdout, stderr] - вывод команды и ошибки @@ -156,7 +164,11 @@ class SSHBase: # Если команда завершилась с ошибкой, добавляем информацию в stderr if exit_status != 0: - logger.warning(f"Команда завершилась с кодом возврата {exit_status}: {command}") + skip_warning = suppress_warnings and any( + err in stderr_data.lower() for err in KNOWN_ZFS_ERRORS + ) + if not skip_warning: + logger.warning(f"Команда завершилась с кодом возврата {exit_status}: {command}") if not stderr_data.strip(): stderr_data = f"Команда завершилась с кодом возврата {exit_status}" else: diff --git a/requirements.txt b/requirements.txt index 0f17dbc..ec36f3a 100644 --- a/requirements.txt +++ b/requirements.txt @@ -9,6 +9,9 @@ # SSH клиент для подключения к удаленным серверам paramiko>=2.12.0,<4.0.0 +# ZFS Backup: парсинг конфигурации +PyYAML>=6.0 + diff --git a/zfs_backup.py b/zfs_backup.py new file mode 100755 index 0000000..3bd95b5 --- /dev/null +++ b/zfs_backup.py @@ -0,0 +1,358 @@ +#!/usr/bin/python3 +# -*- coding: utf-8 -*- +""" +Модуль ZFS Backup — централизованное управление ZFS-бэкапами удалённых серверов. +Интегрирован с SSH-модулем. Cron: ежедневно 20:00. + +Запуск: python zfs_backup.py --config=config.yaml +""" +import argparse +import logging +import os +import re +import sys +from datetime import datetime, timedelta +from typing import Any, Dict, List, Optional, Tuple + +import yaml + +# Добавляем корень проекта в path для импорта modules +SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__)) +if SCRIPT_DIR not in sys.path: + sys.path.insert(0, SCRIPT_DIR) + +from modules.ssh_base import SSHBase + +# Константы +DEFAULT_LOG_FILE = "/var/log/zfs_backup.log" +FALLBACK_LOG_FILE = "zfs_backup.log" +MAX_RETRIES = 3 +SNAPSHOT_DATE_FMT = "%d-%m-%Y" # dd-mm-yyyy + + +def setup_logging(log_file: Optional[str] = None) -> logging.Logger: + """Настройка логирования в файл и консоль.""" + logger = logging.getLogger("zfs_backup") + logger.setLevel(logging.INFO) + if logger.handlers: + return logger + + formatter = logging.Formatter( + "%(asctime)s - %(name)s - %(levelname)s - %(message)s", + datefmt="%Y-%m-%d %H:%M:%S", + ) + + # Файл + path = log_file or DEFAULT_LOG_FILE + try: + fh = logging.FileHandler(path, encoding="utf-8") + fh.setFormatter(formatter) + logger.addHandler(fh) + except OSError: + path = os.path.join(SCRIPT_DIR, FALLBACK_LOG_FILE) + fh = logging.FileHandler(path, encoding="utf-8") + fh.setFormatter(formatter) + logger.addHandler(fh) + logger.warning("Не удалось писать в %s, используется %s", DEFAULT_LOG_FILE, path) + + # Консоль + ch = logging.StreamHandler(sys.stdout) + ch.setFormatter(formatter) + logger.addHandler(ch) + + return logger + + +def load_config(config_path: str) -> Dict[str, Any]: + """Загрузка конфигурации из YAML → словарь Python (формат v1.1: servers[].pools[]).""" + with open(config_path, "r", encoding="utf-8") as f: + config = yaml.safe_load(f) + if not config or "servers" not in config: + raise ValueError("В config.yaml должен быть раздел 'servers'") + for i, srv in enumerate(config["servers"]): + if "pools" not in srv or not srv["pools"]: + raise ValueError(f"Сервер [{i}] (name={srv.get('name')}) должен содержать непустой раздел 'pools'") + for j, pool in enumerate(srv["pools"]): + for key in ("source_pool", "target_pool", "datasets"): + if key not in pool: + raise ValueError(f"Сервер [{i}] pool [{j}]: отсутствует поле '{key}'") + return config + + +def execute(ssh: SSHBase, command: str, log: logging.Logger) -> Tuple[str, str]: + """ + Выполнение команды по SSH. При ошибке (stderr или ненулевой код) выбрасывает исключение. + Возвращает (stdout, stderr). + """ + stdout, stderr = ssh.cmd(command) + if stderr and stderr.strip(): + raise RuntimeError(f"Команда завершилась с ошибкой: {stderr.strip()}") + return stdout, stderr + + +def create_snapshot( + ssh: SSHBase, + full_dataset: str, + date_str: str, + log: logging.Logger, +) -> bool: + """ + Создать ZFS-снапшот. При сообщении "dataset already exists" логируем и пропускаем (успех). + Возвращает True при успехе (создан или уже существует), False при ошибке (для retry). + """ + cmd = f"/usr/sbin/zfs snapshot {full_dataset}@{date_str}" + stdout, stderr = ssh.cmd(cmd, suppress_warnings=True) + stderr_lower = (stderr or "").lower() + + if "dataset already exists" in stderr_lower: + pool_name = full_dataset.split("/")[0] + snapshot_name = "/".join(full_dataset.split("/")[1:]) + log.info( + "В пуле %s снимок %s@%s уже существует, действие пропущено", + pool_name, + snapshot_name, + date_str, + ) + return True + + if stderr and stderr.strip(): + log.error("❌ Создание снимка failed: %s", stderr.strip()) + return False + + log.info("✅ Снимок %s@%s создан", full_dataset, date_str) + return True + + +def replicate_snapshot( + ssh: SSHBase, + source_dataset: str, + target_dataset: str, + date_str: str, + prev_date: Optional[str], + log: logging.Logger, +) -> bool: + """ + Репликация снапшота: zfs send [ -i @prev ] source@date | zfs recv target. + При ошибке "not an earlier snapshot from the same fs" логируем и пропускаем (успех). + Возвращает True при успехе (репликация выполнена или снимок уже есть на target), False при ошибке (для retry). + """ + if prev_date: + send_cmd = f"/usr/sbin/zfs send -i @{prev_date} {source_dataset}@{date_str}" + else: + send_cmd = f"/usr/sbin/zfs send {source_dataset}@{date_str}" + full_cmd = f"{send_cmd} | /usr/sbin/zfs recv -F {target_dataset}" + stdout, stderr = ssh.cmd(full_cmd, suppress_warnings=True) + + if stderr and "not an earlier snapshot from the same fs" in stderr: + pool_name = target_dataset.split("/")[0] + dataset_name = "/".join(target_dataset.split("/")[1:]) + log.info( + "В пуле %s снимок %s@%s уже существует", + pool_name, + dataset_name, + date_str, + ) + return True + + if stderr and stderr.strip(): + log.error("❌ Репликация failed: %s", stderr.strip()) + return False + + log.info("✅ Репликация %s → %s", source_dataset, target_dataset) + return True + + +def get_previous_snapshot_date( + ssh: SSHBase, + target_dataset: str, + log: logging.Logger, +) -> Optional[str]: + """ + Получить дату последнего снапшота для target_dataset (формат dd-mm-yyyy). + Если снапшотов нет — возвращает None. + """ + cmd = f"/usr/sbin/zfs list -t snapshot -H -o name {target_dataset} 2>/dev/null || true" + stdout, stderr = ssh.cmd(cmd) + lines = [s.strip() for s in stdout.splitlines() if s.strip()] + pattern = re.compile(r"@(\d{2}-\d{2}-\d{4})$") + dates: List[str] = [] + for line in lines: + m = pattern.search(line) + if m: + dates.append(m.group(1)) + + if not dates: + return None + def parse_d(s: str) -> datetime: + return datetime.strptime(s, "%d-%m-%Y") + dates.sort(key=parse_d) + return dates[-1] + + +def run_with_retry( + log: logging.Logger, + server_name: str, + operation_name: str, + func, + *args, + **kwargs, +) -> Any: + """Выполнить операцию до MAX_RETRIES раз при ошибке.""" + last_exc = None + for attempt in range(1, MAX_RETRIES + 1): + try: + return func(*args, **kwargs) + except Exception as e: + last_exc = e + log.warning( + "❌ %s: failed %s (retry %s/%s): %s", + server_name, + operation_name, + attempt, + MAX_RETRIES, + e, + ) + if attempt == MAX_RETRIES: + raise + raise last_exc + + +def backup_server( + server: Dict[str, Any], + ssh_defaults: Dict[str, Any], + log: logging.Logger, +) -> bool: + """ + Выполнить бэкап для одного сервера: снапшоты по пулам/datasets, send/recv, очистка. + Конфиг v1.1: server['pools'] — список пулов с source_pool, datasets, target_pool. + """ + name = server["name"] + pools = server["pools"] + retention_days = int(server.get("retention_days", 30)) + + port = ssh_defaults.get("port", 22) + username = ssh_defaults.get("username", "root") + pkey_file = ssh_defaults.get("pkey_file", "/root/.ssh/id_rsa") + host_keys = ssh_defaults.get("host_keys", "~/.ssh/known_hosts") + + date_str = datetime.now().strftime(SNAPSHOT_DATE_FMT) + log.info("Сервер %s: дата снапшотов %s", name, date_str) + + ssh = SSHBase(hostname=name, port=port, username=username, pkey_file=pkey_file, host_keys=host_keys) + ssh.connect() + + pool_counts: Dict[str, int] = {} + total_datasets = 0 + + try: + # 1. Фаза снапшотов + for pool_config in pools: + source_pool = pool_config["source_pool"] + datasets = pool_config["datasets"] + target_pool = pool_config["target_pool"] + pool_counts[source_pool] = len(datasets) + total_datasets += len(datasets) + + for dataset in datasets: + full_dataset = f"{source_pool}/{dataset}" + + def do_snapshot(fd=full_dataset, d=date_str): + if not create_snapshot(ssh, fd, d, log): + raise RuntimeError(f"Snapshot {fd}@{d} failed") + + run_with_retry(log, name, f"snapshot {full_dataset}", do_snapshot) + + # 2. Фаза send/recv + for pool_config in pools: + source_pool = pool_config["source_pool"] + datasets = pool_config["datasets"] + target_pool = pool_config["target_pool"] + + for dataset in datasets: + full_dataset = f"{source_pool}/{dataset}" + target_dataset = f"{target_pool}/{dataset}" + + def do_send_recv(fd=full_dataset, td=target_dataset, d=date_str): + prev_date = get_previous_snapshot_date(ssh, td, log) + if prev_date: + log.info("Инкрементальная передача %s (от %s)", fd, prev_date) + else: + log.info("Полная передача %s", fd) + if not replicate_snapshot(ssh, fd, td, d, prev_date, log): + raise RuntimeError(f"Replicate {fd}@{d} → {td} failed") + + run_with_retry(log, name, f"send/recv {full_dataset}", do_send_recv) + + # 3. Cleanup + cutoff = datetime.now() - timedelta(days=retention_days) + for pool_config in pools: + source_pool = pool_config["source_pool"] + datasets = pool_config["datasets"] + target_pool = pool_config["target_pool"] + + for dataset in datasets: + for pool, ds in [(source_pool, dataset), (target_pool, dataset)]: + full_ds = f"{pool}/{ds}" + cmd = f"/usr/sbin/zfs list -t snapshot -H -o name {full_ds} 2>/dev/null || true" + stdout, _ = ssh.cmd(cmd) + pattern = re.compile(r"@(\d{2}-\d{2}-\d{4})$") + for line in stdout.splitlines(): + line = line.strip() + if not line: + continue + m = pattern.search(line) + if not m: + continue + try: + snap_date = datetime.strptime(m.group(1), "%d-%m-%Y") + if snap_date < cutoff: + destroy_cmd = f"/usr/sbin/zfs destroy {line}" + execute(ssh, destroy_cmd, log) + log.info("Удалён старый снапшот: %s", line) + except ValueError: + continue + + parts = ", ".join(f"{p}:{c}" for p, c in sorted(pool_counts.items())) + log.info("✅ %s: %s datasets backed up (%s)", name, total_datasets, parts) + return True + + finally: + ssh.close() + + +def main() -> int: + parser = argparse.ArgumentParser(description="ZFS Backup — бэкап ZFS датасетов по config.yaml") + parser.add_argument("--config", default="config.yaml", help="Путь к config.yaml") + parser.add_argument("--log-file", default=None, help="Путь к лог-файлу (по умолчанию /var/log/zfs_backup.log)") + args = parser.parse_args() + + log = setup_logging(args.log_file) + + config_path = args.config + if not os.path.isabs(config_path): + config_path = os.path.join(SCRIPT_DIR, config_path) + if not os.path.isfile(config_path): + log.error("Файл конфигурации не найден: %s", config_path) + return 1 + + try: + config = load_config(config_path) + except Exception as e: + log.exception("Ошибка загрузки config.yaml: %s", e) + return 1 + + ssh_defaults = config.get("ssh_defaults") or {} + servers = config["servers"] + + for server in servers: + try: + backup_server(server, ssh_defaults, log) + except Exception as e: + log.exception("❌ %s: бэкап завершился ошибкой после %s попыток: %s", server["name"], MAX_RETRIES, e) + return 1 + + return 0 + + +if __name__ == "__main__": + sys.exit(main())