#!/bin/bash # nas_backup_vps.sh — 从生产 VPS 拉取状态到 NAS 做每日快照备份(模板)。 # # 方向(铁律):生产 VPS → NAS 单向;**绝不反向写**(NAS 是测试/备份角色, # 不向生产回灌数据,防止测试库污染生产库)。 # # 这是**模板**:源侧(VPS)的具体路径随生产拓扑填(--paths),host 别名随 # ~/.ssh/config 配。脚本不硬编码任何 host / 路径 / 密钥。 # # 策略: # - rsync over ssh,幂等(同一快照目录重复跑结果一致) # - 每日快照子目录 $DEST/YYYYMMDD/,用 --link-dest 指向上一份做硬链接去重 # (未变文件零额外占盘,7 份快照近乎 1 份体积) # - 保留最近 N 份(--keep,默认 7),旧的自动删 # - --dry-run 只看不动;preflight 检查源 host 可达 # # 用法: # nas_backup_vps.sh \ # --host sanguo-vps \ # --paths /var/lib/sanguo/data,/opt/sanguo/config \ # --dest /volume1/stock/backup/sanguo_vps \ # --keep 7 # nas_backup_vps.sh ... --dry-run # 只打印会做什么 # # 前置(生产拓扑就绪后做一次): # 1) 在 NAS 的 ~/.ssh/config 加生产 host 别名 + key(免密) # (现在别名可能还没有——preflight 会清晰报错并给出提示) # 2) 确保 NAS 上 $DEST 根目录已创建并可写 set -euo pipefail # ---------------------------------------------------------------------------- # 参数解析 # ---------------------------------------------------------------------------- HOST="" PATHS="" DEST="" KEEP=7 DRY_RUN=0 usage() { cat <<'EOF' 用法: nas_backup_vps.sh --host --paths --dest [--keep N] [--dry-run] 必填: --host 生产源 host 的 ssh 别名(~/.ssh/config) --paths 生产侧要备份的目录列表(逗号分隔,绝对路径) --dest NAS 上备份根目录(快照建在其下 YYYYMMDD/) 可选: --keep N 保留最近 N 份快照(默认 7) --dry-run 只打印会做什么,不实际传输/删除 -h, --help 显示本帮助 EOF } while [ $# -gt 0 ]; do case "$1" in --host) HOST="$2"; shift 2 ;; --paths) PATHS="$2"; shift 2 ;; --dest) DEST="$2"; shift 2 ;; --keep) KEEP="$2"; shift 2 ;; --dry-run) DRY_RUN=1; shift ;; -h|--help) usage; exit 0 ;; *) echo "未知参数: $1" >&2; usage >&2; exit 2 ;; esac done # 必填校验 MISSING="" [ -z "$HOST" ] && MISSING="$MISSING --host" [ -z "$PATHS" ] && MISSING="$MISSING --paths" [ -z "$DEST" ] && MISSING="$MISSING --dest" if [ -n "$MISSING" ]; then echo "[FAIL] 缺少必填参数:$MISSING" >&2 usage >&2 exit 2 fi # KEEP 数值校验 if ! [[ "$KEEP" =~ ^[0-9]+$ ]] || [ "$KEEP" -lt 1 ]; then echo "[FAIL] --keep 必须是 >=1 的整数,得到: $KEEP" >&2 exit 2 fi RSYNC_FLAGS=(-a --delete --info=stats1) LINK_DEST_OPT="" if [ "$DRY_RUN" -eq 1 ]; then RSYNC_FLAGS+=(-n) echo "[MODE] DRY-RUN(不实际传输/删除)" fi TODAY="$(date +%Y%m%d)" SNAP="$DEST/$TODAY" PREV="$(ls -1 "$DEST" 2>/dev/null | grep -E '^[0-9]{8}$' | sort | tail -1 || true)" echo "=== VPS→NAS 备份 ===" echo " host = $HOST" echo " paths = $PATHS" echo " dest = $DEST" echo " snap = $SNAP" echo " keep = $KEEP" echo " prev = ${PREV:-(无,首份快照)}" echo " dryrun = $DRY_RUN" echo # ---------------------------------------------------------------------------- # Preflight:源 host 可达性 # host 别名可能还不存在(生产拓扑未就绪)——给清晰提示而非晦涩的 ssh 报错。 # ---------------------------------------------------------------------------- echo "[preflight] 检查源 host 可达: $HOST" if ! ssh -o BatchMode=yes -o ConnectTimeout=8 -o StrictHostKeyChecking=accept-new \ "$HOST" 'echo OK' >/dev/null 2>&1; then cat >&2 < User IdentityFile ~/.ssh/<生产 key> 2) key 是否免密?本脚本用 BatchMode(绝不交互输密码),须 key 认证。 3) 网络/防火墙是否放通 ssh 端口。 4) 先手动跑一次确认: ssh $HOST echo OK EOF exit 1 fi echo "[preflight] OK: $HOST 可达" echo # ---------------------------------------------------------------------------- # 备份根目录 # ---------------------------------------------------------------------------- mkdir -p "$SNAP" # --link-dest 指向上份快照(硬链接未变文件,省盘)。dry-run 下也安全。 if [ -n "$PREV" ] && [ -d "$DEST/$PREV" ]; then LINK_DEST_OPT="--link-dest=$DEST/$PREV" echo "[link-dest] 硬链接基准: $DEST/$PREV" fi echo # ---------------------------------------------------------------------------- # 逐路径 rsync 拉取(生产 → NAS 快照) # ---------------------------------------------------------------------------- # 把逗号分隔的 paths 转成数组(去空段) IFS=',' read -ra PATH_ARR <<< "$PATHS" for p in "${PATH_ARR[@]}"; do p="${p%/}" # 去尾斜杠,保证语义一致 [ -z "$p" ] && continue name="$(basename "$p")" target="$SNAP/$name" mkdir -p "$target" echo "[rsync] $HOST:$p/ → $target/" # shellcheck disable=SC2086 # LINK_DEST_OPT 需要按词拆分 rsync "${RSYNC_FLAGS[@]}" $LINK_DEST_OPT \ "$HOST:$p/" "$target/" \ || { echo "[FAIL] rsync 失败: $HOST:$p" >&2; exit 1; } echo "[rsync] done: $name" echo done # ---------------------------------------------------------------------------- # 保留策略:留最近 N 份,旧的删 # 只识别 YYYYMMDD 命名的子目录,绝不碰其他名字(防止误删 dest 下非快照内容)。 # ---------------------------------------------------------------------------- echo "[retention] 保留最近 $KEEP 份快照" mapfile -t SNAPSHOTS < <(ls -1 "$DEST" 2>/dev/null | grep -E '^[0-9]{8}$' | sort -r || true) DELETED=0 idx=0 for s in "${SNAPSHOTS[@]}"; do idx=$((idx + 1)) if [ "$idx" -le "$KEEP" ]; then echo " [keep] $s" else if [ "$DRY_RUN" -eq 1 ]; then echo " [would-delete] $s" else rm -rf "${DEST:?}/$s" && echo " [deleted] $s" fi DELETED=$((DELETED + 1)) fi done echo "[retention] 本次删除/拟删: $DELETED" echo echo "=== 备份完成 ===" echo " 快照: $SNAP" [ "$DRY_RUN" -eq 1 ] && echo " (dry-run,未实际写入/删除)"