Debian 12 目录备份脚本支持流式压缩与 ntfy 通知

Debian 12 下的稳健型目录备份脚本,支持流式压缩、自动轮转保留和 ntfy 推送通知
2025-11-14
5088 字 · 约 13 分钟阅读

Linux 服务器本地备份脚本,实现了一个稳健型目录备份工具,支持按日期命名压缩备份、自动轮转保留指定个数的历史备份、并通过 ntfy 服务推送备份成功/失败的通知。

实现了效果如下:

# 备份 /docker 目录到 /mnt/sda/backup/docker,保留最近 3 个备份
/bin/bash backup.sh /docker /mnt/sda/backup/docker 3

# 检查备份结果
ll /mnt/sda/backup 
total 20G
-rw-r--r-- 1 root root 5.4K Nov 13 16:08 backup.sh
-rw-r--r-- 1 root root  20G Nov 14 11:32 docker-20251114.tar.gz

将脚本根据需要交由 cron 定时执行,即可实现按日期自动化备份。

脚本内容如下:

#!/usr/bin/env bash
# backup.sh - 稳健型目录备份脚本(Debian 12)
# 用法:./backup.sh <源目录> <备份前缀路径> <保留备份个数>

set -Euo pipefail
IFS=$' \t\n'

# ===== 配置 =====
NTFY_TOPIC_URL="https://ntfy.sh/your-topic"  # 替换为你的 ntfy 主题 URL
NTFY_SUCCESS_TITLE="✅ Backup OK"
NTFY_FAILURE_TITLE="❌ Backup FAILED"
NTFY_SUCCESS_TAGS="white_check_mark,floppy_disk"
NTFY_FAILURE_TAGS="rotating_light,skull"
NTFY_SUCCESS_PRIORITY="3"
NTFY_FAILURE_PRIORITY="5"

err()  { echo "[ERROR] $*" >&2; }
warn() { echo "[WARN ] $*" >&2; }
info() { echo "[INFO ] $*"; }
need_cmd() { command -v "$1" >/dev/null 2>&1 || { err "需要命令:$1"; exit 127; }; }

need_cmd tar; need_cmd gzip; need_cmd date; need_cmd du; need_cmd find; need_cmd sort; need_cmd awk
need_cmd mktemp; need_cmd hostname; need_cmd curl; need_cmd stat

# ===== 参数 =====
if [[ $# -ne 3 ]]; then
  err "用法:$0 <源目录> <备份前缀路径> <保留备份个数>"
  exit 2
fi
SRC_DIR="$1"
DST_PREFIX="$2"
KEEP="$3"

[[ -d "$SRC_DIR" ]] || { err "源目录不存在:$SRC_DIR"; exit 2; }

DST_DIR="$(dirname "$DST_PREFIX")"
FILE_PREFIX="$(basename "$DST_PREFIX")"

mkdir -p "$DST_DIR"

if ! [[ "$KEEP" =~ ^[0-9]+$ ]] || [[ "$KEEP" -lt 1 ]]; then
  err "保留备份个数必须为 >=1 的整数:$KEEP"
  exit 2
fi

HOST="$(hostname -s || echo unknown-host)"
DATE_STR="$(date +%F)"
DATE_TAG="$(date +%Y%m%d)"
START_ISO="$(date -Is)"
SECONDS=0

SRC_BASENAME="$(basename "$SRC_DIR")"

# ===== 文件名规则(按日期命名)=====
ARCHIVE_NAME="${FILE_PREFIX}-${DATE_TAG}.tar.gz"
ARCHIVE_PATH="$DST_DIR/$ARCHIVE_NAME"

send_ntfy() {
  curl -fsS -X POST "$NTFY_TOPIC_URL" \
    -H "Title: $1" -H "Priority: $2" -H "Tags: $3" \
    --data-binary @- >/dev/null
}

fail_and_notify() {
  local END_ISO="$(date -Is)"
  local BODY
  BODY="$(cat <<EOF
❌ 备份失败

🖥 主机: $HOST
📁 源: $SRC_DIR
📦 目标: $DST_PREFIX
⏱ 耗时: ${SECONDS}s

详见控制台输出
EOF
)"
  printf '%s\n' "$BODY" | send_ntfy "$NTFY_FAILURE_TITLE" "$NTFY_FAILURE_PRIORITY" "$NTFY_FAILURE_TAGS" || true
  exit 1
}

# ===== 信息(非致命)=====
SRC_SIZE_HUMAN="$(du -sh --apparent-size "$SRC_DIR" 2>/dev/null | awk '{print $1}')"
FILE_COUNT="$(find "$SRC_DIR" -type f 2>/dev/null | wc -l | awk '{print $1}')"

# ===== 流式压缩(不写 /tmp)=====
info "开始打包:$SRC_DIR -> $ARCHIVE_PATH"

PARENT_DIR="$(dirname "$SRC_DIR")"
BASE_DIR="$(basename "$SRC_DIR")"

pushd "$PARENT_DIR" >/dev/null

tar --xattrs --acls --one-file-system --numeric-owner \
    --warning=no-file-changed --ignore-failed-read \
    -cf - "$BASE_DIR" \
  | gzip -9n > "$ARCHIVE_PATH"

# 立即捕获 PIPESTATUS
declare -a PIPE_STATUS=( "${PIPESTATUS[@]}" )
TAR_RC=${PIPE_STATUS[0]:-1}
GZIP_RC=${PIPE_STATUS[1]:-1}

popd >/dev/null

if [[ $TAR_RC -ne 0 || $GZIP_RC -ne 0 ]]; then
  warn "tar/gzip 流式压缩失败:tar=$TAR_RC gzip=$GZIP_RC"
  rm -f "$ARCHIVE_PATH"
  fail_and_notify
fi

END_ISO="$(date -Is)"
ARCHIVE_SIZE_HUMAN="$(du -h "$ARCHIVE_PATH" | awk '{print $1}')"

# ===== 轮转 =====
info "执行备份轮转(保留 $KEEP 个)..."

readarray -d '' FILES < <(
  find "$DST_DIR" -maxdepth 1 -type f \
    -name "${FILE_PREFIX}-*.tar.gz" \
    -print0 2>/dev/null
)

TOTAL="${#FILES[@]}"

if (( TOTAL == 0 )); then
  info "未发现任何匹配备份。"
else
  TMP_LIST="$(mktemp)"
  for f in "${FILES[@]}"; do
    printf "%s\t%s\n" "$(stat -c %Y "$f" 2>/dev/null || echo 0)" "$f" >> "$TMP_LIST"
  done
  mapfile -t SORTED < <(sort -nr -k1,1 "$TMP_LIST" | awk -F'\t' '{print $2}')
  rm -f "$TMP_LIST"

  info "当前匹配到 $TOTAL 个备份:"
  for f in "${SORTED[@]}"; do
    ts="$(stat -c %y "$f" 2>/dev/null | awk '{print $1" "$2}')"
    printf "  - %s  %s\n" "$ts" "$f"
  done

  if (( TOTAL > KEEP )); then
    for (( i=KEEP; i<TOTAL; i++ )); do
      f="${SORTED[$i]}"
      info "删除旧备份:$f"
      rm -f -- "$f" || warn "删除失败:$f"
    done
  else
    info "无需删除:总数 $TOTAL <= 保留数 $KEEP。"
  fi
fi

# ===== 生成备份列表(最新3个)=====
get_backup_list() {
  readarray -d '' FILES < <(
    find "$DST_DIR" -maxdepth 1 -type f \
      -name "${FILE_PREFIX}-*.tar.gz" \
      -print0 2>/dev/null
  )
  
  TOTAL="${#FILES[@]}"
  if (( TOTAL == 0 )); then
    echo "无"
    return
  fi
  
  TMP_LIST="$(mktemp)"
  for f in "${FILES[@]}"; do
    printf "%s\t%s\n" "$(stat -c %Y "$f" 2>/dev/null || echo 0)" "$f" >> "$TMP_LIST"
  done
  mapfile -t SORTED < <(sort -nr -k1,1 "$TMP_LIST" | awk -F'\t' '{print $2}')
  rm -f "$TMP_LIST"
  
  # 最多显示3个
  local DISPLAY_COUNT=$((TOTAL < 3 ? TOTAL : 3))
  for (( i=0; i<DISPLAY_COUNT; i++ )); do
    local f="${SORTED[$i]}"
    local size="$(du -h "$f" | awk '{print $1}')"
    local basename_f="$(basename "$f")"
    echo "  ├─ $basename_f [$size]"
  done
}

# ===== 报告 =====
DURATION_SEC="$SECONDS"
BACKUP_LIST="$(get_backup_list)"

REPORT="$(cat <<EOF
✅ 备份成功

🖥 主机: $HOST
📁 源: $SRC_DIR
  └─ 大小: $SRC_SIZE_HUMAN | 文件数: $FILE_COUNT

📦 备份文件: $(basename "$ARCHIVE_PATH")
  └─ 大小: $ARCHIVE_SIZE_HUMAN

📚 当前备份(最近3个):
$BACKUP_LIST

⏱ 开始: $START_ISO
   结束: $END_ISO
   耗时: ${DURATION_SEC}s

🔄 保留策略: 保留最近 $KEEP 个备份
EOF
)"
printf '%s\n' "$REPORT"

printf '%s\n' "$REPORT" | send_ntfy "$NTFY_SUCCESS_TITLE" "$NTFY_SUCCESS_PRIORITY" "$NTFY_SUCCESS_TAGS" || warn "ntfy 推送失败(成功报告)"

info "备份完成。"

留言

发表留言