Files
server-deploy/gitea/backup.sh
T
joywayerandClaude Opus 5 9be05edc9a gitea 备份与升级脚本缺陷修复
backup.sh:
- 备份产出后校验完整性(gzip、mysqldump 结束标记、建表数、tar 清单),
  校验不通过即中止,且不清理旧备份
- 清理只删自有文件名、按备份集整组判断、保底保留 3 份,
  不再误删 upgrade.sh 的 pre-upgrade-* 回滚锚点与无关文件
- 密码改用 MYSQL_PWD 传递,不再出现在进程命令行
- 新增 --cold(停 Gitea 取一致快照,MySQL 不停)与 --verify-last
- 备份包含 SSH host key 与 JWT 签名密钥,权限收紧为 600
- 补充 root/依赖/磁盘预检与 flock 防 cron 并发

upgrade.sh:
- 修复 curl 状态码被拼接成 000000,导致 wait_healthy 误判就绪。
  该缺陷曾使一次正常的数据库迁移被误杀并触发回滚
- SSH 检测改为按行读 banner;head -c 会阻塞到超时,
  把健康的服务误报成故障
- 跨小版本升级若迁移版本未前进则判定失败,
  避免迁移根本没执行却报告成功
- 健康检查端口参数化 GITEA_HTTP_PORT(默认 3000),
  避免探测到其它实例而验证了错误的对象
- 回滚后提示 .env 已钉在只存在于本机的镜像标签上,并给出解绑方法
- 清理回滚镜像的建议补充说明:删除后 --rollback 会失效
- 数据备份包权限收紧为 600
- 容量显示不再把 780KB 显示成 0MB

验证:生产 1.25.5 → 1.27.1 升级完成,数据逐项核验一致;
隔离沙箱完整演练升级与 --rollback,回滚后数据精确还原。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-05 23:49:01 +08:00

287 lines
12 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.
#!/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"