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 运行) - 外部命令:
rsync、ssh、timeout、flock、date、ps、kill、grep、awk、cut、basename、mktemp、du、wc、gzip、find、tail、mv、bc(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_timeout→script_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