跳过正文

两台 VPS 每天备份到一台廉价存储箱:restic 的空间账、恢复演练与异常巡检

·7360 字·15 分钟
目录

两台 VPS 上的服务越来越多,却没有一条真正被验证过的备份链路。这篇记录最终落地的方案:用 restic 把两台机器每天增量备份到一台租来的 500 GB 存储箱(只能走 SFTP),保留 30 日 + 12 周 + 12 月,配恢复演练和"正常静默、异常才响"的巡检。适用环境:两台 Linux VPS(一台跑 Docker 服务栈,一台跑 LXD + 控制面板)、一台 Debian 13 存储箱(jailshell 受限账号)、restic 0.19.1,2026-09-11 部署并逐项实测。结论先说:这套东西的成本几乎只和"每天的变化量"挂钩,所以"保留多久"这个问题答案永远是"比你以为的长"。

一、方案取舍:为什么是 restic + SFTP 后端
#

三件事按重要性排序:

  1. 客户端加密。数据在本地加密后才上传,存储箱供应商拿不到任何明文。代价必须一起说清楚:仓库口令丢失 = 数据永远不可恢复,供应商也救不了你。所以口令要存两处(本机受限权限文件 + 密码管理器),并且校验两处是否一致。
  2. 去重 + 压缩。两台机器首份全量合计 6.98 GB 的扫描量,落到箱子上的仓库实际占用 4.31 GB(省约 38%);之后每天只上传变化的数据块。
  3. 后端足够傻。箱子只需要开 SFTP、给一个受限 shell 账号,不需要在服务商侧装任何东西,也不依赖任何厂商专用接口。

与之对照的是"每天 rsync 一份完整快照":N 天的保留就是 N 份完整副本,没有跨版本去重、也没有客户端加密(除非再叠一层),保留窗口一拉长,成本线性上涨。这里不是给 rsync 判死刑——它更适合"镜像到另一台机器"这种场景;但"每天一份、留几个月"的需求,去重备份是更省的那条路。

二、配置长什么样
#

整套东西就是两份配置文件 + 两个脚本 + 四个 systemd 单元(备份与巡检各一 service 一 timer)。它们已经按本站的编写原则原样附在下面,只把箱地址、账号名、主机代号与具体服务目录换成了占位符,逻辑一字未改。

backup-to-box.sh —— 备份主脚本(204 行)
#!/usr/bin/env bash
# backup-to-box.sh — daily restic snapshot of this host onto the 500 GB backup box.
#
#   config : /etc/backup-to-box.conf      (per host: REPO, PATHS, KEEP_*, hooks)
#   excludes: /etc/backup-to-box.exclude
#   status  : /var/lib/backup-to-box/status.env  +  <box>:/home/<备份用户>/status/<host>.env
#   log     : /var/log/backup-to-box.log
#
# Exit 0 = ok, 1 = failure(systemd 单元显示 failed,巡检脚本据此告警)
# Manual run:  BACKUP_CONF=/etc/backup-to-box.conf DRY_RUN=1 /usr/local/sbin/backup-to-box.sh
set -uo pipefail

# --- defaults that a per-host config may override --------------------------
declare -a PATHS=() PG_CONTAINERS=() MYSQL_CONTAINERS=()

CONF="${BACKUP_CONF:-/etc/backup-to-box.conf}"
[[ -r "$CONF" ]] || { echo "FATAL: $CONF unreadable" >&2; exit 2; }
# shellcheck disable=SC1090
source "$CONF"

RESTIC="${RESTIC:-/usr/local/bin/restic}"
REPO="${REPO:?REPO missing in $CONF}"
HOST_TAG="${HOST_TAG:-$(hostname -s)}"
BOX_HOST="${BOX_HOST:-<存储箱别名>}"
BOX_STATUS_DIR="${BOX_STATUS_DIR:-/home/<备份用户>/status}"
BOX_REPO_DIR="${BOX_REPO_DIR:-/home/<备份用户>/restic}"
export RESTIC_PASSWORD_FILE="${RESTIC_PASSWORD_FILE:-/root/.restic-box-pass}"
export RESTIC_CACHE_DIR="${RESTIC_CACHE_DIR:-/var/cache/restic}"
export RESTIC_PROGRESS_FPS=0

STATE_DIR="${STATE_DIR:-/var/lib/backup-to-box}"
DUMP_DIR="$STATE_DIR/dbdumps"
LOG_FILE="${LOG_FILE:-/var/log/backup-to-box.log}"
STATUS_ENV="$STATE_DIR/status.env"
EXCLUDE_FILE="${EXCLUDE_FILE:-/etc/backup-to-box.exclude}"
KEEP_DAILY="${KEEP_DAILY:-7}"
KEEP_WEEKLY="${KEEP_WEEKLY:-4}"
KEEP_MONTHLY="${KEEP_MONTHLY:-6}"
KEEP_YEARLY="${KEEP_YEARLY:-0}"
CHECK_SUBSET="${CHECK_SUBSET:-2%}"                # weekly `restic check --read-data-subset`
BOX_CAP_BYTES="${BOX_CAP_BYTES:-483183820800}"    # 450 GiB self-imposed cap on the 500 GB plan
MIN_FREE_MB="${MIN_FREE_MB:-2048}"
DRY_RUN="${DRY_RUN:-0}"

[[ ${#PATHS[@]} -eq 0 ]] && PATHS=(/root)

mkdir -p "$STATE_DIR" "$DUMP_DIR" "$RESTIC_CACHE_DIR" "$(dirname "$LOG_FILE")"

# ---- single instance ------------------------------------------------------
exec 9>"$STATE_DIR/run.lock"
if ! flock -n 9; then echo "backup-to-box: another run is in progress, exiting" >&2; exit 0; fi

# ---- logging --------------------------------------------------------------
if [ -f "$LOG_FILE" ] && [ "$(stat -c%s "$LOG_FILE" 2>/dev/null || echo 0)" -gt 5242880 ]; then
  mv -f "$LOG_FILE" "$LOG_FILE.1"
fi
STARTED=$(date -Is); T0=$(date +%s)
STATUS=ok; declare -a WARN=()
say()  { printf '%s %s\n' "$(date '+%F %T%z')" "$*" | tee -a "$LOG_FILE"; }
warn() { WARN+=("$*"); say "WARN: $*"; }
fail() { STATUS=fail; say "FAIL: $*"; }

# ---- helpers --------------------------------------------------------------
dump_pg() {
  local c="$1" out="$DUMP_DIR/pg-${1}.dump" tmp="$DUMP_DIR/pg-${1}.dump.tmp"
  rm -f "$tmp"
  if ! docker exec "$c" sh -c 'exec pg_dump -U "${POSTGRES_USER:-postgres}" -d "${POSTGRES_DB:-postgres}" -Fc' >"$tmp" 2>"$DUMP_DIR/pg-${1}.err"; then
    rm -f "$tmp"; warn "pg_dump $c failed: $(tail -1 "$DUMP_DIR/pg-${1}.err" 2>/dev/null)"; return 1
  fi
  mv -f "$tmp" "$out"; say "  dump $c -> $(du -h "$out" | cut -f1)"
}

dump_mysql() {
  local c="$1" out="$DUMP_DIR/mysql-${1}.sql.gz" tmp="$DUMP_DIR/mysql-${1}.sql.gz.tmp"
  rm -f "$tmp"
  if ! docker exec "$c" sh -c '
        if command -v mariadb-dump >/dev/null 2>&1; then D=mariadb-dump; else D=mysqldump; fi
        exec "$D" -uroot -p"${MARIADB_ROOT_PASSWORD:-${MYSQL_ROOT_PASSWORD:-}}" \
             --single-transaction --quick --skip-lock-tables \
             --all-databases --events --routines --triggers' 2>"$DUMP_DIR/mysql-${1}.err" | gzip -1 >"$tmp"; then
    rm -f "$tmp"; warn "mysqldump $c failed: $(tail -1 "$DUMP_DIR/mysql-${1}.err" 2>/dev/null)"; return 1
  fi
  mv -f "$tmp" "$out"; say "  dump $c -> $(du -h "$out" | cut -f1)"
}

on_exit() {
  local rc=$?
  if declare -F post_backup >/dev/null; then post_backup || true; fi
  local dur=$(( $(date +%s) - T0 ))
  say "===== backup-to-box $HOST_TAG finished: STATUS=$STATUS rc=$rc duration=${dur}s ====="
  exit "$rc"
}
trap on_exit EXIT
# make sure the cleanup hook runs even if the session is torn down mid-run
trap 'exit 129' HUP
trap 'exit 143' TERM
trap 'exit 130' INT

say "===== backup-to-box start host=$HOST_TAG repo=$REPO ====="

# ---- pre ------------------------------------------------------------------
avail_mb=$(df -Pm "$STATE_DIR" | awk 'NR==2{print $4}')
[[ "${avail_mb:-0}" -lt "$MIN_FREE_MB" ]] && fail "only ${avail_mb}MB free on $STATE_DIR (need ${MIN_FREE_MB}MB)"

if declare -F pre_backup >/dev/null; then
  say "--- pre_backup hook ---"
  if ! pre_backup; then fail "pre_backup hook failed — aborting before taking a snapshot"; exit 1; fi
fi

say "--- database dumps ---"
for c in "${PG_CONTAINERS[@]}"; do [ -n "$c" ] && dump_pg "$c"; done
for c in "${MYSQL_CONTAINERS[@]}"; do [ -n "$c" ] && dump_mysql "$c"; done

# ---- backup ---------------------------------------------------------------
say "--- restic backup (paths: ${PATHS[*]}) ---"
JSON="$STATE_DIR/last-backup.json"
SNAP_ID=""; ADDED=0; LOGICAL=0; FILES=0
if [[ "$DRY_RUN" == "1" ]]; then
  "$RESTIC" -r "$REPO" backup "${PATHS[@]}" --exclude-file="$EXCLUDE_FILE" \
     --host "$HOST_TAG" --tag daily --dry-run 2>&1 | tail -20 | tee -a "$LOG_FILE"
else
  rm -f "$JSON"
  "$RESTIC" -r "$REPO" backup "${PATHS[@]}" \
      --exclude-file="$EXCLUDE_FILE" \
      --exclude-caches --exclude-if-present .nobackup \
      --host "$HOST_TAG" --tag daily \
      --json >"$JSON" 2>>"$LOG_FILE"
  rc=$?
  if [[ $rc -ne 0 && $rc -ne 3 ]]; then fail "restic backup exited $rc (see $LOG_FILE)"; fi
  [[ $rc -eq 3 ]] && warn "restic reported unreadable source files (exit 3)"
  SNAP_ID=$(jq -r 'select(.message_type=="summary")|.snapshot_id' "$JSON" 2>/dev/null | tail -1)
  ADDED=$(jq -r 'select(.message_type=="summary")|.data_added' "$JSON" 2>/dev/null | tail -1)
  LOGICAL=$(jq -r 'select(.message_type=="summary")|.total_bytes_processed' "$JSON" 2>/dev/null | tail -1)
  FILES=$(jq -r 'select(.message_type=="summary")|.total_files_processed' "$JSON" 2>/dev/null | tail -1)
  [[ -z "${SNAP_ID:-}" || "${SNAP_ID}" == "null" ]] && fail "no snapshot id in restic output"
  say "  snapshot=${SNAP_ID:-none} files=${FILES:-0} scanned=$(numfmt --to=iec "${LOGICAL:-0}") new-data=$(numfmt --to=iec "${ADDED:-0}")"
fi

# ---- retention ------------------------------------------------------------
# Forget runs every day (rolling delete is immediate); the real `prune`
# repack only runs on Sundays, because pack rewriting over SFTP is the
# slowest step by far (measured 7.5 min) and the box has plenty of slack.
if [[ "$STATUS" == "ok" && "$DRY_RUN" != "1" ]]; then
  if [[ "$(date +%u)" == "7" ]]; then
    PRUNE_ARGS=(--prune); PRUNE_NOTE="weekly prune (Sunday)"
  else
    PRUNE_ARGS=(); PRUNE_NOTE="prune deferred to Sunday"
  fi
  say "--- retention: keep-daily=$KEEP_DAILY keep-weekly=$KEEP_WEEKLY keep-monthly=$KEEP_MONTHLY keep-yearly=$KEEP_YEARLY [$PRUNE_NOTE] ---"
  KEEP_ARGS=(--keep-daily "$KEEP_DAILY" --keep-weekly "$KEEP_WEEKLY" --keep-monthly "$KEEP_MONTHLY")
  [[ "$KEEP_YEARLY" -gt 0 ]] && KEEP_ARGS+=(--keep-yearly "$KEEP_YEARLY")
  FORGET_OUT="$STATE_DIR/last-forget.txt"
  if ! "$RESTIC" -r "$REPO" forget "${KEEP_ARGS[@]}" "${PRUNE_ARGS[@]}" --host "$HOST_TAG" >"$FORGET_OUT" 2>&1; then
    warn "restic forget/prune failed (see $FORGET_OUT)"
  else
    SUMMARY=$(grep -E 'snapshots have been removed|no snapshots were removed' "$FORGET_OUT" | tail -1)
    [[ -z "$SUMMARY" ]] && SUMMARY=$(grep -E '^keep [0-9]+ snapshots' "$FORGET_OUT" | tail -1)
    say "  ${SUMMARY:-forget finished}"
  fi
  cat "$FORGET_OUT" >>"$LOG_FILE"
fi

# ---- weekly integrity check ----------------------------------------------
if [[ "$STATUS" == "ok" && "$DRY_RUN" != "1" && "$(date +%u)" == "7" ]]; then
  say "--- weekly restic check (--read-data-subset=$CHECK_SUBSET) ---"
  "$RESTIC" -r "$REPO" check --read-data-subset="$CHECK_SUBSET" >>"$LOG_FILE" 2>&1 || warn "restic check reported problems"
fi

# ---- stats ----------------------------------------------------------------
SNAPSHOTS=0; REPO_RAW=0; BOX_BYTES=0; BOX_TOTAL=0
if [[ "$DRY_RUN" != "1" ]]; then
  SNAPSHOTS=$("$RESTIC" -r "$REPO" snapshots --json 2>/dev/null | jq 'length' 2>/dev/null || echo 0)
  REPO_RAW=$("$RESTIC" -r "$REPO" stats --json 2>/dev/null | jq -r '.total_size' 2>/dev/null || echo 0)
  BOX_BYTES=$(ssh -o BatchMode=yes -o ConnectTimeout=25 "$BOX_HOST" "du -sb $BOX_REPO_DIR/$HOST_TAG 2>/dev/null | cut -f1" 2>/dev/null || echo 0)
  BOX_TOTAL=$(ssh -o BatchMode=yes -o ConnectTimeout=25 "$BOX_HOST" "du -sb $BOX_REPO_DIR 2>/dev/null | cut -f1" 2>/dev/null || echo 0)
  say "  snapshots=$SNAPSHOTS repo-raw-data=$(numfmt --to=iec "${REPO_RAW:-0}") box-repo=$(numfmt --to=iec "${BOX_BYTES:-0}") box-total=$(numfmt --to=iec "${BOX_TOTAL:-0}")"
  if [[ "${BOX_TOTAL:-0}" -gt "$BOX_CAP_BYTES" ]]; then
    warn "backup box usage $(numfmt --to=iec "${BOX_TOTAL:-0}") exceeds the self-imposed cap $(numfmt --to=iec "$BOX_CAP_BYTES") — shorten retention"
  fi
fi

# ---- status file (local + on the box) ------------------------------------
cat > "$STATUS_ENV" <<EOF
HOST=$HOST_TAG
STATUS=$STATUS
STARTED=$STARTED
FINISHED=$(date -Is)
SNAPSHOT=${SNAP_ID:-}
FILES=${FILES:-0}
SCANNED_BYTES=${LOGICAL:-0}
ADDED_BYTES=${ADDED:-0}
REPO_RAW_BYTES=${REPO_RAW:-0}
BOX_BYTES=${BOX_BYTES:-0}
BOX_TOTAL_BYTES=${BOX_TOTAL:-0}
SNAPSHOTS=${SNAPSHOTS:-0}
KEEP_DAILY=$KEEP_DAILY
DURATION_S=$(( $(date +%s) - T0 ))
WARN=${WARN[*]:-}
EOF
if ! ssh -o BatchMode=yes -o ConnectTimeout=25 "$BOX_HOST" "cat > $BOX_STATUS_DIR/$HOST_TAG.env" <"$STATUS_ENV" 2>>"$LOG_FILE"; then
  warn "could not publish status file to the backup box"
fi

if [[ "$STATUS" == "ok" ]]; then exit 0; else exit 1; fi
/etc/backup-to-box.conf —— 每台机器一份(28 行)
# /etc/backup-to-box.conf — <本机代号>
# Daily restic snapshot to the 500 GB backup box.
# 每台机器一份,改 REPO / HOST_TAG / PATHS / 容器名单即可

REPO="sftp://<备份用户>@<存储箱地址>:<端口>//home/<备份用户>/restic/<主机代号>"
HOST_TAG="<主机代号>"
BOX_HOST="<存储箱别名>"
EXCLUDE_FILE="/etc/backup-to-box.exclude"

PATHS=(
  /root
  /data
  /etc
  /usr/local
  /var/spool/cron
  /var/lib/docker/volumes
  /var/lib/backup-to-box
)

# databases dumped to /var/lib/backup-to-box/dbdumps before the snapshot
PG_CONTAINERS=(<pg容器1> <pg容器2> <pg容器3> <pg容器4>)   # 换成你自己的 PostgreSQL 容器名
MYSQL_CONTAINERS=(<mysql容器1> <mysql容器2> <mysql容器3>)  # docker ps 里列出来

# retention(容量估算见文中“空间账”一节)
KEEP_DAILY=30
KEEP_WEEKLY=12
KEEP_MONTHLY=12
KEEP_YEARLY=0
/etc/backup-to-box.exclude —— 排除清单(28 行)
# /etc/backup-to-box.exclude — <主机代号>  (restic exclude file: one pattern per line, # = comment)
# Raw database data directories (their contents arrive as pg_dump/mysqldump files instead)
/srv/docker-data/<服务甲>/db
/srv/docker-data/<服务乙>/db
/srv/docker-data/<服务丙>/db
/srv/docker-data/<反向代理>/mysql
# regenerable / bulk material
/srv/docker-data/<反向代理>/data/logs
/srv/docker-data/<反向代理>/letsencrypt-acme-challenge
/srv/docker-data/<在线文档>/onlyoffice-dist
/srv/docker-data/<自建服务>/data/bin
/srv/docker-data/<自建服务>/data/lsp
/srv/docker-data/<自建服务>/data/lazy-packages
/srv/docker-data/<自建服务>/data/logs
/srv/docker-data/<自建服务>/data/models_dev_cache.json
/srv/docker-data/<自建服务>/data/models_dev_cache.json.corrupt
# generic caches / logs
.cache
.npm
node_modules
*.log
*.log.gz
*.pyc
__pycache__
*.tmp
# docker internals that must never travel
/var/lib/docker/volumes/*/backingFsBlockDev
/var/lib/docker/volumes/metadata.db
backup-box-watch.sh —— 巡检脚本(68 行)
#!/usr/bin/env bash
# backup-box-watch.sh — anomaly watchdog for the backup box.
# 只在一台机器上跑。 Reads the status files both hosts publish to the box and
# 只有异常才推送到通知通道。
#   * backup reported a failure
#   * last successful run older than MAX_AGE_H
#   * repos together exceed the self-imposed cap (default 450 GiB of a 500 GB plan)
set -uo pipefail

BOX_HOST="${BOX_HOST:-<存储箱别名>}"
STATUS_DIR="${BOX_STATUS_DIR:-/home/<备份用户>/status}"
REPO_DIR="${BOX_REPO_DIR:-/home/<备份用户>/restic}"
HOSTS=(${WATCH_HOSTS:-<主机甲> <主机乙>})
MAX_AGE_H="${MAX_AGE_H:-30}"
CAP_BYTES="${CAP_BYTES:-483183820800}"
TG=/usr/local/sbin/notify.sh    # 换成你自己的通知脚本(收一个参数:消息文本)
LOG=/var/log/backup-box-watch.log

ALERTS=()
log() { printf '%s %s\n' "$(date '+%F %T%z')" "$*" >>"$LOG"; }

total_used=""
for h in "${HOSTS[@]}"; do
  txt=$(ssh -o BatchMode=yes -o ConnectTimeout=25 -o StrictHostKeyChecking=accept-new \
            "$BOX_HOST" "cat $STATUS_DIR/$h.env 2>/dev/null" 2>/dev/null)
  if [[ -z "${txt//[[:space:]]/}" ]]; then
    ALERTS+=("❌ $h:备份箱上没有状态文件(该机备份从未成功或状态目录被清)")
    continue
  fi
  get() { printf '%s\n' "$txt" | sed -n "s/^$1=//p" | head -1; }
  st=$(get STATUS); fin=$(get FINISHED); snaps=$(get SNAPSHOTS); \
  box=$(get BOX_BYTES); warn=$(get WARN)

  if [[ "$st" != "ok" ]]; then
    ALERTS+=("❌ $h:最近一次备份失败(STATUS=$st,WARN=${warn:-none})")
  fi
  if [[ -n "$fin" ]]; then
    ep=$(date -d "$fin" +%s 2>/dev/null || echo 0)
    if [[ "$ep" -gt 0 ]]; then
      age_h=$(( ( $(date +%s) - ep ) / 3600 ))
      if [[ "$age_h" -gt "$MAX_AGE_H" ]]; then
        ALERTS+=("❌ $h:已 ${age_h}h 没有成功备份(最后成功 $fin)")
      fi
    fi
  fi
  log "host=$h status=$st finished=$fin snapshots=$snaps box_bytes=${box:-?}"
done

used=$(ssh -o BatchMode=yes -o ConnectTimeout=25 "$BOX_HOST" "du -sb $REPO_DIR 2>/dev/null | cut -f1" 2>/dev/null || echo "")
if [[ -n "$used" ]]; then
  total_used=$used
  if [[ "$used" -gt "$CAP_BYTES" ]]; then
    ALERTS+=("⚠️ 备份箱占用 $(numfmt --to=iec "$used") 已超过自设上限 $(numfmt --to=iec "$CAP_BYTES"),请缩短保留策略")
  fi
  log "box total used=$(numfmt --to=iec "$used")"
else
  ALERTS+=("❌ 无法连接备份箱 $BOX_HOST,无法核对备份状态")
fi

if [[ ${#ALERTS[@]} -gt 0 ]]; then
  msg="🗄 备份箱巡检异常 $(date '+%F %H:%M %Z')
$(printf '%s\n' "${ALERTS[@]}")
当前占用:${total_used:+$(numfmt --to=iec "$total_used")}"
  "$TG" "$msg" >>"$LOG" 2>&1
  log "alert sent"
else
  log "all good (box used=$(numfmt --to=iec "${total_used:-0}"))"
fi
# /etc/systemd/system/backup-to-box.service
[Unit]
Description=Daily restic backup to the backup box
Documentation=file:/etc/backup-to-box.conf
Wants=network-online.target
After=network-online.target

[Service]
Type=oneshot
ExecStart=/usr/local/sbin/backup-to-box.sh
TimeoutStartSec=6h
Nice=10
IOSchedulingClass=idle
CPUSchedulingPolicy=batch
# /etc/systemd/system/backup-to-box.timer
[Unit]
Description=Daily restic backup to the backup box

[Timer]
# 两台机器错峰:另一台写 04:30,随机延迟 20 分钟避免同时压同一个箱子
OnCalendar=*-*-* 03:30:00
RandomizedDelaySec=20min
AccuracySec=1min
Persistent=true

[Install]
WantedBy=timers.target
# /etc/systemd/system/backup-box-watch.service(只在一台机器上启用)
[Unit]
Description=Anomaly watchdog for the backup box repos

[Service]
Type=oneshot
ExecStart=/usr/local/sbin/backup-box-watch.sh
# /etc/systemd/system/backup-box-watch.timer
[Unit]
Description=Anomaly watchdog for the backup box repos

[Timer]
OnCalendar=*-*-* 10:00:00
RandomizedDelaySec=10min
AccuracySec=1min
Persistent=true

[Install]
WantedBy=timers.target

几个设计点值得单独说明:

  • Persistent=true:机器错过当天窗口(重启、断电)后,下次启动会补跑一次,而不是直接跳过一天。
  • Nice/IOSchedulingClass/CPUSchedulingPolicy:备份是纯 IO + CPU 的后台活,降优先级避免和线上服务抢资源。
  • 单实例锁(脚本里的 flock -n):手动跑和定时跑撞在一起时,后来者直接退出,不会两个 restic 同时操作同一个仓库。
  • 数据库先转储再备份:脚本会先在容器里跑 pg_dump -Fc / mysqldump,把转储文件落到状态目录,再连同主机文件一起快照——这是后面"省钱第一刀"的基础。

三、保留策略与空间账
#

保留策略:--keep-daily 30 --keep-weekly 12 --keep-monthly 12。下面是实测账(都是真跑出来的数字):

一台(Docker 服务栈) 另一台(LXD + 控制面板) 合计
首份全量:扫描体积 → 箱上占用 1.94 GB(9164 个文件)→ 1.71 GB 5.04 GB(137440 个文件)→ 2.60 GB 6.98 GB → 4.31 GB
后续每次运行的新增数据 128–373 MB 9–10 MB 约 0.3–0.5 GB/日

按箱子上自设的 450 GiB 上限反推(脚本里 BOX_CAP_BYTES 超限会告警,运行日志里每次都打印当前占用):

  • 以 0.5 GB/日计,能存约 900 个每日版本 ≈ 2.5 年
  • 就算完全不靠去重、每天存一份完整独立副本,也还有 约 112 份
  • 30 日 + 12 周 + 12 月这套策略目前只占箱子的 约 6%(约 31 GB)

所以"保留 3–5 天够不够"这个问题,答案是不够,而且没有任何理由这么短:日增量本来就小、预算远远用不完,而数据损坏、配置漂移这类问题往往要几天甚至几周才暴露——留 3–5 天等于把发现问题的窗口直接扔掉,换来的节省是零。当前实际占用(部署两天后)是 4.8 GB。

四、forgetprune 分开跑
#

restic 的保留是两步:forget 只删快照引用(瞬时),prune 才真正重写数据包、回收磁盘。实测在 SFTP 后端上,prune 是最慢的一步:

运行方式 耗时
不带 prune(只 forget) 98 秒
带 prune 451 秒(约 7.5 分钟)
另一台机器的日常(不带 prune) 约 110 秒

所以脚本里的策略是:每天跑 forget,只有周日才带 --prune;同一天顺便跑一次 restic check --read-data-subset=2%(抽查 2% 的数据块做完整性校验)。副作用是周日之前 du 看到的占用会比实际需要多几天(约 3 GB),完全可以忽略。

五、省空间的两刀
#

第一刀:把数据库的原始数据目录排除,改用逻辑转储。 一台机器上某个服务的 PostgreSQL 原始数据目录约 9.5 GB,直接备份它,首份全量会到 11 GB 量级;改成备份 pg_dump -Fc 出来的转储文件后,那个库只有 751 MB,这台机器的首份全量随之落到 1.94 GB。注意转储要在快照之前生成并落到被备份的路径里(脚本里是 /var/lib/backup-to-box/dbdumps),否则快照里只有转储的"上一版"。

第二刀:排除清单。 分四类,判断标准只有一条——能不能重新得到

类别 判断
原始数据库目录 各服务的 db/mysql/ 已有逻辑转储,原始目录排除(见上)
可再生的大件 反向代理的日志与 ACME 挑战目录、在线文档的静态发行目录(约 1.1 GB) 重装/重下即得
通用缓存与日志 .cache(约 1.1 GB)、.npmnode_modules*.log__pycache__ 重生成即得
容器内部件 各容器内 var/log(697 MB)、var/cache(676 MB)、doc/man/locale;Docker 卷的 backingFsBlockDevmetadata.db 绝不该进备份(有些甚至不该被读)

另外一台机器上的容器跑在 LXD + ZFS 上:备份前给容器打只读快照再挂载,所以跨天的增量几乎为 0(实测 9–10 MB/次)——文件内容没变,去重自然全命中。

六、恢复演练:没演练过的备份不算备份
#

部署完只做了三件事,缺一件这套东西就不成立:

  1. 逐字节比对:从快照里 restore 出 /etc/hostname/etc/ssh/sshd_config,与线上文件 diff → 完全一致。
  2. 恢复数据库转储:从快照里取出 pg-*.dump(4.85 MB)→ restore 成功(Restored 5/1 files),证明"转储确实进了快照、也确实能取出来"。
  3. 仓库自检:两个仓库 restic checkno errors were found(一台 4.3 秒;另一台因为要多次往返 SFTP,元数据检查花了 6.5 分钟——慢是正常的,不是坏了)。

演练的命令形态(示例):

export RESTIC_PASSWORD_FILE=/root/.restic-box-pass
restic -r "$REPO" restore latest --target /tmp/drill \
  --include /etc/hostname --include /etc/ssh/sshd_config
diff -s /tmp/drill/etc/hostname /etc/hostname

七、巡检:正常静默,异常才响
#

巡检脚本只在一台机器上跑(这也是它最大的弱点,见下),每天 10:00 左右执行,读两台机器发布到箱子上的 status.env,只在三种情况下推送通知:

触发条件 含义
STATUS != ok 最近一次备份自报失败
最后成功时间超过 30 小时 备份"没在跑"或"跑了没成",两种都算异常
两个仓库合计占用超过自设上限(450 GiB / 500 GB 套餐) 该缩短保留策略了

正常时它只写一行日志(all good),不打扰任何人。告警链路的自测方式是:手动写一个 STATUS=fail 的假状态文件,用环境变量把巡检范围限定到它,跑一次——收到一条测试消息、日志出现 alert sent,再清理掉假文件。

八、风险与教训
#

  • 口令就是数据本体。仓库口令丢失 = 数据永不可恢复,官方 restic init 时就提示过。我们把它生成在机器上(48 字符随机串,不打印到终端)、0600 落文件、另存一份到密码管理器。
  • 比对哈希先统一口径:校验"密码管理器里的口令和机器上那份是否一致"时,两次都对不上——第一次是命令行环境没登录,读出来是空串;第二次是读出的值带了尾随换行。两次都属于"口径没统一",不是口令真的不同。任何哈希比对都要先做同样的规范化处理。
  • 命令输出本身会泄密:排查一个容器时用 docker inspect 取值,结果把另一个服务的数据库口令打印进了日志。处置是轮换那两个库的口令;此后的规矩是——要取容器内的环境变量,一律 docker exec <容器> sh -c '...' 在容器内展开,绝不在宿主侧 inspect 出全部环境。
  • 别信箱子上的 df:共享存储上 df 报的是整卷(几百 TB),套餐配额不在文件系统层体现。判断占用只认 du -sb 数出来的真实字节,这也是脚本里那个上限告警的由来。
  • 巡检依赖单机存活:巡检脚本自己跑在其中的一台机器上,那台机器整体挂掉时,巡检也停,箱子只会静默变旧。这条目前没闭环(要么给巡检加外部存活探测,要么让箱子侧自己也发心跳),先记下。

九、留给后来者的清单
#

  • 先定"容灾目标"再选工具:要的是"能回到任意一天",去重备份 + 长保留几乎不增加成本;只要"镜像一份",rsync 更直接。
  • 客户端加密的前提是口令双份保存 + 校验一致;口令丢了没有任何补救。
  • 保留策略大胆拉长:30 日 + 12 周 + 12 月这套在 500 GB 级箱子上只占个位数百分比。
  • forget 每天跑、prune 每周跑,SFTP 后端下差别是 98 秒对 451 秒。
  • 数据库先逻辑转储再备份:9.5 GB 的原始目录能变成 751 MB 的转储。
  • 排除清单按"能不能重新得到"来分:缓存、日志、可再生大件、容器内部件。
  • 部署当天就做恢复演练:文件逐字节比对 + 从快照里取出并恢复一份数据库转储。
  • 巡检正常静默、异常才响;把"多久没成功"也当成异常条件,而不只是看"最近一次是否失败"。
  • 占用按 du -sb 算,别被共享存储的 df 骗;给仓库设一个自设上限并告警。
  • 日志里出现过的任何口令,都当作已泄露处理。

十、碎碎念
#

备份这件事的悖论是:它的价值只在最坏的那天兑现,所以平时最容易被砍掉、也最容易被"看起来在跑"糊弄。真正让这套方案落地的不是 restic 本身,而是三个附加动作——保留策略敢拉长、部署当天做恢复演练、巡检只在异常时响。前两个决定"真出事时能不能救回来",最后一个决定"这套东西还在不在工作"。