#!/usr/bin/env bash # -E 让 ERR trap 在函数内部同样生效(冷备份模式下必须靠它把服务拉回来) set -Eeuo pipefail # ============================================ # Gitea 备份脚本 (MySQL 版) # # 备份内容:MySQL 数据库 + Gitea 数据(含仓库/LFS/密钥) + 部署配置 # # 用法: # bash backup.sh 热备份(服务不停机,默认) # bash backup.sh --cold 冷备份(停 Gitea 取快照,最一致) # bash backup.sh --keep 60 保留天数(默认 30) # bash backup.sh --verify-last 只校验最近一次备份,不产生新备份 # # 定时执行: crontab -e → 0 3 * * * /opt/gitea/backup.sh # ============================================ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" cd "$SCRIPT_DIR" DATE=$(date +%Y%m%d_%H%M%S) KEEP_DAYS=30 MIN_KEEP_SETS=3 # 无论多旧,至少保留这么多份完整备份 MODE="hot" # hot | cold | verify SVC_APP="server" SVC_DB="db" DB_NAME="gitea" STOPPED=0 log() { echo "[$(date '+%Y-%m-%d %H:%M:%S')] $*"; } warn() { echo "[$(date '+%Y-%m-%d %H:%M:%S')] [WARN] $*" >&2; } die() { echo "[$(date '+%Y-%m-%d %H:%M:%S')] [ERROR] $*" >&2; exit 1; } usage() { sed -n '5,18p' "$0" | sed 's/^# \{0,1\}//' exit 0 } while [ $# -gt 0 ]; do case "$1" in --cold) MODE="cold" ;; --verify-last) MODE="verify" ;; --keep) shift; [ $# -gt 0 ] || die "--keep 需要一个参数" [[ "$1" =~ ^[0-9]+$ ]] || die "--keep 必须是整数天" KEEP_DAYS="$1" ;; -h|--help) usage ;; *) die "未知参数: $1(--help 查看用法)" ;; esac shift done # ===== 加载配置 ===== [ -f .env ] || die ".env 文件不存在" if grep -q $'\r' .env 2>/dev/null; then sed -i 's/\r$//' .env; fi set -a # shellcheck disable=SC1091 source .env set +a BACKUP_DIR="${BACKUP_DIR:-/var/backups/gitea}" GITEA_DATA_DIR="${GITEA_DATA_DIR:-/var/lib/gitea}" [ -n "${DB_ROOT_PASSWORD:-}" ] || die ".env 中缺少 DB_ROOT_PASSWORD" # ===== 前置检查 ===== [ "$(id -u)" -eq 0 ] || die "需要 root 权限运行" for c in docker tar gzip find awk; do command -v "$c" >/dev/null 2>&1 || die "缺少命令: $c" done docker compose version >/dev/null 2>&1 || die "docker compose 不可用" [ -d "$GITEA_DATA_DIR" ] || die "数据目录不存在: $GITEA_DATA_DIR" mkdir -p "$BACKUP_DIR" chmod 700 "$BACKUP_DIR" # 防止 cron 叠加执行:上一次还没跑完就不再起新的 exec 9>"$BACKUP_DIR/.backup.lock" flock -n 9 || die "已有另一个备份进程在运行,本次退出" # MySQL 容器用 MYSQL_PWD 传密码,避免密码出现在 ps 的命令行里 db_exec() { docker compose exec -T -e MYSQL_PWD="$DB_ROOT_PASSWORD" "$SVC_DB" "$@"; } db_running() { local cid cid=$(docker compose ps -a -q "$SVC_DB" 2>/dev/null | head -1) [ -n "$cid" ] || return 1 [ "$(docker inspect "$cid" --format '{{.State.Running}}' 2>/dev/null)" = "true" ] } # ============================================================= # 校验:备份产出后必须自证可用,否则等于没备 # ============================================================= verify_db_dump() { local f="$1" summary tables completed [ -f "$f" ] || { warn "SQL 备份不存在: $f"; return 1; } gzip -t "$f" 2>/dev/null || { warn "SQL 备份 gzip 校验失败: $f"; return 1; } # 一次解压同时数建表语句和找结束标记。用 awk 读完整个流,不能写成 # `gunzip -c | grep -q` —— grep 命中即退出会让 gunzip 收到 SIGPIPE, # 在 pipefail 下把好备份误判成坏的。 summary="$(gunzip -c "$f" | awk ' /^CREATE TABLE / { n++ } /-- Dump completed/ { d = 1 } END { print n+0, d+0 } ')" || { warn "SQL 备份无法完整解压: $f"; return 1; } tables="${summary%% *}"; completed="${summary##* }" # "Dump completed" 只有 mysqldump 正常收尾才会写;被 OOM / 断连截断的没有 [ "$completed" = "1" ] || { warn "SQL 备份缺少结束标记,导出被截断: $f"; return 1; } [ "$tables" -ge 10 ] || { warn "SQL 备份只有 $tables 条 CREATE TABLE: $f"; return 1; } log " SQL 备份校验通过(${tables} 张表,含结束标记)" return 0 } verify_data_tar() { local f="$1" listing repos [ -f "$f" ] || { warn "数据备份不存在: $f"; return 1; } gzip -t "$f" 2>/dev/null || { warn "数据备份 gzip 校验失败: $f"; return 1; } listing="$(tar tzf "$f")" || { warn "数据备份无法列出内容: $f"; return 1; } grep -q 'gitea/conf/app\.ini$' <<<"$listing" || { warn "数据备份中没有 app.ini"; return 1; } grep -q '/ssh/' <<<"$listing" || warn "数据备份中没有 SSH host key 目录" grep -q '/jwt/' <<<"$listing" || warn "数据备份中没有 JWT 密钥目录" repos=$(grep -c 'repositories/[^/]*/[^/]*\.git/$' <<<"$listing" || true) log " 数据备份校验通过($(wc -l <<<"$listing") 个条目,${repos} 个仓库)" return 0 } verify_set() { local ts="$1" ok=0 verify_db_dump "${BACKUP_DIR}/db_${ts}.sql.gz" || ok=1 verify_data_tar "${BACKUP_DIR}/gitea_data_${ts}.tar.gz" || ok=1 [ -f "${BACKUP_DIR}/config_${ts}.tar.gz" ] || { warn "配置备份缺失"; ok=1; } return $ok } # ===== --verify-last:只校验,不产生新备份 ===== if [ "$MODE" = "verify" ]; then last=$(find "$BACKUP_DIR" -maxdepth 1 -name 'db_*.sql.gz' -printf '%f\n' 2>/dev/null \ | sed 's/^db_//; s/\.sql\.gz$//' | sort | tail -1) [ -n "$last" ] || die "$BACKUP_DIR 中没有找到任何备份" log "校验最近一次备份: $last" if verify_set "$last"; then log "===== 备份 $last 校验通过 ====="; exit 0 else die "备份 $last 校验未通过"; fi fi # ============================================================= # 冷备份:停 Gitea 取一致快照,MySQL 全程保持运行 # ============================================================= restart_app_if_stopped() { [ "$STOPPED" -eq 1 ] || return 0 STOPPED=0 log "正在恢复 Gitea 服务..." docker compose up -d "$SVC_APP" >/dev/null 2>&1 || \ warn "Gitea 恢复失败!请手动执行: cd $SCRIPT_DIR && docker compose up -d" } trap 'restart_app_if_stopped' EXIT db_running || die "MySQL 容器未运行,无法备份数据库: docker compose up -d $SVC_DB" # ===== 磁盘空间预检 ===== data_kb=$(du -sk "$GITEA_DATA_DIR" 2>/dev/null | cut -f1 || echo 0) [[ "$data_kb" =~ ^[0-9]+$ ]] || data_kb=0 avail_kb=$(df -Pk "$BACKUP_DIR" | awk 'NR==2{print $4}') need_kb=$(( data_kb * 6 / 5 + 512000 )) if [ -n "$avail_kb" ] && [ "$avail_kb" -lt "$need_kb" ]; then die "磁盘空间不足: 可用 $((avail_kb/1024))MB,预计需要 $((need_kb/1024))MB" fi log "===== 开始备份(模式: $MODE)=====" if [ "$MODE" = "cold" ]; then log "停止 Gitea 以取得一致快照(MySQL 不停)..." docker compose stop "$SVC_APP" >/dev/null 2>&1 STOPPED=1 log "Gitea 已停止" fi # ============================================================= # 1. 数据库 # ============================================================= # 顺序很重要:先导数据库、后打包文件。 # 热备份下两者必然是两个时刻的快照,若中间发生 push: # 先库后文件 → 文件里多出库中没有的对象(孤儿对象,无害) # 先文件后库 → 库里记录了文件中不存在的提交(引用悬空,有害) # 所以先导库是两害相权取其轻。要完全一致请用 --cold。 log "正在备份 MySQL 数据库..." DB_FILE="${BACKUP_DIR}/db_${DATE}.sql.gz" dump_opts=(-u root --single-transaction --routines --triggers --hex-blob --default-character-set=utf8mb4 --databases "$DB_NAME") # 无 GTID 的实例上 mysqldump 会写 SET @@GLOBAL.GTID_PURGED,回灌时报错。 # 先收进变量再匹配,避免 grep -q 的 SIGPIPE 让探测结果反转。 dump_help="$(db_exec mysqldump --help 2>/dev/null || true)" if grep -q 'set-gtid-purged' <<<"$dump_help"; then dump_opts+=(--set-gtid-purged=OFF) fi db_exec mysqldump "${dump_opts[@]}" | gzip > "$DB_FILE" chmod 600 "$DB_FILE" verify_db_dump "$DB_FILE" || die "数据库备份未通过校验,已中止(不清理旧备份)" log "数据库备份完成: db_${DATE}.sql.gz ($(du -h "$DB_FILE" | cut -f1))" # ============================================================= # 2. Gitea 数据目录(仓库、LFS、附件、密钥) # ============================================================= log "正在备份 Gitea 数据目录(含仓库与 LFS)..." DATA_FILE="${BACKUP_DIR}/gitea_data_${DATE}.tar.gz" tar czf "$DATA_FILE" \ -C "$(dirname "$GITEA_DATA_DIR")" \ "$(basename "$GITEA_DATA_DIR")" # 包里有 SSH host key 和 OAuth2 JWT 签名密钥,权限必须收紧 chmod 600 "$DATA_FILE" verify_data_tar "$DATA_FILE" || die "数据目录备份未通过校验,已中止(不清理旧备份)" log "数据目录备份完成: gitea_data_${DATE}.tar.gz ($(du -h "$DATA_FILE" | cut -f1))" # ============================================================= # 3. 部署配置 # ============================================================= log "正在备份配置文件..." CONF_FILE="${BACKUP_DIR}/config_${DATE}.tar.gz" conf_items=(.env docker-compose.yml) [ -d nginx ] && conf_items+=(nginx) tar czf "$CONF_FILE" -C "$SCRIPT_DIR" "${conf_items[@]}" chmod 600 "$CONF_FILE" # 含 .env 里的数据库密码 gzip -t "$CONF_FILE" 2>/dev/null || die "配置备份 gzip 校验失败" log "配置备份完成: config_${DATE}.tar.gz" # 冷备份到此结束,尽早把服务放回去,后面的清理不该占用停机时间 restart_app_if_stopped # ============================================================= # 4. 清理过期备份 # ============================================================= # 三条安全约束,缺一不可: # a) 只删本脚本自己产出的三种文件名,绝不碰 upgrade.sh 的 pre-upgrade-* # 回滚锚点,也不碰别人放进这个目录的东西 # b) 按「备份集」整组判断,不按单个文件,避免删出半套残废备份 # c) 无论多旧,至少保留 MIN_KEEP_SETS 份,防止长期备份失败后 # 把最后一份能用的也清掉 log "清理 ${KEEP_DAYS} 天前的备份(至少保留最近 ${MIN_KEEP_SETS} 份)..." mapfile -t all_sets < <( find "$BACKUP_DIR" -maxdepth 1 -name 'db_*.sql.gz' -printf '%f\n' 2>/dev/null \ | sed 's/^db_//; s/\.sql\.gz$//' | sort ) total=${#all_sets[@]} deleted=0 if [ "$total" -gt "$MIN_KEEP_SETS" ]; then # 只考察最老的那些,末尾 MIN_KEEP_SETS 份无条件保留 candidates=$(( total - MIN_KEEP_SETS )) for (( i = 0; i < candidates; i++ )); do ts="${all_sets[$i]}" f="${BACKUP_DIR}/db_${ts}.sql.gz" # 用 mtime 判断是否过期;-mmin 比 -mtime 精度更好 if [ -n "$(find "$f" -maxdepth 0 -mmin +$(( KEEP_DAYS * 1440 )) 2>/dev/null)" ]; then rm -f "${BACKUP_DIR}/db_${ts}.sql.gz" \ "${BACKUP_DIR}/gitea_data_${ts}.tar.gz" \ "${BACKUP_DIR}/config_${ts}.tar.gz" log " 已删除过期备份集: $ts" deleted=$(( deleted + 1 )) fi done fi log "已清理 ${deleted} 份过期备份集,当前保留 $(( total - deleted )) 份" # ============================================================= # 5. 摘要 # ============================================================= echo "" log "===== 备份完成 =====" log "备份集: ${DATE}(模式: ${MODE})" ls -lh "${BACKUP_DIR}/"*"${DATE}"* 2>/dev/null || true echo "" log "备份目录: ${BACKUP_DIR}/" log "总占用: $(du -sh "$BACKUP_DIR" | cut -f1)" if [ "$MODE" = "hot" ]; then log "提示: 这是热备份,数据库与仓库文件取自不同时刻。" log " 需要完全一致的快照请用: bash backup.sh --cold" fi log "校验最近备份: bash backup.sh --verify-last"