云服务器附件备份到本机内网服务器

1. 环境准备

1.1 安装 rsync

复制代码
yum install rsync -y

检查是否装好:简单验证两边 rsync 是否可用
rsync --version

1.2 ssh 免密登录

复制代码
本机生成密钥,推送公钥到远端,实现免密 ssh 登录
#本机生成密钥,一路回车
ssh‑keygen‑t ed25519
#推送公钥到远端服务器
ssh‑copy‑id root@远端IP
验证免密
ssh root@远端IP

1.3 目录规划

(我这边是在data目录下)

复制代码
备份数据目录:`/data/huaweiyun_backup/backup_files`
日志目录:`/data/huaweiyun_backup/logs`
脚本目录:`/data/huaweiyun_backup/scripts` 

1.4 脚本部署

复制代码
vi //huaweiyun_backup/scripts/huaweiyun_sync.sh 编辑

#!/bin/bash
# ===================== 配置区 =====================
ALI_IP="***.**.***.**"
ALI_USER="root"
DEST_LOCAL="/data/huaweiyun_backup/backup_files"
LOG_ROOT="/data/huaweiyun_backup/logs"

# 备份任务数组,格式:"远程源目录|本地目标目录"
# 在这里新增/删除任务,不限数量
BACKUP_TASKS=(
"/home/***/|${DEST_LOCAL}/***/"
"/home/***/|${DEST_LOCAL}/***/"
"/home/***/|${DEST_LOCAL}/***/"
)

SSH_CONN_TIMEOUT=120
SCRIPT_MAX_RUNTIME=86400   #脚本全局兜底 24小时,防止整体脚本无限僵死,按需调整
TASK_MAX_SEC=0             # =0 关闭单任务rsync时间限制;>0启用timeout限制
MAX_RETRY=3
RETRY_SLEEP=300             #失败后等待多久重试,单位秒
LOG_KEEP_DAYS=90            #时间戳日志目录保留90天
LOCK_FILE="/tmp/huaweiyun_backup.lock"
# ==================================================
export PATH=/usr/bin:/bin:/usr/sbin:/sbin

# 保存本脚本拉起的timeout/rsync pid,只杀这些,不影响系统其它rsync
RSYNC_PIDS=()

# 清理函数:仅精确清除本脚本派生的任务进程组
cleanup_child() {
    if [[ ${#RSYNC_PIDS[@]} -gt 0 ]]; then
        echo ">>> 收到终止信号,清理本脚本派生rsync子进程: ${RSYNC_PIDS[*]}" >> "${LOG_FILE:-/dev/null}"
        local self_pgid
        self_pgid=$(ps -o pgid= -p $$ 2>/dev/null | tr -d ' ')
        for pid in "${RSYNC_PIDS[@]}"; do
            if kill -0 "$pid" 2>/dev/null; then
                local pg
                pg=$(ps -o pgid= -p "$pid" 2>/dev/null | tr -d ' ')
                # timeout会为子命令创建独立进程组,按进程组kill可连rsync+ssh一起清掉
                if [[ -n "$pg" && "$pg" != "$self_pgid" ]]; then
                    kill -- -"$pg" 2>/dev/null
                else
                    kill "$pid" 2>/dev/null
                fi
                sleep 0.2
                if kill -0 "$pid" 2>/dev/null; then
                    kill -9 "$pid" 2>/dev/null
                    if [[ -n "$pg" && "$pg" != "$self_pgid" ]]; then
                        kill -9 -- -"$pg" 2>/dev/null
                    fi
                fi
            fi
        done
    fi
}

trap 'cleanup_child; exit 130' SIGINT SIGTERM

human_size() {
    local bytes=$1
    bytes=$(echo "$bytes" | tr -d ',')
    if [[ -z "$bytes" || "$bytes" == "0" ]];then
        echo "0B"
        return
    fi
    if [[ $bytes -lt 1024 ]]; then
        echo "${bytes}B"
    elif [[ $bytes -lt 1048576 ]]; then
        printf "%.2fK" $(echo "$bytes / 1024" | bc -l)
    elif [[ $bytes -lt 1073741824 ]]; then
        printf "%.2fM" $(echo "$bytes / 1048576" | bc -l)
    else
        printf "%.2fG" $(echo "$bytes / 1073741824" | bc -l)
    fi
}

mkdir -p "${LOG_ROOT}"

exec 9>"${LOCK_FILE}"
if ! flock -n 9 ; then
    echo "已有备份任务正在运行,退出" >> "${LOG_ROOT}/lock_reject.log"
    exit 1
fi

START_TS=$(date +%s)
TS=$(/usr/bin/date +%Y%m%d_%H%M%S)
THIS_LOG_DIR="${LOG_ROOT}/${TS}"
mkdir -p "${THIS_LOG_DIR}"
LOG_FILE="${THIS_LOG_DIR}/run.log"
STAT_FILE="${THIS_LOG_DIR}/backup_stat.log"

script_timeout() {
    echo "===== SCRIPT TIMEOUT REACHED ${SCRIPT_MAX_RUNTIME}s, start cleanup child rsync =====" >> "${LOG_FILE}"
    cleanup_child
    exit 2
}

check_script_timeout() {
    local now=$(date +%s)
    local elapsed=$(( now - START_TS ))
    if [ ${elapsed} -ge ${SCRIPT_MAX_RUNTIME} ];then
        script_timeout
    fi
}

# 判断rsync返回码是否为可重试错误:23部分失败,255ssh连接失败
is_retryable_err(){
    local rc=$1
    if [[ ${rc} =~ ^(23|255)$ ]];then
        return 0
    fi
    return 1
}

echo "===== BACKUP TASK WHOLE START  $(/usr/bin/date '+%Y-%m-%d %H:%M:%S') =====" > "${LOG_FILE}"
echo "Log directory: ${THIS_LOG_DIR}" >> "${LOG_FILE}"
echo "Remote host: ${ALI_USER}@${ALI_IP}" >> "${LOG_FILE}"
echo "TASK_MAX_SEC=${TASK_MAX_SEC} ; 0=关闭单任务timeout限制" >> "${LOG_FILE}"
echo "" >> "${LOG_FILE}"

# 初始化统计文件
echo "===== BACKUP STAT START $(date '+%Y-%m-%d %H:%M:%S') =====" > "${STAT_FILE}"

# 循环处理每一个备份任务
for task in "${BACKUP_TASKS[@]}"; do
    # 拆分源目录、目标目录(用参数替换代替eval,避免安全隐患)
    SRC_REMOTE=$(echo "$task" | cut -d'|' -f1)
    DST_RAW=$(echo "$task" | cut -d'|' -f2)
    DST_LOCAL=${DST_RAW//\$\{DEST_LOCAL\}/${DEST_LOCAL}}
    TASK_NAME=$(basename "${SRC_REMOTE%/}")
    LIST_FILE="${THIS_LOG_DIR}/task_${TASK_NAME}_filelist.txt"

    mkdir -p "${DST_LOCAL}"

    echo "--------[TASK START] ${SRC_REMOTE} -> ${DST_LOCAL} --------" >> "${LOG_FILE}"
    task_retry=0
    task_success=0
    TASK_START=$(date +%s)
    src_total_size=""

    # 单个任务重试循环
    while [ ${task_retry} -lt ${MAX_RETRY} ]; do
        check_script_timeout
        echo "task[${TASK_NAME}] run, retry=${task_retry}/${MAX_RETRY}" >> "${LOG_FILE}"

        RSYNC_STAT_TMP=$(mktemp)
        # ===== 修改点:判断是否启用timeout包装 =====
        if [[ ${TASK_MAX_SEC} -gt 0 ]]; then
            timeout --kill-after=30 ${TASK_MAX_SEC} rsync -avz --no-whole-file \
            --log-file="${LIST_FILE}" \
            --out-format="%o %f %l" \
            -e "ssh -o StrictHostKeyChecking=no -o ConnectTimeout=${SSH_CONN_TIMEOUT}" \
            ${ALI_USER}@${ALI_IP}:${SRC_REMOTE} ${DST_LOCAL} \
            >"${RSYNC_STAT_TMP}" 2>> "${LOG_FILE}" &
        else
            # TASK_MAX_SEC=0:不施加单任务超时限制,rsync自由运行
            rsync -avz --no-whole-file \
            --log-file="${LIST_FILE}" \
            --out-format="%o %f %l" \
            -e "ssh -o StrictHostKeyChecking=no -o ConnectTimeout=${SSH_CONN_TIMEOUT}" \
            ${ALI_USER}@${ALI_IP}:${SRC_REMOTE} ${DST_LOCAL} \
            >"${RSYNC_STAT_TMP}" 2>> "${LOG_FILE}" &
        fi

        RSYNC_PID=$!
        RSYNC_PIDS+=("${RSYNC_PID}")
        wait ${RSYNC_PID}
        RC=$?

        src_total_size=$(grep "total size is" "${RSYNC_STAT_TMP}" | awk '{print $4}')
        rm -f "${RSYNC_STAT_TMP}"

        TASK_END=$(date +%s)
        TASK_DURATION=$(( TASK_END - TASK_START ))

        if [ ${RC} -eq 0 ];then
            task_success=1
            src_human=$(human_size "${src_total_size}")
            # 获取本地目录实际磁盘占用字节
            local_bytes=$(du -sb "${DST_LOCAL}" | awk '{print $1}')
            local_human=$(human_size "${local_bytes}")
            echo "task[${TASK_NAME}] SUCCESS, return_code=${RC}" >> "${LOG_FILE}"
            echo "[TASK_STAT] ${TASK_NAME} -- 云服务磁盘占用:${src_human},拉取后本地磁盘占用:${local_human},任务耗时:${TASK_DURATION}s" >> "${STAT_FILE}"
            # filelist过大(>5M)则压缩,避免日志膨胀
            if [[ -f "${LIST_FILE}" ]]; then
                local lfsize
                lfsize=$(wc -c < "${LIST_FILE}")
                if [[ ${lfsize} -gt 5242880 ]]; then
                    gzip -f "${LIST_FILE}" >> "${LOG_FILE}" 2>&1
                fi
            fi
            break
        fi

        if [ ${RC} -eq 124 ];then
            echo "task[${TASK_NAME}] TIMEOUT(${TASK_MAX_SEC}s), abort this task" >> "${LOG_FILE}"
            task_retry=${MAX_RETRY}
            break
        fi

        if is_retryable_err ${RC}; then
            task_retry=$((task_retry+1))
            if [ ${task_retry} -lt ${MAX_RETRY} ];then
                echo "task[${TASK_NAME}] recoverable error rc=${RC}, sleep ${RETRY_SLEEP}s then retry" >> "${LOG_FILE}"
                # 分片sleep,每次醒来都检查全局超时
                local slept=0
                while [ ${slept} -lt ${RETRY_SLEEP} ]; do
                    check_script_timeout
                    sleep 10
                    slept=$((slept+10))
                done
            fi
        else
            echo "task[${TASK_NAME}] FATAL NON-RETRYABLE ERROR rc=${RC}, abort this task" >> "${LOG_FILE}"
            break
        fi
    done

    if [ ${task_success} -ne 1 ];then
        echo "task[${TASK_NAME}] FAILED after ${MAX_RETRY} attempts" >> "${LOG_FILE}"
        echo "[TASK_STAT] ${TASK_NAME} -- TASK FAILED" >> "${STAT_FILE}"
    fi
    echo "--------[TASK END] ${SRC_REMOTE} --------" >> "${LOG_FILE}"
    echo "" >> "${LOG_FILE}"
done

WHOLE_END=$(date +%s)
WHOLE_DURATION=$(( WHOLE_END - START_TS ))
echo "[WHOLE_STAT] 全部备份执行总耗时: ${WHOLE_DURATION}s" >> "${STAT_FILE}"
echo "===== BACKUP STAT FINISHED $(date '+%Y-%m-%d %H:%M:%S') =====" >> "${STAT_FILE}"

echo "===== WHOLE BACKUP FINISHED $(/usr/bin/date '+%Y-%m-%d %H:%M:%S') =====" >> "${LOG_FILE}"

echo ">>> 开始日志清理:删除超过${LOG_KEEP_DAYS}天的旧日志目录" >> "${LOG_FILE}"
find "${LOG_ROOT}" -maxdepth 1 -type d -mtime +${LOG_KEEP_DAYS} -exec rm -rf {} \;

locklog="${LOG_ROOT}/lock_reject.log"
tail -n2000 "${locklog}" > "${locklog}.tmp" && mv "${locklog}.tmp" "${locklog}"

exit 0

添加执行权限
chmod +x /data/huaweiyun_backup/scripts/huaweiyun_sync.sh

1.5 运行主流程

复制代码
启动
 ├─ 定义常量/函数
 ├─ 初始化日志根目录
 ├─ 尝试获取互斥锁(flock),失败则退出
 ├─ 创建本轮的 时间戳日志目录 与 run.log / backup_stat.log
 └─ 逐任务循环(每个任务最多 MAX_RETRY 轮):
      ├─ 检查脚本级超时
      ├─ timeout 包裹 rsync 后台执行,并记录 pid
      ├─ 按返回码分流:
      │   0   → 成功,写统计,压缩超大 filelist,结束本任务
      │   124 → 单任务超时,判失败且不重试
      │   23/255 → 可重试错误,分片 sleep 后重试
      │   其它 → 致命错误,放弃本任务
      ├─ 记录任务结果到统计文件
      └─ 下一个任务
 ├─ 输出整体耗时统计
 ├─ 清理过期日志目录
 └─ 截断 lock_reject.log 后退出

1.6 依赖项

  • bash(#!/bin/bash,使用了数组、正则匹配 =~[[ ]] 等 bash 特性,不能以 sh 运行)
  • 外部命令:rsyncsshtimeoutflockdatepskillgrepawkcutbasenamemktempduwcgzipfindtailmvbc(human_size 里除法用)
  • SSH 免密:需提前配置 `root@IP 的免密登录

2. 逐行解析

2.1 第 1 行:解释器声明

bash 复制代码
#!/bin/bash
  • 使用 bash 解释器执行。脚本中用到 bash 专有特性:数组、[[ ... ]] 正则匹配(=~)、${var//a/b} 参数替换、local 局部变量,因此不能用 /bin/sh 执行

2.2 第 2 行:区块注释

bash 复制代码
# ===================== 配置区 =====================
  • 纯注释,标记下方为"配置区",所有需要人工调整的常量集中在此。

2.3 第 3 行:远程主机 IP

bash 复制代码
ALI_IP="***.**.***.**"
  • 定义远程服务器 IP。脚本所有 ssh/rsync 连接都使用该地址。

2.4 第 4 行:远程登录用户

bash 复制代码
ALI_USER="root"
  • 登录用户,需 root 权限读取 /home/*** 下业务目录。

2.5 第 5 行:本地备份根目录

bash 复制代码
DEST_LOCAL="/data/huaweiyun_backup/backup_files"
  • 全部任务统一下载到该目录下,任务数组里通过 ${DEST_LOCAL} 引用它。

2.6 第 6 行:日志根目录

bash 复制代码
LOG_ROOT="/data/huaweiyun_backup/logs"
  • 所有时间戳日志目录的父目录,由第 77 行创建。

2.7 第 7 行:空行

  • 无实际作用,仅用于视觉分隔配置区头部与任务数组。

2.8 第 8-9 行:任务数组注释

bash 复制代码
# 备份任务数组,格式:"远程源目录|本地目标目录"
# 在这里新增/删除任务,不限数量
  • 说明任务格式与扩展方式:增删备份只需编辑第 10-14 行的数组条目。

2.9 第 10-14 行:BACKUP_TASKS 任务数组

bash 复制代码
BACKUP_TASKS=(
"/home/***/|${DEST_LOCAL}/***/"
"/home/***/|${DEST_LOCAL}/***/"
"/home/***/|${DEST_LOCAL}/***/"
)
  • bash 数组,每个元素格式固定为 远程源路径|本地目标路径,分隔符为管道符 |
  • 远程路径:/home/... 是从服务器拉取的源目录(当然以 root 身份可读)。
  • 本地路径:通过 ${DEST_LOCAL} 变量引用第 5 行的备份根目录,展开为绝对路径。
  • 数组初始化时 ${DEST_LOCAL} 会立即被展开成实际字符串,因此第 128 行的参数替换最终处理的是已展开字符串(见 2.35 节)。
  • 约定 :目录名中不能包含 | 字符,否则第 126/127 行的 cut 会拆错。

2.10 第 15 行:空行

  • 视觉分隔。

2.11 第 16 行:SSH 连接超时

bash 复制代码
SSH_CONN_TIMEOUT=120
  • ssh 建立连接的最大等待秒数,传给 rsync 的 -e 参数使用。

2.12 第 17 行:脚本级最大运行时长

bash 复制代码
SCRIPT_MAX_RUNTIME=14400   #脚本最大总运行时长,单位秒;一轮耗时久就调大,示例4小时
  • 整个备份过程允许的最长秒数(14400s = 4 小时)。到期后 check_script_timeoutscript_timeout 收尾退出。

2.13 第 18 行:任务级单次运行时长(新增)

bash 复制代码
TASK_MAX_SEC=10800         #单个任务rsync最长运行时长,单位秒;由timeout控制,只影响本任务派生的进程
  • 单个 rsync 一次传输的最长秒数(10800s = 3 小时),由外部 timeout 命令强制约束,到点返回码 124。
  • 只作用于本任务派生进程,不影响系统其它 rsync,这是本次改造的核心之一。

2.14 第 19 行:最大重试次数

bash 复制代码
MAX_RETRY=3
  • 单个任务最多尝试 3 轮(首跑 + 2 次重试)。

2.15 第 20 行:重试间隔

bash 复制代码
RETRY_SLEEP=300             #失败后等待多久重试,单位秒
  • 可重试失败之间等待 300s(5 分钟),给网络/远端恢复时间。实际等待由分片 sleep 循环执行(见 2.56 节)。

2.16 第 21 行:日志保留天数

bash 复制代码
LOG_KEEP_DAYS=90            #时间戳日志目录保留90天
  • 时间戳日志目录超过 90 天,在脚本结束时被 find 清理。

2.17 第 22 行:互斥锁文件

bash 复制代码
LOCK_FILE="/tmp/huaweiyun_backup.lock"
  • flock 锁文件路径,位于内存文件系统 /tmp。进程退出后锁随文件描述符释放,无需删除文件。

2.18 第 23 行:配置区结束注释

bash 复制代码
# ==================================================
  • 标记配置区结束。以下内容为逻辑区,日常一般不需要改动。

2.19 第 24 行:固化 PATH

bash 复制代码
export PATH=/usr/bin:/bin:/usr/sbin:/sbin
  • 将 PATH 固定为系统常规目录,避免 cron 精简环境(PATH 为空)导致找不到命令。

2.20 第 26-27 行:RSYNC_PIDS 数组

bash 复制代码
# 保存本脚本拉起的timeout/rsync pid,只杀这些,不影响系统其它rsync
RSYNC_PIDS=()
  • 记录本脚本每次派生的 timeout 进程 pid(timeout 是 rsync 的直接父进程)。
  • 清理只遍历该数组,是"不影响其它 rsync"的根本保证

2.21 第 30-55 行:cleanup_child 函数

bash 复制代码
cleanup_child() {
  • 终止信号处理核心:仅精确清理本脚本派生的任务进程组。
第 31 行
bash 复制代码
    if [[ ${#RSYNC_PIDS[@]} -gt 0 ]]; then
  • ${#array[@]} 取数组元素个数,大于 0 才进入清理流程。
第 32 行
bash 复制代码
        echo ">>> 收到终止信号,清理本脚本派生rsync子进程: ${RSYNC_PIDS[*]}" >> "${LOG_FILE:-/dev/null}"
  • 记录清理动作到当前日志;${LOG_FILE:-/dev/null} 防止信号来临时 LOG_FILE 尚未定义导致写错文件。
第 33-34 行
bash 复制代码
        local self_pgid
        self_pgid=$(ps -o pgid= -p $$ 2>/dev/null | tr -d ' ')
  • 脚本自身 的进程组号 PGID,作为安全基准。$$ 为当前 PID,ps -o pgid= 输出组号,tr -d ' ' 去掉空白。
第 35 行
bash 复制代码
        for pid in "${RSYNC_PIDS[@]}"; do
  • 遍历记录的所有派生进程。
第 36 行
bash 复制代码
            if kill -0 "$pid" 2>/dev/null; then
  • kill -0 只探测进程是否存在(不发信号),存在才继续。
第 37-38 行
bash 复制代码
                local pg
                pg=$(ps -o pgid= -p "$pid" 2>/dev/null | tr -d ' ')
  • 取该进程(timeout 以及由其创建的任务进程组)的 PGID,tr -d ' ' 去空白。
第 39 行
bash 复制代码
                # timeout会为子命令创建独立进程组,按进程组kill可连rsync+ssh一起清掉
  • 注释解释原理:timeout 默认会给子命令创建独立进程组,组内含本次的 rsync 和其拉起的 ssh
第 40-44 行
bash 复制代码
                if [[ -n "$pg" && "$pg" != "$self_pgid" ]]; then
                    kill -- -"$pg" 2>/dev/null
                else
                    kill "$pid" 2>/dev/null
                fi
  • 安全闸门 :只有当该进程的 PGID 与脚本自身 PGID 不同(说明 timeout 已创建独立组)时,才用 kill -- -PGID 按进程组终止(负号前缀表示"组",连 rsync+ssh 一起收掉)。
  • 若 PGID 与脚本相同(timeout 尚未建立子组等边界情况),退化为只杀单进程,确保绝不误杀脚本自身
  • 系统里其它 rsync 不在该任务组内,绝不会被波及。
第 45 行
bash 复制代码
                sleep 0.2
  • 给信号传递 0.2s 缓冲。
第 46-51 行
bash 复制代码
                if kill -0 "$pid" 2>/dev/null; then
                    kill -9 "$pid" 2>/dev/null
                    if [[ -n "$pg" && "$pg" != "$self_pgid" ]]; then
                        kill -9 -- -"$pg" 2>/dev/null
                    fi
                fi
  • 0.2s 后若进程仍在,升级为 kill -9(SIGKILL)强制终止,同样按进程组处理。
第 52-55 行
bash 复制代码
            fi
        done
    fi
}
  • 收尾:关闭 if / for / if / 函数体。

2.22 第 57 行:信号绑定

bash 复制代码
trap 'cleanup_child; exit 130' SIGINT SIGTERM
  • 捕获 SIGINT(Ctrl+C,码 2)与 SIGTERM(kill 默认,码 15)时先清理派生进程,再以 130 退出(130 = 128 + 2,符合 SIGINT 惯例)。

2.23 第 59-75 行:human_size 函数

bash 复制代码
human_size() {
  • 将字节数转成可读单位(B/K/M/G)。
第 60-61 行
bash 复制代码
    local bytes=$1
    bytes=$(echo "$bytes" | tr -d ',')
  • 接收字节参数,去掉可能存在的千分位逗号(du -sb 输出无逗号,此处兼容带逗号输入)。
第 62-65 行
bash 复制代码
    if [[ -z "$bytes" || "$bytes" == "0" ]];then
        echo "0B"
        return
    fi
  • 空输入或 0 直接返回 0B(兼容 rsync 失败时统计字段为空的场景)。
第 66-67 行
bash 复制代码
    if [[ $bytes -lt 1024 ]]; then
        echo "${bytes}B"
  • 小于 1KB 输出原始 B
第 68-69 行
bash 复制代码
    elif [[ $bytes -lt 1048576 ]]; then
        printf "%.2fK" $(echo "$bytes / 1024" | bc -l)
  • 小于 1MB(1024²=1048576)时显示 K,用 bc -l 做浮点除法并保留 2 位小数。
第 70-71 行
bash 复制代码
    elif [[ $bytes -lt 1073741824 ]]; then
        printf "%.2fM" $(echo "$bytes / 1048576" | bc -l)
  • 小于 1GB(1024³=1073741824)时显示 M。
第 72-74 行
bash 复制代码
    else
        printf "%.2fG" $(echo "$bytes / 1073741824" | bc -l)
  • 达到 1GB 及以上显示 G。
第 75 行
bash 复制代码
}
  • 函数结束。

2.24 第 77 行:创建日志根目录

bash 复制代码
mkdir -p "${LOG_ROOT}"
  • -p 父目录一并创建,目录已存在也不报错。

2.25 第 79-83 行:互斥锁

bash 复制代码
exec 9>"${LOCK_FILE}"
  • 写模式 打开锁文件并绑定到文件描述符 9,exec 使该句柄在当前 shell 持续有效。
bash 复制代码
if ! flock -n 9 ; then
  • flock -n:非阻塞加锁,失败(已有人持锁)返回非零。
bash 复制代码
    echo "已有备份任务正在运行,退出" >> "${LOG_ROOT}/lock_reject.log"
    exit 1
fi
  • 加锁失败:向 lock_reject.log 记一行并被 1 退出,避免备份互相打架。

2.26 第 85-90 行:本轮时间戳与日志文件规划

bash 复制代码
START_TS=$(date +%s)
  • 记录脚本启动时间戳(秒级),供全局超时与总耗时计算。
bash 复制代码
TS=$(/usr/bin/date +%Y%m%d_%H%M%S)
  • 生成本轮目录名,如 20260904_112000。用绝对路径 /usr/bin/date 规避 PATH 差异。
bash 复制代码
THIS_LOG_DIR="${LOG_ROOT}/${TS}"
mkdir -p "${THIS_LOG_DIR}"
LOG_FILE="${THIS_LOG_DIR}/run.log"
STAT_FILE="${THIS_LOG_DIR}/backup_stat.log"
  • 组装本轮日志目录并创建;定义运行日志与统计日志文件路径。

2.27 第 92-96 行:script_timeout 函数

bash 复制代码
script_timeout() {
    echo "===== SCRIPT TIMEOUT REACHED ${SCRIPT_MAX_RUNTIME}s, start cleanup child rsync =====" >> "${LOG_FILE}"
    cleanup_child
    exit 2
}
  • 脚本级超时的最终动作:写超时标记 → 清理派生进程 → 以退出码 2 结束(退出码 2 区别于正常 0 与锁拒绝 1)。

2.28 第 98-104 行:check_script_timeout 函数

bash 复制代码
check_script_timeout() {
    local now=$(date +%s)
    local elapsed=$(( now - START_TS ))
    if [ ${elapsed} -ge ${SCRIPT_MAX_RUNTIME} ];then
        script_timeout
    fi
}
  • 常规检查点:当前时刻减启动时刻,超过 SCRIPT_MAX_RUNTIME 就触发 script_timeout
  • 被调用位置:第 142 行(每轮任务前)、第 196 行(重试分片 sleep 每片)、从而保证 sleep/等待期间也不失守。

2.29 第 106-113 行:is_retryable_err 函数

bash 复制代码
# 判断rsync返回码是否为可重试错误:23部分失败,255ssh连接失败
is_retryable_err(){
    local rc=$1
    if [[ ${rc} =~ ^(23|255)$ ]];then
        return 0
    fi
    return 1
}
  • 23:部分文件传输失败(如文件传输中被改/删、单文件权限问题)。
  • 255:ssh 连接异常(连接失败、超时、认证被拒)。
  • 匹配则返回 0(真)表示可重试,否则 1。=~ 正则要求整串恰好等于 23 或 255。

2.30 第 115-118 行:运行日志开端

bash 复制代码
echo "===== BACKUP TASK WHOLE START  $(/usr/bin/date '+%Y-%m-%d %H:%M:%S') =====" > "${LOG_FILE}"
  • > 单箭头覆盖:清空/创建 run.log 并写入启动标记。
bash 复制代码
echo "Log directory: ${THIS_LOG_DIR}" >> "${LOG_FILE}"
echo "Remote host: ${ALI_USER}@${ALI_IP}" >> "${LOG_FILE}"
echo "" >> "${LOG_FILE}"
  • 追加日志目录、远端主机信息与空行;此后全部为 >>(追加)。

2.31 第 120-121 行:统计文件初始化

bash 复制代码
# 初始化统计文件
echo "===== BACKUP STAT START $(date '+%Y-%m-%d %H:%M:%S') =====" > "${STAT_FILE}"
  • 单箭头覆盖创建 backup_stat.log,写统计起止标记。

2.32 第 124 行:主循环入口

bash 复制代码
for task in "${BACKUP_TASKS[@]}"; do
  • 遍历任务数组的每一个元素(每个元素是一条 远程|本地的组合)。

2.33 第 126-128 行:拆分任务字段

bash 复制代码
    SRC_REMOTE=$(echo "$task" | cut -d'|' -f1)
  • 取管道符左侧:远程源目录,例如 /home/***/
bash 复制代码
    DST_RAW=$(echo "$task" | cut -d'|' -f2)
  • 取右侧:本地目标目录的原始串(含已展开后的绝对路径文本)。
bash 复制代码
    DST_LOCAL=${DST_RAW//\$\{DEST_LOCAL\}/${DEST_LOCAL}}
  • 用 bash 参数替换取代原来的 eval echo
    • 模式 \$\{DEST_LOCAL\} 转义匹配字面文本 ${DEST_LOCAL}
    • 匹配处替换为变量 DEST_LOCAL 的真实值;
    • 安全性:只做文本替换,不执行任何命令解释 ,杜绝注入风险(原 eval 的缺点)。

2.34 第 129-130 行:任务派生名称与清单文件

bash 复制代码
    TASK_NAME=$(basename "${SRC_REMOTE%/}")
  • 取远程源目录的最后一段做任务名(%/ 先去掉尾部斜杠),如 resultdata,用于日志/统计/清单命名。
bash 复制代码
    LIST_FILE="${THIS_LOG_DIR}/task_${TASK_NAME}_filelist.txt"
  • 本任务的文件级清单文件路径。

2.35 第 132 行:准备本地目标目录

bash 复制代码
    mkdir -p "${DST_LOCAL}"
  • 目标目录不存在则创建,确保 rsync 写入有落点。

2.36 第 134 行:任务开始日志

bash 复制代码
    echo "--------[TASK START] ${SRC_REMOTE} -> ${DST_LOCAL} --------" >> "${LOG_FILE}"
  • 记录"远程 → 本地"映射。

2.37 第 135-138 行:任务局部变量初始化

bash 复制代码
    task_retry=0
    task_success=0
    TASK_START=$(date +%s)
    src_total_size=""
  • task_retry:已重试次数。task_success:0 失败 / 1 成功。
  • TASK_START:本任务开始时间。src_total_size:远程总大小占位,成功时由 rsync 输出填充。

2.38 第 141 行:单任务重试循环

bash 复制代码
    while [ ${task_retry} -lt ${MAX_RETRY} ]; do
  • 只要重试次数尚未达到上限就继续尝试。

2.39 第 142-143 行:先查超时、再记日志

bash 复制代码
        check_script_timeout
        echo "task[${TASK_NAME}] run, retry=${task_retry}/${MAX_RETRY}" >> "${LOG_FILE}"
  • 每轮开始前先看全局超时是否到期;再写当轮执行记录。

2.40 第 145 行:临时统计文件

bash 复制代码
        RSYNC_STAT_TMP=$(mktemp)
  • 创建临时文件承接 rsync 的标准输出(内含 total size is 统计行),每个任务每轮独立。

2.41 第 147-152 行:rsync 主传输命令

bash 复制代码
        timeout --kill-after=30 ${TASK_MAX_SEC} rsync -avz --no-whole-file \
        --log-file="${LIST_FILE}" \
        --out-format="%o %f %l" \
        -e "ssh -o StrictHostKeyChecking=no -o ConnectTimeout=${SSH_CONN_TIMEOUT}" \
        ${ALI_USER}@${ALI_IP}:${SRC_REMOTE} ${DST_LOCAL} \
        >"${RSYNC_STAT_TMP}" 2>> "${LOG_FILE}" &
  • timeout --kill-after=30 TASK_MAX_SEC:给 rsync 套 3 小时沙箱,超时先 TERM,30s 后未退再 KILL;timeout 创建独立进程组由 cleanup_child 精确管理。
  • -a 归档(保留权限/时间/软链等);-v 详情;-z 压缩传输。
  • --no-whole-file:只传差异块(增量)。
  • --log-file:将每个文件动作写入清单。
  • --out-format="%o %f %l":输出格式为 动作/文件名/长度。
  • -e "ssh ...":指定传输通道 ssh,跳过主机指纹检查(StrictHostKeyChecking=no),连接超时 120s。
  • ${ALI_USER}@${ALI_IP}:${SRC_REMOTE}:远端源;DST_LOCAL:本地目标。
  • > 标准输出进临时文件;2>> 错误追加到 run.log;末尾 & 后台执行。

2.42 第 153-155 行:记录 pid 并等待

bash 复制代码
        RSYNC_PID=$!
        RSYNC_PIDS+=("${RSYNC_PID}")
        wait ${RSYNC_PID}
  • $! 取刚放入后台的 timeout 进程 pid;+= 追加进待清理数组;wait 阻塞等待本次传输结束并把返回码留在 $?

2.43 第 156 行:取返回码

bash 复制代码
        RC=$?
  • 捕获本次 rsync(经 timeout 包装)的返回码。

2.44 第 158-159 行:解析统计并清理临时文件

bash 复制代码
        src_total_size=$(grep "total size is" "${RSYNC_STAT_TMP}" | awk '{print $4}')
        rm -f "${RSYNC_STAT_TMP}"
  • 从输出中抓 total size is 行的第 4 字段拿到远程总字节数;随后删除临时文件。

2.45 第 161-162 行:任务耗时

bash 复制代码
        TASK_END=$(date +%s)
        TASK_DURATION=$(( TASK_END - TASK_START ))
  • 记录结束时刻并求差得本任务总耗时(含重试)。

2.46 第 164-181 行:成功分支(RC=0)

bash 复制代码
        if [ ${RC} -eq 0 ];then
            task_success=1
  • 置成功标记。
bash 复制代码
            src_human=$(human_size "${src_total_size}")
            local_bytes=$(du -sb "${DST_LOCAL}" | awk '{print $1}')
            local_human=$(human_size "${local_bytes}")
  • 远程大小转可读单位;du -sb 统计本地目标目录实际占盘字节并同样转可读单位。
bash 复制代码
            echo "task[${TASK_NAME}] SUCCESS, return_code=${RC}" >> "${LOG_FILE}"
            echo "[TASK_STAT] ${TASK_NAME} -- 云服务磁盘占用:${src_human},拉取后本地磁盘占用:${local_human},任务耗时:${TASK_DURATION}s" >> "${STAT_FILE}"
  • 写运行日志与统计文件一行(供后续统计报表解析)。
bash 复制代码
            if [[ -f "${LIST_FILE}" ]]; then
                local lfsize
                lfsize=$(wc -c < "${LIST_FILE}")
                if [[ ${lfsize} -gt 5242880 ]]; then
                    gzip -f "${LIST_FILE}" >> "${LOG_FILE}" 2>&1
                fi
            fi
            break
        fi
  • 文件清单超过 5MB(5242880 字节)时用 gzip 压缩,防日志膨胀;随后跳出重试循环结束本任务。

2.47 第 183-187 行:单任务超时分支(RC=124,新增)

bash 复制代码
        if [ ${RC} -eq 124 ];then
            echo "task[${TASK_NAME}] TIMEOUT(${TASK_MAX_SEC}s), abort this task" >> "${LOG_FILE}"
            task_retry=${MAX_RETRY}
            break
        fi
  • 124 是 timeout 的专属超时返回码:说明单次传输超过 3 小时没跑完。
  • 直接判失败并跳过重试(重试大概率再次超时),交给外层统计。

2.48 第 189-200 行:可重试错误分支

bash 复制代码
        if is_retryable_err ${RC}; then
            task_retry=$((task_retry+1))
  • 是 23/255:重试计数加一。
bash 复制代码
            if [ ${task_retry} -lt ${MAX_RETRY} ];then
                echo "task[${TASK_NAME}] recoverable error rc=${RC}, sleep ${RETRY_SLEEP}s then retry" >> "${LOG_FILE}"
                local slept=0
                while [ ${slept} -lt ${RETRY_SLEEP} ]; do
                    check_script_timeout
                    sleep 10
                    slept=$((slept+10))
                done
            fi
  • 重试次数没到上限:写日志后进入分片 sleep (每 10s 醒一次),每次醒来先执行 check_script_timeout,累计满 300s 才放行下一轮。
  • 好处:重试等待期间全局超时也能及时触发,不再出现"睡觉到天亮"的窗口。

2.49 第 201-204 行:致命错误分支

bash 复制代码
        else
            echo "task[${TASK_NAME}] FATAL NON-RETRYABLE ERROR rc=${RC}, abort this task" >> "${LOG_FILE}"
            break
        fi
  • 未知返回码(如 12 本地 IO 错误、其它 rsync 错误号)视为不可恢复,记日志并放弃本任务。

2.50 第 205 行:重试循环结束

bash 复制代码
    done
  • 成功 / 超时 / 致命错误 / 重试用尽 任一情形都会走到此处。

2.51 第 207-210 行:任务结果落统计

bash 复制代码
    if [ ${task_success} -ne 1 ];then
        echo "task[${TASK_NAME}] FAILED after ${MAX_RETRY} attempts" >> "${LOG_FILE}"
        echo "[TASK_STAT] ${TASK_NAME} -- TASK FAILED" >> "${STAT_FILE}"
    fi
  • 只要不是成功,就在运行日志与统计文件中记录 FAILED,保证每个任务必有结果行。

2.52 第 211-212 行:任务结束标记

bash 复制代码
    echo "--------[TASK END] ${SRC_REMOTE} --------" >> "${LOG_FILE}"
    echo "" >> "${LOG_FILE}"
  • 任务边界标识与空行,便于阅读日志。

2.53 第 213 行:主循环结束

bash 复制代码
done
  • 所有任务处理完毕。

2.54 第 215-216 行:整体耗时

bash 复制代码
WHOLE_END=$(date +%s)
WHOLE_DURATION=$(( WHOLE_END - START_TS ))
  • 计算整轮备份总耗时。

2.55 第 217-218 行:整体统计收尾

bash 复制代码
echo "[WHOLE_STAT] 全部备份执行总耗时: ${WHOLE_DURATION}s" >> "${STAT_FILE}"
echo "===== BACKUP STAT FINISHED $(date '+%Y-%m-%d %H:%M:%S') =====" >> "${STAT_FILE}"
  • 在统计文件里记录总耗时与结束标记。

2.56 第 220 行:运行日志收尾

bash 复制代码
echo "===== WHOLE BACKUP FINISHED $(/usr/bin/date '+%Y-%m-%d %H:%M:%S') =====" >> "${LOG_FILE}"
  • 运行日志写入完成标记。

2.57 第 222-223 行:清理过期日志

bash 复制代码
echo ">>> 开始日志清理:删除超过${LOG_KEEP_DAYS}天的旧日志目录" >> "${LOG_FILE}"
find "${LOG_ROOT}" -maxdepth 1 -type d -mtime +${LOG_KEEP_DAYS} -exec rm -rf {} \;
  • -maxdepth 1 只扫日志根目录第一层;-type d 只匹配目录;-mtime +90 超过 90 天;-exec rm -rf {} \; 逐一递归删除。

2.58 第 225-226 行:截断锁拒绝日志

bash 复制代码
locklog="${LOG_ROOT}/lock_reject.log"
tail -n2000 "${locklog}" > "${locklog}.tmp" && mv "${locklog}.tmp" "${locklog}"
  • 保留最近 2000 行,用 mv 原子替换,防止该文件无限增长。

2.59 第 227 行:空行

  • 无实际作用。

2.60 第 228 行:正常退出

bash 复制代码
exit 0
  • 显式以 0 表示整轮备份执行成功(含子任务失败标记,但脚本自身流程完整)。

3. 退出码速查

退出码 含义 触发点
0 整轮流程正常结束 第 228 行
1 互斥锁获取失败(已有实例在跑) 第 82 行
2 脚本级总超时,收尾退出 第 95 行
130 收到 SIGINT/SIGTERM,清理后退出 第 57 行

rsync 侧返回码(被透传判断):

  • 0 成功;23 部分文件失败(可重试);255 ssh 连接失败(可重试);124 单任务超时(timeout 特有,不可重试)。

4. 输出产物说明

每轮运行在 LOG_ROOT/yyyyMMdd_HHmmss/ 目录:

文件 内容 用途
run.log 完整运行流水、每轮重试、返回码、超时/失败原因 排障主依据
backup_stat.log 每任务一行:远端/本地大小、耗时;整体总耗时 统计报表、监控告警
task_任务名_filelist.txt 每个文件的操作记录(可能被 gzip 压缩) 审计、差异追查

LOG_ROOT/lock_reject.log:被锁拒绝的运行记录(最多保留 2000 行)。


5. 信号与安全机制小结

机制 实现 保护对象
互斥锁 flock 非阻塞 防止多实例并发备份写冲突
全局超时 check_script_timeout + 分片 sleep 检查 避免脚本无限挂起
任务超时 timeout --kill-after=30 单个大任务不拖死整体
精确清理 RSYNC_PIDS + PGID 校验 + 进程组 kill 只杀自身派生,不碰其它 rsync
安全路径展开 参数替换替代 eval 杜绝命令注入
日志防膨胀 >5MB 自动 gzip、90 天清理、2000 行截断 磁盘占用可控

6.定时任务

6.1. cron 时间字段:0 1 * * 6

复制代码
0 1 * * 6 /data/huaweiyun_backup/scripts/huaweiyun_sync.sh >/dev/null 2>&1

这是 Linux crontab 定时任务

cron 格式:分 时 日 月 周

表格

字段 含义
0 第 0 分钟
1 1 点
* 每月所有日期
* 每年所有月份
6 星期 6(周六)

👉 时间含义:每周六 凌晨 01:00 执行

6.2. 输出重定向 >/dev/null 2>&1

  • >/dev/null:把**标准输出 (stdout)**扔到空设备,不输出正常打印信息
  • 2>&1:把**错误输出 (stderr)**重定向到标准输出,同样也丢弃

效果:脚本不管正常输出还是报错信息,全部屏蔽,不会产生邮件、不会写日志到控制台。 缺点:脚本出错你看不到报错;如果需要排查问题,建议改成输出日志文件,例如:

复制代码
0 1 * * 6 /data/huaweiyun_backup/scripts/huaweiyun_sync.sh >>/var/log/huaweiyun_sync.log 2>&1

每周六凌晨 01:00,执行华为云备份同步脚本,所有标准输出和错误输出全部丢弃,不保存任何输出日志。

6.3 编辑定时

复制代码
crontab -e 

编辑 crontab 任务

打开 vi 编辑界面,把下面这一行粘贴进去:

复制代码
0 1 * * 6 /data/huaweiyun_backup/scripts/huaweiyun_sync.sh >/dev/null 2>&1

Esc,输入:wq回车

复制代码
crontab -l

查看已配置的定时任务,确认写入成功

7.清除 \r 字符

遇到这种报错

复制代码
-bash: /data/aliyun_backup/scripts/aliyun_sync.sh: /bin/bash^M: bad interpreter: No such file or directory

^M 代表 Windows 换行符(CRLF)

这个脚本是在 Windows 记事本 / 编辑器编辑保存,传到 Linux;Windows 换行 \r\n,Linux 只识别 \n,多出的\r被解析成^M,导致 bash 无法识别脚本头部。

用 sed 命令直接清除 \r 字符

复制代码
sed -i 's/\r$//' /data/aliyun_backup/scripts/aliyun_sync.sh

查看脚本第一行,看是否还有^M符号

复制代码
cat -v /data/aliyun_backup/scripts/aliyun_sync.sh
相关推荐
梦想不只是梦与想1 小时前
Web 服务器网关协议:ASGI 和 WSGI
服务器·python·fastapi
智恒百亿1 小时前
8 卡 RTX 5090 服务器深度实测:256GB 显存能否支撑 70B 模型微调?选型参考
大数据·运维·服务器·人工智能
志栋智能6 小时前
超自动化运维如何支持合规审计?
运维·自动化
支支დ6 小时前
VO by Vercel 前端特定优势:为什么它是构建 AI 应用的新范式
前端·人工智能
小诗懂技术6 小时前
【网络通信UDP】基础 进程间不同主机的通信
运维·服务器·网络
weixin_440730506 小时前
selenium实战记录-从登陆-首页-进入应用流程用例三种参数化+日志+报告
服务器·selenium·测试工具
名字还没想好☜6 小时前
Docker 容器安全加固实战:非 root、只读根文件系统、drop capabilities 与最小攻击面
运维·安全·docker·容器·kubernetes
香芋芋圆7 小时前
AI 冲击内卷之下,普通前端如何破局?WebGIS—— 低门槛突围赛道
前端·javascript·人工智能·学习·职场发展
鹤落晴春7 小时前
有状态应用 vs 无状态应用
运维·云原生·k8s