Refactor backup config and Telegram notifications

This commit is contained in:
Codex
2026-03-16 17:30:55 +08:00
parent 51c8c37c69
commit d4b69f10f9
8 changed files with 293 additions and 254 deletions
+77 -150
View File
@@ -3,24 +3,19 @@
# - 从同目录 env.conf 加载配置,支持命令行覆盖
# - 打包多目录(支持 pigz),生成 SHA256 校验,obsutil 分片/并发上传
# - 上传 .tar.gz 以及 .tar.gz.sha256,并下载远端 .sha256 比对确保完整性
# - 成功/失败写入日志并 Telegram 通知Markdown
# - 成功/失败写入日志并 Telegram 通知
# - 支持 --dry-run / --verify / --keep-local / --concurrency / --rate / --label / --sse
# 退出码:0 成功;2 配置缺失;3 打包失败;4 上传失败;5 校验失败
set -Eeuo pipefail
#############################
# 通用工具函数
#############################
# 兼容性更好的脚本路径解析(在 set -u 下也安全)
SCRIPT_PATH="${BASH_SOURCE[0]:-$0}"
SCRIPT_DIR="$(cd "$(dirname -- "$SCRIPT_PATH")" && pwd -P)"
export PATH="$SCRIPT_DIR:$PATH"
cd "$SCRIPT_DIR"
# 记录日志到文件与终端
log_ts() { date '+%Y-%m-%d %H:%M:%S'; }
LOG_PREFIX="[BACKUP]"
log_ts() { date '+%Y-%m-%d %H:%M:%S'; }
log() {
local level="$1"; shift
local line
@@ -28,82 +23,20 @@ log() {
echo "$line" | tee -a "${LOG_FILE:-./backup.log}"
}
#############################
# Telegram 通知(Markdown
#############################
send_telegram() {
local status="${1-}" # success | error | dryrun
local reason="${2-}" # 错误原因(可选)
local error_log="${3-}" # 详细错误日志(可选)
# shellcheck disable=SC1091
source "${SCRIPT_DIR}/lib/telegram.sh"
if [[ -z "${TG_BOT_TOKEN:-}" || -z "${TG_USER_ID:-}" ]]; then
log "INFO" "未配置Telegram通知,跳过发送"
return 0
fi
local api="https://api.telegram.org/bot${TG_BOT_TOKEN}/sendMessage"
local current_date; current_date="$(date +'%Y-%m-%d %H:%M:%S')"
local message
if [[ "$status" == "success" ]]; then
message="*备份成功通知*\n\n"
message+="备份主机:${LABEL}\n"
message+="备份时间:${current_date}\n"
message+="备份内容:${BACKUP_DIR}\n"
message+="文件大小:${BACKUP_SIZE_HUMAN}\n"
message+="存储位置:obs://${OBS_BUCKET}/${OBS_BACKUP_DIR}/${ARCHIVE_BASE}\n"
message+="打包用时:${PACK_SECONDS}s\n"
message+="上传用时:${UP_SECONDS}s\n"
message+="平均速率:${SPEED_HUMAN}"
elif [[ "$status" == "dryrun" ]]; then
message="*备份自检(Dry-run*\n\n"
message+="备份主机:${LABEL}\n"
message+="备份时间:${current_date}\n"
message+="备份内容:${BACKUP_DIR}\n"
message+="归档名称:${ARCHIVE_BASE}\n"
message+="文件大小:${BACKUP_SIZE_HUMAN}\n"
message+="打包用时:${PACK_SECONDS}s"
else
message="*备份失败通知*\n\n${LABEL} 备份失败!"
if [[ -n "$reason" ]]; then
message+="\n原因:${reason}"
fi
if [[ -n "$error_log" ]]; then
message+="\n错误日志:\n\`\`\`${error_log}\`\`\`"
fi
fi
# 仅把字面 \n 转成 %0A;保留其他字符原样
local encoded_message=${message//\\n/%0A}
local response
response="$(curl -sS -X POST "$api" \
-d "chat_id=${TG_USER_ID}" \
-d "parse_mode=Markdown" \
-d "disable_web_page_preview=true" \
-d "text=${encoded_message}" 2>&1 || true)"
if echo "$response" | grep -q '"ok":true'; then
log "INFO" "Telegram通知发送成功"
else
log "WARN" "Telegram通知发送失败: $response"
fi
}
# 失败统一处理(触发 trap
on_error() {
local exit_code=$?
local line_no=${BASH_LINENO[0]:-}
log "ERROR" "脚本异常退出(exit=$exit_code, line=${line_no})."
local tail_log
log "ERROR" "脚本异常退出(exit=$exit_code, line=${line_no})."
tail_log="$(tail -n 80 "${LOG_FILE:-./backup.log}" 2>/dev/null || true)"
send_telegram "error" "脚本异常退出(exit=${exit_code}, line=${line_no}" "$tail_log"
tg_backup_error "$LABEL" "$(date +'%Y-%m-%d %H:%M:%S')" "脚本异常退出(exit=${exit_code}, line=${line_no}" "$tail_log"
exit "$exit_code"
}
trap on_error ERR
# 命令可用性检查
need_cmd() {
command -v "$1" >/dev/null 2>&1 || {
log "ERROR" "未找到命令:$1"
@@ -111,7 +44,6 @@ need_cmd() {
}
}
# 通用重试(指数回退 2^n 秒)
retry() {
local max="$1"; shift
local desc="$1"; shift
@@ -129,10 +61,6 @@ retry() {
done
}
#############################
# 加载配置与参数
#############################
CONF_FILE="${SCRIPT_DIR}/env.conf"
if [[ ! -f "$CONF_FILE" ]]; then
echo "未找到配置文件 ${CONF_FILE},请先复制/编辑 env.conf" >&2
@@ -142,14 +70,15 @@ fi
# shellcheck disable=SC1090
source "$CONF_FILE"
# 默认参数值(可被命令行覆盖)
BACKUP_PATHS=()
KEEP_LOCAL=false
DRY_RUN=false
VERIFY_ONLY=false
CLI_LABEL=""
CLI_SSE="none" # none|kms|aes256
CLI_CONCURRENCY="" # 映射到 -j
CLI_RATE="" # 速率限制(示例 10MB/s
CLI_SSE="none"
CLI_CONCURRENCY=""
CLI_RATE=""
GPG_RECIPIENT=""
GPG_SYM=false
@@ -164,7 +93,6 @@ usage() {
USAGE
}
# 解析参数
while [[ $# -gt 0 ]]; do
case "$1" in
--dry-run) DRY_RUN=true; shift;;
@@ -181,39 +109,52 @@ while [[ $# -gt 0 ]]; do
esac
done
# 应用 label 覆盖(仅影响远端前缀,不改变本地)
if [[ -n "$CLI_LABEL" ]]; then
LABEL="$CLI_LABEL"
OBS_BACKUP_DIR="backup/${LABEL}"
fi
# 并发覆盖
if [[ -n "$CLI_CONCURRENCY" ]]; then
OBS_JOBS="$CLI_CONCURRENCY"
fi
# 速率(obsutil 具体参数随版本可能不同,这里传递 --rate 或 -limit 的等价形式)
OBS_RATE_FLAG=""
if [[ -n "${CLI_RATE:-}" ]]; then
OBS_RATE_FLAG="--rate=${CLI_RATE}"
fi
# 路径与文件
EXCLUDE_FILE="${SCRIPT_DIR}/exclude.list"
[[ -f "$EXCLUDE_FILE" ]] || touch "$EXCLUDE_FILE"
DEFAULT_EXCLUDE_FILE="${SCRIPT_DIR}/exclude.list"
USER_EXCLUDE_FILE="${SCRIPT_DIR}/exclude.user.list"
[[ -f "$DEFAULT_EXCLUDE_FILE" ]] || touch "$DEFAULT_EXCLUDE_FILE"
[[ -f "$USER_EXCLUDE_FILE" ]] || cp -f "${SCRIPT_DIR}/exclude.user.list.example" "$USER_EXCLUDE_FILE"
# 基础校验
: "${OBS_BUCKET:?缺少 OBS_BUCKET}"
: "${OBS_BACKUP_DIR:?缺少 OBS_BACKUP_DIR}"
: "${BACKUP_DIR:?缺少 BACKUP_DIR}"
: "${LOG_FILE:?缺少 LOG_FILE}"
: "${OBS_PARALLEL:?缺少 OBS_PARALLEL}" "${OBS_JOBS:?缺少 OBS_JOBS}"
: "${OBS_PARTSIZE:?缺少 OBS_PARTSIZE}" "${OBS_THRESHOLD:?缺少 OBS_THRESHOLD}"
: "${RETAIN_DAYS:?缺少 RETAIN_DAYS}"
#############################
# 依赖检查与自检
#############################
if [[ -z "${BACKUP_DIRS:-}" && -n "${BACKUP_DIR:-}" ]]; then
BACKUP_DIRS="$(printf '%s\n' "$BACKUP_DIR")"
fi
if [[ -z "${BACKUP_DIRS:-}" ]]; then
echo "缺少 BACKUP_DIRS 配置" >&2
exit 2
fi
while IFS= read -r path; do
[[ -n "$path" ]] || continue
[[ "$path" =~ ^[[:space:]]*# ]] && continue
BACKUP_PATHS+=("$path")
done <<< "$BACKUP_DIRS"
if [[ "${#BACKUP_PATHS[@]}" -eq 0 ]]; then
echo "BACKUP_DIRS 没有有效目录" >&2
exit 2
fi
need_cmd tar
need_cmd sha256sum
need_cmd curl
@@ -225,7 +166,6 @@ if ! command -v obsutil >/dev/null 2>&1; then
exit 2
fi
# 严格按要求:配置文件位于 ~/.obsutilconfig
OBSUTIL_CONFIG_PATH="${HOME}/.obsutilconfig"
if [[ ! -f "$OBSUTIL_CONFIG_PATH" ]]; then
log "ERROR" "未检测到 obsutil 配置文件:${OBSUTIL_CONFIG_PATH}"
@@ -233,9 +173,6 @@ if [[ ! -f "$OBSUTIL_CONFIG_PATH" ]]; then
exit 2
fi
#############################
# verify-only: 远端可用性检查
#############################
if $VERIFY_ONLY; then
log "INFO" "开始远端可用性检查(ls + stat"
set +e
@@ -256,39 +193,33 @@ if $VERIFY_ONLY; then
fi
set -e
#############################
# 打包准备
#############################
# 组装要打包的 -C 与相对路径参数
build_tar_file_list() {
local args=()
for p in $BACKUP_DIR; do
local p abs parent base
for p in "${BACKUP_PATHS[@]}"; do
if [[ ! -e "$p" ]]; then
log "WARN" "路径不存在,跳过:$p"
continue
fi
local abs; abs="$(readlink -f "$p")"
local parent; parent="$(dirname "$abs")"
local base; base="$(basename "$abs")"
abs="$(readlink -f "$p")"
parent="$(dirname "$abs")"
base="$(basename "$abs")"
args+=("-C" "$parent" "$base")
done
printf '%s\n' "${args[@]}"
}
# 粗略空间检查
check_space() {
local total=0
for p in $BACKUP_DIR; do
local p sz avail need
for p in "${BACKUP_PATHS[@]}"; do
[[ -e "$p" ]] || continue
local sz
sz=$(du -sb --apparent-size "$p" 2>/dev/null | awk '{s+=$1} END{print s+0}')
sz="$(du -sb --apparent-size "$p" 2>/dev/null | cut -f1)"
total=$((total + sz))
done
local avail
avail=$(df -P "$SCRIPT_DIR" | awk 'NR==2{print $4}') # KB
avail=$(df -P "$SCRIPT_DIR" | awk 'NR==2{print $4}')
avail=$((avail * 1024))
local need=$(( total + total/10 + 100*1024*1024 ))
need=$(( total + total/10 + 100*1024*1024 ))
if (( avail < need )); then
log "ERROR" "磁盘空间不足。需要约 $need 字节,可用 $avail 字节。"
return 1
@@ -305,30 +236,35 @@ SHA_PATH="${ARCHIVE_PATH}.sha256"
USE_PIGZ=false
if command -v pigz >/dev/null 2>&1; then USE_PIGZ=true; fi
# 打包与压缩
pack() {
local start; start=$(date +%s)
local tar_args; mapfile -t tar_args < <(build_tar_file_list)
local start end
local tar_args
start=$(date +%s)
mapfile -t tar_args < <(build_tar_file_list)
if [[ "${#tar_args[@]}" -eq 0 ]]; then
log "ERROR" "没有可打包的路径(BACKUP_DIR 均不存在)"
log "ERROR" "没有可打包的路径(BACKUP_DIRS 均不存在)"
return 1
fi
log "INFO" "开始打包(支持 exclude.list),pigz=${USE_PIGZ}"
log "INFO" "开始打包(支持默认与用户排除规则),pigz=${USE_PIGZ}"
if $USE_PIGZ; then
tar --warning=no-file-changed --exclude-from="$EXCLUDE_FILE" \
tar --warning=no-file-changed \
--exclude-from="$DEFAULT_EXCLUDE_FILE" \
--exclude-from="$USER_EXCLUDE_FILE" \
-c "${tar_args[@]}" | pigz > "$ARCHIVE_PATH"
else
tar --warning=no-file-changed --exclude-from="$EXCLUDE_FILE" \
tar --warning=no-file-changed \
--exclude-from="$DEFAULT_EXCLUDE_FILE" \
--exclude-from="$USER_EXCLUDE_FILE" \
-czf "$ARCHIVE_PATH" "${tar_args[@]}"
fi
local end; end=$(date +%s)
end=$(date +%s)
PACK_SECONDS=$((end - start))
PACK_SIZE=$(stat -c '%s' "$ARCHIVE_PATH")
log "INFO" "打包完成:$(numfmt --to=iec "$PACK_SIZE") 用时 ${PACK_SECONDS}s"
PACK_SIZE="$(stat -c '%s' "$ARCHIVE_PATH")"
BACKUP_SIZE_HUMAN="$(numfmt --to=iec "$PACK_SIZE")"
log "INFO" "打包完成:${BACKUP_SIZE_HUMAN} 用时 ${PACK_SECONDS}s"
}
# 可选:GPG 加密
maybe_encrypt() {
if [[ -n "$GPG_RECIPIENT" ]]; then
log "INFO" "使用 GPG 收件人加密:$GPG_RECIPIENT"
@@ -349,15 +285,16 @@ maybe_encrypt() {
ARCHIVE_PATH="${ARCHIVE_PATH}.gpg"
ARCHIVE_BASE="${ARCHIVE_BASE}.gpg"
fi
PACK_SIZE="$(stat -c '%s' "$ARCHIVE_PATH")"
BACKUP_SIZE_HUMAN="$(numfmt --to=iec "$PACK_SIZE")"
}
# 生成 sha256
gen_sha() {
sha256sum "$ARCHIVE_PATH" > "$SHA_PATH"
log "INFO" "已生成校验文件:${SHA_PATH##*/}"
}
# 构建 obsutil 参数
build_obs_flags() {
local flags=()
flags+=("-f")
@@ -383,14 +320,15 @@ build_obs_flags() {
printf '%s\n' "${flags[@]}"
}
# 上传(含重试)
upload() {
local start; start=$(date +%s)
local dst_dir="obs://${OBS_BUCKET}/${OBS_BACKUP_DIR}/"
local flags; mapfile -t flags < <(build_obs_flags)
local start end dst_dir
local flags
start=$(date +%s)
dst_dir="obs://${OBS_BUCKET}/${OBS_BACKUP_DIR}/"
mapfile -t flags < <(build_obs_flags)
retry 3 "上传归档 ${ARCHIVE_BASE}" obsutil cp "$ARCHIVE_PATH" "${dst_dir}${ARCHIVE_BASE}" "${flags[@]}"
retry 3 "上传校验 ${ARCHIVE_BASE}.sha256" obsutil cp "$SHA_PATH" "${dst_dir}${ARCHIVE_BASE}.sha256" "${flags[@]}"
local end; end=$(date +%s)
end=$(date +%s)
UP_SECONDS=$((end - start))
if (( UP_SECONDS > 0 )); then
AVG_SPEED="$(( PACK_SIZE / UP_SECONDS ))"
@@ -399,11 +337,11 @@ upload() {
fi
}
# 远端完整性校验(下载 .sha256 比对)
verify_remote() {
local tmp_remote="${SHA_PATH}.remote"
local tmp_remote dst
tmp_remote="${SHA_PATH}.remote"
rm -f "$tmp_remote"
local dst="obs://${OBS_BUCKET}/${OBS_BACKUP_DIR}/${ARCHIVE_BASE}.sha256"
dst="obs://${OBS_BUCKET}/${OBS_BACKUP_DIR}/${ARCHIVE_BASE}.sha256"
retry 3 "下载远端校验 ${ARCHIVE_BASE}.sha256" obsutil cp "$dst" "$tmp_remote" -f >/dev/null
if ! cmp -s "$SHA_PATH" "$tmp_remote"; then
log "ERROR" "远端 .sha256 与本地不一致"
@@ -413,10 +351,6 @@ verify_remote() {
log "INFO" "远端完整性校验通过(.sha256 一致)"
}
#############################
# 主流程
#############################
SECONDS=0
PACK_SECONDS=0
UP_SECONDS=0
@@ -430,37 +364,30 @@ retry 3 "打包与压缩" pack || { log "ERROR" "打包失败"; exit 3; }
maybe_encrypt
gen_sha
# 计算人类可读大小
BACKUP_SIZE_HUMAN="$(numfmt --to=iec "$PACK_SIZE")"
if $DRY_RUN; then
log "INFO" "Dry-run 模式:不执行上传。归档:${ARCHIVE_BASE} 大小:${BACKUP_SIZE_HUMAN}"
log "INFO" "本次总用时:${SECONDS}s"
send_telegram "dryrun"
tg_backup_dryrun "$LABEL" "$(date +'%Y-%m-%d %H:%M:%S')" "$(printf '%s\n' "${BACKUP_PATHS[@]}")" "$(tg_read_user_excludes "$USER_EXCLUDE_FILE")" "$ARCHIVE_BASE" "$BACKUP_SIZE_HUMAN" "${PACK_SECONDS}s"
exit 0
fi
# 上传
retry 3 "上传到 OBS" upload || { log "ERROR" "上传失败"; exit 4; }
# 完整性校验
if ! verify_remote; then
local_tail_log="$(tail -n 80 "${LOG_FILE:-./backup.log}" 2>/dev/null || true)"
log "ERROR" "上传后完整性校验失败"
local tail_log; tail_log="$(tail -n 80 "${LOG_FILE:-./backup.log}" 2>/dev/null || true)"
send_telegram "error" "上传后完整性校验失败:${ARCHIVE_BASE}" "$tail_log"
tg_backup_error "$LABEL" "$(date +'%Y-%m-%d %H:%M:%S')" "上传后完整性校验失败:${ARCHIVE_BASE}" "$local_tail_log"
exit 5
fi
# 清理本地
if ! $KEEP_LOCAL; then
rm -f "$ARCHIVE_PATH" "$SHA_PATH" || true
log "INFO" "已删除本地临时归档与校验文件"
fi
# 汇总
TOTAL_SECS=$SECONDS
SPEED_HUMAN="$(numfmt --to=iec "$AVG_SPEED")/s"
log "INFO" "完成:归档大小=${BACKUP_SIZE_HUMAN} 打包=${PACK_SECONDS}s 上传=${UP_SECONDS}s 平均速率=${SPEED_HUMAN} 总用时=${TOTAL_SECS}s"
send_telegram "success"
tg_backup_success "$LABEL" "$(date +'%Y-%m-%d %H:%M:%S')" "$(printf '%s\n' "${BACKUP_PATHS[@]}")" "$(tg_read_user_excludes "$USER_EXCLUDE_FILE")" "$BACKUP_SIZE_HUMAN" "$ARCHIVE_BASE" "obs://${OBS_BUCKET}/${OBS_BACKUP_DIR}/${ARCHIVE_BASE}" "${PACK_SECONDS}s" "${UP_SECONDS}s" "$SPEED_HUMAN"
exit 0