1. 首页
  2. Linux

Linux 自动备份实战:MySQL + 目录打包 + 腾讯云 COS 一体化方案

在生产环境中,数据备份是运维的最后一道防线。一个合格的备份脚本不仅要能“把文件传上去”,更要解决备份完整性验证、网络抖动重试、本地历史轮转、并发安全等一系列工程问题。

本文分享一个经过生产验证的 Bash 自动备份脚本,支持 MySQL 分库备份、指定目录打包、腾讯云 COS 上传及智能历史清理,开箱即用。

1. 功能介绍

该脚本并非简单的命令拼接,而是针对生产痛点做了针对性设计:

  • MySQL 分库备份与完整性校验:自动遍历所有业务数据库(排除系统库),逐库执行 mysqldump。关键创新在于三重校验机制——不仅检查返回码和文件大小,还会验证 SQL 文件末尾的 -- Dump completed 标记,有效拦截中途断开导致的不完整备份。

  • COS 上传指数退避重试:网络传输不可避免会遇到抖动或 API 限流。脚本内置可配置的重试次数与指数退避延迟(5s → 10s → 20s),大幅提升弱网环境下的上传成功率。

  • 双策略历史清理:支持“保留天数”和“最大容量”两种清理策略同时生效。当磁盘空间紧张时,自动从最旧文件开始删除,避免备份撑爆服务器磁盘。

  • 并发安全与优雅退出:使用 flock 文件描述符锁防止 cron 重叠执行;捕获 SIGTERM/SIGINT 信号后仅清理本次产生的临时文件,不会误删历史备份,锁随进程退出自动释放。

  • 跨平台兼容:对 dustat 等命令在 GNU/Linux 与 BSD/macOS 上的差异做了自适应处理,同一份脚本可在不同发行版上运行。

  • 结构化日志与汇总报告:每个阶段输出带时间戳的结构化日志,MySQL 备份和 COS 上传结束后分别输出成功/失败计数汇总,便于监控告警系统解析。

2. 配置介绍

脚本顶部为集中配置区,所有可调参数一目了然:

# ======================== 配置区域 ============================
BACKUP_DIR="/data/backup"           # 备份工作目录
HISTORY_DIR="${BACKUP_DIR}/history" # 已上传文件的本地归档目录
LOG_FILE="${BACKUP_DIR}/backup.log" # 日志文件路径
LOCK_FILE="/tmp/auto_backup.lock"   # 并发锁文件

# 尽量不要使用硬编码密码,避免密码暴漏在ps,history等命令中!请使用 MYSQL_CNF 指向配置文件(权限600)
MYSQL_USER="root"
MYSQL_PASS=""                       # 留空,通过 cnf 文件认证
MYSQL_HOST="127.0.0.1"
MYSQL_PORT="3306"
MYSQL_CNF="/etc/mysql/backup.cnf"   # 推荐方式
EXCLUDE_DBS="^(information_schema|performance_schema|sys|mysql)$"

# 需要备份的目录,格式: "源路径:别名"
BACKUP_DIRS=(
    "/etc/redis:redis-config"
    "/etc/nginx:nginx-config"
)

COSCLI_PATH="/usr/local/bin/coscli"         # coscli 二进制路径
COS_BUCKET="cos://your-bucket/backup-path/" # COS 目标路径
COS_MAX_RETRIES=3                           # 上传最大重试次数
COS_RETRY_BASE_DELAY=5                      # 首次重试延迟(秒),后续指数增长

HISTORY_RETAIN_DAYS=30      # 历史文件保留天数
HISTORY_MAX_SIZE_GB=50      # 历史目录最大容量(GB)
# ======================== 配置结束 ============================

MySQL 安全认证配置

请尽量不要在脚本中硬编码数据库密码。请创建专用配置文件:

cat > /etc/mysql/backup.cnf << 'EOF'
[mysqldump]
user=root
password=YourStrongPassword
host=127.0.0.1
port=3306
EOF

chmod 600 /etc/mysql/backup.cnf

然后将脚本中的 MYSQL_CNF 指向该文件路径。脚本会优先使用 --defaults-extra-file 方式读取凭证,密码不会出现在进程列表中。

3. 安装说明

3.1 前置依赖安装

安装 coscli(腾讯云对象存储命令行工具)

# 下载最新版 coscli ,链接地址随时可变,请到腾讯云官网找到最新地址(以 Linux amd64 为例)
# 腾讯云官网地址:https://cloud.tencent.com/document/product/436/63144
wget https://coscli-1253960454.cos.ap-guangzhou.myqcloud.com/coscli-linux-amd64 -O /usr/local/bin/coscli
chmod +x /usr/local/bin/coscli

# 交互式初始化配置(填写 SecretId/SecretKey/Region)
coscli config init

# 验证连通性
coscli ls cos://your-bucket/

提示:建议使用 CAM 子账号并仅授予目标 Bucket 的写入权限,遵循最小权限原则。

确认 MySQL 客户端工具

# 确认mysqldump命令存在
mysqldump 

3.2 部署脚本

# 创建备份目录
mkdir -p /data/backup/history

# 放置脚本并赋予执行权限
cp auto_backup.sh /usr/local/sbin/auto_backup.sh
chmod 700 /usr/local/sbin/auto_backup.sh

# 手动执行一次验证
bash /usr/local/sbin/auto_backup.sh

# 检查日志确认各阶段正常
tail -f /data/backup/backup.log

3.3 配置定时任务

编辑 crontab,建议安排在业务低峰期执行:

crontab -e

添加以下条目(每天凌晨 3:00 执行):

0 3 * * * /usr/local/sbin/auto_backup.sh >> /data/backup/cron.log 2>&1

3.4 验证清单

部署完成后,请逐项确认:

检查项

验证方法

并发锁生效

同时启动两个实例,第二个应输出“另一个备份实例正在运行”并退出

MySQL 备份完整

解压任意 .tar.gz,检查 SQL 文件末尾含 -- Dump completed

COS 上传成功

登录腾讯云控制台确认文件存在且大小一致

历史清理正常

手动调小 HISTORY_MAX_SIZE_GB 触发清理,观察日志删除记录

中断清理安全

备份过程中 kill -SIGTERM <pid>,确认仅本次临时文件被清理

日志可解析

确认汇总行格式固定,便于接入 Prometheus/Grafana 等监控系统

最后提醒:备份的终极验证是恢复测试。请定期从 COS 下载备份文件并在隔离环境中执行恢复演练,确保备份真正可用。未经恢复验证的备份,等于没有备份。

4.完整文件

以下为完整脚本文件,附件里也可以直接下载

#!/bin/bash
#================================================================
# Linux 自动备份脚本 (MySQL + 指定目录 + 腾讯云COS + History清理)
# 版本: 2.4
# 注意: 必须使用 bash 执行 (./auto_backup.sh 或 bash auto_backup.sh)
#================================================================

set -o pipefail

# ======================== 配置区域 ============================
BACKUP_DIR="/data/backup"
HISTORY_DIR="${BACKUP_DIR}/history"
LOG_FILE="${BACKUP_DIR}/backup.log"
TIMESTAMP=$(date +"%Y%m%d-%H%M%S")
LOCK_FILE="/tmp/auto_backup.lock"

# [安全] 禁止硬编码密码,请使用 MYSQL_CNF 指向含 [mysqldump] 段的配置文件(权限600)
MYSQL_USER="root"
MYSQL_PASS=""
MYSQL_HOST="127.0.0.1"
MYSQL_PORT="3306"
MYSQL_CNF=""
EXCLUDE_DBS="^(information_schema|performance_schema|sys|mysql)$"

BACKUP_DIRS=(
    "/etc/redis:redis-config"
    "/etc/nginx:nginx-config"
)

COSCLI_PATH="/usr/local/bin/coscli"
COS_BUCKET="cos://your-bucket-name/backup-path/"
COS_MAX_RETRIES=3
COS_RETRY_BASE_DELAY=5

HISTORY_RETAIN_DAYS=30
HISTORY_MAX_SIZE_GB=50
# ======================== 配置结束 ============================

log() {
    echo "[$(date '+%Y-%m-%d %H:%M:%S')] $1" | tee -a "$LOG_FILE"
}

# 跨平台获取目录字节大小 (GNU du -sb / BSD du -sk)
get_dir_bytes() {
    local dir="$1"
    if du --version &>/dev/null 2>&1; then
        du -sb "$dir" 2>/dev/null | awk '{print $1}'
    else
        local kb
        kb=$(du -sk "$dir" 2>/dev/null | awk '{print $1}')
        echo $((kb * 1024))
    fi
}

format_bytes() {
    local bytes="$1"
    local gb=$((bytes / 1073741824))
    local mb_pct=$(( (bytes % 1073741824) * 100 / 1073741824 ))
    printf "%d.%02dGB" "$gb" "$mb_pct"
}

# 跨平台查找最旧文件 (GNU stat -c / BSD stat -f)
find_oldest_file() {
    local dir="$1"
    if stat --version &>/dev/null 2>&1; then
        find "$dir" -maxdepth 1 -type f -exec stat -c '%Y %n' {} \; 2>/dev/null \
            | sort -n | head -1 | cut -d' ' -f2-
    else
        find "$dir" -maxdepth 1 -type f -exec stat -f '%m %N' {} \; 2>/dev/null \
            | sort -n | head -1 | cut -d' ' -f2-
    fi
}

# 中断时仅清理本次时间戳产生的不完整文件,锁随进程退出自动释放
cleanup_on_exit() {
    log "WARN: 收到中断信号,正在清理本次临时文件..."
    local cleaned=0
    while IFS= read -r f; do
        [ -z "$f" ] && continue
        rm -f "$f" 2>>"$LOG_FILE"
        ((cleaned++))
    done < <(find "$BACKUP_DIR" -maxdepth 1 -type f -name "*-${TIMESTAMP}.*" 2>/dev/null)
    log "已清理 ${cleaned} 个临时文件"
    exit 130
}
trap cleanup_on_exit SIGTERM SIGINT SIGHUP

check_env() {
    mkdir -p "$BACKUP_DIR" "$HISTORY_DIR"

    if ! command -v mysqldump &> /dev/null; then
        log "ERROR: mysqldump 未安装"; exit 1
    fi

    if [ ! -x "$COSCLI_PATH" ]; then
        COSCLI_PATH=$(command -v coscli 2>/dev/null || true)
    fi
    if [ -z "$COSCLI_PATH" ] || [ ! -x "$COSCLI_PATH" ]; then
        log "ERROR: coscli 不可用,请检查配置或PATH"; exit 1
    fi
    log "INFO: 使用 coscli: $COSCLI_PATH"
}

backup_mysql() {
    log "===== 开始 MySQL 分库备份 ====="
    local mysql_auth=""
    if [ -n "$MYSQL_CNF" ] && [ -f "$MYSQL_CNF" ]; then
        mysql_auth="--defaults-extra-file=$MYSQL_CNF"
    elif [ -n "$MYSQL_PASS" ]; then
        mysql_auth="-u${MYSQL_USER} -p${MYSQL_PASS} -h${MYSQL_HOST} -P${MYSQL_PORT}"
    else
        mysql_auth="-u${MYSQL_USER} -h${MYSQL_HOST} -P${MYSQL_PORT}"
    fi

    databases=$(mysql $mysql_auth -N -e "SHOW DATABASES;" 2>>"$LOG_FILE")
    if [ $? -ne 0 ]; then
        log "ERROR: 无法连接 MySQL"; return 1
    fi

    local db_total=0 db_success=0 db_fail=0

    for db in $databases; do
        [[ "$db" =~ $EXCLUDE_DBS ]] && continue
        ((db_total++))

        local dump_file="${BACKUP_DIR}/mysql-${db}-${TIMESTAMP}.sql"
        local tar_file="${BACKUP_DIR}/mysql-${db}-${TIMESTAMP}.tar.gz"

        log "正在备份数据库: $db"
        mysqldump $mysql_auth --single-transaction --routines --triggers "$db" > "$dump_file" 2>>"$LOG_FILE"

        # 三重校验: 返回码 + 非空 + 尾部完成标记(防止中途断开的不完整备份)
        local dump_ok=false
        if [ $? -eq 0 ] && [ -s "$dump_file" ]; then
            if tail -1 "$dump_file" 2>/dev/null | grep -q "^-- Dump completed"; then
                dump_ok=true
            else
                log "ERROR: 数据库 $db dump缺少完成标记,可能不完整"
            fi
        else
            log "ERROR: 数据库 $db mysqldump 失败或输出为空"
        fi

        if $dump_ok; then
            tar -czf "$tar_file" -C "$BACKUP_DIR" "$(basename "$dump_file")" 2>>"$LOG_FILE"
            rm -f "$dump_file"
            if [ $? -eq 0 ] && [ -s "$tar_file" ]; then
                log "SUCCESS: $tar_file ($(du -h "$tar_file" | cut -f1))"
                ((db_success++))
            else
                log "ERROR: 数据库 $db 压缩失败"
                rm -f "$tar_file"
                ((db_fail++))
            fi
        else
            rm -f "$dump_file"
            ((db_fail++))
        fi
    done

    log "MySQL 备份汇总: 总计=${db_total} 成功=${db_success} 失败=${db_fail}"
    [ "$db_fail" -gt 0 ] && log "WARN: 有 ${db_fail} 个数据库备份失败"
    return 0
}

backup_dirs() {
    log "===== 开始目录备份 ====="

    local valid_count=0
    for item in "${BACKUP_DIRS[@]}"; do
        [ -n "$item" ] && ((valid_count++))
    done
    if [ "$valid_count" -eq 0 ]; then
        log "INFO: BACKUP_DIRS 为空,跳过目录备份"
        return 0
    fi

    for item in "${BACKUP_DIRS[@]}"; do
        [ -z "$item" ] && continue

        local src_path="${item%%:*}"
        local alias_name="${item##*:}"
        local tar_file="${BACKUP_DIR}/${alias_name}-${TIMESTAMP}.tar.gz"

        if [ ! -d "$src_path" ]; then
            log "WARN: 目录不存在,跳过: $src_path"
            continue
        fi

        log "正在备份目录: $src_path -> $alias_name"
        tar -czf "$tar_file" -C "$(dirname "$src_path")" "$(basename "$src_path")" 2>>"$LOG_FILE"

        if [ $? -eq 0 ] && [ -s "$tar_file" ]; then
            log "SUCCESS: $tar_file ($(du -h "$tar_file" | cut -f1))"
        else
            log "ERROR: 目录 $src_path 备份失败"
        fi
    done
}

upload_with_retry() {
    local file="$1"
    local dest="$2"
    local retry_delay=$COS_RETRY_BASE_DELAY

    for ((i = 1; i <= COS_MAX_RETRIES; i++)); do
        "$COSCLI_PATH" cp "$file" "$dest" 2>>"$LOG_FILE" && return 0

        if [ "$i" -lt "$COS_MAX_RETRIES" ]; then
            log "WARN: 第${i}次上传失败,${retry_delay}s后重试..."
            sleep "$retry_delay"
            retry_delay=$((retry_delay * 2))
        fi
    done
    return 1
}

# 使用进程替换而非管道while,确保计数器在当前Shell作用域内正确累加
upload_to_cos() {
    log "===== 开始上传至腾讯云 COS ====="
    local upload_ok=0 upload_fail=0

    while IFS= read -r file; do
        [ -z "$file" ] && continue
        local filename
        filename=$(basename "$file")
        log "上传中: $filename"

        if upload_with_retry "$file" "${COS_BUCKET}${filename}"; then
            mv "$file" "$HISTORY_DIR/"
            log "SUCCESS: $filename 已上传并移至 history"
            ((upload_ok++))
        else
            log "ERROR: $filename 上传失败(已重试${COS_MAX_RETRIES}次),保留在备份目录"
            ((upload_fail++))
        fi
    done < <(find "$BACKUP_DIR" -maxdepth 1 -type f \( -name "*.tar.gz" -o -name "*.sql" \) 2>/dev/null)

    log "COS 上传汇总: 成功=${upload_ok} 失败=${upload_fail}"
}

cleanup_history() {
    log "===== 开始清理 History 目录 ====="

    if [ "$HISTORY_RETAIN_DAYS" -gt 0 ] 2>/dev/null; then
        local old_files
        old_files=$(find "$HISTORY_DIR" -maxdepth 1 -type f -mtime +"$HISTORY_RETAIN_DAYS" 2>>"$LOG_FILE")
        if [ -n "$old_files" ]; then
            local count
            count=$(echo "$old_files" | wc -l)
            echo "$old_files" | xargs rm -f 2>>"$LOG_FILE"
            log "按时间清理: 删除超过 ${HISTORY_RETAIN_DAYS} 天的文件 ${count} 个"
        else
            log "按时间清理: 无过期文件"
        fi
    fi

    if [ "$HISTORY_MAX_SIZE_GB" -gt 0 ] 2>/dev/null; then
        local max_bytes=$((HISTORY_MAX_SIZE_GB * 1024 * 1024 * 1024))

        while true; do
            local current_bytes
            current_bytes=$(get_dir_bytes "$HISTORY_DIR")
            [ -z "$current_bytes" ] && break

            if [ "$current_bytes" -le "$max_bytes" ]; then
                log "按容量清理: 当前 $(format_bytes "$current_bytes"), 未超限(${HISTORY_MAX_SIZE_GB}GB)"
                break
            fi

            local oldest_file
            oldest_file=$(find_oldest_file "$HISTORY_DIR")
            if [ -z "$oldest_file" ]; then
                log "按容量清理: 目录已空但仍超限,请检查子目录或非备份文件"
                break
            fi

            rm -f "$oldest_file" 2>>"$LOG_FILE"
            log "按容量清理: 已删除最旧文件 $(basename "$oldest_file")"
        done
    fi

    log "History 清理完成"
}

main() {
    exec 200>"$LOCK_FILE"
    if ! flock -n 200; then
        log "WARN: 另一个备份实例正在运行,本次跳过"
        exit 0
    fi

    local start_seconds=$SECONDS

    log "=========================================="
    log "备份任务启动 | 时间戳: $TIMESTAMP"
    log "=========================================="

    check_env
    backup_mysql
    backup_dirs
    upload_to_cos
    cleanup_history

    local elapsed=$((SECONDS - start_seconds))
    local exit_code=0

    local residual_count
    residual_count=$(find "$BACKUP_DIR" -maxdepth 1 -type f \( -name "*.tar.gz" -o -name "*.sql" \) 2>/dev/null | wc -l)
    if [ "$residual_count" -gt 0 ]; then
        exit_code=1
        log "WARN: 备份目录存在 ${residual_count} 个未上传文件"
    fi

    log "========== 备份任务结束 | 耗时:${elapsed}s | 退出码:${exit_code} =========="
    exit $exit_code
}

main "$@"


TOP