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 "备份完成。"