Mój backup

Od dłuższego czasu miałem wrażenie, że temat kopii zapasowych odkładam na później bardziej niż powinienem. Pracuję na MacBooku, mam dużo projektów, trochę prywatnych plików, trochę rzeczy rozrzuconych po różnych katalogach i zawsze zakładałem, że „jakoś to będzie”. W praktyce oznaczało to brak spójnego systemu i poleganie na tym, że nic się nie stanie.

Ostatnio uporządkowałem ten temat i w końcu mam działający backup, który działa automatycznie i niezależnie ode mnie. Zdecydowałem się na Restic i zewnętrzny storage od Hetznera. Całość jest szyfrowana i działa przyrostowo, więc nie kopiuję wszystkiego od nowa, tylko zmieniające się fragmenty danych.

Najważniejsze dla mnie było to, żeby ten system był prosty w utrzymaniu. Nie chciałem rozbudowanych paneli, klikanych interfejsów ani usług, które trzeba regularnie kontrolować. Wystarczy mi jeden skrypt i jedno miejsce, w którym wiem, że są moje dane.

Poniżej zostawiam aktualny skrypt bash, którego używam. To jest wersja, którą uruchamiam automatycznie przez launchd na macOS. Pokazuje mi na bieżąco, co się dzieje, zapisuje logi i wysyła powiadomienie, kiedy backup się kończy lub kiedy coś pójdzie nie tak.

#!/usr/bin/env bash
#
# Automated restic backup for macOS (launchd).
# Configurable via environment variables; see readonly defaults below.
#
set -Eeuo pipefail
IFS=$'\n\t'

umask 077

readonly RESTIC_DIR="${RESTIC_DIR:-${HOME}/.restic}"
readonly PASSWORD_FILE="${RESTIC_PASSWORD_FILE:-${RESTIC_DIR}/password}"
readonly REPO="${RESTIC_REPOSITORY:-sftp:hetzner-backup:/home/backups/macbook}"
readonly EXCLUDE_FILE_DEFAULT="${RESTIC_EXCLUDE_FILE:-${RESTIC_DIR}/excludes.txt}"
readonly LOCK_DIR="${RESTIC_LOCK_DIR:-${RESTIC_DIR}/backup.lock.d}"
readonly LOG_FILE="${RESTIC_LOG_FILE:-${RESTIC_DIR}/backup.log}"
readonly LAST_CHECK_FILE="${RESTIC_DIR}/last-check"
readonly LOG_MAX_BYTES="${RESTIC_LOG_MAX_BYTES:-10485760}"

readonly KEEP_DAILY="${RESTIC_KEEP_DAILY:-7}"
readonly KEEP_WEEKLY="${RESTIC_KEEP_WEEKLY:-4}"
readonly KEEP_MONTHLY="${RESTIC_KEEP_MONTHLY:-6}"
readonly CHECK_INTERVAL_DAYS="${RESTIC_CHECK_INTERVAL_DAYS:-7}"

readonly -a BACKUP_PATHS=(
  "${HOME}/Documents"
  "${HOME}/IdeaProjects"
  "${HOME}/.ssh"
  "${HOME}/.config"
)

export PATH="/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin"
export RESTIC_PASSWORD_FILE="${PASSWORD_FILE}"

VALID_BACKUP_PATHS=()
EXCLUDE_FILE="${EXCLUDE_FILE_DEFAULT}"

USE_COLOR=false
if [[ -t 1 ]]; then
  USE_COLOR=true
fi

readonly C_GREEN=$'\033[0;32m'
readonly C_RED=$'\033[0;31m'
readonly C_YELLOW=$'\033[1;33m'
readonly C_BLUE=$'\033[0;34m'
readonly C_RESET=$'\033[0m'

log() {
  local level="$1"
  local message="$2"
  local prefix=""
  local plain=""
  local display=""
  local timestamp=""

  case "${level}" in
    info)  prefix="[INFO] " ;;
    warn)  prefix="[WARN] " ;;
    error) prefix="[ERROR] " ;;
  esac

  plain="${prefix}${message}"
  display="${plain}"

  if [[ "${USE_COLOR}" == true ]]; then
    case "${level}" in
      info)  display="${C_BLUE}${plain}${C_RESET}" ;;
      warn)  display="${C_YELLOW}${plain}${C_RESET}" ;;
      error) display="${C_RED}${plain}${C_RESET}" ;;
      ok)    display="${C_GREEN}${message}${C_RESET}" ;;
    esac
  fi

  timestamp="$(date '+%F %T')"
  printf '%s\n' "${display}"
  printf '%s %s\n' "${timestamp}" "${plain}" >>"${LOG_FILE}"
}

die() {
  log error "$1"
  exit "${2:-1}"
}

escape_applescript() {
  local value="$1"
  value="${value//\\/\\\\}"
  value="${value//\"/\\\"}"
  printf '%s' "${value}"
}

notify() {
  local title message
  title="$(escape_applescript "$1")"
  message="$(escape_applescript "$2")"

  if command -v osascript >/dev/null 2>&1; then
    osascript -e "display notification \"${message}\" with title \"${title}\"" >/dev/null 2>&1 || true
  fi
}

rotate_log_if_needed() {
  [[ -f "${LOG_FILE}" ]] || return 0

  local size=0
  size="$(wc -c <"${LOG_FILE}" | tr -d '[:space:]')"

  if (( size > LOG_MAX_BYTES )); then
    mv -f "${LOG_FILE}" "${LOG_FILE}.1"
    : >"${LOG_FILE}"
    log info "Rotated log file (${LOG_MAX_BYTES} byte limit)"
  fi
}

validate_password_file() {
  [[ -r "${PASSWORD_FILE}" ]] || die "Password file not readable: ${PASSWORD_FILE}"

  local mode=""
  mode="$(stat -f '%OLp' "${PASSWORD_FILE}" 2>/dev/null || stat -c '%a' "${PASSWORD_FILE}" 2>/dev/null || true)"
  if [[ -n "${mode}" && "${mode}" != "600" && "${mode}" != "400" ]]; then
    die "Password file permissions must be 600 or 400, got ${mode}: ${PASSWORD_FILE}"
  fi
}

preflight() {
  command -v restic >/dev/null 2>&1 || die "restic not found in PATH"

  mkdir -p "${RESTIC_DIR}"
  chmod 700 "${RESTIC_DIR}" 2>/dev/null || true

  validate_password_file

  if [[ -n "${EXCLUDE_FILE}" && ! -f "${EXCLUDE_FILE}" ]]; then
    log warn "Exclude file not found, continuing without excludes: ${EXCLUDE_FILE}"
    EXCLUDE_FILE=""
  fi
}

collect_backup_paths() {
  VALID_BACKUP_PATHS=()
  local path=""

  for path in "${BACKUP_PATHS[@]}"; do
    if [[ -e "${path}" ]]; then
      VALID_BACKUP_PATHS+=("${path}")
    else
      log warn "Skipping missing path: ${path}"
    fi
  done

  ((${#VALID_BACKUP_PATHS[@]} > 0)) || die "No valid backup paths found"
}

acquire_lock() {
  if mkdir "${LOCK_DIR}" 2>/dev/null; then
    printf '%s\n' "$$" >"${LOCK_DIR}/pid"
    return 0
  fi

  if [[ -f "${LOCK_DIR}/pid" ]]; then
    local old_pid=""
    old_pid="$(<"${LOCK_DIR}/pid")"
    if [[ "${old_pid}" =~ ^[0-9]+$ ]] && ! kill -0 "${old_pid}" 2>/dev/null; then
      log warn "Removing stale lock (pid ${old_pid})"
      rm -rf "${LOCK_DIR}"
      mkdir "${LOCK_DIR}" 2>/dev/null || die "Backup already running"
      printf '%s\n' "$$" >"${LOCK_DIR}/pid"
      return 0
    fi
  fi

  die "Backup already running (lock: ${LOCK_DIR})"
}

release_lock() {
  rm -rf "${LOCK_DIR}"
}

on_exit() {
  release_lock
}

on_interrupt() {
  log error "Backup interrupted"
  notify "Restic Backup" "Backup interrupted"
  exit 130
}

should_run_check() {
  if (( CHECK_INTERVAL_DAYS < 0 )); then
    return 1
  fi
  if (( CHECK_INTERVAL_DAYS == 0 )); then
    return 0
  fi
  if [[ ! -f "${LAST_CHECK_FILE}" ]]; then
    return 0
  fi

  local last_run now age_days
  last_run="$(<"${LAST_CHECK_FILE}")"
  now="$(date +%s)"
  age_days=$(( (now - last_run) / 86400 ))
  (( age_days >= CHECK_INTERVAL_DAYS ))
}

mark_check_completed() {
  date +%s >"${LAST_CHECK_FILE}"
}

run_restic() {
  local -a cmd=(restic -r "${REPO}" "$@")
  log info "Running: ${cmd[*]}"
  "${cmd[@]}"
}

run_backup() {
  local -a args=(
    backup
    --verbose=1
    --tag "host-$(hostname -s)"
    --tag automated
  )

  collect_backup_paths
  args+=("${VALID_BACKUP_PATHS[@]}")

  if [[ -n "${EXCLUDE_FILE}" ]]; then
    args+=(--exclude-file="${EXCLUDE_FILE}")
  fi

  run_restic "${args[@]}"
}

run_forget_prune() {
  run_restic forget \
    --keep-daily "${KEEP_DAILY}" \
    --keep-weekly "${KEEP_WEEKLY}" \
    --keep-monthly "${KEEP_MONTHLY}" \
    --prune
}

run_check() {
  run_restic check --read-data-subset=5%
  mark_check_completed
}

main() {
  local stage_failed=false

  trap on_interrupt INT TERM
  trap on_exit EXIT

  preflight
  rotate_log_if_needed
  acquire_lock

  log ok "====================================="
  log info "Starting backup: $(date)"
  log info "Repository: ${REPO}"
  log ok "====================================="

  log warn "[1/3] Running backup..."
  if ! run_backup; then
    log error "Backup stage failed"
    notify "Backup Failed" "Backup stage failed"
    exit 1
  fi

  log warn "[2/3] Applying retention policy..."
  if ! run_forget_prune; then
    log error "Retention stage failed"
    notify "Backup Warning" "Backup succeeded, but retention policy failed"
    stage_failed=true
  fi

  if should_run_check; then
    log warn "[3/3] Verifying repository..."
    if ! run_check; then
      log error "Repository check failed"
      notify "Backup Warning" "Backup succeeded, but repository check failed"
      stage_failed=true
    fi
  else
    log info "[3/3] Skipping repository check (interval: ${CHECK_INTERVAL_DAYS} days)"
  fi

  if [[ "${stage_failed}" == true ]]; then
    exit 2
  fi

  log ok "====================================="
  log ok "Backup completed successfully"
  log ok "====================================="
  notify "Backup Completed" "Backup completed successfully"
}

main "$@"

Nie traktuję tego jako rozwiązania idealnego. Raczej jako coś, co wreszcie daje mi spokój w tle. Kopie zapasowe przestały być tematem, o którym muszę pamiętać, a stały się procesem, który po prostu działa. Być może jest ktoś, kto potrafiłby napisać lepszy skrypt, ale na razie ten mi wystarcza.

PGP SIGNATURE 9670 B378 988E 18A5 B30E 75D7 5CDE 946A 4160 C229
author
Dominik Labudzinski
key
9670 B378 988E 18A5 B30E 75D7 5CDE 946A 4160 C229
-----BEGIN PGP SIGNATURE-----

iQJMBAABCgA2FiEElnCzeJiOGKWzDnXXXN6UakFgwikFAmpD6wcYHGRvbWluaWtA
bGFidWR6aW5za2kuY29tAAoJEFzelGpBYMIpqvgP+wTYOVRn/S1QsDjzAA8Olx09
V1fgivc30W/J9xtdgGeBGLSd+CvtoBMNw++IHcRxyb+tA/OkTZAqVNnwHWq2M8EW
/5p/if5+CdpBLHZF2h02uZRdWDhkdVsySuMAZ1GJpuS8VoeM3x1Vsbb3wFbTDYzN
fdbBONZJKO/E/4y/69veGlFy1fEXfi/IWq5WgEVeqHZ6LSJW1gwzKTJJoxFNYuBq
E6SaBt7vCCXElb7Ut+XPOMLA6zbq9flrT25jqz8UDh9OcZDEioAlgB6neMNYT/ye
itEVkyVbWjo61s6xttB2w1H3IODYRjR66rJQdo9yt7VDOiSVo/0GjklOL/iUuDoW
VwJD+vD9bNAGh2wIxUUFTMYpSg/VQztfCcrZ3jf8jasGhhkSWRfdmMUb3rDby3gr
rB7WgabP/gF+cM5DSz/26+GHmP6NN7H8pYQkPM56XxVM6Gt0lD0OTD6Ky8SwVWLB
iit8EJTyaew+WvhyGxAEKlPTiUL04rXglQZZZG/fctCb8WZEbJ9/PEv9iun3VF3r
w8qj5m3eMU6Y7eqbB1K7ptgL5V0NBBfmZSPmskINuf7Kb/N9e/hsluGSokrYGL+S
RlSgVwwgja/I75uTa90zUu2tmDT24rLQHNEnVk7EwVabxAMqpIRKJ/6CmO/hT0lq
ozJnAbl7tUyVYo2tCRQz
=C7QH
-----END PGP SIGNATURE-----

OK integrity verified via OpenPGP