#!/bin/bash ############################################################################### # Docker Backup Script # 功能:备份指定文件夹和 MySQL 容器数据库 ############################################################################### set -e # 遇到错误立即退出 set -o pipefail # 管道命令任何一个失败都返回失败 # 脚本所在目录 SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" # 配置文件路径(系统级配置) CONFIG_FILE="${CONFIG_FILE:-/etc/docker-backup/config.yml}" # 临时目录 TEMP_DIR="" # 颜色输出 RED='\033[0;31m' GREEN='\033[0;32m' YELLOW='\033[1;33m' NC='\033[0m' # No Color # 日志级别映射 declare -A LOG_LEVEL_MAP=( ["DEBUG"]=0 ["INFO"]=1 ["WARN"]=2 ["ERROR"]=3 ) # 当前日志级别(默认 INFO) CURRENT_LOG_LEVEL=1 ############################################################################### # 日志函数 ############################################################################### log() { local level=$1 shift local message="$@" local timestamp=$(date '+%Y-%m-%d %H:%M:%S') # 获取当前日志级别的数值 local level_value=${LOG_LEVEL_MAP[$level]:-1} # 如果日志级别低于配置的级别,则不输出 if [[ $level_value -lt $CURRENT_LOG_LEVEL ]]; then return 0 fi # 输出到 stderr,避免干扰函数返回值 echo -e "[${timestamp}] [${level}] ${message}" >&2 # 如果配置了日志文件,同时写入文件 if [[ -n "${LOG_FILE}" ]] && [[ "${LOGGING_ENABLED}" == "true" ]]; then # 移除 ANSI 颜色代码后写入文件 local clean_message=$(echo -e "${message}" | sed 's/\x1b\[[0-9;]*m//g') echo "[${timestamp}] [${level}] ${clean_message}" >> "${LOG_FILE}" fi } log_info() { log "INFO" "${GREEN}$@${NC}" } log_warn() { log "WARN" "${YELLOW}$@${NC}" } log_error() { log "ERROR" "${RED}$@${NC}" } ############################################################################### # 清理函数 ############################################################################### cleanup() { if [[ -n "${TEMP_DIR}" ]] && [[ -d "${TEMP_DIR}" ]]; then log_info "清理临时目录: ${TEMP_DIR}" rm -rf "${TEMP_DIR}" fi } # 注册清理函数 trap cleanup EXIT INT TERM ############################################################################### # 检查依赖 ############################################################################### check_dependencies() { log_info "检查依赖工具..." local missing_deps=() # 检查 yq if ! command -v yq &> /dev/null; then missing_deps+=("yq") fi # 检查 tar if ! command -v tar &> /dev/null; then missing_deps+=("tar") fi # 检查 gzip if ! command -v gzip &> /dev/null; then missing_deps+=("gzip") fi # 检查 docker if ! command -v docker &> /dev/null; then missing_deps+=("docker") fi if [[ ${#missing_deps[@]} -gt 0 ]]; then log_error "缺少以下依赖工具: ${missing_deps[*]}" log_error "请运行 install.sh 安装依赖" exit 1 fi log_info "依赖检查完成" } ############################################################################### # 加载配置 ############################################################################### load_config() { log_info "加载配置文件: ${CONFIG_FILE}" if [[ ! -f "${CONFIG_FILE}" ]]; then log_error "配置文件不存在: ${CONFIG_FILE}" exit 1 fi # 读取备份基础配置 OUTPUT_DIR=$(yq eval '.backup.output_dir' "${CONFIG_FILE}") BACKUP_PREFIX=$(yq eval '.backup.prefix' "${CONFIG_FILE}") # 读取清理策略 RETENTION_ENABLED=$(yq eval '.backup.retention.enabled' "${CONFIG_FILE}") KEEP_DAYS=$(yq eval '.backup.retention.keep_days' "${CONFIG_FILE}") # 读取文件夹备份配置 FOLDERS_ENABLED=$(yq eval '.folders.enabled' "${CONFIG_FILE}") # 读取 MySQL 配置 MYSQL_ENABLED=$(yq eval '.mysql.enabled' "${CONFIG_FILE}") MYSQL_CONTAINER=$(yq eval '.mysql.container_name' "${CONFIG_FILE}") MYSQL_USERNAME=$(yq eval '.mysql.username' "${CONFIG_FILE}") MYSQL_PASSWORD=$(yq eval '.mysql.password' "${CONFIG_FILE}") # 读取日志配置 LOGGING_ENABLED=$(yq eval '.logging.enabled' "${CONFIG_FILE}") LOG_FILE=$(yq eval '.logging.log_file' "${CONFIG_FILE}") LOG_LEVEL=$(yq eval '.logging.level' "${CONFIG_FILE}") # 设置日志级别 if [[ -n "${LOG_LEVEL}" ]] && [[ "${LOG_LEVEL}" != "null" ]]; then # 转换为大写 LOG_LEVEL=$(echo "${LOG_LEVEL}" | tr '[:lower:]' '[:upper:]') # 设置当前日志级别 CURRENT_LOG_LEVEL=${LOG_LEVEL_MAP[$LOG_LEVEL]:-1} log_info "日志级别设置为: ${LOG_LEVEL}" else log_info "使用默认日志级别: INFO" fi # 创建输出目录 mkdir -p "${OUTPUT_DIR}" # 创建日志目录 if [[ "${LOGGING_ENABLED}" == "true" ]] && [[ -n "${LOG_FILE}" ]]; then mkdir -p "$(dirname "${LOG_FILE}")" fi log_info "配置加载完成" } ############################################################################### # 备份文件夹 ############################################################################### backup_folders() { if [[ "${FOLDERS_ENABLED}" != "true" ]]; then log_info "文件夹备份未启用,跳过" return 0 fi log_info "开始备份文件夹..." # 创建临时目录 local temp_backup_dir="${TEMP_DIR}/folders" mkdir -p "${temp_backup_dir}" # 获取要备份的文件夹数量 local source_count=$(yq eval '.folders.sources | length' "${CONFIG_FILE}") if [[ "${source_count}" == "0" ]] || [[ "${source_count}" == "null" ]]; then log_warn "未配置要备份的文件夹" return 0 fi # 构建排除参数 local exclude_args="" local exclude_count=$(yq eval '.folders.excludes | length' "${CONFIG_FILE}") if [[ "${exclude_count}" != "0" ]] && [[ "${exclude_count}" != "null" ]]; then for i in $(seq 0 $((exclude_count - 1))); do local exclude_pattern=$(yq eval ".folders.excludes[$i]" "${CONFIG_FILE}") exclude_args+=" --exclude='${exclude_pattern}'" done fi # 备份每个文件夹 local folders_tar="${temp_backup_dir}/folders.tar.gz" local tar_sources="" for i in $(seq 0 $((source_count - 1))); do local source_dir=$(yq eval ".folders.sources[$i]" "${CONFIG_FILE}") if [[ ! -d "${source_dir}" ]]; then log_warn "源目录不存在,跳过: ${source_dir}" continue fi tar_sources+=" ${source_dir}" log_info "添加备份源: ${source_dir}" done if [[ -z "${tar_sources}" ]]; then log_warn "没有有效的备份源目录" return 0 fi # 执行打包 log_info "开始打包文件夹..." log_info "排除规则: ${exclude_args}" # 将 tar 的 stdout 和 stderr 都重定向到 stderr,避免干扰函数返回值 eval "tar -czf '${folders_tar}' ${exclude_args} ${tar_sources}" >&2 2>&1 if [[ -f "${folders_tar}" ]]; then local tar_size=$(du -h "${folders_tar}" | cut -f1) log_info "文件夹备份完成: ${folders_tar} (大小: ${tar_size})" echo "${folders_tar}" else log_error "文件夹备份失败" return 1 fi } ############################################################################### # 备份 MySQL 数据库 ############################################################################### backup_mysql() { if [[ "${MYSQL_ENABLED}" != "true" ]]; then log_info "MySQL 备份未启用,跳过" return 0 fi log_info "开始备份 MySQL 数据库..." # 检查容器是否存在且运行中 if ! docker ps --format '{{.Names}}' | grep -q "^${MYSQL_CONTAINER}$"; then log_error "MySQL 容器不存在或未运行: ${MYSQL_CONTAINER}" return 1 fi # 创建临时目录 local temp_mysql_dir="${TEMP_DIR}/mysql" mkdir -p "${temp_mysql_dir}" # 获取要备份的数据库列表 local databases=$(yq eval '.mysql.databases' "${CONFIG_FILE}") local mysql_dump="${temp_mysql_dir}/mysql.sql.gz" if [[ "${databases}" == "all" ]] || [[ "${databases}" == "null" ]]; then # 备份所有数据库 log_info "备份所有数据库..." docker exec "${MYSQL_CONTAINER}" mysqldump \ --single-transaction \ --quick \ --skip-lock-tables \ -u"${MYSQL_USERNAME}" \ -p"${MYSQL_PASSWORD}" \ --all-databases \ | gzip > "${mysql_dump}" else # 备份指定数据库 local db_count=$(yq eval '.mysql.databases | length' "${CONFIG_FILE}") local db_list="" for i in $(seq 0 $((db_count - 1))); do local db_name=$(yq eval ".mysql.databases[$i]" "${CONFIG_FILE}") db_list+=" ${db_name}" log_info "添加数据库: ${db_name}" done log_info "备份数据库: ${db_list}" docker exec "${MYSQL_CONTAINER}" mysqldump \ --single-transaction \ --quick \ --skip-lock-tables \ -u"${MYSQL_USERNAME}" \ -p"${MYSQL_PASSWORD}" \ --databases ${db_list} \ | gzip > "${mysql_dump}" fi if [[ -f "${mysql_dump}" ]]; then log_info "MySQL 备份完成: ${mysql_dump}" echo "${mysql_dump}" else log_error "MySQL 备份失败" return 1 fi } ############################################################################### # 合并备份文件 ############################################################################### merge_backups() { local folders_tar=$1 local mysql_dump=$2 log_info "开始合并备份文件..." # 生成时间戳 local timestamp=$(date '+%Y%m%d-%H%M%S') local final_backup="${OUTPUT_DIR}/${BACKUP_PREFIX}-${timestamp}.tar.gz" # 要打包的文件列表 local files_to_pack="" if [[ -n "${folders_tar}" ]] && [[ -f "${folders_tar}" ]]; then files_to_pack+=" ${folders_tar}" fi if [[ -n "${mysql_dump}" ]] && [[ -f "${mysql_dump}" ]]; then files_to_pack+=" ${mysql_dump}" fi if [[ -z "${files_to_pack}" ]]; then log_error "没有需要打包的文件" return 1 fi # 合并打包 # 构建相对于 TEMP_DIR 的路径列表 local files_list="" if [[ -n "${folders_tar}" ]] && [[ -f "${folders_tar}" ]]; then files_list+=" ${folders_tar#${TEMP_DIR}/}" log_info "添加到合并: ${folders_tar#${TEMP_DIR}/} ($(du -h "${folders_tar}" | cut -f1))" fi if [[ -n "${mysql_dump}" ]] && [[ -f "${mysql_dump}" ]]; then files_list+=" ${mysql_dump#${TEMP_DIR}/}" log_info "添加到合并: ${mysql_dump#${TEMP_DIR}/} ($(du -h "${mysql_dump}" | cut -f1))" fi log_info "执行合并打包..." if ! tar -czf "${final_backup}" -C "${TEMP_DIR}" ${files_list} 2>&1; then log_error "tar 合并命令执行失败" return 1 fi if [[ -f "${final_backup}" ]]; then local file_size=$(du -h "${final_backup}" | cut -f1) log_info "备份完成: ${final_backup} (大小: ${file_size})" echo "${final_backup}" else log_error "合并备份失败" return 1 fi } ############################################################################### # 清理旧备份 ############################################################################### cleanup_old_backups() { if [[ "${RETENTION_ENABLED}" != "true" ]]; then log_info "自动清理未启用,跳过" return 0 fi log_info "开始清理旧备份..." if [[ "${KEEP_DAYS}" != "null" ]] && [[ "${KEEP_DAYS}" =~ ^[0-9]+$ ]]; then log_info "删除 ${KEEP_DAYS} 天前的备份文件..." # 查找并删除旧文件 find "${OUTPUT_DIR}" -name "${BACKUP_PREFIX}-*.tar.gz" -type f -mtime +${KEEP_DAYS} -print | while read old_file; do log_info "删除旧备份: ${old_file}" rm -f "${old_file}" done fi log_info "清理完成" } ############################################################################### # 主函数 ############################################################################### main() { log_info "==========================================" log_info "Docker Backup 开始执行" log_info "==========================================" # 检查依赖 check_dependencies # 加载配置 load_config # 创建临时目录 TEMP_DIR=$(mktemp -d -t docker-backup.XXXXXX) log_info "临时目录: ${TEMP_DIR}" # 备份文件夹 local folders_tar=$(backup_folders) # 备份 MySQL local mysql_dump=$(backup_mysql) # 合并备份 local final_backup=$(merge_backups "${folders_tar}" "${mysql_dump}") # 清理旧备份 cleanup_old_backups log_info "==========================================" log_info "Docker Backup 执行完成" log_info "备份文件: ${final_backup}" log_info "==========================================" } # 执行主函数 main "$@"