Добавил модуль для работы с ZFS снапшотами
This commit is contained in:
35
config.yaml
Normal file
35
config.yaml
Normal file
@@ -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
|
||||
@@ -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:
|
||||
|
||||
@@ -9,6 +9,9 @@
|
||||
# SSH клиент для подключения к удаленным серверам
|
||||
paramiko>=2.12.0,<4.0.0
|
||||
|
||||
# ZFS Backup: парсинг конфигурации
|
||||
PyYAML>=6.0
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
358
zfs_backup.py
Executable file
358
zfs_backup.py
Executable file
@@ -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())
|
||||
Reference in New Issue
Block a user