Files
docker-backup/bin/backup.sh
Wang Defa a00314964d feat(backup): 添加单文件备份功能
- 添加 files 配置节,支持备份多个单独文件
- 实现 backup_files() 函数,处理单文件备份逻辑
- 更新 merge_backups() 函数,支持合并 files 备份
- 更新配置文件示例,添加 files 配置说明
- 更新 README 文档:
  - 添加单文件备份功能说明
  - 添加 files 配置示例
  - 添加单文件恢复详细步骤
  - 更新备份文件结构说明
  - 添加单文件完整性验证方法
  - 添加 v1.3.0 更新日志

使用场景:
- 备份配置文件(如 /etc/nginx/nginx.conf)
- 备份环境变量文件(如 /opt/app/.env)
- 备份其他重要的单个文件

备份结构:
backup.tar.gz
├── folders/folders.tar.gz  # 文件夹备份
├── files/files.tar.gz      # 单文件备份
└── mysql/mysql.sql.gz      # 数据库备份
2025-12-26 13:06:33 +08:00

709 lines
22 KiB
Bash
Executable File
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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}")
KEEP_COUNT=$(yq eval '.backup.retention.keep_count' "${CONFIG_FILE}")
# 读取文件夹备份配置
FOLDERS_ENABLED=$(yq eval '.folders.enabled' "${CONFIG_FILE}")
# 读取单文件备份配置
FILES_ENABLED=$(yq eval '.files.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
}
###############################################################################
# 备份单个文件
###############################################################################
backup_files() {
if [[ "${FILES_ENABLED}" != "true" ]]; then
log_info "单文件备份未启用,跳过"
return 0
fi
log_info "开始备份单个文件..."
# 创建临时目录
local temp_backup_dir="${TEMP_DIR}/files"
mkdir -p "${temp_backup_dir}"
# 获取要备份的文件数量
local source_count=$(yq eval '.files.sources | length' "${CONFIG_FILE}")
if [[ "${source_count}" == "0" ]] || [[ "${source_count}" == "null" ]]; then
log_warn "未配置要备份的文件"
return 0
fi
# 收集要备份的文件
local files_tar="${temp_backup_dir}/files.tar.gz"
local tar_sources=""
local valid_files=0
for i in $(seq 0 $((source_count - 1))); do
local source_file=$(yq eval ".files.sources[$i]" "${CONFIG_FILE}")
if [[ ! -f "${source_file}" ]]; then
log_warn "源文件不存在,跳过: ${source_file}"
continue
fi
tar_sources+=" ${source_file}"
valid_files=$((valid_files + 1))
log_info "添加备份文件: ${source_file}"
done
if [[ ${valid_files} -eq 0 ]]; then
log_warn "没有有效的备份文件"
return 0
fi
# 执行打包
log_info "开始打包文件..."
# 将 tar 的 stdout 和 stderr 都重定向到 stderr避免干扰函数返回值
eval "tar -czf '${files_tar}' ${tar_sources}" >&2 2>&1
if [[ -f "${files_tar}" ]]; then
local tar_size=$(du -h "${files_tar}" | cut -f1)
log_info "文件备份完成: ${files_tar} (大小: ${tar_size})"
echo "${files_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 files_tar=$2
local mysql_dump=$3
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 "${files_tar}" ]] && [[ -f "${files_tar}" ]]; then
files_to_pack+=" ${files_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 "${files_tar}" ]] && [[ -f "${files_tar}" ]]; then
files_list+=" ${files_tar#${TEMP_DIR}/}"
log_info "添加到合并: ${files_tar#${TEMP_DIR}/} ($(du -h "${files_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 "开始清理旧备份..."
# 优先使用 keep_days 策略
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
# 如果没有设置 keep_days使用 keep_count 策略
elif [[ "${KEEP_COUNT}" != "null" ]] && [[ "${KEEP_COUNT}" =~ ^[0-9]+$ ]]; then
log_info "使用保留数量策略: 保留最近 ${KEEP_COUNT} 个备份文件..."
# 获取所有备份文件,按修改时间倒序排列(最新的在前)
local backup_files=($(find "${OUTPUT_DIR}" -name "${BACKUP_PREFIX}-*.tar.gz" -type f -printf '%T@ %p\n' | sort -rn | awk '{print $2}'))
local total_files=${#backup_files[@]}
log_info "当前备份文件总数: ${total_files}"
# 如果文件数量超过保留数量,删除多余的
if [[ ${total_files} -gt ${KEEP_COUNT} ]]; then
local files_to_delete=$((total_files - KEEP_COUNT))
log_info "需要删除 ${files_to_delete} 个旧备份文件"
# 删除超出保留数量的文件(从数组末尾开始,即最旧的文件)
for ((i=${KEEP_COUNT}; i<${total_files}; i++)); do
local old_file="${backup_files[$i]}"
log_info "删除旧备份: ${old_file}"
rm -f "${old_file}"
done
else
log_info "备份文件数量未超过保留限制,无需清理"
fi
else
log_info "未配置有效的清理策略,跳过清理"
fi
log_info "清理完成"
}
###############################################################################
# 更新脚本
###############################################################################
update_script() {
log_info "=========================================="
log_info "Docker Backup 自动更新"
log_info "=========================================="
# 检查 root 权限
if [[ $EUID -ne 0 ]]; then
log_error "更新功能需要 root 权限"
log_info "请使用: sudo docker-backup update"
exit 1
fi
# 远程仓库地址
local repo_url="https://gitea.bcde.io/wangdefa/docker-backup/raw/branch/main"
# 安装路径
local bin_dir="/usr/local/bin"
local backup_script="${bin_dir}/docker-backup"
local cleanup_script="${bin_dir}/docker-backup-cleanup"
log_info "从远程仓库更新脚本..."
log_info "仓库地址: ${repo_url}"
# 备份当前版本
if [[ -f "${backup_script}" ]]; then
local backup_backup="${backup_script}.backup"
log_info "备份当前 backup.sh 到: ${backup_backup}"
cp "${backup_script}" "${backup_backup}"
fi
if [[ -f "${cleanup_script}" ]]; then
local cleanup_backup="${cleanup_script}.backup"
log_info "备份当前 cleanup.sh 到: ${cleanup_backup}"
cp "${cleanup_script}" "${cleanup_backup}"
fi
# 下载新版本 backup.sh
log_info "下载最新 backup.sh..."
if command -v wget &> /dev/null; then
if ! wget -q "${repo_url}/bin/backup.sh" -O "${backup_script}.new"; then
log_error "下载 backup.sh 失败"
log_info "恢复备份..."
[[ -f "${backup_script}.backup" ]] && mv "${backup_script}.backup" "${backup_script}"
exit 1
fi
else
if ! curl -sL "${repo_url}/bin/backup.sh" -o "${backup_script}.new"; then
log_error "下载 backup.sh 失败"
log_info "恢复备份..."
[[ -f "${backup_script}.backup" ]] && mv "${backup_script}.backup" "${backup_script}"
exit 1
fi
fi
# 下载新版本 cleanup.sh
log_info "下载最新 cleanup.sh..."
if command -v wget &> /dev/null; then
if ! wget -q "${repo_url}/bin/cleanup.sh" -O "${cleanup_script}.new"; then
log_error "下载 cleanup.sh 失败"
log_info "恢复备份..."
[[ -f "${backup_script}.backup" ]] && mv "${backup_script}.backup" "${backup_script}"
[[ -f "${cleanup_script}.backup" ]] && mv "${cleanup_script}.backup" "${cleanup_script}"
rm -f "${backup_script}.new"
exit 1
fi
else
if ! curl -sL "${repo_url}/bin/cleanup.sh" -o "${cleanup_script}.new"; then
log_error "下载 cleanup.sh 失败"
log_info "恢复备份..."
[[ -f "${backup_script}.backup" ]] && mv "${backup_script}.backup" "${backup_script}"
[[ -f "${cleanup_script}.backup" ]] && mv "${cleanup_script}.backup" "${cleanup_script}"
rm -f "${backup_script}.new"
exit 1
fi
fi
# 替换为新版本
log_info "安装新版本..."
mv "${backup_script}.new" "${backup_script}"
mv "${cleanup_script}.new" "${cleanup_script}"
chmod +x "${backup_script}"
chmod +x "${cleanup_script}"
# 清理备份文件
rm -f "${backup_script}.backup"
rm -f "${cleanup_script}.backup"
log_info "=========================================="
log_info "更新完成!"
log_info "=========================================="
}
###############################################################################
# 显示帮助信息
###############################################################################
show_help() {
cat << EOF
Docker Backup - Docker 数据备份工具
使用方法:
docker-backup [命令]
命令:
(无参数) 执行备份任务
update 从远程仓库更新脚本到最新版本
help 显示此帮助信息
示例:
docker-backup # 执行备份
docker-backup update # 更新脚本
sudo docker-backup update # 以 root 权限更新脚本
配置文件:
/etc/docker-backup/config.yml
日志文件:
配置文件中 logging.log_file 指定的路径
默认: /var/log/docker-backup.log
项目地址:
https://gitea.bcde.io/wangdefa/docker-backup
EOF
}
###############################################################################
# 主函数
###############################################################################
run_backup() {
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)
# 备份单个文件
local files_tar=$(backup_files)
# 备份 MySQL
local mysql_dump=$(backup_mysql)
# 合并备份
local final_backup=$(merge_backups "${folders_tar}" "${files_tar}" "${mysql_dump}")
# 清理旧备份
cleanup_old_backups
log_info "=========================================="
log_info "Docker Backup 执行完成"
log_info "备份文件: ${final_backup}"
log_info "=========================================="
}
###############################################################################
# 参数解析
###############################################################################
main() {
local command="${1:-}"
case "${command}" in
update)
update_script
;;
help|--help|-h)
show_help
;;
"")
run_backup
;;
*)
log_error "未知命令: ${command}"
echo ""
show_help
exit 1
;;
esac
}
# 执行主函数
main "$@"