commit 3875d6701f9fd197c28effacf48d3521ac59c9c1 Author: Codex Date: Mon Mar 16 15:53:38 2026 +0800 Initial backup diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..4c08b6c --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +env.conf +backup.log diff --git a/backup.sh b/backup.sh new file mode 100755 index 0000000..3b3bd3a --- /dev/null +++ b/backup.sh @@ -0,0 +1,466 @@ +#!/usr/bin/env bash +# 备份并上传到华为云 OBS +# - 从同目录 env.conf 加载配置,支持命令行覆盖 +# - 打包多目录(支持 pigz),生成 SHA256 校验,obsutil 分片/并发上传 +# - 上传 .tar.gz 以及 .tar.gz.sha256,并下载远端 .sha256 比对确保完整性 +# - 成功/失败写入日志并 Telegram 通知(Markdown) +# - 支持 --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() { + local level="$1"; shift + local line + line="$(log_ts) ${LOG_PREFIX}[$level] $*" + 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-}" # 详细错误日志(可选) + + 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 + tail_log="$(tail -n 80 "${LOG_FILE:-./backup.log}" 2>/dev/null || true)" + send_telegram "error" "脚本异常退出(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" + return 1 + } +} + +# 通用重试(指数回退 2^n 秒) +retry() { + local max="$1"; shift + local desc="$1"; shift + local n=1 + until "$@"; do + local ec=$? + if (( n >= max )); then + log "ERROR" "重试用尽:${desc}(exit=$ec)" + return "$ec" + fi + local backoff=$((2**n)) + log "WARN" "步骤失败,将在 ${backoff}s 后第 $((n+1)) 次重试:${desc}(exit=$ec)" + sleep "$backoff" + ((n++)) + done +} + +############################# +# 加载配置与参数 +############################# + +CONF_FILE="${SCRIPT_DIR}/env.conf" +if [[ ! -f "$CONF_FILE" ]]; then + echo "未找到配置文件 ${CONF_FILE},请先复制/编辑 env.conf" >&2 + exit 2 +fi + +# shellcheck disable=SC1090 +source "$CONF_FILE" + +# 默认参数值(可被命令行覆盖) +KEEP_LOCAL=false +DRY_RUN=false +VERIFY_ONLY=false +CLI_LABEL="" +CLI_SSE="none" # none|kms|aes256 +CLI_CONCURRENCY="" # 映射到 -j +CLI_RATE="" # 速率限制(示例 10MB/s) +GPG_RECIPIENT="" +GPG_SYM=false + +usage() { + cat <<'USAGE' +用法: + ./backup.sh [--dry-run] [--verify] [--keep-local] + [--concurrency=N] [--rate=10MB/s] + [--label=NAME] + [--sse=none|kms|aes256] + [--gpg-recipient=KMS_OR_PGP_ID | --gpg-symmetric] +USAGE +} + +# 解析参数 +while [[ $# -gt 0 ]]; do + case "$1" in + --dry-run) DRY_RUN=true; shift;; + --verify) VERIFY_ONLY=true; shift;; + --keep-local) KEEP_LOCAL=true; shift;; + --concurrency=*) CLI_CONCURRENCY="${1#*=}"; shift;; + --rate=*) CLI_RATE="${1#*=}"; shift;; + --label=*) CLI_LABEL="${1#*=}"; shift;; + --sse=*) CLI_SSE="${1#*=}"; shift;; + --gpg-recipient=*) GPG_RECIPIENT="${1#*=}"; shift;; + --gpg-symmetric) GPG_SYM=true; shift;; + -h|--help) usage; exit 0;; + *) echo "未知参数:$1"; usage; exit 2;; + 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" + +# 基础校验 +: "${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}" + +############################# +# 依赖检查与自检 +############################# +need_cmd tar +need_cmd sha256sum +need_cmd curl +need_cmd bash + +if ! command -v obsutil >/dev/null 2>&1; then + log "ERROR" "未找到 obsutil。请下载 obsutil,并确保其在 PATH 或与脚本同目录。" + log "ERROR" "首次使用请执行:./obsutil config -interactive" + exit 2 +fi + +# 严格按要求:配置文件位于 ~/.obsutilconfig +OBSUTIL_CONFIG_PATH="${HOME}/.obsutilconfig" +if [[ ! -f "$OBSUTIL_CONFIG_PATH" ]]; then + log "ERROR" "未检测到 obsutil 配置文件:${OBSUTIL_CONFIG_PATH}" + log "ERROR" "请先执行:./obsutil config -interactive 完成初始化(AK/SK、Endpoint、默认桶)" + exit 2 +fi + +############################# +# verify-only: 远端可用性检查 +############################# +if $VERIFY_ONLY; then + log "INFO" "开始远端可用性检查(ls + stat)" + set +e + obsutil ls "obs://${OBS_BUCKET}/${OBS_BACKUP_DIR}/" > /dev/null 2>&1 + rc_ls=$? + if (( rc_ls != 0 )); then + log "ERROR" "obsutil 无法列举 obs://${OBS_BUCKET}/${OBS_BACKUP_DIR}/" + exit 5 + fi + one_obj="$(obsutil ls "obs://${OBS_BUCKET}/${OBS_BACKUP_DIR}/" 2>/dev/null | awk '/obs:\/\//{print $1; exit}')" + if [[ -n "$one_obj" ]]; then + obsutil stat "$one_obj" >/dev/null 2>&1 || { log "ERROR" "stat 失败:$one_obj"; exit 5; } + log "INFO" "远端可访问,示例对象 stat 正常:$one_obj" + else + log "INFO" "远端可访问,但当前前缀下没有对象。" + fi + exit 0 +fi +set -e + +############################# +# 打包准备 +############################# + +# 组装要打包的 -C 与相对路径参数 +build_tar_file_list() { + local args=() + for p in $BACKUP_DIR; 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")" + args+=("-C" "$parent" "$base") + done + printf '%s\n' "${args[@]}" +} + +# 粗略空间检查 +check_space() { + local total=0 + for p in $BACKUP_DIR; do + [[ -e "$p" ]] || continue + local sz + sz=$(du -sb --apparent-size "$p" 2>/dev/null | awk '{s+=$1} END{print s+0}') + total=$((total + sz)) + done + local avail + avail=$(df -P "$SCRIPT_DIR" | awk 'NR==2{print $4}') # KB + avail=$((avail * 1024)) + local need=$(( total + total/10 + 100*1024*1024 )) + if (( avail < need )); then + log "ERROR" "磁盘空间不足。需要约 $need 字节,可用 $avail 字节。" + return 1 + fi +} + +log "INFO" "开始备份:bucket=${OBS_BUCKET} prefix=${OBS_BACKUP_DIR} 并发(-j)=${OBS_JOBS} 分片(-p)=${OBS_PARALLEL}" +check_space || { log "ERROR" "空间检查未通过"; exit 3; } + +TS="$(date +%Y%m%d_%H%M%S)" +ARCHIVE_BASE="backup_${TS}.tar.gz" +ARCHIVE_PATH="${SCRIPT_DIR}/${ARCHIVE_BASE}" +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) + if [[ "${#tar_args[@]}" -eq 0 ]]; then + log "ERROR" "没有可打包的路径(BACKUP_DIR 均不存在)" + return 1 + fi + + log "INFO" "开始打包(支持 exclude.list),pigz=${USE_PIGZ}" + if $USE_PIGZ; then + tar --warning=no-file-changed --exclude-ignore="$EXCLUDE_FILE" --exclude-from="$EXCLUDE_FILE" \ + -c "${tar_args[@]}" | pigz > "$ARCHIVE_PATH" + else + tar --warning=no-file-changed --exclude-ignore="$EXCLUDE_FILE" --exclude-from="$EXCLUDE_FILE" \ + -czf "$ARCHIVE_PATH" "${tar_args[@]}" + fi + local end; end=$(date +%s) + PACK_SECONDS=$((end - start)) + PACK_SIZE=$(stat -c '%s' "$ARCHIVE_PATH") + log "INFO" "打包完成:$(numfmt --to=iec "$PACK_SIZE") 用时 ${PACK_SECONDS}s" +} + +# 可选:GPG 加密 +maybe_encrypt() { + if [[ -n "$GPG_RECIPIENT" ]]; then + log "INFO" "使用 GPG 收件人加密:$GPG_RECIPIENT" + need_cmd gpg + gpg --batch --yes --output "${ARCHIVE_PATH}.gpg" --encrypt --recipient "$GPG_RECIPIENT" "$ARCHIVE_PATH" + rm -f "$ARCHIVE_PATH" + ARCHIVE_PATH="${ARCHIVE_PATH}.gpg" + ARCHIVE_BASE="${ARCHIVE_BASE}.gpg" + elif $GPG_SYM; then + log "INFO" "使用 GPG 对称加密" + need_cmd gpg + if [[ -n "${GPG_PASSPHRASE:-}" ]]; then + gpg --batch --yes --passphrase "$GPG_PASSPHRASE" --symmetric --output "${ARCHIVE_PATH}.gpg" "$ARCHIVE_PATH" + else + gpg --batch --yes --symmetric --output "${ARCHIVE_PATH}.gpg" "$ARCHIVE_PATH" + fi + rm -f "$ARCHIVE_PATH" + ARCHIVE_PATH="${ARCHIVE_PATH}.gpg" + ARCHIVE_BASE="${ARCHIVE_BASE}.gpg" + fi +} + +# 生成 sha256 +gen_sha() { + sha256sum "$ARCHIVE_PATH" > "$SHA_PATH" + log "INFO" "已生成校验文件:${SHA_PATH##*/}" +} + +# 构建 obsutil 参数 +build_obs_flags() { + local flags=() + flags+=("-f") + flags+=("-p=${OBS_PARALLEL}") + flags+=("-j=${OBS_JOBS}") + flags+=("-ps=${OBS_PARTSIZE}") + flags+=("-threshold=${OBS_THRESHOLD}") + [[ -n "$OBS_RATE_FLAG" ]] && flags+=("$OBS_RATE_FLAG") + case "$CLI_SSE" in + aes256|AES256) flags+=("-h=x-obs-server-side-encryption:AES256");; + kms|KMS) + if [[ -z "${OBS_KMS_KEY_ID:-}" ]]; then + log "WARN" "已选择 SSE-KMS,但未设置 OBS_KMS_KEY_ID,将只设置 kms 算法头。" + flags+=("-h=x-obs-server-side-encryption:kms") + else + flags+=("-h=x-obs-server-side-encryption:kms") + flags+=("-h=x-obs-server-side-encryption-kms-key-id:${OBS_KMS_KEY_ID}") + fi + ;; + none|None|"") ;; + *) log "WARN" "未知 --sse=$CLI_SSE,忽略。";; + esac + 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) + 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) + UP_SECONDS=$((end - start)) + if (( UP_SECONDS > 0 )); then + AVG_SPEED="$(( PACK_SIZE / UP_SECONDS ))" + else + AVG_SPEED="$PACK_SIZE" + fi +} + +# 远端完整性校验(下载 .sha256 比对) +verify_remote() { + local tmp_remote="${SHA_PATH}.remote" + rm -f "$tmp_remote" + local 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 与本地不一致" + return 1 + fi + rm -f "$tmp_remote" + log "INFO" "远端完整性校验通过(.sha256 一致)" +} + +############################# +# 主流程 +############################# + +SECONDS=0 +PACK_SECONDS=0 +UP_SECONDS=0 +AVG_SPEED=0 +PACK_SIZE=0 +BACKUP_SIZE_HUMAN="" + +log "INFO" "准备开始备份。DRY_RUN=${DRY_RUN} KEEP_LOCAL=${KEEP_LOCAL} SSE=${CLI_SSE}" + +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" + exit 0 +fi + +# 上传 +retry 3 "上传到 OBS" upload || { log "ERROR" "上传失败"; exit 4; } + +# 完整性校验 +if ! verify_remote; then + 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" + 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" + +exit 0 diff --git a/clean_backups.sh b/clean_backups.sh new file mode 100644 index 0000000..5c512e2 --- /dev/null +++ b/clean_backups.sh @@ -0,0 +1,198 @@ +#!/usr/bin/env bash +# 清理 OBS 历史备份:按文件名前缀与日期,仅保留最近 N 天 +# - 从同目录 env.conf 加载配置;支持 --retain-days / --prefix 覆盖 +# - 删除匹配 backup_YYYYmmdd_HHMMSS.tar.gz[.gpg] 以及对应 .sha256 +# - 记录日志并发送 Telegram 摘要(Markdown) +# 退出码:0 成功;2 配置缺失/依赖缺失;6 清理失败 + +set -Eeuo pipefail + +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="[CLEAN]" +log() { + local level="$1"; shift + local line + line="$(log_ts) ${LOG_PREFIX}[$level] $*" + echo "$line" | tee -a "${LOG_FILE:-./backup.log}" +} + +############################# +# Telegram 通知(Markdown) +############################# +send_telegram() { + local status="${1-}" # success | error + local reason="${2-}" # 错误原因(可选) + local error_log="${3-}" # 详细错误日志(可选) + + 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+="前缀:obs://${OBS_BUCKET}/${TARGET_PREFIX}\n" + message+="保留天数:${RETAIN_DAYS}\n" + message+="保留对象数:${#TO_KEEP[@]}\n" + message+="删除成功:${DEL_OK}\n" + message+="删除失败:${DEL_FAIL}" + 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 +} + +on_error() { + local exit_code=$? + log "ERROR" "清理异常退出(exit=${exit_code})" + local tail_log; tail_log="$(tail -n 80 "${LOG_FILE:-./backup.log}" 2>/dev/null || true)" + send_telegram "error" "脚本异常退出(exit=${exit_code})" "$tail_log" + exit "$exit_code" +} +trap on_error ERR + +need_cmd() { + command -v "$1" >/dev/null 2>&1 || { + log "ERROR" "未找到命令:$1" + return 1 + } +} + +CONF_FILE="${SCRIPT_DIR}/env.conf" +[[ -f "$CONF_FILE" ]] || { echo "缺少配置 ${CONF_FILE}" >&2; exit 2; } +# shellcheck disable=SC1090 +source "$CONF_FILE" + +: "${OBS_BUCKET:?缺少 OBS_BUCKET}" +: "${OBS_BACKUP_DIR:?缺少 OBS_BACKUP_DIR}" +: "${RETAIN_DAYS:?缺少 RETAIN_DAYS}" +: "${LOG_FILE:?缺少 LOG_FILE}" + +need_cmd obsutil + +# 严格按要求:配置文件位于 ~/.obsutilconfig +OBSUTIL_CONFIG_PATH="${HOME}/.obsutilconfig" +if [[ ! -f "$OBSUTIL_CONFIG_PATH" ]]; then + log "ERROR" "未检测到 obsutil 配置文件:${OBSUTIL_CONFIG_PATH}" + log "ERROR" "请先执行:./obsutil config -interactive 完成初始化(AK/SK、Endpoint、默认桶)" + exit 2 +fi + +# 覆盖参数 +CLI_RETAIN="" +CLI_PREFIX="" +usage() { + cat <<'USAGE' +用法: + ./clean_backups.sh [--retain-days=N] [--prefix=SUBDIR] +USAGE +} +while [[ $# -gt 0 ]]; do + case "$1" in + --retain-days=*) CLI_RETAIN="${1#*=}"; shift;; + --prefix=*) CLI_PREFIX="${1#*=}"; shift;; + -h|--help) usage; exit 0;; + *) echo "未知参数:$1"; usage; exit 2;; + esac +done + +if [[ -n "$CLI_RETAIN" ]]; then RETAIN_DAYS="$CLI_RETAIN"; fi +TARGET_PREFIX="${OBS_BACKUP_DIR%/}/" +if [[ -n "$CLI_PREFIX" ]]; then TARGET_PREFIX="${TARGET_PREFIX}${CLI_PREFIX%/}/"; fi + +log "INFO" "开始清理:bucket=${OBS_BUCKET} prefix=${TARGET_PREFIX} 保留最近 ${RETAIN_DAYS} 天" + +# 计算截止日期(N 天前零点,格式 YYYYmmdd) +CUTOFF_DATE="$(date -d "-${RETAIN_DAYS} days" +%Y%m%d)" + +# 列举对象 +LIST_OUT="$(mktemp)" +if ! obsutil ls "obs://${OBS_BUCKET}/${TARGET_PREFIX}" >"$LIST_OUT" 2>/dev/null; then + log "ERROR" "无法列举 obs://${OBS_BUCKET}/${TARGET_PREFIX}" + rm -f "$LIST_OUT" + send_telegram "error" "无法列举 obs://${OBS_BUCKET}/${TARGET_PREFIX}" "" + exit 6 +fi + +mapfile -t OBJECTS < <(awk '{for(i=1;i<=NF;i++){if($i~/^obs:\/\//)print $i}}' "$LIST_OUT") +rm -f "$LIST_OUT" + +TO_DELETE=() +TO_KEEP=() + +for obj in "${OBJECTS[@]}"; do + fname="${obj##*/}" + if [[ "$fname" =~ ^backup_([0-9]{8})_[0-9]{6}\.tar\.gz(\.gpg)?(\.sha256)?$ ]]; then + day="${BASH_REMATCH[1]}" + if [[ "$day" -lt "$CUTOFF_DATE" ]]; then + TO_DELETE+=("$obj") + if [[ ! "$fname" =~ \.sha256$ ]]; then + TO_DELETE+=("${obj}.sha256") + fi + else + TO_KEEP+=("$obj") + fi + fi +done + +# 去重 +if [[ "${#TO_DELETE[@]}" -gt 0 ]]; then + mapfile -t TO_DELETE < <(printf '%s\n' "${TO_DELETE[@]}" | awk '!a[$0]++') +fi + +DEL_OK=0 +DEL_FAIL=0 + +if [[ "${#TO_DELETE[@]}" -eq 0 ]]; then + log "INFO" "没有过期对象需要清理。" +else + log "INFO" "准备删除 ${#TO_DELETE[@]} 个对象(含 sha256)" + for o in "${TO_DELETE[@]}"; do + if obsutil rm "$o" -f >/dev/null 2>&1; then + ((DEL_OK++)) + log "INFO" "删除:$o" + else + ((DEL_FAIL++)) + log "WARN" "删除失败:$o" + fi + done +fi + +# 通知与退出码 +send_telegram "success" +if (( DEL_FAIL > 0 )); then + exit 6 +fi +exit 0 diff --git a/env.conf.example b/env.conf.example new file mode 100644 index 0000000..7b183f3 --- /dev/null +++ b/env.conf.example @@ -0,0 +1,23 @@ +# OBS 配置 +OBS_BUCKET="your-bucket-name" # 你的桶名 +LABEL="your-host-or-service" # 机器/业务标识 +OBS_BACKUP_DIR="backup/${LABEL}" # 备份存放目录 + +# Telegram 通知配置 +TG_BOT_TOKEN="" # 留空表示不启用 +TG_USER_ID="" # 留空表示不启用 + +# 备份目录,支持多个目录,用空格分隔 +BACKUP_DIR="/root/data" + +# 日志目录 +LOG_FILE="./backup.log" + +# 并发/分片(obsutil 上传) +OBS_PARALLEL="5" # -p(分片并发,单文件) +OBS_JOBS="5" # -j(目录内任务并发) +OBS_PARTSIZE="auto" # -ps(分片大小:数值或 auto) +OBS_THRESHOLD="50MB" # -threshold(多段上传阈值) + +# 清理策略 +RETAIN_DAYS="5" # 保留最近 N 天 diff --git a/exclude.list b/exclude.list new file mode 100644 index 0000000..cbf9553 --- /dev/null +++ b/exclude.list @@ -0,0 +1,24 @@ +# 常见不需要纳入备份的临时/缓存/日志等 +*.log +*.log.* +*.tmp +*.swp +*.swo +.cache/ +.npm/_cacache/ +node_modules/ +tmp/ +temp/ +__pycache__/ +.DS_Store +Thumbs.db +*.pid +*.sock +*.lock +.idea/ +.vscode/ +*.o +*.class +target/ +build/ +dist/ \ No newline at end of file diff --git a/obsutil b/obsutil new file mode 100755 index 0000000..b2853bc Binary files /dev/null and b/obsutil differ diff --git a/readme.md b/readme.md new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/readme.md @@ -0,0 +1 @@ +